diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ec0a2c36e2c..7558cf1faa6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -9,11 +9,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - - uses: gradle/gradle-build-action@v2 + - uses: gradle/gradle-build-action@v3 - name: Check for missing qq strings run: ./scripts/missing-qq.py - name: Checkout submodules @@ -38,7 +38,7 @@ jobs: run: git rev-parse HEAD > app/build/outputs/apk/alpha/release/rev-hash.txt - name: Rename APK to universal run: mv app/build/outputs/apk/alpha/release/app-alpha-release-signed.apk app/build/outputs/apk/alpha/release/app-alpha-universal-release.apk - - uses: dev-drprasad/delete-tag-and-release@v0.2.1 + - uses: dev-drprasad/delete-tag-and-release@v1.1 name: Delete latest alpha tag and release with: tag_name: latest @@ -47,7 +47,7 @@ jobs: - name: Sleep for 30 seconds, to allow the tag to be deleted run: sleep 30s shell: bash - - uses: ncipollo/release-action@v1.12.0 + - uses: ncipollo/release-action@v1.14.0 name: Create new tag and release and upload artifacts with: name: latest diff --git a/.github/workflows/android_branch.yml b/.github/workflows/android_branch.yml index cd491de2787..29be6c00b28 100644 --- a/.github/workflows/android_branch.yml +++ b/.github/workflows/android_branch.yml @@ -10,11 +10,11 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - - uses: gradle/gradle-build-action@v2 + - uses: gradle/gradle-build-action@v3 - name: Checkout submodules run: git submodule update --init --recursive - name: Build, test, and lint @@ -33,7 +33,7 @@ jobs: env: # override default build-tools version (29.0.3) -- optional BUILD_TOOLS_VERSION: "34.0.0" - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 name: Upload APK artifact with: name: app_alpha_release diff --git a/.github/workflows/android_phab.yml b/.github/workflows/android_phab.yml new file mode 100644 index 00000000000..e27ccfe91ca --- /dev/null +++ b/.github/workflows/android_phab.yml @@ -0,0 +1,24 @@ +name: Post to Phabricator + +on: + pull_request: + types: [opened, closed] + +jobs: + post_to_phab: + runs-on: ubuntu-latest + steps: + - name: Post to Phabricator when pull request is opened or closed + if: ${{ github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'closed') }} + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + message="${{ github.actor }} ${{ github.event.action }} ${{ github.event.pull_request._links.html.href }}" + echo -e "${PR_BODY}" | grep -oEi "(^Bug:\s*T[0-9]+)|(^([*]*phabricator[*]*:[*]*\s*)?https:\/\/phabricator\.wikimedia\.org\/T[0-9]+)" | grep -oEi "T[0-9]+" | while IFS= read -r line; do + echo "Processing: $line" + curl https://phabricator.wikimedia.org/api/maniphest.edit \ + -d api.token=${{ secrets.PHAB_BOT_API_KEY }} \ + -d transactions[0][type]=comment \ + -d transactions[0][value]="${message}" \ + -d objectIdentifier=${line} + done diff --git a/.github/workflows/android_pr.yml b/.github/workflows/android_pr.yml index 6701cafcee4..f0e68f1ba9d 100644 --- a/.github/workflows/android_pr.yml +++ b/.github/workflows/android_pr.yml @@ -10,12 +10,12 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: gradle/wrapper-validation-action@v1 - - uses: actions/setup-java@v3 + - uses: gradle/actions/wrapper-validation@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - - uses: gradle/gradle-build-action@v2 + - uses: gradle/gradle-build-action@v3 - name: Check for missing qq strings run: ./scripts/missing-qq.py - name: Build, test, and lint diff --git a/.github/workflows/android_smoke_test.yml b/.github/workflows/android_smoke_test.yml index fe9d0285cd0..1a8bb510fb4 100644 --- a/.github/workflows/android_smoke_test.yml +++ b/.github/workflows/android_smoke_test.yml @@ -12,11 +12,11 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-java@v3 + - uses: actions/setup-java@v4 with: distribution: 'temurin' java-version: '17' - - uses: gradle/gradle-build-action@v2 + - uses: gradle/gradle-build-action@v3 - name: Instrumentation Tests uses: reactivecircus/android-emulator-runner@v2 @@ -26,7 +26,7 @@ jobs: - name: Upload results if: ${{ always() }} - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: instrumentation-test-results ${{ matrix.api-level }} path: ./**/build/reports/androidTests/connected/** diff --git a/.mailmap b/.mailmap new file mode 100644 index 00000000000..b140127f9cc --- /dev/null +++ b/.mailmap @@ -0,0 +1,5 @@ +# See: https://git-scm.com/docs/git-shortlog#_mapping_authors +# +Brooke Vibber +Brooke Vibber +Brooke Vibber diff --git a/app/build.gradle b/app/build.gradle index 05b069965bf..f1ff63f50d1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,7 +37,7 @@ android { applicationId 'org.wikipedia' minSdk 21 targetSdk 34 - versionCode 50467 + versionCode 50485 testApplicationId 'org.wikipedia.test' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments clearPackageData: 'true' @@ -65,13 +65,16 @@ android { buildConfig true } + androidResources { + generateLocaleConfig = true + } + sourceSets { - prod { java.srcDirs += 'src/extra/java' } - beta { java.srcDirs += 'src/extra/java' } - alpha { java.srcDirs += 'src/extra/java' } - dev { java.srcDirs += 'src/extra/java' } - custom { java.srcDirs += 'src/extra/java' } + [ prod, beta, alpha, dev, custom ].forEach { + it.java.srcDirs += 'src/extra/java' + it.res.srcDirs += 'src/extra/res' + } androidTest { assets.srcDirs += files("$projectDir/schemas".toString()) @@ -169,17 +172,18 @@ dependencies { // Debug with ./gradlew -q app:dependencies --configuration compile String okHttpVersion = '4.12.0' - String retrofitVersion = '2.9.0' + String retrofitVersion = '2.11.0' String glideVersion = '4.16.0' String mockitoVersion = '5.2.0' - String leakCanaryVersion = '2.13' - String kotlinCoroutinesVersion = '1.7.3' - String firebaseMessagingVersion = '23.4.0' - String mlKitVersion = '17.0.4' + String leakCanaryVersion = '2.14' + String kotlinCoroutinesVersion = '1.8.0' + String firebaseMessagingVersion = '23.4.1' + String mlKitVersion = '17.0.5' + String googlePayVersion = '19.3.0' String roomVersion = "2.6.1" String espressoVersion = '3.5.1' - String serialization_version = '1.6.2' - String metricsVersion = '2.1' + String serialization_version = '1.6.3' + String metricsVersion = '2.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' @@ -190,8 +194,8 @@ dependencies { implementation "com.google.android.material:material:1.11.0" implementation 'androidx.appcompat:appcompat:1.6.1' - implementation "androidx.core:core-ktx:1.12.0" - implementation "androidx.browser:browser:1.7.0" + implementation "androidx.core:core-ktx:1.13.0" + implementation "androidx.browser:browser:1.8.0" implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation "androidx.fragment:fragment-ktx:1.6.2" implementation "androidx.paging:paging-runtime-ktx:3.2.1" @@ -213,6 +217,7 @@ dependencies { implementation "com.github.bumptech.glide:okhttp3-integration:$glideVersion" ksp "com.github.bumptech.glide:ksp:$glideVersion" + implementation "com.squareup.okhttp3:okhttp-tls:$okHttpVersion" implementation "com.squareup.okhttp3:logging-interceptor:$okHttpVersion" implementation "com.squareup.retrofit2:retrofit:$retrofitVersion" implementation "com.squareup.retrofit2:adapter-rxjava3:$retrofitVersion" @@ -224,6 +229,9 @@ dependencies { implementation 'com.github.skydoves:balloon:1.6.4' implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0" + implementation 'org.maplibre.gl:android-sdk:10.3.0' + implementation 'org.maplibre.gl:android-plugin-annotation-v9:2.0.2' + implementation("androidx.room:room-runtime:$roomVersion") annotationProcessor "androidx.room:room-compiler:$roomVersion" ksp "androidx.room:room-compiler:$roomVersion" @@ -244,12 +252,19 @@ dependencies { devImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" customImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" + // For integrating with Google Pay for donations + prodImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + betaImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + alphaImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + devImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + customImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + debugImplementation "com.squareup.leakcanary:leakcanary-android:$leakCanaryVersion" implementation "com.squareup.leakcanary:plumber-android:$leakCanaryVersion" testImplementation 'junit:junit:4.13.2' testImplementation "org.mockito:mockito-inline:$mockitoVersion" - testImplementation 'org.robolectric:robolectric:4.11.1' + testImplementation 'org.robolectric:robolectric:4.12.1' testImplementation "com.squareup.okhttp3:okhttp:$okHttpVersion" testImplementation "com.squareup.okhttp3:mockwebserver:$okHttpVersion" testImplementation 'org.hamcrest:hamcrest:2.2' @@ -260,7 +275,7 @@ dependencies { androidTestImplementation "androidx.test.espresso:espresso-intents:$espressoVersion" androidTestImplementation "androidx.test.espresso:espresso-web:$espressoVersion" androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.3.0' androidTestImplementation "androidx.room:room-testing:$roomVersion" androidTestUtil 'androidx.test:orchestrator:1.4.2' } diff --git a/app/src/androidTest/java/org/wikipedia/TestUtil.kt b/app/src/androidTest/java/org/wikipedia/TestUtil.kt index 74b87f846ae..97d217910a6 100644 --- a/app/src/androidTest/java/org/wikipedia/TestUtil.kt +++ b/app/src/androidTest/java/org/wikipedia/TestUtil.kt @@ -5,9 +5,18 @@ import android.view.View import android.view.ViewGroup import androidx.annotation.ColorInt import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.NoMatchingViewException import androidx.test.espresso.UiController import androidx.test.espresso.ViewAction -import androidx.test.espresso.action.* +import androidx.test.espresso.ViewInteraction +import androidx.test.espresso.action.CoordinatesProvider +import androidx.test.espresso.action.GeneralLocation +import androidx.test.espresso.action.GeneralSwipeAction +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.Swipe +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.isRoot import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -27,6 +36,15 @@ object TestUtil { return WithGrandparentMatcher(grandparentMatcher) } + fun ViewInteraction.isDisplayed(): Boolean { + return try { + check(matches(ViewMatchers.isDisplayed())) + true + } catch (e: NoMatchingViewException) { + false + } + } + fun isNotVisible(): Matcher { return IsNotVisibleMatcher() } diff --git a/app/src/androidTest/java/org/wikipedia/main/ExploreFeedTests.kt b/app/src/androidTest/java/org/wikipedia/main/ExploreFeedTests.kt deleted file mode 100644 index 4c36c7b1df7..00000000000 --- a/app/src/androidTest/java/org/wikipedia/main/ExploreFeedTests.kt +++ /dev/null @@ -1,342 +0,0 @@ -package org.wikipedia.main - -import androidx.recyclerview.widget.RecyclerView -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.Espresso.pressBack -import androidx.test.espresso.action.ViewActions.* -import androidx.test.espresso.assertion.ViewAssertions -import androidx.test.espresso.contrib.RecyclerViewActions -import androidx.test.espresso.matcher.ViewMatchers.* -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest -import org.hamcrest.Matchers -import org.hamcrest.Matchers.allOf -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.wikipedia.R -import org.wikipedia.TestUtil -import org.wikipedia.TestUtil.childAtPosition -import org.wikipedia.TestUtil.isNotVisible -import org.wikipedia.auth.AccountUtil - -@LargeTest -@RunWith(AndroidJUnit4::class) -class ExploreFeedTests { - - @Rule - @JvmField - var mActivityTestRule = ActivityScenarioRule(MainActivity::class.java) - private lateinit var activity: MainActivity - - @Before - fun setActivity() { - mActivityTestRule.scenario.onActivity { - activity = it - } - } - @Test - fun exploreFeedTest() { - - // Skip the initial onboarding screens... - onView(allOf(withId(R.id.fragment_onboarding_skip_button), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - // Dismiss the Feed customization onboarding card in the feed - onView(allOf(withId(R.id.view_announcement_action_negative), withText("Got it"), isDisplayed())) - .perform(click()) - - TestUtil.delay(1) - - // Featured article card seen and saved to reading lists - onView(allOf(withId(R.id.view_featured_article_card_content_container), - childAtPosition(childAtPosition(withClassName(Matchers.`is`("org.wikipedia.feed.featured.FeaturedArticleCardView")), 0), 1), isDisplayed())) - .perform(scrollTo(), longClick()) - - onView(allOf(withId(R.id.title), withText("Save"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.feed_view), isNotFocused())).perform(RecyclerViewActions.scrollToPosition(3)) - TestUtil.delay(2) - - // Top read card seen and saved to reading lists - onView(allOf(withId(R.id.view_list_card_list), childAtPosition(withId(R.id.view_list_card_list_container), 0))) - .perform(RecyclerViewActions.actionOnItemAtPosition(1, longClick())) - - onView(allOf(withId(R.id.title), withText("Save"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.feed_view), isNotFocused())).perform(RecyclerViewActions.scrollToPosition(4)) - - TestUtil.delay(2) - - // Picture of the day card seen and clicked - onView(allOf(withId(R.id.view_featured_image_card_content_container), - childAtPosition(childAtPosition(withClassName(Matchers.`is`("org.wikipedia.feed.image.FeaturedImageCardView")), 0), 1), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - onView(allOf(withId(R.id.feed_view), isNotFocused())).perform(RecyclerViewActions.scrollToPosition(5), click()) - - TestUtil.delay(2) - - // News card seen and news item saved to reading lists - onView(allOf(withId(R.id.news_story_items_recyclerview), - childAtPosition(withClassName(Matchers.`is`("android.widget.LinearLayout")), 1))) - .perform(RecyclerViewActions.actionOnItemAtPosition(0, longClick())) - - onView(allOf(withId(R.id.title), withText("Save"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - onView(allOf(withId(R.id.feed_view), isNotFocused())).perform(RecyclerViewActions.scrollToPosition(6)) - - TestUtil.delay(2) - - // On this day card seen and saved to reading lists - onView(allOf(withId(R.id.on_this_day_page), - childAtPosition(allOf(withId(R.id.event_layout), childAtPosition(withId(R.id.on_this_day_card_view_click_container), 0)), 3), isDisplayed())) - .perform(longClick()) - - onView(allOf(withId(R.id.title), withText("Save"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - goToTop() - - TestUtil.delay(2) - - onView(allOf(withId(R.id.feed_view), isDisplayed())).perform(RecyclerViewActions.scrollToPosition(7)) - - TestUtil.delay(2) - - // Random article card seen and saved to reading lists - onView(allOf(withId(R.id.view_featured_article_card_content_container), - childAtPosition(childAtPosition(withClassName(Matchers.`is`("org.wikipedia.feed.random.RandomCardView")), 0), 1), isDisplayed())) - .perform(scrollTo(), longClick()) - - onView(allOf(withId(R.id.title), withText("Save"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - if (AccountUtil.isLoggedIn) { - // Access Suggested edits card - onView(allOf(withId(R.id.feed_view), isDisplayed())).perform(RecyclerViewActions.scrollToPosition(9)) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.callToActionButton), withText("Add article description"), - childAtPosition(allOf(withId(R.id.viewArticleContainer), childAtPosition(withId(R.id.cardItemContainer), 1)), 6), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - } - - onView(allOf(withId(R.id.feed_view), isDisplayed())).perform(RecyclerViewActions.scrollToPosition(8)) - - TestUtil.delay(2) - - // Main page card seen clicked - onView(allOf(withId(R.id.footerActionButton), withText("View main page "), - childAtPosition(allOf(withId(R.id.card_footer), childAtPosition(withClassName(Matchers.`is`("android.widget.LinearLayout")), 1)), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - // Access the other navigation tabs - `Saved`, `Search` and `Edits` - onView(allOf(withId(R.id.nav_tab_reading_lists), withContentDescription("Saved"), - childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 1), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.nav_tab_search), withContentDescription("Search"), - childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 2), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.nav_tab_edits), withContentDescription("Edits"), - childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 3), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - if (AccountUtil.isLoggedIn) { - // Click through `Edits` screen stats onboarding - for (i in 1 until 5) { - onView(allOf(withId(R.id.buttonView), withText("Got it"), - childAtPosition(childAtPosition(withClassName(Matchers.`is`("android.widget.LinearLayout")), 1), 0), isDisplayed())) - .perform(click()) - TestUtil.delay(2) - } - } - - // Click on `More` menu - onView(allOf(withId(R.id.nav_more_container), withContentDescription("More"), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - // Click on `Settings` option - onView(allOf(withId(R.id.main_drawer_settings_container), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - // Click on `Explore feed` option - onView(allOf(withId(R.id.recycler_view), - childAtPosition(withId(android.R.id.list_container), 0))) - .perform(RecyclerViewActions.actionOnItemAtPosition(2, click())) - - TestUtil.delay(2) - - onView(allOf(withContentDescription("More options"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.action_bar), 2), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.title), withText("Hide all"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - onView(allOf(withId(R.id.nav_tab_explore), withContentDescription("Explore"), - childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 0), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.empty_container), withParent(withParent(withId(R.id.swipe_refresh_layout))), isDisplayed())) - .check(ViewAssertions.matches(isDisplayed())) - - TestUtil.delay(2) - - // Click on `More` menu - onView(allOf(withId(R.id.nav_more_container), withContentDescription("More"), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - // Click on `Settings` option - onView(allOf(withId(R.id.main_drawer_settings_container), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - // Click on `Explore feed` option - onView(allOf(withId(R.id.recycler_view), - childAtPosition(withId(android.R.id.list_container), 0))) - .perform(RecyclerViewActions.actionOnItemAtPosition(2, click())) - TestUtil.delay(2) - - onView(allOf(withContentDescription("More options"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.action_bar), 2), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.title), withText("Show all"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - onView(allOf(withId(R.id.empty_container), withParent(withParent(withId(R.id.swipe_refresh_layout))), isNotVisible())) - .check(ViewAssertions.matches(isNotVisible())) - - TestUtil.delay(2) - - // Test `Developer settings activation process via `Settings` screen - onView(allOf(withId(R.id.nav_more_container), withContentDescription("More"), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.main_drawer_settings_container), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.recycler_view), childAtPosition(withId(android.R.id.list_container), 0))) - .perform(RecyclerViewActions.actionOnItemAtPosition(16, click())) - - TestUtil.delay(2) - - for (i in 1 until 8) { - onView(allOf(withId(R.id.about_logo_image), - childAtPosition(childAtPosition(withId(R.id.about_container), 0), 0))) - .perform(scrollTo(), click()) - TestUtil.delay(2) - } - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(2) - - onView(allOf(withId(R.id.developer_settings), withContentDescription("Developer settings"), - childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.action_bar), 2), 0), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - onView(allOf(withText("Developer settings"), - withParent(allOf(withId(androidx.appcompat.R.id.action_bar), - withParent(withId(androidx.appcompat.R.id.action_bar_container)))), isDisplayed())) - .check(ViewAssertions.matches(withText("Developer settings"))) - - TestUtil.delay(2) - } - - private fun goToTop() { - onView(allOf(withId(R.id.feed_view))).perform(RecyclerViewActions.scrollToPosition(0)) - TestUtil.delay(2) - } -} diff --git a/app/src/androidTest/java/org/wikipedia/main/SmokeTests.kt b/app/src/androidTest/java/org/wikipedia/main/SmokeTests.kt index 42ab6ef6a79..5c0e463dfad 100644 --- a/app/src/androidTest/java/org/wikipedia/main/SmokeTests.kt +++ b/app/src/androidTest/java/org/wikipedia/main/SmokeTests.kt @@ -2,12 +2,14 @@ package org.wikipedia.main import android.graphics.Color import android.os.Build -import androidx.recyclerview.widget.RecyclerView.ViewHolder +import androidx.recyclerview.widget.RecyclerView import androidx.test.espresso.Espresso.onView import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.IdlingPolicies import androidx.test.espresso.action.ViewActions.* +import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition import androidx.test.espresso.matcher.RootMatchers.withDecorView import androidx.test.espresso.matcher.ViewMatchers.* @@ -22,16 +24,19 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.LargeTest import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.UiDevice +import org.hamcrest.Matchers import org.hamcrest.Matchers.allOf -import org.hamcrest.Matchers.`is` import org.hamcrest.Matchers.not +import org.hamcrest.core.IsInstanceOf import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.wikipedia.BuildConfig import org.wikipedia.R import org.wikipedia.TestUtil -import org.wikipedia.auth.AccountUtil +import org.wikipedia.TestUtil.childAtPosition +import org.wikipedia.TestUtil.isDisplayed import org.wikipedia.navtab.NavTab import java.util.concurrent.TimeUnit @@ -71,8 +76,8 @@ class SmokeTests { onView(allOf(withId(R.id.fragment_onboarding_forward_button), isDisplayed())) .perform(click()) - // Dismiss initial onboarding by accepting analytics collection - onView(allOf(withId(R.id.acceptButton), withText("Accept"), isDisplayed())) + // Dismiss initial onboarding by clicking on the "Get started" button + onView(allOf(withId(R.id.fragment_onboarding_done_button), withText("Get started"), isDisplayed())) .perform(click()) TestUtil.delay(2) @@ -102,10 +107,12 @@ class SmokeTests { // Rotate the device device.setOrientationRight() + TestUtil.delay(2) - // Make the keyboard disappear + // Dismiss keyboard pressBack() + TestUtil.delay(1) // Make sure the same title appears in the new screen orientation @@ -114,22 +121,23 @@ class SmokeTests { // Rotate the device back to the original orientation device.setOrientationNatural() + TestUtil.delay(2) device.unfreezeRotation() + TestUtil.delay(2) // Click on the first search result onView(withId(R.id.search_results_list)) - .perform(actionOnItemAtPosition(0, click())) + .perform(actionOnItemAtPosition(0, click())) // Give the page plenty of time to load fully TestUtil.delay(5) - // Dismiss tooltip, if any - onView(allOf(withId(R.id.buttonView))) - .inRoot(withDecorView(not(`is`(activity.window.decorView)))) - .perform(click()) + // Dismiss tooltip + onView(allOf(withId(R.id.buttonView))).inRoot(withDecorView(not(Matchers.`is`(activity.window.decorView)))) + .perform(click()) TestUtil.delay(2) @@ -163,6 +171,7 @@ class SmokeTests { // Go back to the original article pressBack() + TestUtil.delay(2) // Ensure the header view (with lead image) is displayed @@ -175,11 +184,24 @@ class SmokeTests { TestUtil.delay(3) - onView(allOf(withId(R.id.pager))) - .perform(swipeLeft()) + // Swipe to next image + onView(allOf(withId(R.id.pager))).perform(swipeLeft()) TestUtil.delay(2) + // Click the overflow menu + onView(allOf(withContentDescription("More options"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click to visit image page + onView(allOf(withId(R.id.title), withText("Go to image page"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Go back to gallery view + pressBack() + // Go back to the article pressBack() @@ -187,10 +209,11 @@ class SmokeTests { // Ensure the article title matches what we expect onWebView().withElement(findElement(Locator.CSS_SELECTOR, "h1")) - .check(WebViewAssertions.webMatches(DriverAtoms.getText(), `is`(ARTICLE_TITLE))) + .check(WebViewAssertions.webMatches(DriverAtoms.getText(), Matchers.`is`(ARTICLE_TITLE))) // Rotate the display to landscape device.setOrientationRight() + TestUtil.delay(2) // Make sure the header view (with lead image) is not shown in landscape mode @@ -199,17 +222,17 @@ class SmokeTests { // Make sure the article title still matches what we expect onWebView().withElement(findElement(Locator.CSS_SELECTOR, "h1")) - .check(WebViewAssertions.webMatches(DriverAtoms.getText(), `is`(ARTICLE_TITLE))) + .check(WebViewAssertions.webMatches(DriverAtoms.getText(), Matchers.`is`(ARTICLE_TITLE))) // Rotate the device back to the original orientation device.setOrientationNatural() + TestUtil.delay(2) device.unfreezeRotation() // Bring up the theme chooser dialog - onView(withId(R.id.page_theme)) - .perform(click()) + onView(withId(R.id.page_theme)).perform(click()) TestUtil.delay(2) @@ -219,14 +242,13 @@ class SmokeTests { .check(matches(TestUtil.isNotVisible())) } else { onView(withId(R.id.theme_chooser_match_system_theme_switch)) - .perform(scrollTo(), click()) + .perform(scrollTo(), click()) TestUtil.delay(1) } // Select the Black theme - onView(withId(R.id.button_theme_black)) - .perform(scrollTo(), click()) + onView(withId(R.id.button_theme_black)).perform(scrollTo(), click()) TestUtil.delay(2) @@ -235,8 +257,7 @@ class SmokeTests { TestUtil.delay(1) // Make sure the background is black - onView(withId(R.id.page_actions_tab_layout)) - .check(matches(TestUtil.hasBackgroundColor(Color.BLACK))) + onView(withId(R.id.page_actions_tab_layout)).check(matches(TestUtil.hasBackgroundColor(Color.BLACK))) // Go back to the Light theme onView(withId(R.id.page_theme)) @@ -244,8 +265,7 @@ class SmokeTests { TestUtil.delay(1) - onView(withId(R.id.button_theme_light)) - .perform(scrollTo(), click()) + onView(withId(R.id.button_theme_light)).perform(scrollTo(), click()) TestUtil.delay(2) @@ -270,13 +290,11 @@ class SmokeTests { TestUtil.delay(2) // Increase text size by clicking on increase text icon - onView(allOf(withId(R.id.buttonIncreaseTextSize))) - .perform(scrollTo(), click()) + onView(allOf(withId(R.id.buttonIncreaseTextSize))).perform(scrollTo(), click()) TestUtil.delay(2) - onView(allOf(withId(R.id.buttonIncreaseTextSize))) - .perform(scrollTo(), click()) + onView(allOf(withId(R.id.buttonIncreaseTextSize))).perform(scrollTo(), click()) TestUtil.delay(2) @@ -291,13 +309,11 @@ class SmokeTests { TestUtil.delay(3) // Decrease text size by clicking on decrease text icon - onView(allOf(withId(R.id.buttonDecreaseTextSize))) - .perform(scrollTo(), click()) + onView(allOf(withId(R.id.buttonDecreaseTextSize))).perform(scrollTo(), click()) TestUtil.delay(2) - onView(allOf(withId(R.id.buttonDecreaseTextSize))) - .perform(scrollTo(), click()) + onView(allOf(withId(R.id.buttonDecreaseTextSize))).perform(scrollTo(), click()) TestUtil.delay(2) @@ -307,48 +323,40 @@ class SmokeTests { TestUtil.delay(3) // Type in some stuff into the edit window - onView(allOf(withId(R.id.edit_section_text))) - .perform(replaceText("abc")) + onView(allOf(withId(R.id.edit_section_text))).perform(replaceText("abc")) TestUtil.delay(3) // Proceed to edit preview - onView(allOf(withId(R.id.edit_actionbar_button_text), isDisplayed())) - .perform(click()) + onView(allOf(withId(R.id.edit_actionbar_button_text), isDisplayed())).perform(click()) // Give sufficient time for the API to load the preview TestUtil.delay(2) - onView(allOf(withId(R.id.edit_actionbar_button_text), isDisplayed())) - .perform(click()) + onView(allOf(withId(R.id.edit_actionbar_button_text), isDisplayed())).perform(click()) TestUtil.delay(3) // Click one of the default edit summary choices - onView(allOf(withText("Fixed typo"))) - .perform(scrollTo(), click()) + onView(allOf(withText("Fixed typo"))).perform(scrollTo(), click()) TestUtil.delay(3) // Go back out of the editing workflow - onView(allOf(withContentDescription("Navigate up"), isDisplayed())) - .perform(click()) + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) TestUtil.delay(1) - onView(allOf(withContentDescription("Navigate up"), isDisplayed())) - .perform(click()) + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) TestUtil.delay(1) - onView(allOf(withContentDescription("Navigate up"), isDisplayed())) - .perform(click()) + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) TestUtil.delay(1) // Choose to remain in the editing workflow - onView(allOf(withId(android.R.id.button2), withText("No"))) - .perform(scrollTo(), click()) + onView(allOf(withId(android.R.id.button2), withText("No"))).perform(scrollTo(), click()) TestUtil.delay(1) @@ -393,7 +401,7 @@ class SmokeTests { // Ensure that the title in the WebView is still what we expect onWebView().withElement(findElement(Locator.CSS_SELECTOR, "h1")) - .check(WebViewAssertions.webMatches(DriverAtoms.getText(), `is`(ARTICLE_TITLE))) + .check(WebViewAssertions.webMatches(DriverAtoms.getText(), Matchers.`is`(ARTICLE_TITLE))) // Swipe left to show the table of contents onView(allOf(withId(R.id.page_web_view))) @@ -421,7 +429,7 @@ class SmokeTests { // Click on the 3rd topic onView(withId(R.id.talkRecyclerView)) - .perform(actionOnItemAtPosition(2, click())) + .perform(actionOnItemAtPosition(2, click())) // Give the page plenty of time to load fully TestUtil.delay(5) @@ -432,49 +440,323 @@ class SmokeTests { // Get back to article screen pressBack() - if (AccountUtil.isLoggedIn) { - // Click on the 5th topic - onView(withId(R.id.page_toolbar_button_notifications)).perform(click()) + TestUtil.delay(2) - // Give the page plenty of time to load fully - TestUtil.delay(5) + // Click on the Save button to add article to reading list + onView(withId(R.id.page_save)).perform(click()) - // Click on the search bar - onView(withId(R.id.notifications_recycler_view)) - .perform(actionOnItemAtPosition(0, click())) + TestUtil.delay(1) - // Make the keyboard disappear - pressBack() - TestUtil.delay(1) + // Access article in a different language + onView(allOf(withId(R.id.page_language), withContentDescription("Language"), isDisplayed())).perform(click()) - // Get out of search action mode - pressBack() + TestUtil.delay(2) - TestUtil.delay(1) + onView(allOf(withId(R.id.langlinks_recycler))).perform(actionOnItemAtPosition(3, click())) - // Go back out of notification - pressBack() - } + TestUtil.delay(2) + + // Ensure that the title in the WebView is still what we expect + onWebView().withElement(findElement(Locator.CSS_SELECTOR, "h1")) + .check(WebViewAssertions.webMatches(DriverAtoms.getText(), Matchers.`is`(ARTICLE_TITLE_ESPANOL))) TestUtil.delay(1) - // Click on the Save button to add article to reading list - onView(withId(R.id.page_save)).perform(click()) + onView(withId(R.id.page_toolbar_button_show_overflow_menu)).perform(click()) TestUtil.delay(1) - // Click anywhere to show the toolbar - device.click(screenWidth / 2, screenHeight * 10 / 100) + // Navigate back to Explore feed + onView(withText("Explore")).perform(click()) - TestUtil.delay(1) + TestUtil.delay(2) - onView(withId(R.id.page_toolbar_button_show_overflow_menu)).perform(click()) + // Featured article card seen and saved to reading lists + onView(allOf(withId(R.id.view_featured_article_card_content_container), + childAtPosition(childAtPosition(withClassName(Matchers.`is`("org.wikipedia.feed.featured.FeaturedArticleCardView")), 0), 1), isDisplayed())) + .perform(scrollTo(), longClick()) - TestUtil.delay(1) + onView(allOf(withId(R.id.title), withText("Save"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) + .perform(click()) - onView(withText("Explore")).perform(click()) + TestUtil.delay(2) - TestUtil.delay(1) + onView(allOf(withId(R.id.feed_view), isNotFocused())).perform(RecyclerViewActions.scrollToPosition(3)) + + TestUtil.delay(2) + + // Top read card seen and saved to reading lists + onView(allOf(withId(R.id.view_list_card_list), childAtPosition(withId(R.id.view_list_card_list_container), 0))) + .perform(actionOnItemAtPosition(1, longClick())) + + onView(allOf(withId(R.id.title), withText("Save"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + onView(allOf(withId(R.id.feed_view), isNotFocused())).perform(RecyclerViewActions.scrollToPosition(4)) + .perform(click()) + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + + goToTop() + + TestUtil.delay(2) + + onView(allOf(withId(R.id.feed_view), isNotFocused())).perform(RecyclerViewActions.scrollToPosition(6)) + + TestUtil.delay(3) + + onView(allOf(withId(R.id.news_cardview_recycler_view), childAtPosition(withId(R.id.rtl_container), 1))) + .perform(actionOnItemAtPosition(0, click())) + + TestUtil.delay(3) + + // News card seen and news item saved to reading lists + onView(allOf(withId(R.id.news_story_items_recyclerview), + childAtPosition(withClassName(Matchers.`is`("android.widget.LinearLayout")), 1))) + .perform(actionOnItemAtPosition(0, longClick())) + + onView(allOf(withId(R.id.title), withText("Save"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + + onView(allOf(withId(R.id.feed_view), isNotFocused())).perform(RecyclerViewActions.scrollToPosition(7)) + + TestUtil.delay(2) + + // On this day card seen and saved to reading lists + onView(allOf(withId(R.id.on_this_day_page), childAtPosition(allOf(withId(R.id.event_layout), + childAtPosition(withId(R.id.on_this_day_card_view_click_container), 0)), 3), isDisplayed())) + .perform(longClick()) + + onView(allOf(withId(R.id.title), withText("Save"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + goToTop() + + TestUtil.delay(2) + + onView(allOf(withId(R.id.feed_view), isDisplayed())).perform(RecyclerViewActions.scrollToPosition(8)) + + TestUtil.delay(2) + + // Random article card seen and saved to reading lists + onView(allOf(withId(R.id.view_featured_article_card_content_container), + childAtPosition(childAtPosition(withClassName(Matchers.`is`("org.wikipedia.feed.random.RandomCardView")), 0), 1), isDisplayed())) + .perform(scrollTo(), longClick()) + + onView(allOf(withId(R.id.title), withText("Save"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + onView(allOf(withId(R.id.feed_view), isDisplayed())).perform(RecyclerViewActions.scrollToPosition(9)) + + TestUtil.delay(5) + + // Main page card seen clicked + onView(allOf(withId(R.id.footerActionButton), withText("View main page "), + childAtPosition(allOf(withId(R.id.card_footer), childAtPosition(withClassName(Matchers.`is`("android.widget.LinearLayout")), 1)), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + + // Access the other navigation tabs - `Saved`, `Search` and `Edits` + onView(allOf(withId(R.id.nav_tab_reading_lists), withContentDescription("Saved"), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 1), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + onView(allOf(withId(R.id.nav_tab_search), withContentDescription("Search"), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 2), isDisplayed())).perform(click()) + + TestUtil.delay(2) + onView(allOf(withId(R.id.nav_tab_edits), withContentDescription("Edits"), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 3), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on `More` menu + onView(allOf(withId(R.id.nav_more_container), withContentDescription("More"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on `Settings` option + onView(allOf(withId(R.id.main_drawer_settings_container), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on `Explore feed` option + onView(allOf(withId(R.id.recycler_view), + childAtPosition(withId(android.R.id.list_container), 0))) + .perform(actionOnItemAtPosition(2, click())) + + TestUtil.delay(2) + + onView(allOf(withContentDescription("More options"), + childAtPosition(childAtPosition(withId(R.id.toolbar), 2), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + // Choose the option to hide all explore feed cards + onView(allOf(withId(R.id.title), withText("Hide all"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + + // Navigate to Explore feed + onView(allOf(withId(R.id.nav_tab_explore), withContentDescription("Explore"), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 0), isDisplayed())).perform(click()) + + TestUtil.delay(4) + + // Assert that all cards are hidden and empty container is shown + onView(allOf(withId(R.id.empty_container), withParent(withParent(withId(R.id.swipe_refresh_layout))), isDisplayed())) + .check(matches(isDisplayed())) + + TestUtil.delay(2) + + // Click on `More` menu + onView(allOf(withId(R.id.nav_more_container), withContentDescription("More"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on `Settings` option + onView(allOf(withId(R.id.main_drawer_settings_container), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on `Explore feed` option + onView(allOf(withId(R.id.recycler_view), + childAtPosition(withId(android.R.id.list_container), 0))) + .perform(actionOnItemAtPosition(2, click())) + TestUtil.delay(2) + + onView(allOf(withContentDescription("More options"), + childAtPosition(childAtPosition(withId(R.id.toolbar), 2), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + // Show all cards again + onView(allOf(withId(R.id.title), withText("Show all"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + + // Ensure that empty message is not shown on explore feed + onView(allOf(withId(R.id.empty_container), withParent(withParent(withId(R.id.swipe_refresh_layout))), + TestUtil.isNotVisible())).check(matches(TestUtil.isNotVisible())) + + TestUtil.delay(2) + + // Test `Developer settings activation process via `Settings` screen + onView(allOf(withId(R.id.nav_more_container), withContentDescription("More"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Open settings screen + onView(allOf(withId(R.id.main_drawer_settings_container), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click `About the wikipedia app` option + onView(allOf(withId(R.id.recycler_view), childAtPosition(withId(android.R.id.list_container), 0))) + .perform(actionOnItemAtPosition(14, click())) + + TestUtil.delay(2) + + // Click 7 times to activate developer mode + for (i in 1 until 8) { + onView(allOf(withId(R.id.about_logo_image), + childAtPosition(childAtPosition(withId(R.id.about_container), 0), 0))) + .perform(scrollTo(), click()) + TestUtil.delay(2) + } + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + + // Assert that developer mode is activated + onView(allOf(withId(R.id.developer_settings), withContentDescription("Developer settings"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.action_bar), 2), 0), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + onView(allOf(withText("Developer settings"), + withParent(allOf(withId(androidx.appcompat.R.id.action_bar), + withParent(withId(androidx.appcompat.R.id.action_bar_container)))), isDisplayed())) + .check(matches(withText("Developer settings"))) + + TestUtil.delay(2) + + // Go back to Settings + onView(allOf(withContentDescription("Navigate up"), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + // Test disabling of images from settings + onView(withId(androidx.preference.R.id.recycler_view)) + .perform(RecyclerViewActions.actionOnItem + (hasDescendant(withText(R.string.preference_title_show_images)), click())) + + TestUtil.delay(2) + + // Go to explore feed + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Assert that images arent shown anymore + onView(allOf(withId(R.id.articleImage), withParent(allOf(withId(R.id.articleImageContainer), + withParent(withId(R.id.view_wiki_article_card)))), isDisplayed())).check(ViewAssertions.doesNotExist()) + + TestUtil.delay(2) // Go to Saved tab onView(withId(NavTab.READING_LISTS.id)).perform(click()) @@ -483,15 +765,14 @@ class SmokeTests { // Click on first item in the list onView(withId(R.id.recycler_view)) - .perform(actionOnItemAtPosition(0, click())) + .perform(actionOnItemAtPosition(0, click())) // Waiting for the article to be saved to the database TestUtil.delay(5) // Dismiss tooltip, if any onView(allOf(withId(R.id.buttonView))) - .inRoot(withDecorView(not(`is`(activity.window.decorView)))) - .perform(click()) + .inRoot(withDecorView(not(Matchers.`is`(activity.window.decorView)))).perform(click()) TestUtil.delay(1) @@ -499,10 +780,12 @@ class SmokeTests { onView(allOf(withId(R.id.page_list_item_title), withText(ARTICLE_TITLE), isDisplayed())) .check(matches(withText(ARTICLE_TITLE))) + // Turn on airplane mode to test offline reading TestUtil.setAirplaneMode(true) TestUtil.delay(2) + // Access article in offline mode onView(allOf(withId(R.id.page_list_item_title), withText(ARTICLE_TITLE), isDisplayed())) .perform(click()) @@ -516,15 +799,444 @@ class SmokeTests { // Remove article from reading list onView(withText("Remove from Saved")).perform(click()) + TestUtil.delay(5) + + // Back to reading list screen + pressBack() + + TestUtil.delay(2) + + // Back to `Saved` tab + pressBack() + TestUtil.delay(2) + // Test history clearing feature - Go to search tab + onView(allOf(withId(R.id.nav_tab_search), withContentDescription("Search"), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 2), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on `Clear history` icon + onView(allOf(withId(R.id.history_delete), withContentDescription("Clear history"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Assert deletion message + onView(allOf(withId(androidx.appcompat.R.id.alertTitle), isDisplayed())).check(matches(withText("Clear browsing history"))) + + TestUtil.delay(2) + + onView(allOf(withId(android.R.id.button2), withText("No"), isDisplayed())).perform(scrollTo(), click()) + + TestUtil.delay(2) + + // Turn off airplane mode TestUtil.setAirplaneMode(false) + TestUtil.delay(5) + + // Click the More menu + onView(allOf(withId(R.id.nav_more_container), isDisplayed())).perform(click()) + + TestUtil.delay(1) + + // Log-in the user + // Click the Login menu item + onView(allOf(withId(R.id.main_drawer_login_button), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click the login button + onView(allOf(withId(R.id.create_account_login_button), withText("Log in"), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + // Set environment variables to hold correct username and password + onView(allOf(TestUtil.withGrandparent(withId(R.id.login_username_text)), withClassName(Matchers.`is`("org.wikipedia.views.PlainPasteEditText")))) + .perform(replaceText(BuildConfig.TEST_LOGIN_USERNAME), closeSoftKeyboard()) + + onView(allOf(TestUtil.withGrandparent(withId(R.id.login_password_input)), withClassName(Matchers.`is`("org.wikipedia.views.PlainPasteEditText")))) + .perform(replaceText(BuildConfig.TEST_LOGIN_PASSWORD), closeSoftKeyboard()) + + // Click the login button + onView(withId(R.id.login_button)).perform(scrollTo(), click()) + + TestUtil.delay(5) + + // Check if the list sync dialog is shown and subsequently dismiss it + val listSyncDialogButton = onView(allOf(withId(android.R.id.button2), withText("No thanks"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.buttonPanel), 0), 2))) + + if (listSyncDialogButton.isDisplayed()) { + listSyncDialogButton.perform(scrollTo(), click()) + } + + TestUtil.delay(1) + + // Click on Notifications from the app bar on Explore feed + onView(allOf(withId(R.id.menu_notifications), withContentDescription("Notifications"), + isDisplayed())).perform(click()) + + // Give the page plenty of time to load fully + TestUtil.delay(3) + + // Click on the search bar + onView(withId(R.id.notifications_recycler_view)) + .perform(actionOnItemAtPosition(0, click())) + + // Make the keyboard disappear + pressBack() + + TestUtil.delay(1) + + // Get out of search action mode + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(1) + + // Return to explore tab + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(1) + + onView(allOf(withId(R.id.nav_tab_explore), withContentDescription("Explore"), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 0), isDisplayed())).perform(click()) + + TestUtil.delay(1) + + goToTop() + + // Access Suggested edits card + // On some devices espresso doesnt scroll to >9 position directly, but scrolls in 2 steps + onView(allOf(withId(R.id.feed_view), isDisplayed())).perform(RecyclerViewActions.scrollToPosition(9)) + onView(allOf(withId(R.id.feed_view), isDisplayed())).perform(RecyclerViewActions.scrollToPosition(10)) + + TestUtil.delay(2) + + val callToActionView = onView(allOf(withId(R.id.callToActionButton), withText("Add article description"))) + + // Need to scroll when suggested edits card is very long + if (!callToActionView.isDisplayed()) { + callToActionView.perform(scrollTo()) + } + + // Very often the scroll above doesnt work, so we need to ensure that the callToActionView is displayed + if (callToActionView.isDisplayed()) { + onView(allOf(withId(R.id.callToActionButton), withText("Add article description"), + childAtPosition(allOf(withId(R.id.viewArticleContainer), childAtPosition(withId(R.id.cardItemContainer), 1)), 6), isDisplayed())).perform(click()) + } + + TestUtil.delay(2) + + // Dismiss onboarding + pressBack() + + TestUtil.delay(2) + + // Dismiss tooltip if the user gets put into the mgad bucket + val mgadTooltipView = onView( + allOf(withId(com.skydoves.balloon.R.id.balloon_content)) + ) + + if (mgadTooltipView.isDisplayed()) { + + // Extra back presses to dismiss the tooltip + pressBack() + + TestUtil.delay(2) + + pressBack() + + TestUtil.delay(2) + } + + // Back to explore feed + pressBack() + + TestUtil.delay(2) + + // Click on `More` menu + onView(allOf(withId(R.id.nav_more_container), withContentDescription("More"), isDisplayed())) + .perform(click()) + + TestUtil.delay(1) + + // Click on `Watchlist` option + onView(allOf(withId(R.id.main_drawer_watchlist_container), isDisplayed())).perform(click()) + + TestUtil.delay(1) + + // Return to explore tab + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + goToTop() + + // Click on featured article card + onView(allOf(withId(R.id.view_featured_article_card_content_container))) + .perform(scrollTo(), click()) + + TestUtil.delay(2) + + // Add the article to watchlist + onView(withId(R.id.page_toolbar_button_show_overflow_menu)).perform(click()) + + TestUtil.delay(1) + + onView(withText("Watch")).perform(click()) + + TestUtil.delay(1) + + // Assert we see a snackbar after adding article to watchlist + onView(allOf(withId(com.google.android.material.R.id.snackbar_text), isDisplayed())) + .check(matches(isDisplayed())) + + onView(allOf(withId(com.google.android.material.R.id.snackbar_action), isDisplayed())) + .check(matches(isDisplayed())) + + // Change article watchlist expiry via the snackbar action button + onView(allOf(withId(com.google.android.material.R.id.snackbar_action), withText("Change"), + isDisplayed())).perform(click()) + + onView(allOf(withId(R.id.watchlistExpiryOneMonth), isDisplayed())).perform(click()) + + TestUtil.delay(1) + + onView(allOf(withId(com.google.android.material.R.id.snackbar_text), isDisplayed())) + .check(matches(isDisplayed())) + + TestUtil.delay(1) + + onView(withId(R.id.page_toolbar_button_show_overflow_menu)).perform(click()) + + TestUtil.delay(1) + + // Make sure that the `Unwatch` option is shown for the article that is being watched + onView(withText("Unwatch")).perform(click()) + + TestUtil.delay(1) + + pressBack() + + TestUtil.delay(1) + + // Go to `Edits` tab + onView(allOf(withId(R.id.nav_tab_edits), withContentDescription("Edits"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // If it is a new account, SE tasks will not be available. Check to make sure they are. + val seDisabledView = onView(allOf(withId(R.id.disabledStatesView), withParent(withParent(withId(R.id.suggestedEditsScrollView))), isDisplayed())) + + if (!seDisabledView.isDisplayed()) { + // Click through `Edits` screen stats onboarding - also confirming tooltip display + for (i in 1 until 5) { + onView(allOf(withId(R.id.buttonView), withText("Got it"), + childAtPosition(childAtPosition(withClassName(Matchers.`is`("android.widget.LinearLayout")), 1), 0), isDisplayed())).perform(click()) + TestUtil.delay(2) + } + + // User contributions screen tests. Enter contributions screen + onView(allOf(withId(R.id.userStatsArrow), withContentDescription("My contributions"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on filter button to view filter options + onView(allOf(withId(R.id.filter_by_button), withContentDescription("Filter by"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Assert the presence of all filters + onView(allOf(withId(R.id.item_title), withText("Wikimedia Commons"), withParent(withParent(withId(R.id.recycler_view))), isDisplayed())) + .check(matches(withText("Wikimedia Commons"))) + + onView(allOf(withId(R.id.item_title), withText("Wikidata"), withParent(withParent(withId(R.id.recycler_view))), isDisplayed())) + .check(matches(withText("Wikidata"))) + + onView(allOf(withId(R.id.item_title), withText("Article"), withParent(withParent(withId(R.id.recycler_view))), isDisplayed())) + .check(matches(withText("Article"))) + + onView(allOf(withId(R.id.item_title), withText("Talk"), withParent(withParent(withId(R.id.recycler_view))), isDisplayed())) + .check(matches(withText("Talk"))) + + onView(allOf(withId(R.id.item_title), withText("User talk"), withParent(withParent(withId(R.id.recycler_view))), isDisplayed())) + .check(matches(withText("User talk"))) + + onView(allOf(withId(R.id.item_title), withText("User"), withParent(withParent(withId(R.id.recycler_view))), isDisplayed())) + .check(matches(withText("User"))) + + TestUtil.delay(2) + + // Navigate back to se tasks screen + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on one of the contributions + onView(allOf(withId(R.id.user_contrib_recycler), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + onView(allOf(withContentDescription("Navigate up"), isDisplayed())) + .perform(click()) + + TestUtil.delay(2) + + // Click on `Add description` task + onView(allOf(withId(R.id.tasksRecyclerView), childAtPosition(withId(R.id.tasksContainer), 2))) + .perform(actionOnItemAtPosition(0, click())) + + TestUtil.delay(2) + + // Assert the presence of correct action button + onView(allOf(withId(R.id.addContributionButton), withText("Add description"), + withParent(allOf(withId(R.id.bottomButtonContainer))), isDisplayed())) + .check(matches(isDisplayed())) + + TestUtil.delay(2) + + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Assert `Translate` button leading to add languages screen when there is only one language + onView(allOf(withId(R.id.secondaryButton), withText("Translate"), + withContentDescription("Translate Article descriptions"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on add language button + onView(allOf(withId(R.id.wikipedia_languages_recycler), childAtPosition(withClassName(Matchers.`is`("android.widget.LinearLayout")), 1))) + .perform(actionOnItemAtPosition(2, click())) + + TestUtil.delay(2) + + // Select a language + onView(allOf(withId(R.id.languages_list_recycler), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Assert `Translate` button leading to translate description screen, when there is more than one language + val button = onView(allOf(withId(R.id.secondaryButton), withText("Translate"), withContentDescription("Translate Article descriptions"), + withParent(withParent(IsInstanceOf.instanceOf(androidx.cardview.widget.CardView::class.java))), isDisplayed())) + button.check(matches(isDisplayed())).perform(click()) + + // Assert the presence of correct action button text + onView(allOf(withId(R.id.addContributionButton), withText("Add translation"), + withParent(allOf(withId(R.id.bottomButtonContainer), withParent(IsInstanceOf.instanceOf(android.view.ViewGroup::class.java)))), isDisplayed())) + .check(matches(isDisplayed())) + + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Assertion of image caption translation task and subsequent action text + onView(allOf(withId(R.id.secondaryButton), withText("Translate"), + withContentDescription("Translate Image captions"), + childAtPosition(childAtPosition(withClassName(Matchers.`is`("org.wikipedia.suggestededits.SuggestedEditsTaskView")), 0), 6), isDisplayed())) + .perform(click()) + + onView(allOf(withId(R.id.addContributionButton), withText("Add translation"), + withParent(allOf(withId(R.id.bottomButtonContainer), withParent(IsInstanceOf.instanceOf(android.view.ViewGroup::class.java)))), isDisplayed())) + .check(matches(isDisplayed())) + + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on `Image captions` task + onView(allOf(withId(R.id.tasksRecyclerView), childAtPosition(withId(R.id.tasksContainer), 2))) + .perform(actionOnItemAtPosition(1, click())) + + TestUtil.delay(2) + + // Assert the presence of correct action button + onView(allOf(withId(R.id.addContributionButton), withText("Add caption"), withParent(allOf(withId(R.id.bottomButtonContainer))), isDisplayed())) + .check(matches(isDisplayed())) + + TestUtil.delay(2) + + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // CScroll to `Image tags` task + onView(allOf(withId(R.id.tasksRecyclerView), childAtPosition(withId(R.id.tasksContainer), 2))) + .perform(actionOnItemAtPosition(2, scrollTo())) + + TestUtil.delay(2) + + onView(allOf(withId(R.id.tasksRecyclerView), childAtPosition(withId(R.id.tasksContainer), 2))) + .perform(actionOnItemAtPosition(2, click())) + + TestUtil.delay(2) + + onView(allOf(withId(R.id.onboarding_done_button), withText("Get started"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Assert the presence of correct action button for image tags task + onView(allOf(withText("Add tag"), withParent(allOf(withId(R.id.tagsChipGroup))), isDisplayed())) + .check(matches(isDisplayed())) + + TestUtil.delay(2) + + onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Assert the presence of tutorial button + onView(allOf(withId(R.id.learnMoreButton), withText("Learn more"), + childAtPosition(allOf(withId(R.id.learnMoreCard)), 2))).perform(scrollTo()) + + TestUtil.delay(2) + + onView(allOf(withText("What is Suggested edits?"), withParent(allOf(withId(R.id.learnMoreCard))), isDisplayed())) + .check(matches(withText("What is Suggested edits?"))) + } + + TestUtil.delay(2) + + // Click on `More` menu + onView(allOf(withId(R.id.nav_more_container), withContentDescription("More"), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Click on `Settings` option + onView(allOf(withId(R.id.main_drawer_settings_container), isDisplayed())).perform(click()) + + TestUtil.delay(2) + + // Scroll to logOut option and click + onView(withId(androidx.preference.R.id.recycler_view)).perform(RecyclerViewActions.actionOnItem + (hasDescendant(withText(R.string.preference_title_logout)), click())) + + TestUtil.delay(2) + + onView(allOf(withText("Log out"), isDisplayed())).perform(scrollTo(), click()) + + TestUtil.delay(2) + + onView(allOf(withId(android.R.id.message), isDisplayed())) + .check(matches(withText("This will log you out on all devices where you are currently logged in. Do you want to continue?"))) + + TestUtil.delay(2) + } + private fun goToTop() { + onView(allOf(withId(R.id.feed_view))).perform(RecyclerViewActions.scrollToPosition(0)) TestUtil.delay(2) } companion object { - private val SEARCH_TERM = "hopf fibration" - private val ARTICLE_TITLE = "Hopf fibration" + private const val SEARCH_TERM = "hopf fibration" + private const val ARTICLE_TITLE = "Hopf fibration" + private const val ARTICLE_TITLE_ESPANOL = "Fibración de Hopf" } } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml deleted file mode 100644 index 9e2b3eb9b7b..00000000000 --- a/app/src/debug/AndroidManifest.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - diff --git a/app/src/debug/res/xml/network_security_config.xml b/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 00000000000..6fe35f0c0f5 --- /dev/null +++ b/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt b/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt new file mode 100644 index 00000000000..c1e12df6c16 --- /dev/null +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt @@ -0,0 +1,10 @@ +package org.wikipedia.donate + +import android.os.Bundle +import org.wikipedia.activity.BaseActivity + +class GooglePayActivity : BaseActivity() { + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + } +} diff --git a/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt b/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt new file mode 100644 index 00000000000..e807f0fff05 --- /dev/null +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt @@ -0,0 +1,12 @@ +package org.wikipedia.donate + +import android.app.Activity + +object GooglePayComponent { + suspend fun isGooglePayAvailable(activity: Activity): Boolean { + return false + } + + fun onGooglePayButtonClicked(activity: Activity) { + } +} diff --git a/app/src/fdroid/AndroidManifest.xml b/app/src/fdroid/AndroidManifest.xml index 235a93bb0ff..e5bcba74239 100644 --- a/app/src/fdroid/AndroidManifest.xml +++ b/app/src/fdroid/AndroidManifest.xml @@ -7,10 +7,17 @@ android:value="F-Droid" tools:replace="android:value" /> + + + + diff --git a/app/src/fdroid/java/org/wikipedia/donate/GooglePayComponent.kt b/app/src/fdroid/java/org/wikipedia/donate/GooglePayComponent.kt new file mode 100644 index 00000000000..e807f0fff05 --- /dev/null +++ b/app/src/fdroid/java/org/wikipedia/donate/GooglePayComponent.kt @@ -0,0 +1,12 @@ +package org.wikipedia.donate + +import android.app.Activity + +object GooglePayComponent { + suspend fun isGooglePayAvailable(activity: Activity): Boolean { + return false + } + + fun onGooglePayButtonClicked(activity: Activity) { + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f8f1a3d5b8a..d7677e4ab57 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,6 +37,9 @@ + + + + + + @@ -300,7 +308,8 @@ android:name=".talk.TalkTopicActivity" /> + android:name=".talk.TalkReplyActivity" + android:configChanges="orientation|screenSize"/> @@ -310,6 +319,7 @@ + + + + + + + android:name=".donate.GooglePayActivity"/> =", + "admin_level", + 4 + ], + [ + "<=", + "admin_level", + 8 + ], + [ + "!=", + "maritime", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#27272a", + "line-dasharray": [ + 3, + 1, + 1, + 1 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 0.4 + ], + [ + 5, + 1 + ], + [ + 12, + 3 + ] + ] + } + } + }, + { + "id": "admin-land-level-2", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "!=", + "maritime", + 1 + ], + [ + "!=", + "disputed", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 33%)", + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "admin-land-disputed", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "filter": [ + "all", + [ + "!=", + "maritime", + 1 + ], + [ + "==", + "disputed", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 35%)", + "line-dasharray": [ + 1, + 3 + ], + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "admin-water", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "admin_level", + 2, + 4 + ], + [ + "==", + "maritime", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#28323c", + "line-opacity": { + "stops": [ + [ + 6, + 0.6 + ], + [ + 10, + 1 + ] + ] + }, + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "waterway-name", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "waterway", + "minzoom": 13, + "filter": [ + "all", + [ + "has", + "name" + ] + ], + "layout": { + "text-size": 14, + "symbol-spacing": 350, + "text-font": [ + "Open Sans Italic" + ], + "symbol-placement": "line", + "visibility": "none", + "text-rotation-alignment": "map", + "text-field": "{name:latin} {name:nonlatin}", + "text-letter-spacing": 0.2, + "text-max-width": 5 + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "poi-level-3", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 15, + "filter": [ + "all", + [ + ">=", + "scalerank", + 3 + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": { + "stops": [ + [ + 15, + "{maki}-12" + ], + [ + 16, + "{maki}-18" + ] + ] + }, + "visibility": "visible" + } + }, + { + "id": "poi-level-2", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "scalerank", + 2 + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{maki}-18", + "visibility": "visible" + } + }, + { + "id": "poi-level-1", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 13, + "filter": [ + "all", + [ + "<=", + "scalerank", + 1 + ], + [ + "has", + "name" + ], + [ + "has", + "maki" + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{maki}-18", + "visibility": "visible" + } + }, + { + "id": "poi-label", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 13, + "filter": [ + "all", + [ + "<=", + "scalerank", + 1 + ], + [ + "has", + "name" + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "text-size": 12, + "icon-image": "{maki}-12", + "text-font": [ + "Open Sans Regular" + ], + "text-padding": 2, + "visibility": "none", + "text-offset": [ + 0, + 0.6 + ], + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-max-width": 9 + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 0.5, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1 + } + }, + { + "id": "poi-railway", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 13, + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "class", + "railway" + ], + [ + "==", + "subclass", + "station" + ] + ], + "layout": { + "text-optional": true, + "text-size": 12, + "text-allow-overlap": false, + "icon-image": "{class}_11", + "text-ignore-placement": false, + "text-font": [ + "Open Sans Regular" + ], + "icon-allow-overlap": false, + "text-padding": 2, + "visibility": "none", + "text-offset": [ + 0, + 0.6 + ], + "icon-optional": false, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-max-width": 9, + "icon-ignore-placement": false + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 0.5, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1 + } + }, + { + "id": "road-oneway", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "oneway", + 1 + ], + [ + "in", + "class", + "motorway", + "trunk", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": 90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "visibility": "none" + }, + "paint": { + "icon-opacity": 0.5 + } + }, + { + "id": "road-oneway-opposite", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "oneway", + -1 + ], + [ + "in", + "class", + "motorway", + "trunk", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": -90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "visibility": "none" + }, + "paint": { + "icon-opacity": 0.5 + } + }, + { + "id": "highway-name-path", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 15.5, + "filter": [ + "==", + "class", + "path" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Open Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + }, + "visibility": "none" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 0.5 + } + }, + { + "id": "highway-name-minor", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 15, + "filter": [ + "in", + "class", + "minor", + "service", + "track", + "street" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + }, + "visibility": "none" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-name-major", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 12.2, + "filter": [ + "in", + "class", + "main", + "secondary", + "tertiary", + "trunk" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Open Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + }, + "visibility": "none" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "road-name-generic", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 12.2, + "layout": { + "symbol-placement": "line", + "text-font": [ + "Open Sans Regular" + ], + "text-field": "{name}", + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-shield", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 8, + "filter": [ + "all", + [ + "<=", + "reflen", + 6 + ] + ], + "layout": { + "text-size": 10, + "icon-image": "{shield}-{reflen}", + "icon-rotation-alignment": "viewport", + "symbol-spacing": 200, + "text-font": [ + "Open Sans Regular" + ], + "symbol-placement": { + "base": 1, + "stops": [ + [ + 10, + "point" + ], + [ + 11, + "line" + ] + ] + }, + "visibility": "visible", + "text-rotation-alignment": "viewport", + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 14, + 1.23 + ], + [ + 16, + 1.23 + ] + ] + }, + "text-field": "{ref}" + }, + "paint": {} + }, + { + "id": "place-other", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "filter": [ + "!in", + "type", + "city", + "town", + "village", + "country", + "continent" + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 15, + 14 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-village", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "filter": [ + "==", + "type", + "village" + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 12 + ], + [ + 15, + 22 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-town", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "filter": [ + "==", + "type", + "town" + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 14 + ], + [ + 15, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "filter": [ + "==", + "type", + "city" + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 7, + 14 + ], + [ + 11, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1.2, + "text-translate": [ + 20, + 10 + ] + } + }, + { + "minzoom": 3, + "layout": { + "visibility": "visible" + }, + "maxzoom": 7, + "filter": [ + "==", + "type", + "city" + ], + "type": "circle", + "source": "wikimaptiles", + "id": "place-city-circle", + "paint": { + "circle-color": "#3f3f3f", + "circle-radius": 1.5, + "circle-stroke-color": "#0c0c0c", + "circle-stroke-width": 1, + "circle-opacity": 0 + }, + "source-layer": "place_label" + }, + { + "id": "place-country-1", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 1 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 9 + ], + [ + 2, + 12 + ], + [ + 3, + 14 + ], + [ + 4, + 20 + ], + [ + 5, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 1, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-2", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 2 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 10 + ], + [ + 2, + 11 + ], + [ + 3, + 13 + ], + [ + 4, + 17 + ], + [ + 5, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 1, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-3", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 3 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 2, + 9 + ], + [ + 3, + 11 + ], + [ + 4, + 15 + ], + [ + 5, + 17 + ], + [ + 6, + 18 + ], + [ + 7, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 1, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-4", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 4 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 9 + ], + [ + 3, + 9 + ], + [ + 4, + 13 + ], + [ + 5, + 15 + ], + [ + 6, + 16 + ], + [ + 7, + 18 + ], + [ + 8, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 1, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-5", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 5 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 9 + ], + [ + 3, + 9 + ], + [ + 4, + 11 + ], + [ + 5, + 13 + ], + [ + 6, + 14 + ], + [ + 7, + 16 + ], + [ + 8, + 18 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 1, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-6", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + ">=", + "scalerank", + 6 + ], + "layout": { + "text-field": { + "stops": [ + [ + 1, + "{code}" + ], + [ + 3, + "{name}" + ] + ] + }, + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 9 + ], + [ + 5, + 9 + ], + [ + 6, + 12 + ], + [ + 7, + 14 + ], + [ + 8, + 16 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 1, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 2 + } + }, + { + "id": "landuse-generic", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "landuse", + "paint": { + "fill-color": "#192a11" + }, + "layout": { + "visibility": "none" + } + }, + { + "id": "highway-generic", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 0, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "none" + }, + "paint": { + "line-color": "#3a2b1d", + "line-opacity": 1, + "line-width": 4 + } + }, + { + "id": "water", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "water", + "filter": [ + "all", + [ + "!=", + "intermittent", + 1 + ], + [ + "!=", + "is", + "tunnel" + ] + ], + "layout": { + "visibility": "none" + }, + "paint": { + "fill-color": "#2d343b" + } + }, + { + "id": "road-label-point", + "source": "wikimaptiles", + "type": "circle", + "source-layer": "road_label", + "layout": { + "visibility": "none" + } + }, + { + "id": "admin-generic", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "minzoom": 0, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "none" + }, + "filter": [ + "has", + "maritime" + ], + "paint": { + "line-color": "#0c0c0c", + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 1 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "place-generic", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": 10, + "visibility": "none" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1.2 + } + }, + { + "id": "country-generic", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": 10, + "visibility": "none" + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1.2 + } + }, + { + "id": "poi-level-generic", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "filter": [ + "all", + [ + "==", + "scalerank", + 1 + ] + ], + "layout": { + "text-size": 12, + "icon-image": "aerialway-12", + "text-font": [ + "Open Sans Regular" + ], + "text-padding": 2, + "visibility": "none", + "text-offset": [ + 0, + 0.6 + ], + "text-anchor": "top", + "text-field": "{name}", + "text-max-width": 9 + }, + "paint": { + "text-color": "#c0c0c0", + "text-halo-blur": 0.5, + "text-halo-color": "rgba(0,0,0,0.7)", + "text-halo-width": 1 + } + } + ], + "sprite": "https://maps.wikimedia.org/static/webgl/wikisprites", + "glyphs": "https://maps.wikimedia.org/static/webgl/font/{fontstack}/{range}.pbf", + "name": "wikimedia-bright7", + "bearing": 0, + "zoom": 1, + "center": [ + 0, + 0 + ], + "version": 8, + "sources": { + "wikimaptiles": { + "type": "vector", + "url": "https://maps.wikimedia.org/osm-pbf/info.json" + } + }, + "id": "97c64ffb-2e86-4baf-845b-2b9b0980d364" +} \ No newline at end of file diff --git a/app/src/main/assets/mapstyle.json b/app/src/main/assets/mapstyle.json new file mode 100644 index 00000000000..784845bdc39 --- /dev/null +++ b/app/src/main/assets/mapstyle.json @@ -0,0 +1,4589 @@ +{ + "pitch": 0, + "layers": [ + { + "id": "background", + "type": "background", + "paint": { + "background-color": "#f8f4f0" + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "landuse-cemetery", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "cemetery" + ], + "paint": { + "fill-color": "#e0e4dd" + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "landuse-hospital", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "hospital" + ], + "paint": { + "fill-color": "#fde" + } + }, + { + "id": "landuse-school", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "school" + ], + "paint": { + "fill-color": "#f0e8f8" + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "landuse-park", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "park" + ], + "paint": { + "fill-color": "#d8e8c8", + "fill-opacity": 1 + } + }, + { + "id": "landuse-wood", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "landuse", + "filter": [ + "==", + "class", + "wood" + ], + "paint": { + "fill-antialias": { + "base": 1, + "stops": [ + [ + 0, + false + ], + [ + 9, + true + ] + ] + }, + "fill-color": "#6a4", + "fill-opacity": 0.1, + "fill-outline-color": "hsla(0, 0%, 0%, 0.03)" + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "waterway_tunnel", + "type": "line", + "source": "wikimaptiles", + "source-layer": "waterway", + "minzoom": 14, + "filter": [ + "all", + [ + "in", + "class", + "river", + "stream", + "canal" + ], + [ + "==", + "is", + "tunnel" + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [ + 2, + 4 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-other", + "type": "line", + "source": "wikimaptiles", + "source-layer": "waterway", + "filter": [ + "all", + [ + "!in", + "class", + "canal", + "river", + "stream" + ], + [ + "==", + "intermittent", + 0 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "waterway-other-intermittent", + "type": "line", + "source": "wikimaptiles", + "source-layer": "waterway", + "filter": [ + "all", + [ + "!in", + "class", + "canal", + "river", + "stream" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [ + 4, + 3 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "waterway-stream-canal", + "type": "line", + "source": "wikimaptiles", + "source-layer": "waterway", + "filter": [ + "all", + [ + "in", + "class", + "canal", + "stream" + ], + [ + "!=", + "is", + "tunnel" + ], + [ + "==", + "intermittent", + 0 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-stream-canal-intermittent", + "type": "line", + "source": "wikimaptiles", + "source-layer": "waterway", + "filter": [ + "all", + [ + "in", + "class", + "canal", + "stream" + ], + [ + "!=", + "is", + "tunnel" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [ + 4, + 3 + ], + "line-width": { + "base": 1.3, + "stops": [ + [ + 13, + 0.5 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-river", + "type": "line", + "source": "wikimaptiles", + "source-layer": "waterway", + "filter": [ + "all", + [ + "==", + "class", + "river" + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.8 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "waterway-river-intermittent", + "type": "line", + "source": "wikimaptiles", + "source-layer": "waterway", + "filter": [ + "all", + [ + "==", + "class", + "river" + ], + [ + "!=", + "is", + "tunnel" + ], + [ + "==", + "intermittent", + 1 + ] + ], + "layout": { + "line-cap": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-dasharray": [ + 3, + 2.5 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 10, + 0.8 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "water-offset", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "water", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#a0c8f0", + "fill-opacity": 1, + "fill-translate": { + "stops": [ + [ + 0, + [ + 0, + -1.5 + ] + ], + [ + 12, + [ + 0, + -2 + ] + ] + ] + } + } + }, + { + "id": "water-area", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "water", + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#b6d2ec" + } + }, + { + "id": "water-pattern", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "water", + "filter": [ + "all" + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-pattern": "wave", + "fill-translate": [ + 0, + 2.5 + ], + "fill-opacity": 0.4 + } + }, + { + "id": "aeroway", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "type", + "runway", + "taxiway", + "apron" + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "fill-color": "#f3efeb" + } + }, + { + "id": "building-shadow", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "building", + "layout": { + "visibility": "visible" + }, + "minzoom": 17, + "paint": { + "fill-color": "#dfdbd7", + "fill-translate": [ + 1, + 2 + ] + } + }, + { + "id": "building", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "building", + "paint": { + "fill-color": "#dd9390", + "fill-opacity": 0.1 + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "building_outline", + "type": "line", + "source": "wikimaptiles", + "source-layer": "building", + "paint": { + "line-color": "#dfdbd7", + "line-width": 0.5 + }, + "minzoom": 17, + "layout": { + "visibility": "visible" + } + }, + { + "id": "tunnel-service-track-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "in", + "class", + "service", + "track" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 16, + 4 + ], + [ + 20, + 11 + ] + ] + } + } + }, + { + "id": "tunnel-minor-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "==", + "class", + "minor" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 1.5 + ], + [ + 20, + 17 + ] + ] + } + } + }, + { + "id": "tunnel-trunk-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "main" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "tunnel-motorway-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-dasharray": [ + 0.5, + 0.25 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "tunnel-path", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "==", + "class", + "path" + ] + ] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "tunnel-service-track", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "in", + "class", + "service", + "track" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15.5, + 0 + ], + [ + 16, + 2 + ], + [ + 20, + 7.5 + ] + ] + } + } + }, + { + "id": "tunnel-minor", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "==", + "class", + "minor_road" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "tunnel-secondary-tertiary", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 10 + ] + ] + } + } + }, + { + "id": "tunnel-trunk", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "in", + "class", + "trunk", + "main" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fff4c6", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "tunnel-motorway", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#ffdaa6", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "tunnel-railway", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "tunnel" + ], + [ + "in", + "class", + "rail", + "major_rail" + ] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [ + 2, + 2 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + }, + "layout": { + "visibility": "visible" + } + }, + { + "id": "aeroway-taxiway", + "type": "line", + "source": "wikimaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "type", + "taxiway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#eeeae6", + "line-width": { + "base": 1.5, + "stops": [ + [ + 11, + 1 + ], + [ + 15, + 2 + ] + ] + } + } + }, + { + "id": "aeroway-runway", + "type": "line", + "source": "wikimaptiles", + "source-layer": "aeroway", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "type", + "runway" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#eeeae6", + "line-width": 5 + } + }, + { + "id": "road-pier", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "in", + "class", + "pier" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#f8f4f0", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1 + ], + [ + 17, + 4 + ] + ] + } + } + }, + { + "id": "highway-motorway-link-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 12, + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway_link" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-link-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-minor-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!=", + "is", + "tunnel" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#cfcdca", + "line-width": { + "base": 1.2, + "stops": [ + [ + 13, + 1 + ], + [ + 14.9, + 1 + ], + [ + 15, + 4 + ], + [ + 16, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-street_limited-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!=", + "is", + "tunnel" + ], + [ + "in", + "class", + "street_limited" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-opacity": { + "stops": [ + [ + 12, + 0 + ], + [ + 12.5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-street-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!=", + "is", + "tunnel" + ], + [ + "in", + "class", + "street" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#cfcdca", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0.5 + ], + [ + 13, + 1.3 + ], + [ + 13.99, + 4 + ], + [ + 14, + 4.5 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "highway-secondary-tertiary-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 1.5 + ], + [ + 20, + 17 + ] + ] + } + } + }, + { + "id": "highway-main-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 5, + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "main" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "stops": [ + [ + 5, + 0.2 + ], + [ + 6, + 0.4 + ], + [ + 7, + 1.5 + ], + [ + 9, + 2.4 + ], + [ + 12, + 2.5 + ], + [ + 13, + 4 + ], + [ + 14, + 5 + ], + [ + 15, + 8 + ] + ] + } + } + }, + { + "id": "highway-trunk-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 5, + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [ + 5, + 0 + ], + [ + 6, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "highway-motorway-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 4, + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "line-cap": "butt", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [ + 4, + 0 + ], + [ + 5, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 4, + 0 + ], + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "highway-path", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "path" + ] + ] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "highway-motorway-link", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 12, + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway_link" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-link", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 13, + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-minor", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!=", + "is", + "tunnel" + ], + [ + "in", + "class", + "minor", + "service", + "track" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "stops": [ + [ + 14.99, + 0 + ], + [ + 15, + 2.5 + ], + [ + 17, + 3 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-street_limited", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!=", + "is", + "tunnel" + ], + [ + "in", + "class", + "street_limited" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#eeeeed", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 13.5, + 0 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-street", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!=", + "is", + "tunnel" + ], + [ + "in", + "class", + "street" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round" + }, + "paint": { + "line-color": "#fff", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 0 + ], + [ + 13, + 0 + ], + [ + 13.99, + 2.5 + ], + [ + 14, + 3 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "highway-secondary-tertiary", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 8, + 0.5 + ], + [ + 20, + 13 + ] + ] + } + } + }, + { + "id": "highway-main", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 7, + "filter": [ + "all", + [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "main" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "stops": [ + [ + 7, + 0.5 + ], + [ + 9, + 1 + ], + [ + 12, + 1.5 + ], + [ + 13, + 2.5 + ], + [ + 14, + 3.5 + ], + [ + 15, + 6 + ] + ] + } + } + }, + { + "id": "highway-trunk", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "in", + "class", + "trunk" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "highway-motorway", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 5, + "filter": [ + "all", + [ + "all", + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "motorway" + ] + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "railway-transit", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "==", + "class", + "transit" + ], + [ + "!in", + "is", + "tunnel" + ] + ] + ], + "layout": { + "visibility": "none" + }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "railway-transit-hatch", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "==", + "class", + "transit" + ], + [ + "!in", + "is", + "tunnel" + ] + ] + ], + "layout": { + "visibility": "none" + }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 2 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "railway-service", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "==", + "class", + "major_rail" + ], + [ + "has", + "service" + ] + ] + ], + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.77)", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 20, + 1 + ] + ] + } + } + }, + { + "id": "railway-service-hatching", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "==", + "class", + "major_rail" + ], + [ + "has", + "service" + ] + ] + ], + "layout": { + "visibility": "visible" + }, + "paint": { + "line-color": "hsla(0, 0%, 73%, 0.68)", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 2 + ], + [ + 20, + 6 + ] + ] + } + } + }, + { + "id": "railway", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!has", + "service" + ], + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "major_rail" + ] + ] + ], + "paint": { + "line-color": "#bbb", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "railway-hatch", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "!has", + "service" + ], + [ + "!in", + "is", + "bridge", + "tunnel" + ], + [ + "==", + "class", + "major_rail" + ] + ] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + } + }, + { + "id": "bridge-motorway-link-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "==", + "class", + "motorway_link" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "bridge-link-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 12, + 1 + ], + [ + 13, + 3 + ], + [ + 14, + 4 + ], + [ + 20, + 15 + ] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": { + "base": 1.2, + "stops": [ + [ + 8, + 1.5 + ], + [ + 20, + 28 + ] + ] + } + } + }, + { + "id": "bridge-trunk-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "in", + "class", + "trunk", + "main" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": { + "stops": [ + [ + 7, + 0 + ], + [ + 8, + 1 + ] + ] + }, + "line-width": { + "base": 1.2, + "stops": [ + [ + 7, + 0 + ], + [ + 8, + 0.6 + ], + [ + 9, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "bridge-motorway-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#e9ac77", + "line-width": { + "base": 1.2, + "stops": [ + [ + 5, + 0.4 + ], + [ + 6, + 0.6 + ], + [ + 7, + 1.5 + ], + [ + 20, + 22 + ] + ] + } + } + }, + { + "id": "bridge-path-casing", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "==", + "class", + "path" + ] + ] + ], + "paint": { + "line-color": "#f8f4f0", + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-path", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "==", + "class", + "path" + ] + ] + ], + "paint": { + "line-color": "#cba", + "line-dasharray": [ + 1.5, + 0.75 + ], + "line-width": { + "base": 1.2, + "stops": [ + [ + 15, + 1.2 + ], + [ + 20, + 4 + ] + ] + } + } + }, + { + "id": "bridge-motorway-link", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "==", + "class", + "motorway_link" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "bridge-link", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "in", + "class", + "primary_link", + "secondary_link", + "tertiary_link", + "trunk_link" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 12.5, + 0 + ], + [ + 13, + 1.5 + ], + [ + 14, + 2.5 + ], + [ + 20, + 11.5 + ] + ] + } + } + }, + { + "id": "bridge-secondary-tertiary", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "in", + "class", + "secondary", + "tertiary" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 20 + ] + ] + } + } + }, + { + "id": "bridge-trunk", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "in", + "class", + "trunk", + "main" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fea", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-motorway", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "==", + "class", + "motorway" + ] + ], + "layout": { + "line-join": "round" + }, + "paint": { + "line-color": "#fc8", + "line-width": { + "base": 1.2, + "stops": [ + [ + 6.5, + 0 + ], + [ + 7, + 0.5 + ], + [ + 20, + 18 + ] + ] + } + } + }, + { + "id": "bridge-railway", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#bbb", + "line-width": { + "base": 1.4, + "stops": [ + [ + 14, + 0.4 + ], + [ + 15, + 0.75 + ], + [ + 20, + 2 + ] + ] + } + } + }, + { + "id": "bridge-railway-hatching", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "filter": [ + "all", + [ + "==", + "is", + "bridge" + ], + [ + "==", + "class", + "rail" + ] + ], + "paint": { + "line-color": "#bbb", + "line-dasharray": [ + 0.2, + 8 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 14.5, + 0 + ], + [ + 15, + 3 + ], + [ + 20, + 8 + ] + ] + } + } + }, + { + "id": "admin-land-level-4", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "filter": [ + "all", + [ + ">=", + "admin_level", + 4 + ], + [ + "<=", + "admin_level", + 8 + ], + [ + "!=", + "maritime", + 1 + ] + ], + "layout": { + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#9e9cab", + "line-dasharray": [ + 3, + 1, + 1, + 1 + ], + "line-width": { + "base": 1.4, + "stops": [ + [ + 4, + 0.4 + ], + [ + 5, + 1 + ], + [ + 12, + 3 + ] + ] + } + } + }, + { + "id": "admin-land-level-2", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "filter": [ + "all", + [ + "==", + "admin_level", + 2 + ], + [ + "!=", + "maritime", + 1 + ], + [ + "!=", + "disputed", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 66%)", + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "admin-land-disputed", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "filter": [ + "all", + [ + "!=", + "maritime", + 1 + ], + [ + "==", + "disputed", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "hsl(248, 7%, 70%)", + "line-dasharray": [ + 1, + 3 + ], + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "admin-water", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "minzoom": 4, + "filter": [ + "all", + [ + "in", + "admin_level", + 2, + 4 + ], + [ + "==", + "maritime", + 1 + ] + ], + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "visible" + }, + "paint": { + "line-color": "#a0c8f0", + "line-opacity": { + "stops": [ + [ + 6, + 0.6 + ], + [ + 10, + 1 + ] + ] + }, + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 0.6 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "waterway-name", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "waterway", + "minzoom": 13, + "filter": [ + "all", + [ + "has", + "name" + ] + ], + "layout": { + "text-size": 14, + "symbol-spacing": 350, + "text-font": [ + "Open Sans Italic" + ], + "symbol-placement": "line", + "visibility": "none", + "text-rotation-alignment": "map", + "text-field": "{name:latin} {name:nonlatin}", + "text-letter-spacing": 0.2, + "text-max-width": 5 + }, + "paint": { + "text-color": "#74aee9", + "text-halo-color": "rgba(255,255,255,0.7)", + "text-halo-width": 1.5 + } + }, + { + "id": "poi-level-3", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 15, + "filter": [ + "all", + [ + ">=", + "scalerank", + 3 + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": { + "stops": [ + [ + 15, + "{maki}-12" + ], + [ + 16, + "{maki}-18" + ] + ] + }, + "visibility": "visible" + } + }, + { + "id": "poi-level-2", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 14, + "filter": [ + "all", + [ + "==", + "scalerank", + 2 + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{maki}-18", + "visibility": "visible" + } + }, + { + "id": "poi-level-1", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 13, + "filter": [ + "all", + [ + "<=", + "scalerank", + 1 + ], + [ + "has", + "name" + ], + [ + "has", + "maki" + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "icon-image": "{maki}-18", + "visibility": "visible" + } + }, + { + "id": "poi-label", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 13, + "filter": [ + "all", + [ + "<=", + "scalerank", + 1 + ], + [ + "has", + "name" + ], + [ + "any", + [ + "!has", + "level" + ], + [ + "==", + "level", + 0 + ] + ] + ], + "layout": { + "text-size": 12, + "icon-image": "{maki}-12", + "text-font": [ + "Open Sans Regular" + ], + "text-padding": 2, + "visibility": "none", + "text-offset": [ + 0, + 0.6 + ], + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-max-width": 9 + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "poi-railway", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "minzoom": 13, + "filter": [ + "all", + [ + "has", + "name" + ], + [ + "==", + "class", + "railway" + ], + [ + "==", + "subclass", + "station" + ] + ], + "layout": { + "text-optional": true, + "text-size": 12, + "text-allow-overlap": false, + "icon-image": "{class}_11", + "text-ignore-placement": false, + "text-font": [ + "Open Sans Regular" + ], + "icon-allow-overlap": false, + "text-padding": 2, + "visibility": "none", + "text-offset": [ + 0, + 0.6 + ], + "icon-optional": false, + "text-anchor": "top", + "text-field": "{name:latin}\n{name:nonlatin}", + "text-max-width": 9, + "icon-ignore-placement": false + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + }, + { + "id": "road-oneway", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "oneway", + 1 + ], + [ + "in", + "class", + "motorway", + "trunk", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": 90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "visibility": "none" + }, + "paint": { + "icon-opacity": 0.5 + } + }, + { + "id": "road-oneway-opposite", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 15, + "filter": [ + "all", + [ + "==", + "oneway", + -1 + ], + [ + "in", + "class", + "motorway", + "trunk", + "secondary", + "tertiary", + "minor", + "service" + ] + ], + "layout": { + "icon-image": "oneway", + "icon-padding": 2, + "icon-rotate": -90, + "icon-rotation-alignment": "map", + "icon-size": { + "stops": [ + [ + 15, + 0.5 + ], + [ + 19, + 1 + ] + ] + }, + "symbol-placement": "line", + "symbol-spacing": 75, + "visibility": "none" + }, + "paint": { + "icon-opacity": 0.5 + } + }, + { + "id": "highway-name-path", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 15.5, + "filter": [ + "==", + "class", + "path" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Open Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + }, + "visibility": "none" + }, + "paint": { + "text-color": "hsl(30, 23%, 62%)", + "text-halo-color": "#f8f4f0", + "text-halo-width": 0.5 + } + }, + { + "id": "highway-name-minor", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 15, + "filter": [ + "in", + "class", + "minor", + "service", + "track", + "street" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + }, + "visibility": "none" + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-name-major", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 12.2, + "filter": [ + "in", + "class", + "main", + "secondary", + "tertiary", + "trunk" + ], + "layout": { + "symbol-placement": "line", + "text-field": "{name:latin} {name:nonlatin}", + "text-font": [ + "Open Sans Regular" + ], + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + }, + "visibility": "none" + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "road-name-generic", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 12.2, + "layout": { + "symbol-placement": "line", + "text-font": [ + "Open Sans Regular" + ], + "text-field": "{name}", + "text-rotation-alignment": "map", + "text-size": { + "base": 1, + "stops": [ + [ + 13, + 12 + ], + [ + 14, + 13 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#765", + "text-halo-blur": 0.5, + "text-halo-width": 1 + } + }, + { + "id": "highway-shield", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "road_label", + "minzoom": 8, + "filter": [ + "all", + [ + "<=", + "reflen", + 6 + ] + ], + "layout": { + "text-size": 10, + "icon-image": "{shield}-{reflen}", + "icon-rotation-alignment": "viewport", + "symbol-spacing": 200, + "text-font": [ + "Open Sans Regular" + ], + "symbol-placement": { + "base": 1, + "stops": [ + [ + 10, + "point" + ], + [ + 11, + "line" + ] + ] + }, + "visibility": "visible", + "text-rotation-alignment": "viewport", + "icon-size": { + "stops": [ + [ + 11, + 1 + ], + [ + 14, + 1.23 + ], + [ + 16, + 1.23 + ] + ] + }, + "text-field": "{ref}" + }, + "paint": {} + }, + { + "id": "place-other", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "filter": [ + "!in", + "type", + "city", + "town", + "village", + "country", + "continent" + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-letter-spacing": 0.1, + "text-max-width": 9, + "text-size": { + "base": 1.2, + "stops": [ + [ + 12, + 10 + ], + [ + 15, + 14 + ] + ] + }, + "text-transform": "uppercase", + "visibility": "visible" + }, + "paint": { + "text-color": "#633", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-village", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "filter": [ + "==", + "type", + "village" + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 12 + ], + [ + 15, + 22 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-town", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "filter": [ + "==", + "type", + "town" + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 10, + 14 + ], + [ + 15, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "place-city", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "filter": [ + "==", + "type", + "city" + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": { + "base": 1.2, + "stops": [ + [ + 7, + 14 + ], + [ + 11, + 24 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2, + "text-translate": [ + 20, + 10 + ] + } + }, + { + "minzoom": 3, + "layout": { + "visibility": "visible" + }, + "maxzoom": 7, + "filter": [ + "==", + "type", + "city" + ], + "type": "circle", + "source": "wikimaptiles", + "id": "place-city-circle", + "paint": { + "circle-color": "#fff", + "circle-radius": 1.5, + "circle-stroke-color": "#333", + "circle-stroke-width": 1, + "circle-opacity": 0 + }, + "source-layer": "place_label" + }, + { + "id": "place-country-1", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 1 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 9 + ], + [ + 2, + 12 + ], + [ + 3, + 14 + ], + [ + 4, + 20 + ], + [ + 5, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-2", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 2 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 10 + ], + [ + 2, + 11 + ], + [ + 3, + 13 + ], + [ + 4, + 17 + ], + [ + 5, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-3", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 3 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 2, + 9 + ], + [ + 3, + 11 + ], + [ + 4, + 15 + ], + [ + 5, + 17 + ], + [ + 6, + 18 + ], + [ + 7, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-4", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 4 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 9 + ], + [ + 3, + 9 + ], + [ + 4, + 13 + ], + [ + 5, + 15 + ], + [ + 6, + 16 + ], + [ + 7, + 18 + ], + [ + 8, + 20 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-5", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + "==", + "scalerank", + 5 + ], + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 9 + ], + [ + 3, + 9 + ], + [ + 4, + 11 + ], + [ + 5, + 13 + ], + [ + 6, + 14 + ], + [ + 7, + 16 + ], + [ + 8, + 18 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "place-country-6", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "filter": [ + ">=", + "scalerank", + 6 + ], + "layout": { + "text-field": { + "stops": [ + [ + 1, + "{code}" + ], + [ + 3, + "{name}" + ] + ] + }, + "text-font": [ + "Open Sans Bold" + ], + "text-max-width": 6.25, + "text-size": { + "stops": [ + [ + 1, + 9 + ], + [ + 5, + 9 + ], + [ + 6, + 12 + ], + [ + 7, + 14 + ], + [ + 8, + 16 + ] + ] + }, + "visibility": "visible" + }, + "paint": { + "text-color": "#334", + "text-halo-blur": 1, + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 2 + } + }, + { + "id": "landuse-generic", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "landuse", + "paint": { + "fill-color": "#6a4" + }, + "layout": { + "visibility": "none" + } + }, + { + "id": "highway-generic", + "type": "line", + "source": "wikimaptiles", + "source-layer": "road", + "minzoom": 0, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "none" + }, + "paint": { + "line-color": "#e9ac77", + "line-opacity": 1, + "line-width": 4 + } + }, + { + "id": "water", + "type": "fill", + "source": "wikimaptiles", + "source-layer": "water", + "filter": [ + "all", + [ + "!=", + "intermittent", + 1 + ], + [ + "!=", + "is", + "tunnel" + ] + ], + "layout": { + "visibility": "none" + }, + "paint": { + "fill-color": "#b6d2ec" + } + }, + { + "id": "road-label-point", + "source": "wikimaptiles", + "type": "circle", + "source-layer": "road_label", + "layout": { + "visibility": "none" + } + }, + { + "id": "admin-generic", + "type": "line", + "source": "wikimaptiles", + "source-layer": "admin", + "minzoom": 0, + "layout": { + "line-cap": "round", + "line-join": "round", + "visibility": "none" + }, + "filter": [ + "has", + "maritime" + ], + "paint": { + "line-color": "#333", + "line-width": { + "base": 1, + "stops": [ + [ + 0, + 1 + ], + [ + 4, + 1.4 + ], + [ + 5, + 2 + ], + [ + 12, + 8 + ] + ] + } + } + }, + { + "id": "place-generic", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "place_label", + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": 10, + "visibility": "none" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "country-generic", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "country_label", + "layout": { + "text-field": "{name}", + "text-font": [ + "Open Sans Regular" + ], + "text-max-width": 8, + "text-size": 10, + "visibility": "none" + }, + "paint": { + "text-color": "#333", + "text-halo-color": "rgba(255,255,255,0.8)", + "text-halo-width": 1.2 + } + }, + { + "id": "poi-level-generic", + "type": "symbol", + "source": "wikimaptiles", + "source-layer": "poi_label", + "filter": [ + "all", + [ + "==", + "scalerank", + 1 + ] + ], + "layout": { + "text-size": 12, + "icon-image": "aerialway-12", + "text-font": [ + "Open Sans Regular" + ], + "text-padding": 2, + "visibility": "none", + "text-offset": [ + 0, + 0.6 + ], + "text-anchor": "top", + "text-field": "{name}", + "text-max-width": 9 + }, + "paint": { + "text-color": "#666", + "text-halo-blur": 0.5, + "text-halo-color": "#ffffff", + "text-halo-width": 1 + } + } + ], + "sprite": "https://maps.wikimedia.org/static/webgl/wikisprites", + "glyphs": "https://maps.wikimedia.org/static/webgl/font/{fontstack}/{range}.pbf", + "name": "wikimedia-bright7", + "bearing": 0, + "zoom": 1, + "center": [ + 0, + 0 + ], + "version": 8, + "sources": { + "wikimaptiles": { + "type": "vector", + "url": "https://maps.wikimedia.org/osm-pbf/info.json" + } + }, + "id": "97c64ffb-2e86-4baf-845b-2b9b0980d364" +} diff --git a/app/src/main/java/org/wikipedia/Constants.kt b/app/src/main/java/org/wikipedia/Constants.kt index c171ea9ede1..853215d92d2 100644 --- a/app/src/main/java/org/wikipedia/Constants.kt +++ b/app/src/main/java/org/wikipedia/Constants.kt @@ -11,7 +11,6 @@ object Constants { const val ACTIVITY_REQUEST_FEED_CONFIGURE = 58 const val ACTIVITY_REQUEST_GALLERY = 52 const val ACTIVITY_REQUEST_IMAGE_TAGS_ONBOARDING = 65 - const val ACTIVITY_REQUEST_LANGLINKS = 50 const val ACTIVITY_REQUEST_LOGIN = 53 const val ACTIVITY_REQUEST_OPEN_SEARCH_ACTIVITY = 62 const val ACTIVITY_REQUEST_SETTINGS = 41 @@ -19,6 +18,7 @@ object Constants { const val ARG_TITLE = "title" const val ARG_WIKISITE = "wikiSite" + const val ARG_TEXT = "text" const val INTENT_APP_SHORTCUT_CONTINUE_READING = "appShortcutContinueReading" const val INTENT_APP_SHORTCUT_RANDOMIZER = "appShortcutRandomizer" const val INTENT_APP_SHORTCUT_SEARCH = "appShortcutSearch" @@ -34,7 +34,6 @@ object Constants { const val INTENT_EXTRA_NOTIFICATION_SYNC_PAUSE_RESUME = "syncPauseResume" const val INTENT_EXTRA_NOTIFICATION_TYPE = "notificationType" const val INTENT_EXTRA_REVERT_QNUMBER = "revertQNumber" - const val INTENT_FEATURED_ARTICLE_FROM_WIDGET = "featuredArticleFromWidget" const val INTENT_RETURN_TO_MAIN = "returnToMain" const val MAX_READING_LIST_ARTICLE_LIMIT = 5000 @@ -51,6 +50,8 @@ object Constants { const val WIKI_CODE_WIKIDATA = "wikidata" const val WIKIDATA_DB_NAME = "wikidatawiki" + val NON_LANGUAGE_SUBDOMAINS = listOf("donate", "thankyou", "quote", "textbook", "sources", "species", "commons", "meta") + val commonsWikiSite = WikiSite(Service.COMMONS_URL) val wikidataWikiSite = WikiSite(Service.WIKIDATA_URL) @@ -86,6 +87,7 @@ object Constants { PAGE_EDIT_PENCIL("pageEditPencil"), PAGE_EDIT_HIGHLIGHT("pageEditHighlight"), PAGE_OVERFLOW_MENU("pageOverflowMenu"), + PLACES("places"), RANDOM_ACTIVITY("random"), READING_LIST_ACTIVITY("readingList"), SEARCH("search"), @@ -96,7 +98,6 @@ object Constants { TALK_TOPICS_ACTIVITY("talkTopicsActivity"), TALK_TOPIC_ACTIVITY("talkTopicActivity"), TALK_REPLY_ACTIVITY("talkReplyActivity"), - ADD_TEMPLATE_ACTIVITY("addTemplateActivity"), EDIT_ACTIVITY("editActivity"), TOOLBAR("toolbar"), VOICE("voice"), diff --git a/app/src/main/java/org/wikipedia/WikipediaApp.kt b/app/src/main/java/org/wikipedia/WikipediaApp.kt index e93648ef268..2b9c455fca4 100644 --- a/app/src/main/java/org/wikipedia/WikipediaApp.kt +++ b/app/src/main/java/org/wikipedia/WikipediaApp.kt @@ -10,7 +10,6 @@ import android.speech.RecognizerIntent import android.view.Window import android.webkit.WebView import androidx.appcompat.app.AppCompatDelegate -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.internal.functions.Functions import io.reactivex.rxjava3.plugins.RxJavaPlugins import io.reactivex.rxjava3.schedulers.Schedulers @@ -33,7 +32,6 @@ import org.wikipedia.notifications.NotificationPollBroadcastReceiver import org.wikipedia.page.tabs.Tab import org.wikipedia.push.WikipediaFirebaseMessagingService import org.wikipedia.settings.Prefs -import org.wikipedia.settings.SiteInfoClient import org.wikipedia.theme.Theme import org.wikipedia.util.DimenUtil import org.wikipedia.util.ReleaseUtil @@ -79,13 +77,7 @@ class WikipediaApp : Application() { } val appOrSystemLanguageCode: String - get() { - val code = languageState.appLanguageCode - if (AccountUtil.getUserIdForLanguage(code) == 0) { - getUserIdForLanguage(code) - } - return code - } + get() = languageState.appLanguageCode val versionCode: Int get() { @@ -114,10 +106,7 @@ class WikipediaApp : Application() { // TODO: why don't we ensure that the app language hasn't changed here instead of the client? if (defaultWikiSite == null) { val lang = if (Prefs.mediaWikiBaseUriSupportsLangCode) appOrSystemLanguageCode else "" - val newWiki = WikiSite.forLanguageCode(lang) - // Kick off a task to retrieve the site info for the current wiki - SiteInfoClient.updateFor(newWiki) - defaultWikiSite = newWiki + defaultWikiSite = WikiSite.forLanguageCode(lang) } return defaultWikiSite!! } @@ -282,25 +271,6 @@ class WikipediaApp : Application() { return result } - @SuppressLint("CheckResult") - private fun getUserIdForLanguage(code: String) { - if (!AccountUtil.isLoggedIn || AccountUtil.userName.isNullOrEmpty()) { - return - } - val wikiSite = WikiSite.forLanguageCode(code) - ServiceFactory.get(wikiSite).userInfo - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - if (AccountUtil.isLoggedIn && it.query!!.userInfo != null) { - // noinspection ConstantConditions - val id = it.query!!.userInfo!!.id - AccountUtil.putUserIdForLanguage(code, id) - L.d("Found user ID $id for $code") - } - }) { L.e("Failed to get user ID for $code", it) } - } - private fun initTabs() { if (Prefs.hasTabs) { tabList.addAll(Prefs.tabs) diff --git a/app/src/main/java/org/wikipedia/activity/BaseActivity.kt b/app/src/main/java/org/wikipedia/activity/BaseActivity.kt index 013dfe5c227..8ff190ff962 100644 --- a/app/src/main/java/org/wikipedia/activity/BaseActivity.kt +++ b/app/src/main/java/org/wikipedia/activity/BaseActivity.kt @@ -35,7 +35,6 @@ import org.wikipedia.readinglist.sync.ReadingListSyncEvent import org.wikipedia.recurring.RecurringTasksExecutor import org.wikipedia.richtext.CustomHtmlParser import org.wikipedia.settings.Prefs -import org.wikipedia.settings.SiteInfoClient import org.wikipedia.theme.Theme import org.wikipedia.util.DeviceUtil import org.wikipedia.util.FeedbackUtil @@ -232,7 +231,7 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba override fun accept(event: Any) { if (event is SplitLargeListsEvent) { MaterialAlertDialogBuilder(this@BaseActivity) - .setMessage(getString(R.string.split_reading_list_message, SiteInfoClient.maxPagesPerReadingList)) + .setMessage(getString(R.string.split_reading_list_message, Constants.MAX_READING_LIST_ARTICLE_LIMIT)) .setPositiveButton(R.string.reading_list_split_dialog_ok_button_text, null) .show() } else if (event is ReadingListsNoLongerSyncedEvent) { diff --git a/app/src/main/java/org/wikipedia/activity/SingleWebViewActivity.kt b/app/src/main/java/org/wikipedia/activity/SingleWebViewActivity.kt index 3f0cb238449..ddf295e4f9a 100644 --- a/app/src/main/java/org/wikipedia/activity/SingleWebViewActivity.kt +++ b/app/src/main/java/org/wikipedia/activity/SingleWebViewActivity.kt @@ -9,12 +9,18 @@ import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.ViewGroup +import android.webkit.CookieManager +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebView import androidx.core.view.isVisible +import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.bridge.JavaScriptActionHandler import org.wikipedia.databinding.ActivitySingleWebViewBinding +import org.wikipedia.dataclient.SharedPreferenceCookieManager import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.okhttp.OkHttpWebViewClient import org.wikipedia.extensions.parcelableExtra @@ -23,6 +29,8 @@ import org.wikipedia.page.LinkHandler import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle import org.wikipedia.page.PageViewModel +import org.wikipedia.staticdata.MainPageNameData +import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil class SingleWebViewActivity : BaseActivity() { @@ -30,8 +38,10 @@ class SingleWebViewActivity : BaseActivity() { private lateinit var blankLinkHandler: LinkHandler private lateinit var targetUrl: String private var currentUrl: String? = null - private var pageTitle: PageTitle? = null - private var showBackButton: Boolean = false + private var pageTitleToLoadOnBackPress: PageTitle? = null + private var showBackButton = false + private var isWebForm = false + private var wasFormSubmitted = false val blankModel = PageViewModel() @SuppressLint("SetJavaScriptEnabled") @@ -45,8 +55,9 @@ class SingleWebViewActivity : BaseActivity() { targetUrl = intent.getStringExtra(EXTRA_URL)!! showBackButton = intent.getBooleanExtra(EXTRA_SHOW_BACK_BUTTON, false) - pageTitle = intent.parcelableExtra(EXTRA_PAGE_TITLE) - blankLinkHandler = EditLinkHandler(this, WikipediaApp.instance.wikiSite) + isWebForm = intent.getBooleanExtra(EXTRA_IS_WEB_FORM, false) + pageTitleToLoadOnBackPress = intent.parcelableExtra(Constants.ARG_TITLE) + blankLinkHandler = SingleWebViewLinkHandler(this, WikipediaApp.instance.wikiSite) binding.backButton.isVisible = showBackButton binding.backButton.setOnClickListener { @@ -58,6 +69,28 @@ class SingleWebViewActivity : BaseActivity() { override val model get() = blankModel override val linkHandler get() = blankLinkHandler + override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { + if (isWebForm) { + finish() + request?.let { + // Special case: If the URL is the main page, then just allow the activity to close, + // otherwise, open the URL in an external browser. + if (!it.url.path.orEmpty().endsWith(StringUtil.addUnderscores(MainPageNameData.valueFor("en")))) { + UriUtil.visitInExternalBrowser(this@SingleWebViewActivity, it.url) + } + } + return true + } + return false + } + + override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? { + if (request.method == "POST") { + wasFormSubmitted = true + } + return super.shouldInterceptRequest(view, request) + } + override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) { super.onPageStarted(view, url, favicon) binding.progressBar.isVisible = true @@ -68,10 +101,23 @@ class SingleWebViewActivity : BaseActivity() { super.onPageFinished(view, url) binding.progressBar.isVisible = false currentUrl = url + view?.evaluateJavascript(JavaScriptActionHandler.mobileWebChromeShim(0, 0), null) invalidateOptionsMenu() + if (isWebForm && wasFormSubmitted) { + binding.backButton.isVisible = true + } + + // Explicitly apply any other cookies that we received during this call to the WebView, + // so that subsequent POST(s) can inherit them. + setCookies(url.orEmpty()) } } + // Explicitly apply our cookies to the default CookieManager of the WebView. + // This is because our custom WebViewClient doesn't allow intercepting POST requests + // properly, so in the case of POST requests the cookies will be supplied automatically. + setCookies(targetUrl) + if (savedInstanceState == null) { binding.webView.loadUrl(targetUrl) } @@ -107,7 +153,7 @@ class SingleWebViewActivity : BaseActivity() { } override fun onBackPressed() { - if (binding.webView.canGoBack()) { + if (!isWebForm && binding.webView.canGoBack()) { binding.webView.goBack() return } @@ -119,14 +165,23 @@ class SingleWebViewActivity : BaseActivity() { if (intent.getStringExtra(EXTRA_PAGE_CONTENT_INFO).orEmpty() == PAGE_CONTENT_SOURCE_DONOR_EXPERIENCE) { DonorExperienceEvent.logAction("article_return_click", "webpay_processed") } - pageTitle?.let { + pageTitleToLoadOnBackPress?.let { val entry = HistoryEntry(it, HistoryEntry.SOURCE_SINGLE_WEBVIEW) startActivity(PageActivity.newIntentForExistingTab(this@SingleWebViewActivity, entry, entry.title)) } finish() } - inner class EditLinkHandler constructor(context: Context, override var wikiSite: WikiSite) : LinkHandler(context) { + private fun setCookies(url: String) { + CookieManager.getInstance().let { + val cookies = SharedPreferenceCookieManager.instance.loadForRequest(url) + for (cookie in cookies) { + it.setCookie(url, cookie.toString()) + } + } + } + + inner class SingleWebViewLinkHandler(context: Context, override var wikiSite: WikiSite) : LinkHandler(context) { override fun onPageLinkClicked(anchor: String, linkText: String) { } override fun onInternalLinkClicked(title: PageTitle) { } override fun onMediaLinkClicked(title: PageTitle) { } @@ -136,16 +191,18 @@ class SingleWebViewActivity : BaseActivity() { companion object { const val EXTRA_URL = "url" const val EXTRA_SHOW_BACK_BUTTON = "goBack" - const val EXTRA_PAGE_TITLE = "pageTitle" const val EXTRA_PAGE_CONTENT_INFO = "pageContentInfo" const val PAGE_CONTENT_SOURCE_DONOR_EXPERIENCE = "donorExperience" + const val EXTRA_IS_WEB_FORM = "isWebForm" - fun newIntent(context: Context, url: String, showBackButton: Boolean = false, pageTitle: PageTitle? = null, pageContentInfo: String? = null): Intent { + fun newIntent(context: Context, url: String, showBackButton: Boolean = false, pageTitleToLoadOnBackPress: PageTitle? = null, + pageContentInfo: String? = null, isWebForm: Boolean = false): Intent { return Intent(context, SingleWebViewActivity::class.java) .putExtra(EXTRA_URL, url) .putExtra(EXTRA_SHOW_BACK_BUTTON, showBackButton) - .putExtra(EXTRA_PAGE_TITLE, pageTitle) + .putExtra(Constants.ARG_TITLE, pageTitleToLoadOnBackPress) .putExtra(EXTRA_PAGE_CONTENT_INFO, pageContentInfo) + .putExtra(EXTRA_IS_WEB_FORM, isWebForm) } } } diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/EditAttemptStepEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/EditAttemptStepEvent.kt index 61ed73eee51..eccfedbc0ee 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/EditAttemptStepEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/EditAttemptStepEvent.kt @@ -5,11 +5,13 @@ import kotlinx.serialization.Serializable import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil +import org.wikipedia.dataclient.SharedPreferenceCookieManager +import org.wikipedia.dataclient.WikiSite import org.wikipedia.page.PageTitle @Suppress("unused") @Serializable -@SerialName("/analytics/legacy/editattemptstep/1.4.0") +@SerialName("/analytics/legacy/editattemptstep/2.0.3") class EditAttemptStepEvent(private val event: EditAttemptStepInteractionEvent) : Event(STREAM_NAME) { companion object { @@ -40,17 +42,22 @@ class EditAttemptStepEvent(private val event: EditAttemptStepInteractionEvent) : } private fun submitEditAttemptEvent(action: String, editorInterface: String, pageTitle: PageTitle) { - EventPlatformClient.submit(EditAttemptStepEvent(EditAttemptStepInteractionEvent(action, "", editorInterface, - INTEGRATION_ID, "", WikipediaApp.instance.getString(R.string.device_type).lowercase(), 0, - if (AccountUtil.isLoggedIn) AccountUtil.getUserIdForLanguage(pageTitle.wikiSite.languageCode) else 0, + EventPlatformClient.submit(EditAttemptStepEvent(EditAttemptStepInteractionEvent(action, + WikipediaApp.instance.appInstallID, "", editorInterface, + INTEGRATION_ID, "", WikipediaApp.instance.getString(R.string.device_type).lowercase(), 0, getUserIdForWikiSite(pageTitle.wikiSite), 1, pageTitle.prefixedText, pageTitle.namespace().code()))) } + + private fun getUserIdForWikiSite(wikiSite: WikiSite): Int { + return if (AccountUtil.isLoggedIn) SharedPreferenceCookieManager.instance.getCookieByName("UserID", wikiSite.authority(), false)?.toIntOrNull() ?: 0 else 0 + } } } @Suppress("unused") @Serializable class EditAttemptStepInteractionEvent(private val action: String, + private val app_install_id: String, private val editing_session_id: String, private val editor_interface: String, private val integration: String, diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/EventPlatformClient.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/EventPlatformClient.kt index 9a33f6d6907..bc04c9e92b8 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/EventPlatformClient.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/EventPlatformClient.kt @@ -14,12 +14,13 @@ import org.wikipedia.util.ReleaseUtil import org.wikipedia.util.log.L import java.net.HttpURLConnection import java.util.* +import java.util.concurrent.ConcurrentHashMap object EventPlatformClient { /** * Stream configs to be fetched on startup and stored for the duration of the app lifecycle. */ - private val STREAM_CONFIGS = mutableMapOf() + private val STREAM_CONFIGS = ConcurrentHashMap() /* * When ENABLED is false, items can be enqueued but not dequeued. @@ -43,7 +44,6 @@ object EventPlatformClient { * Set whether the client is enabled. This can react to device online/offline state as well * as other considerations. */ - @Synchronized fun setEnabled(enabled: Boolean) { ENABLED = enabled if (ENABLED) { @@ -60,7 +60,6 @@ object EventPlatformClient { * * @param event event */ - @Synchronized fun submit(event: Event) { if (!SamplingController.isInSample(event)) { return @@ -79,14 +78,12 @@ object EventPlatformClient { .subscribe({ updateStreamConfigs(it.streamConfigs) }) { L.e(it) } } - @Synchronized private fun updateStreamConfigs(streamConfigs: Map) { STREAM_CONFIGS.clear() STREAM_CONFIGS.putAll(streamConfigs) Prefs.streamConfigs = STREAM_CONFIGS } - @Synchronized fun setUpStreamConfigs() { STREAM_CONFIGS.clear() STREAM_CONFIGS.putAll(Prefs.streamConfigs) @@ -113,12 +110,15 @@ object EventPlatformClient { private const val TOKEN = "sendScheduled" private val MAX_QUEUE_SIZE get() = Prefs.analyticsQueueSize - @Synchronized fun sendAllScheduled() { WikipediaApp.instance.mainThreadHandler.removeCallbacksAndMessages(TOKEN) if (ENABLED) { - send() - QUEUE.clear() + val eventsByStream: Map> + synchronized(QUEUE) { + eventsByStream = QUEUE.groupBy { it.stream } + QUEUE.clear() + } + send(eventsByStream) } } @@ -127,10 +127,11 @@ object EventPlatformClient { * * @param event event data */ - @Synchronized fun schedule(event: Event) { if (ENABLED || QUEUE.size <= MAX_QUEUE_SIZE) { - QUEUE.add(event) + synchronized(QUEUE) { + QUEUE.add(event) + } } if (ENABLED) { if (QUEUE.size >= MAX_QUEUE_SIZE) { @@ -150,14 +151,16 @@ object EventPlatformClient { * Also batch the events ordered by their streams, as the QUEUE * can contain events of different streams */ - private fun send() { - QUEUE.groupBy { it.stream }.forEach { (stream, events) -> - sendEventsForStream(STREAM_CONFIGS[stream]!!, events) + private fun send(eventsByStream: Map>) { + eventsByStream.forEach { (stream, events) -> + getStreamConfig(stream)?.let { + sendEventsForStream(it, events) + } } } @SuppressLint("CheckResult") - fun sendEventsForStream(streamConfig: StreamConfig, events: List) { + private fun sendEventsForStream(streamConfig: StreamConfig, events: List) { (if (ReleaseUtil.isDevRelease) ServiceFactory.getAnalyticsRest(streamConfig).postEvents(events) else @@ -289,7 +292,7 @@ object EventPlatformClient { if (SAMPLING_CACHE.containsKey(stream)) { return SAMPLING_CACHE[stream]!! } - val streamConfig = STREAM_CONFIGS[stream] ?: return false + val streamConfig = getStreamConfig(stream) ?: return false val samplingConfig = streamConfig.samplingConfig if (samplingConfig == null || samplingConfig.rate == 1.0) { return true diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/PatrollerExperienceEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/PatrollerExperienceEvent.kt index a3d5269d8bd..e890dde8580 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/PatrollerExperienceEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/PatrollerExperienceEvent.kt @@ -41,6 +41,14 @@ class PatrollerExperienceEvent { filterSelectedStr + filterWikiStr + filtersListStr + appLanguageCodeAddedStr + appLanguageCodesStr } + fun getPublishMessageActionString(isModified: Boolean? = null, isSaved: Boolean? = null, isExample: Boolean? = null, exampleMessage: String? = null): String { + val isModifiedStr = isModified?.let { "is_modified: $it, " }.orEmpty() + val isSavedStr = isSaved?.let { "is_saved: $it, " }.orEmpty() + val isExampleStr = isExample?.let { "is_example: $it, " }.orEmpty() + val exampleMessageStr = exampleMessage?.let { "example_message: $it " }.orEmpty() + return isModifiedStr + isSavedStr + isExampleStr + exampleMessageStr + } + private fun submitPatrollerActivityEvent(action: String, activeInterface: String, actionData: String = "") { EventPlatformClient.submit( AppInteractionEvent( diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/PlacesEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/PlacesEvent.kt new file mode 100644 index 00000000000..85955167deb --- /dev/null +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/PlacesEvent.kt @@ -0,0 +1,30 @@ +package org.wikipedia.analytics.eventplatform + +import org.wikipedia.WikipediaApp +import org.wikipedia.settings.Prefs + +class PlacesEvent { + companion object { + + fun logImpression(activeInterface: String) { + submitPlacesInteractionEvent("impression", activeInterface) + } + + fun logAction(action: String, activeInterface: String, actionData: String = "") { + submitPlacesInteractionEvent(action, activeInterface, actionData) + } + + private fun submitPlacesInteractionEvent(action: String, activeInterface: String, actionData: String = "") { + EventPlatformClient.submit( + AppInteractionEvent( + action, + activeInterface, + actionData, + WikipediaApp.instance.languageState.appLanguageCode, + Prefs.placesWikiCode, + "app_places_interaction" + ) + ) + } + } +} diff --git a/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt b/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt index 846dc8dbd04..c00de61c80c 100644 --- a/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt @@ -24,7 +24,7 @@ class ArticleFindInPageInteraction(private val fragment: PageFragment) : TimedMe fun logDone() { submitEvent( "android.product_metrics.find_in_page_interaction", - "/analytics/mobile_apps/product_metrics/android_find_in_page_interaction/1.0.0", + "/analytics/mobile_apps/product_metrics/android_find_in_page_interaction/1.1.0", "find_in_page_interaction", mapOf( "find_text" to findText, @@ -147,9 +147,9 @@ class ArticleToolbarInteraction(private val fragment: PageFragment) : TimedMetri "android.product_metrics.article_toolbar_interaction", "article_toolbar_interaction", getInteractionData( - "article_toolbar_interaction", action, null, + null, "time_spent_ms.${timer.elapsedMillis}" ), getPageData(fragment) @@ -187,7 +187,7 @@ class ArticleTocInteraction(private val fragment: PageFragment, private val numS } submitEvent( "android.product_metrics.article_toc_interaction", - "/analytics/mobile_apps/product_metrics/android_article_toc_interaction/1.0.0", + "/analytics/mobile_apps/product_metrics/android_article_toc_interaction/1.1.0", "article_toc_interaction", mapOf( "num_opens" to numOpens, @@ -237,8 +237,8 @@ class ArticleLinkPreviewInteraction : TimedMetricsEvent { "android.product_metrics.article_link_preview_interaction", "article_link_preview_interaction", getInteractionData( - "article_link_preview_interaction", action, + null, source.toString(), "time_spent_ms.${timer.elapsedMillis}", ), diff --git a/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsEvent.kt b/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsEvent.kt index 40f1f501190..2910ce37556 100644 --- a/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsEvent.kt @@ -11,7 +11,6 @@ import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.page.Namespace import org.wikipedia.page.PageFragment import org.wikipedia.page.PageTitle -import org.wikipedia.util.ReleaseUtil open class MetricsEvent { @@ -29,13 +28,11 @@ open class MetricsEvent { interactionData: InteractionData?, pageData: PageData? = null ) { - if (ReleaseUtil.isPreProdRelease) { - MetricsPlatform.client.submitInteraction( - streamName, - EVENT_NAME_BASE + eventName, - getClientData(pageData), - interactionData) - } + MetricsPlatform.client.submitInteraction( + streamName, + EVENT_NAME_BASE + eventName, + getClientData(pageData), + interactionData) } /** @@ -56,16 +53,14 @@ open class MetricsEvent { interactionData: InteractionData?, pageData: PageData? = null ) { - if (ReleaseUtil.isPreProdRelease) { - MetricsPlatform.client.submitInteraction( - streamName, - schemaId, - EVENT_NAME_BASE + eventName, - getClientData(pageData), - interactionData, - customData - ) - } + MetricsPlatform.client.submitInteraction( + streamName, + schemaId, + EVENT_NAME_BASE + eventName, + getClientData(pageData), + interactionData, + customData + ) } private fun getClientData(pageData: PageData?): ClientData { diff --git a/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsPlatform.kt b/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsPlatform.kt index b5004a133e6..fab839c52f3 100644 --- a/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsPlatform.kt +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsPlatform.kt @@ -15,7 +15,8 @@ object MetricsPlatform { BuildConfig.FLAVOR + BuildConfig.BUILD_TYPE, WikipediaApp.instance.appInstallID, WikipediaApp.instance.currentTheme.toString(), - WikipediaApp.instance.versionCode.toString(), + WikipediaApp.instance.versionCode, + "WikipediaApp/" + BuildConfig.VERSION_NAME, "android", "app", WikipediaApp.instance.languageState.systemLanguageCode, diff --git a/app/src/main/java/org/wikipedia/auth/AccountUtil.kt b/app/src/main/java/org/wikipedia/auth/AccountUtil.kt index c6d23c7cd8d..ae5f5dce8ac 100644 --- a/app/src/main/java/org/wikipedia/auth/AccountUtil.kt +++ b/app/src/main/java/org/wikipedia/auth/AccountUtil.kt @@ -25,7 +25,6 @@ object AccountUtil { return } setPassword(result.password) - putUserIdForLanguage(result.site.languageCode, result.userId) groups = result.groups } @@ -44,14 +43,6 @@ object AccountUtil { return if (account == null) null else accountManager().getPassword(account) } - fun getUserIdForLanguage(code: String): Int { - return userIds.getOrElse(code) { 0 } - } - - fun putUserIdForLanguage(code: String, id: Int) { - userIds += code to id - } - var groups: Set get() { val account = account() ?: return emptySet() @@ -114,19 +105,6 @@ object AccountUtil { } } - private var userIds: Map - get() { - val account = account() ?: return emptyMap() - val mapStr = accountManager().getUserData(account, WikipediaApp.instance.getString(R.string.preference_key_login_user_id_map)) - return if (mapStr.isNullOrEmpty()) emptyMap() else (JsonUtil.decodeFromString(mapStr) ?: emptyMap()) - } - private set(ids) { - val account = account() ?: return - accountManager().setUserData(account, - WikipediaApp.instance.getString(R.string.preference_key_login_user_id_map), - JsonUtil.encodeToString(ids)) - } - private fun accountManager(): AccountManager { return AccountManager.get(WikipediaApp.instance) } diff --git a/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt b/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt index b2e56062b58..4d25a643e64 100644 --- a/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt +++ b/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt @@ -160,10 +160,10 @@ object JavaScriptActionHandler { "})" } - fun mobileWebChromeShim(): String { + fun mobileWebChromeShim(marginTop: Int, marginBottom: Int): String { return "(function() {" + "let style = document.createElement('style');" + - "style.innerHTML = '.header-chrome { visibility: hidden; margin-top: 48px; height: 0px; } #page-secondary-actions { display: none; } .mw-footer { padding-bottom: 72px; } .page-actions-menu { display: none; } .minerva__tab-container { display: none; }';" + + "style.innerHTML = '.header-chrome { visibility: hidden; margin-top: ${marginTop}px; height: 0px; } #page-secondary-actions { display: none; } .mw-footer { padding-bottom: ${marginBottom}px; } .page-actions-menu { display: none; } .minerva__tab-container { display: none; }';" + "document.head.appendChild(style);" + "})();" } diff --git a/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt b/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt index eaa43aa88b4..6ac59200e7a 100644 --- a/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt +++ b/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt @@ -1,11 +1,11 @@ package org.wikipedia.captcha -import android.app.Activity import android.view.View import androidx.appcompat.app.AppCompatActivity -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.databinding.GroupCaptchaBinding import org.wikipedia.dataclient.ServiceFactory @@ -17,12 +17,13 @@ import org.wikipedia.util.StringUtil import org.wikipedia.views.ViewAnimations import org.wikipedia.views.ViewUtil -class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, +class CaptchaHandler(private val activity: AppCompatActivity, private val wiki: WikiSite, captchaView: View, private val primaryView: View, private val prevTitle: String, submitButtonText: String?) { private val binding = GroupCaptchaBinding.bind(captchaView) - private val disposables = CompositeDisposable() private var captchaResult: CaptchaResult? = null + private var clientJob: Job? = null + var token: String? = null val isActive get() = captchaResult != null @@ -45,7 +46,7 @@ class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, } fun dispose() { - disposables.clear() + clientJob?.cancel() } fun handleCaptcha(token: String?, captchaResult: CaptchaResult) { @@ -56,17 +57,15 @@ class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, fun requestNewCaptcha() { binding.captchaImageProgress.visibility = View.VISIBLE - disposables.add(ServiceFactory.get(wiki).newCaptcha - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { binding.captchaImageProgress.visibility = View.GONE } - .subscribe({ response -> - captchaResult = CaptchaResult(response.captchaId()) - handleCaptcha(true) - }) { caught -> - cancelCaptcha() - FeedbackUtil.showError(activity, caught) - }) + activity.lifecycleScope.launch(CoroutineExceptionHandler { _, throwable -> + cancelCaptcha() + FeedbackUtil.showError(activity, throwable) + }) { + val response = ServiceFactory.get(wiki).getNewCaptcha() + captchaResult = CaptchaResult(response.captchaId()) + handleCaptcha(true) + binding.captchaImageProgress.visibility = View.GONE + } } private fun handleCaptcha(isReload: Boolean) { @@ -83,7 +82,7 @@ class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, } fun hideCaptcha() { - (activity as AppCompatActivity).supportActionBar?.title = prevTitle + activity.supportActionBar?.title = prevTitle ViewAnimations.crossFade(binding.root, primaryView) } diff --git a/app/src/main/java/org/wikipedia/commons/FilePage.kt b/app/src/main/java/org/wikipedia/commons/FilePage.kt new file mode 100644 index 00000000000..cef9cd08353 --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/FilePage.kt @@ -0,0 +1,13 @@ +package org.wikipedia.commons + +import org.wikipedia.dataclient.mwapi.MwQueryPage + +class FilePage( + val thumbnailWidth: Int = 0, + val thumbnailHeight: Int = 0, + val imageFromCommons: Boolean = false, + val showEditButton: Boolean = false, + val showFilename: Boolean = false, + val page: MwQueryPage = MwQueryPage(), + val imageTags: Map> = emptyMap() +) diff --git a/app/src/main/java/org/wikipedia/commons/FilePageFragment.kt b/app/src/main/java/org/wikipedia/commons/FilePageFragment.kt index e26fa6bf180..561319c5a0e 100644 --- a/app/src/main/java/org/wikipedia/commons/FilePageFragment.kt +++ b/app/src/main/java/org/wikipedia/commons/FilePageFragment.kt @@ -8,60 +8,48 @@ import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent import org.wikipedia.databinding.FragmentFilePageBinding -import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.descriptions.DescriptionEditActivity import org.wikipedia.descriptions.DescriptionEditActivity.Action -import org.wikipedia.extensions.parcelable -import org.wikipedia.language.LanguageUtil import org.wikipedia.page.PageTitle import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.suggestededits.SuggestedEditsImageTagEditActivity import org.wikipedia.suggestededits.SuggestedEditsSnackbars +import org.wikipedia.util.DimenUtil import org.wikipedia.util.L10nUtil -import org.wikipedia.util.StringUtil -import org.wikipedia.util.log.L +import org.wikipedia.util.Resource class FilePageFragment : Fragment(), FilePageView.Callback { private var _binding: FragmentFilePageBinding? = null private val binding get() = _binding!! - private lateinit var pageTitle: PageTitle - private lateinit var pageSummaryForEdit: PageSummaryForEdit - private var allowEdit = true - private val disposables = CompositeDisposable() + private val viewModel: FilePageViewModel by viewModels { FilePageViewModel.Factory(requireArguments()) } private val addImageCaptionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { SuggestedEditsSnackbars.show(requireActivity(), Action.ADD_CAPTION, true) - loadImageInfo() + viewModel.loadImageInfo() } } private val addImageTagsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { SuggestedEditsSnackbars.show(requireActivity(), Action.ADD_IMAGE_TAGS, true) - loadImageInfo() + viewModel.loadImageInfo() } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pageTitle = requireArguments().parcelable(Constants.ARG_TITLE)!! - allowEdit = requireArguments().getBoolean(FilePageActivity.INTENT_EXTRA_ALLOW_EDIT) - retainInstance = true - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) _binding = FragmentFilePageBinding.inflate(inflater, container, false) - L10nUtil.setConditionalLayoutDirection(container!!, pageTitle.wikiSite.languageCode) + L10nUtil.setConditionalLayoutDirection(binding.root, viewModel.pageTitle.wikiSite.languageCode) return binding.root } @@ -69,109 +57,70 @@ class FilePageFragment : Fragment(), FilePageView.Callback { super.onViewCreated(view, savedInstanceState) binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.isRefreshing = false - loadImageInfo() + viewModel.loadImageInfo() } binding.errorView.backClickListener = View.OnClickListener { requireActivity().finish() } - loadImageInfo() - ImageRecommendationsEvent.logImpression("imagedetails_dialog", ImageRecommendationsEvent.getActionDataString(filename = pageTitle.prefixedText)) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> onLoading() + is Resource.Success -> onSuccess(it.data) + is Resource.Error -> onError(it.throwable) + } + } + } + } + } + ImageRecommendationsEvent.logImpression("imagedetails_dialog", ImageRecommendationsEvent.getActionDataString(filename = viewModel.pageTitle.prefixedText)) } override fun onDestroyView() { - disposables.clear() _binding = null super.onDestroyView() } - private fun showError(caught: Throwable?) { + private fun onError(caught: Throwable?) { binding.progressBar.visibility = View.GONE binding.filePageView.visibility = View.GONE binding.errorView.visibility = View.VISIBLE binding.errorView.setError(caught) } - private fun loadImageInfo() { - lateinit var imageTags: Map> - lateinit var page: MwQueryPage - var isFromCommons = false - var isEditProtected = false - var thumbnailWidth = 0 - var thumbnailHeight = 0 - + private fun onLoading() { binding.errorView.visibility = View.GONE binding.filePageView.visibility = View.GONE binding.progressBar.visibility = View.VISIBLE + } - disposables.add(ServiceFactory.get(Constants.commonsWikiSite).getImageInfoWithEntityTerms(pageTitle.prefixedText, pageTitle.wikiSite.languageCode, LanguageUtil.convertToUselangIfNeeded(pageTitle.wikiSite.languageCode)) - .subscribeOn(Schedulers.io()) - .flatMap { - // set image caption to pageTitle description - pageTitle.description = it.query?.firstPage()?.entityTerms?.label?.firstOrNull() - if (it.query?.firstPage()?.imageInfo() == null) { - // If file page originally comes from *.wikipedia.org (i.e. movie posters), it will not have imageInfo and pageId. - ServiceFactory.get(pageTitle.wikiSite).getImageInfo(pageTitle.prefixedText, pageTitle.wikiSite.languageCode) - } else { - // Fetch API from commons.wikimedia.org and check whether if it is not a "shared" image. - isFromCommons = !(it.query?.firstPage()?.isImageShared ?: false) - Observable.just(it) - } - } - .subscribeOn(Schedulers.io()) - .flatMap { - page = it.query?.firstPage()!! - val imageInfo = page.imageInfo()!! - pageSummaryForEdit = PageSummaryForEdit( - pageTitle.prefixedText, - pageTitle.wikiSite.languageCode, - pageTitle, - pageTitle.displayText, - StringUtil.fromHtml(imageInfo.metadata!!.imageDescription()).toString().ifBlank { null }, - imageInfo.thumbUrl, - null, - null, - imageInfo.timestamp, - imageInfo.user, - imageInfo.metadata - ) - thumbnailHeight = imageInfo.thumbHeight - thumbnailWidth = imageInfo.thumbWidth - ImageTagsProvider.getImageTagsObservable(page.pageId, pageSummaryForEdit.lang) - } - .flatMap { - imageTags = it - ServiceFactory.get(Constants.commonsWikiSite).getProtectionInfo(pageTitle.prefixedText) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { - binding.filePageView.visibility = View.VISIBLE - binding.progressBar.visibility = View.GONE - binding.filePageView.setup( - pageSummaryForEdit, - imageTags, - page, - binding.container.width, - thumbnailWidth, - thumbnailHeight, - imageFromCommons = isFromCommons, - showFilename = true, - showEditButton = allowEdit && isFromCommons && !isEditProtected, - callback = this - ) - } - .subscribe({ - isEditProtected = it.query?.isEditProtected ?: false - }, { caught -> - L.e(caught) - showError(caught) - })) + private fun onSuccess(filePage: FilePage) { + viewModel.pageSummaryForEdit?.let { + binding.filePageView.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + binding.filePageView.setup( + it, + filePage.imageTags, + filePage.page, + DimenUtil.displayWidthPx, + filePage.thumbnailWidth, + filePage.thumbnailHeight, + imageFromCommons = filePage.imageFromCommons, + showFilename = filePage.showFilename, + showEditButton = filePage.showEditButton, + callback = this + ) + } } override fun onImageCaptionClick(summaryForEdit: PageSummaryForEdit) { - addImageCaptionLauncher.launch( - DescriptionEditActivity.newIntent(requireContext(), - pageSummaryForEdit.pageTitle, null, summaryForEdit, null, - Action.ADD_CAPTION, Constants.InvokeSource.FILE_PAGE_ACTIVITY) - ) + viewModel.pageSummaryForEdit?.let { + addImageCaptionLauncher.launch( + DescriptionEditActivity.newIntent(requireContext(), + it.pageTitle, null, summaryForEdit, null, + Action.ADD_CAPTION, Constants.InvokeSource.FILE_PAGE_ACTIVITY) + ) + } } override fun onImageTagsClick(page: MwQueryPage) { diff --git a/app/src/main/java/org/wikipedia/commons/FilePageView.kt b/app/src/main/java/org/wikipedia/commons/FilePageView.kt index 803de60a5aa..e376b10a93c 100644 --- a/app/src/main/java/org/wikipedia/commons/FilePageView.kt +++ b/app/src/main/java/org/wikipedia/commons/FilePageView.kt @@ -31,7 +31,7 @@ import org.wikipedia.util.UriUtil import org.wikipedia.views.ImageDetailView import org.wikipedia.views.ImageZoomHelper import org.wikipedia.views.ViewUtil -import java.util.* +import java.util.Locale class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { @@ -66,7 +66,7 @@ class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : binding.filenameView.binding.contentText.setTextIsSelectable(false) binding.filenameView.binding.contentText.maxLines = 3 binding.filenameView.binding.contentText.ellipsize = TextUtils.TruncateAt.END - binding.filenameView.binding.contentText.text = StringUtil.removeNamespace(summaryForEdit.displayTitle.orEmpty()) + binding.filenameView.binding.contentText.text = StringUtil.removeNamespace(summaryForEdit.displayTitle) binding.filenameView.binding.divider.visibility = View.GONE } @@ -76,25 +76,30 @@ class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : addActionButton(context.getString(R.string.file_page_add_image_caption_button), imageCaptionOnClickListener(summaryForEdit, callback)) } else if ((action == DescriptionEditActivity.Action.ADD_CAPTION || action == null) && summaryForEdit.pageTitle.description.isNullOrEmpty()) { // Show the image description when a structured caption does not exist. - addDetail(context.getString(R.string.description_edit_add_caption_label), summaryForEdit.description, - if (showEditButton) imageCaptionOnClickListener(summaryForEdit, callback) else null) + addDetail( + titleString = context.getString(R.string.description_edit_add_caption_label), + detail = summaryForEdit.description, + listener = if (showEditButton) imageCaptionOnClickListener(summaryForEdit, callback) else null + ) } else { - addDetail(context.getString(R.string.suggested_edits_image_preview_dialog_caption_in_language_title, + addDetail( + titleString = context.getString(R.string.suggested_edits_image_preview_dialog_caption_in_language_title, WikipediaApp.instance.languageState.getAppLanguageLocalizedName(getProperLanguageCode(summaryForEdit, imageFromCommons))), - if (summaryForEdit.pageTitle.description.isNullOrEmpty()) summaryForEdit.description - else summaryForEdit.pageTitle.description, if (showEditButton) imageCaptionOnClickListener(summaryForEdit, callback) else null) + detail = if (summaryForEdit.pageTitle.description.isNullOrEmpty()) summaryForEdit.description else summaryForEdit.pageTitle.description, + listener = if (showEditButton) imageCaptionOnClickListener(summaryForEdit, callback) else null + ) } if ((imageTags.isEmpty() || !imageTags.containsKey(getProperLanguageCode(summaryForEdit, imageFromCommons))) && showEditButton) { addActionButton(context.getString(R.string.file_page_add_image_tags_button), imageTagsOnClickListener(page, callback)) } else { - addDetail(context.getString(R.string.suggested_edits_image_tags), getImageTags(imageTags, getProperLanguageCode(summaryForEdit, imageFromCommons))) + addDetail(titleString = context.getString(R.string.suggested_edits_image_tags), detail = getImageTags(imageTags, getProperLanguageCode(summaryForEdit, imageFromCommons))) } - addDetail(context.getString(R.string.suggested_edits_image_caption_summary_title_author), summaryForEdit.metadata!!.artist()) - addDetail(context.getString(R.string.suggested_edits_image_preview_dialog_date), summaryForEdit.metadata!!.dateTime()) - addDetail(context.getString(R.string.suggested_edits_image_caption_summary_title_source), summaryForEdit.metadata!!.credit()) - addDetail(true, context.getString(R.string.suggested_edits_image_preview_dialog_licensing), summaryForEdit.metadata!!.licenseShortName(), summaryForEdit.metadata!!.licenseUrl()) + addDetail(titleString = context.getString(R.string.suggested_edits_image_caption_summary_title_author), detail = summaryForEdit.metadata!!.artist()) + addDetail(titleString = context.getString(R.string.suggested_edits_image_preview_dialog_date), detail = summaryForEdit.metadata!!.dateTime()) + addDetail(titleString = context.getString(R.string.suggested_edits_image_caption_summary_title_source), detail = summaryForEdit.metadata!!.credit()) + addDetail(titleString = context.getString(R.string.suggested_edits_image_preview_dialog_licensing), detail = summaryForEdit.metadata!!.licenseShortName(), externalLink = summaryForEdit.metadata!!.licenseUrl()) if (imageFromCommons) { addDetail(false, context.getString(R.string.suggested_edits_image_preview_dialog_more_info), context.getString(R.string.suggested_edits_image_preview_dialog_file_page_link_text), context.getString(R.string.suggested_edits_image_file_page_commons_link, summaryForEdit.title)) } else { @@ -146,19 +151,10 @@ class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : } } - private fun addDetail(titleString: String, detail: String?) { - addDetail(true, titleString, detail, null, null) - } - - private fun addDetail(titleString: String, detail: String?, listener: OnClickListener?) { - addDetail(true, titleString, detail, null, listener) - } - - private fun addDetail(showDivider: Boolean, titleString: String, detail: String?, externalLink: String?) { - addDetail(showDivider, titleString, detail, externalLink, null) - } - - private fun addDetail(showDivider: Boolean, titleString: String, detail: String?, externalLink: String?, listener: OnClickListener?) { + private fun addDetail( + showDivider: Boolean = true, titleString: String, detail: String? = null, + externalLink: String? = null, listener: OnClickListener? = null + ) { if (!detail.isNullOrEmpty()) { val view = ImageDetailView(context) view.binding.titleText.text = titleString diff --git a/app/src/main/java/org/wikipedia/commons/FilePageViewModel.kt b/app/src/main/java/org/wikipedia/commons/FilePageViewModel.kt new file mode 100644 index 00000000000..99a464d6a08 --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/FilePageViewModel.kt @@ -0,0 +1,112 @@ +package org.wikipedia.commons + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.extensions.parcelable +import org.wikipedia.language.LanguageUtil +import org.wikipedia.page.PageTitle +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil + +class FilePageViewModel(bundle: Bundle) : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + private val allowEdit = bundle.getBoolean(FilePageActivity.INTENT_EXTRA_ALLOW_EDIT, true) + val pageTitle = bundle.parcelable(Constants.ARG_TITLE)!! + var pageSummaryForEdit: PageSummaryForEdit? = null + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + loadImageInfo() + } + + fun loadImageInfo() { + _uiState.value = Resource.Loading() + viewModelScope.launch(handler) { + var isFromCommons = false + var firstPage = ServiceFactory.get(Constants.commonsWikiSite) + .getImageInfoWithEntityTerms( + pageTitle.prefixedText, pageTitle.wikiSite.languageCode, + LanguageUtil.convertToUselangIfNeeded(pageTitle.wikiSite.languageCode) + ).query?.firstPage() + + // set image caption to pageTitle description + pageTitle.description = firstPage?.entityTerms?.label?.firstOrNull() + if (firstPage?.imageInfo() == null) { + // If file page originally comes from *.wikipedia.org (i.e. movie posters), it will not have imageInfo and pageId. + firstPage = ServiceFactory.get(pageTitle.wikiSite) + .getImageInfoSuspend( + pageTitle.prefixedText, + pageTitle.wikiSite.languageCode + ).query?.firstPage() + } else { + // Fetch API from commons.wikimedia.org and check whether if it is not a "shared" image. + isFromCommons = !(firstPage.isImageShared) + } + + firstPage?.imageInfo()?.let { imageInfo -> + pageSummaryForEdit = PageSummaryForEdit( + pageTitle.prefixedText, + pageTitle.wikiSite.languageCode, + pageTitle, + pageTitle.displayText, + StringUtil.fromHtml(imageInfo.metadata!!.imageDescription()).toString() + .ifBlank { null }, + imageInfo.thumbUrl, + null, + null, + imageInfo.timestamp, + imageInfo.user, + imageInfo.metadata + ) + + val imageTagsResponse = async { + ImageTagsProvider.getImageTags( + firstPage.pageId, + pageSummaryForEdit!!.lang + ) + } + val isEditProtected = async { + ServiceFactory.get(Constants.commonsWikiSite) + .getProtectionInfoSuspend(pageTitle.prefixedText).query?.isEditProtected + ?: false + } + + val filePage = FilePage( + imageFromCommons = isFromCommons, + showEditButton = allowEdit && isFromCommons && !isEditProtected.await(), + showFilename = true, + page = firstPage, + imageTags = imageTagsResponse.await(), + thumbnailWidth = imageInfo.thumbWidth, + thumbnailHeight = imageInfo.thumbHeight + ) + + _uiState.value = Resource.Success(filePage) + } ?: run { + _uiState.value = Resource.Error(Throwable("No image info found.")) + } + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return FilePageViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/commons/ImagePreviewDialog.kt b/app/src/main/java/org/wikipedia/commons/ImagePreviewDialog.kt new file mode 100644 index 00000000000..1cd74f3c832 --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/ImagePreviewDialog.kt @@ -0,0 +1,109 @@ +package org.wikipedia.commons + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.bottomsheet.BottomSheetBehavior +import kotlinx.coroutines.launch +import org.wikipedia.R +import org.wikipedia.databinding.DialogImagePreviewBinding +import org.wikipedia.descriptions.DescriptionEditActivity.Action +import org.wikipedia.page.ExtendedBottomSheetDialogFragment +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.L10nUtil.setConditionalLayoutDirection +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil + +class ImagePreviewDialog : ExtendedBottomSheetDialogFragment(), DialogInterface.OnDismissListener { + + private var _binding: DialogImagePreviewBinding? = null + private val binding get() = _binding!! + private val viewModel: ImagePreviewViewModel by viewModels { ImagePreviewViewModel.Factory(requireArguments()) } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = DialogImagePreviewBinding.inflate(inflater, container, false) + setConditionalLayoutDirection(binding.root, viewModel.pageSummaryForEdit.lang) + return binding.root + } + + override fun onStart() { + super.onStart() + BottomSheetBehavior.from(requireView().parent as View).peekHeight = DimenUtil.roundedDpToPx(DimenUtil.getDimension(R.dimen.imagePreviewSheetPeekHeight)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbarView.setOnClickListener { dismiss() } + binding.titleText.text = StringUtil.removeHTMLTags(StringUtil.removeNamespace(viewModel.pageSummaryForEdit.displayTitle)) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> onLoading() + is Resource.Success -> onSuccess(it.data) + is Resource.Error -> onError(it.throwable) + } + } + } + } + } + } + + override fun onDestroyView() { + binding.toolbarView.setOnClickListener(null) + _binding = null + super.onDestroyView() + } + + private fun onLoading() { + binding.progressBar.visibility = View.VISIBLE + } + + private fun onError(caught: Throwable?) { + binding.dialogDetailContainer.layoutTransition = null + binding.dialogDetailContainer.minimumHeight = 0 + binding.progressBar.visibility = View.GONE + binding.filePageView.visibility = View.GONE + binding.errorView.visibility = View.VISIBLE + binding.errorView.setError(caught, viewModel.pageSummaryForEdit.pageTitle) + } + + private fun onSuccess(filePage: FilePage) { + binding.filePageView.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + binding.filePageView.setup( + viewModel.pageSummaryForEdit, + filePage.imageTags, + filePage.page, + binding.dialogDetailContainer.width, + filePage.thumbnailWidth, + filePage.thumbnailHeight, + imageFromCommons = filePage.imageFromCommons, + showFilename = filePage.showFilename, + showEditButton = filePage.showEditButton, + action = viewModel.action + ) + } + + companion object { + const val ARG_SUMMARY = "summary" + const val ARG_ACTION = "action" + + fun newInstance(pageSummaryForEdit: PageSummaryForEdit, action: Action? = null): ImagePreviewDialog { + val dialog = ImagePreviewDialog().apply { + arguments = bundleOf(ARG_SUMMARY to pageSummaryForEdit, ARG_ACTION to action) + } + return dialog + } + } +} diff --git a/app/src/main/java/org/wikipedia/commons/ImagePreviewViewModel.kt b/app/src/main/java/org/wikipedia/commons/ImagePreviewViewModel.kt new file mode 100644 index 00000000000..9fe4a06e8bc --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/ImagePreviewViewModel.kt @@ -0,0 +1,78 @@ +package org.wikipedia.commons + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.descriptions.DescriptionEditActivity +import org.wikipedia.extensions.parcelable +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.util.Resource + +class ImagePreviewViewModel(bundle: Bundle) : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + var pageSummaryForEdit = bundle.parcelable(ImagePreviewDialog.ARG_SUMMARY)!! + var action = bundle.getSerializable(ImagePreviewDialog.ARG_ACTION) as DescriptionEditActivity.Action? + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + loadImageInfo() + } + + private fun loadImageInfo() { + _uiState.value = Resource.Loading() + viewModelScope.launch(handler) { + var isFromCommons = false + var firstPage = ServiceFactory.get(Constants.commonsWikiSite) + .getImageInfoSuspend(pageSummaryForEdit.title, pageSummaryForEdit.lang).query?.firstPage() + + if (firstPage?.imageInfo() == null) { + // If file page originally comes from *.wikipedia.org (i.e. movie posters), it will not have imageInfo and pageId. + firstPage = ServiceFactory.get(pageSummaryForEdit.pageTitle.wikiSite) + .getImageInfoSuspend(pageSummaryForEdit.title, pageSummaryForEdit.lang).query?.firstPage() + } else { + // Fetch API from commons.wikimedia.org and check whether if it is not a "shared" image. + isFromCommons = !(firstPage.isImageShared) + } + + firstPage?.imageInfo()?.let { imageInfo -> + pageSummaryForEdit.timestamp = imageInfo.timestamp + pageSummaryForEdit.user = imageInfo.user + pageSummaryForEdit.metadata = imageInfo.metadata + + val imageTagsResponse = async { ImageTagsProvider.getImageTags(firstPage.pageId, pageSummaryForEdit.lang) } + + val filePage = FilePage( + imageFromCommons = isFromCommons, + page = firstPage, + imageTags = imageTagsResponse.await(), + thumbnailWidth = imageInfo.thumbWidth, + thumbnailHeight = imageInfo.thumbHeight, + ) + + _uiState.value = Resource.Success(filePage) + } ?: run { + _uiState.value = Resource.Error(Throwable("No image info found.")) + } + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return ImagePreviewViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt b/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt index 2dc101a3b84..c38c4d56c1e 100644 --- a/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt +++ b/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt @@ -15,7 +15,7 @@ object ImageTagsProvider { .onErrorReturnItem(Claims()) .flatMap { claims -> val ids = getDepictsClaims(claims.claims) - if (ids.isNullOrEmpty()) { + if (ids.isEmpty()) { Observable.just(MwQueryResponse()) } else { ServiceFactory.get(Constants.wikidataWikiSite).getWikidataEntityTerms(ids.joinToString(separator = "|"), LanguageUtil.convertToUselangIfNeeded(langCode)) @@ -30,6 +30,25 @@ object ImageTagsProvider { } } + suspend fun getImageTags(pageId: Int, langCode: String): Map> { + try { + val claims = ServiceFactory.get(Constants.commonsWikiSite).getClaimsSuspend("M$pageId", "P180") + val ids = getDepictsClaims(claims.claims) + return if (ids.isEmpty()) { + emptyMap() + } else { + val response = ServiceFactory.get(Constants.wikidataWikiSite).getWikidataEntityTermsSuspend(ids.joinToString(separator = "|"), + LanguageUtil.convertToUselangIfNeeded(langCode)) + val labelList = response.query?.pages?.mapNotNull { + it.entityTerms?.label?.firstOrNull() + } + if (labelList.isNullOrEmpty()) emptyMap() else mapOf(langCode to labelList) + } + } catch (e: Exception) { + return emptyMap() + } + } + fun getDepictsClaims(claims: Map>): List { return claims["P180"]?.mapNotNull { it.mainSnak?.dataValue?.value() }.orEmpty() } diff --git a/app/src/main/java/org/wikipedia/dataclient/RestService.kt b/app/src/main/java/org/wikipedia/dataclient/RestService.kt index 55529503c55..3ddb73fa62d 100644 --- a/app/src/main/java/org/wikipedia/dataclient/RestService.kt +++ b/app/src/main/java/org/wikipedia/dataclient/RestService.kt @@ -12,11 +12,23 @@ import org.wikipedia.feed.configure.FeedAvailability import org.wikipedia.feed.onthisday.OnThisDay import org.wikipedia.gallery.MediaList import org.wikipedia.readinglist.sync.SyncedReadingLists -import org.wikipedia.readinglist.sync.SyncedReadingLists.* +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteIdResponse +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteIdResponseBatch +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteReadingList +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteReadingListEntry +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteReadingListEntryBatch import org.wikipedia.suggestededits.provider.SuggestedEditItem import retrofit2.Call import retrofit2.Response -import retrofit2.http.* +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query interface RestService { @@ -61,20 +73,13 @@ interface RestService { @Path("title") title: String ): PageSummary - // todo: this Content Service-only endpoint is under page/ but that implementation detail should - // probably not be reflected here. Move to WordDefinitionClient - /** - * Gets selected Wiktionary content for a given title derived from user-selected text - * - * @param title the Wiktionary page title derived from user-selected Wikipedia article text - */ @Headers("Accept: $ACCEPT_HEADER_DEFINITION") @GET("page/definition/{title}") - fun getDefinition(@Path("title") title: String): Observable>> + suspend fun getDefinition(@Path("title") title: String): Map> - @get:GET("page/random/summary") - @get:Headers("Accept: $ACCEPT_HEADER_SUMMARY") - val randomSummary: Observable + @GET("page/random/summary") + @Headers("Accept: $ACCEPT_HEADER_SUMMARY") + suspend fun getRandomSummary(): PageSummary @GET("page/media-list/{title}/{revision}") fun getMediaList( @@ -102,17 +107,17 @@ interface RestService { fun getOnThisDay(@Path("mm") month: Int, @Path("dd") day: Int): Observable // TODO: Remove this before next fundraising campaign in 2024 - @get:GET("feed/announcements") - @get:Headers("Accept: " + ACCEPT_HEADER_PREFIX + "announcements/0.1.0\"") - val announcements: Observable + @GET("feed/announcements") + @Headers("Accept: " + ACCEPT_HEADER_PREFIX + "announcements/0.1.0\"") + suspend fun getAnnouncements(): AnnouncementList @Headers("Accept: " + ACCEPT_HEADER_PREFIX + "aggregated-feed/0.5.0\"") @GET("feed/featured/{year}/{month}/{day}") - fun getAggregatedFeed( + suspend fun getFeedFeatured( @Path("year") year: String?, @Path("month") month: String?, @Path("day") day: String? - ): Observable + ): AggregatedFeedContent @get:GET("feed/availability") val feedAvailability: Observable diff --git a/app/src/main/java/org/wikipedia/dataclient/Service.kt b/app/src/main/java/org/wikipedia/dataclient/Service.kt index a41fc636eaa..63fe513544a 100644 --- a/app/src/main/java/org/wikipedia/dataclient/Service.kt +++ b/app/src/main/java/org/wikipedia/dataclient/Service.kt @@ -6,7 +6,16 @@ import org.wikipedia.dataclient.discussiontools.DiscussionToolsEditResponse import org.wikipedia.dataclient.discussiontools.DiscussionToolsInfoResponse import org.wikipedia.dataclient.discussiontools.DiscussionToolsSubscribeResponse import org.wikipedia.dataclient.discussiontools.DiscussionToolsSubscriptionList -import org.wikipedia.dataclient.mwapi.* +import org.wikipedia.dataclient.donate.PaymentResponseContainer +import org.wikipedia.dataclient.mwapi.CreateAccountResponse +import org.wikipedia.dataclient.mwapi.MwParseResponse +import org.wikipedia.dataclient.mwapi.MwPostResponse +import org.wikipedia.dataclient.mwapi.MwQueryResponse +import org.wikipedia.dataclient.mwapi.MwStreamConfigsResponse +import org.wikipedia.dataclient.mwapi.ParamInfoResponse +import org.wikipedia.dataclient.mwapi.ShortenUrlResponse +import org.wikipedia.dataclient.mwapi.SiteMatrix +import org.wikipedia.dataclient.mwapi.TemplateDataResponse import org.wikipedia.dataclient.okhttp.OfflineCacheInterceptor import org.wikipedia.dataclient.rollback.RollbackPostResponse import org.wikipedia.dataclient.watch.WatchPostResponse @@ -16,7 +25,13 @@ import org.wikipedia.dataclient.wikidata.EntityPostResponse import org.wikipedia.dataclient.wikidata.Search import org.wikipedia.edit.Edit import org.wikipedia.login.LoginClient.LoginResponse -import retrofit2.http.* +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Query /** * Retrofit service layer for all API interactions, including regular MediaWiki and RESTBase. @@ -32,7 +47,7 @@ interface Service { fun getPageImages(@Query("titles") titles: String): Observable @GET( - MW_API_PREFIX + "action=query&redirects=&converttitles=&prop=description|pageimages|info&piprop=thumbnail" + + MW_API_PREFIX + "action=query&redirects=&converttitles=&prop=description|pageimages|coordinates|info&piprop=thumbnail" + "&pilicense=any&generator=prefixsearch&gpsnamespace=0&inprop=varianttitles|displaytitle&pithumbsize=" + PREFERRED_THUMB_SIZE ) suspend fun prefixSearch(@Query("gpssearch") searchTerm: String?, @@ -41,7 +56,7 @@ interface Service { @GET( MW_API_PREFIX + "action=query&converttitles=" + - "&prop=description|pageimages|pageprops|info&ppprop=mainpage|disambiguation" + + "&prop=description|pageimages|pageprops|coordinates|info&ppprop=mainpage|disambiguation" + "&generator=search&gsrnamespace=0&gsrwhat=text" + "&inprop=varianttitles|displaytitle" + "&gsrinfo=&gsrprop=redirecttitle&piprop=thumbnail&pilicense=any&pithumbsize=" + @@ -69,21 +84,34 @@ interface Service { @Query("gsroffset") gsrOffset: Int?, ): MwQueryResponse + @GET( + MW_API_PREFIX + "action=query&converttitles=" + + "&prop=description|info" + + "&generator=search&gsrnamespace=0&gsrwhat=text" + + "&inprop=varianttitles|displaytitle" + + "&gsrinfo=&gsrprop=redirecttitle" + ) + suspend fun fullTextSearchTemplates( + @Query("gsrsearch") searchTerm: String, + @Query("gsrlimit") gsrLimit: Int, + @Query("gsroffset") gsrOffset: Int?, + ): MwQueryResponse + @GET( MW_API_PREFIX + "action=query&generator=search&gsrnamespace=0&gsrqiprofile=classic_noboostlinks" + "&origin=*&piprop=thumbnail&prop=pageimages|description|info|pageprops" + "&inprop=varianttitles&smaxage=86400&maxage=86400&pithumbsize=" + PREFERRED_THUMB_SIZE ) - fun searchMoreLike( + suspend fun searchMoreLike( @Query("gsrsearch") searchTerm: String?, @Query("gsrlimit") gsrLimit: Int, @Query("pilimit") piLimit: Int, - ): Observable + ): MwQueryResponse // ------- Miscellaneous ------- - @get:GET(MW_API_PREFIX + "action=fancycaptchareload") - val newCaptcha: Observable + @GET(MW_API_PREFIX + "action=fancycaptchareload") + suspend fun getNewCaptcha(): Captcha @GET(MW_API_PREFIX + "action=query&prop=langlinks&lllimit=500&redirects=&converttitles=") suspend fun getLangLinks(@Query("titles") title: String): MwQueryResponse @@ -116,15 +144,18 @@ interface Service { ): Observable @GET(MW_API_PREFIX + "action=query&prop=imageinfo|entityterms&iiprop=timestamp|user|url|mime|extmetadata&iiurlwidth=" + PREFERRED_THUMB_SIZE) - fun getImageInfoWithEntityTerms( + suspend fun getImageInfoWithEntityTerms( @Query("titles") titles: String, @Query("iiextmetadatalanguage") metadataLang: String, @Query("wbetlanguage") entityLang: String - ): Observable + ): MwQueryResponse @GET(MW_API_PREFIX + "action=query&meta=userinfo&prop=info&inprop=protection&uiprop=groups") fun getProtectionInfo(@Query("titles") titles: String): Observable + @GET(MW_API_PREFIX + "action=query&meta=userinfo&prop=info&inprop=protection&uiprop=groups") + suspend fun getProtectionInfoSuspend(@Query("titles") titles: String): MwQueryResponse + @get:GET(MW_API_PREFIX + "action=sitematrix&smtype=language&smlangprop=code|name|localname&maxage=" + SITE_INFO_MAXAGE + "&smaxage=" + SITE_INFO_MAXAGE) val siteMatrix: Observable @@ -152,7 +183,7 @@ interface Service { fun parseText(@Query("text") text: String): Observable @GET(MW_API_PREFIX + "action=parse&prop=text&mobileformat=1&mainpage=1") - fun parseTextForMainPage(@Query("page") mainPageTitle: String): Observable + suspend fun parseTextForMainPage(@Query("page") mainPageTitle: String): MwParseResponse @GET(MW_API_PREFIX + "action=query&prop=info&generator=categories&inprop=varianttitles|displaytitle&gclshow=!hidden&gcllimit=500") suspend fun getCategories(@Query("titles") titles: String): MwQueryResponse @@ -210,6 +241,35 @@ interface Service { @Query("colimit") coLimit: Int, ): MwQueryResponse + @GET("api.php?format=json&action=getPaymentMethods") + suspend fun getPaymentMethods(@Query("country") country: String): PaymentResponseContainer + + @FormUrlEncoded + @POST("api.php?format=json&action=submitPayment") + suspend fun submitPayment( + @Field("amount") amount: String, + @Field("app_version") appVersion: String, + @Field("banner") banner: String, + @Field("city") city: String, + @Field("country") country: String, + @Field("currency") currency: String, + @Field("donor_country") donorCountry: String, + @Field("email") email: String, + @Field("first_name") firstName: String, + @Field("full_name") fullName: String, + @Field("language") language: String, + @Field("last_name") lastName: String, + @Field("recurring") recurring: String, + @Field("payment_token") paymentToken: String, + @Field("opt_in") optIn: String, + @Field("pay_the_fee") payTheFee: String, + @Field("payment_method") paymentMethod: String, + @Field("payment_network") paymentNetwork: String, + @Field("postal_code") postalCode: String, + @Field("state_province") stateProvince: String, + @Field("street_address") streetAddress: String + ): PaymentResponseContainer + // ------- CSRF, Login, and Create Account ------- @Headers("Cache-Control: no-cache") @@ -383,6 +443,7 @@ interface Service { suspend fun getUserContributions( @Query("ucuser") username: String, @Query("uclimit") maxCount: Int, + @Query("ucnamespace") ns: Int?, @Query("uccontinue") uccontinue: String? ): MwQueryResponse @@ -418,6 +479,12 @@ interface Service { @Query("sites") sites: String ): Observable + @GET(MW_API_PREFIX + "action=wbgetentities") + suspend fun getEntitiesByTitleSuspend( + @Query("titles") titles: String, + @Query("sites") sites: String + ): Entities + @GET(MW_API_PREFIX + "action=wbsearchentities&type=item&limit=20") fun searchEntities( @Query("search") searchTerm: String, @@ -431,15 +498,32 @@ interface Service { @Query("wbetlanguage") lang: String ): Observable + @GET(MW_API_PREFIX + "action=query&prop=entityterms") + suspend fun getWikidataEntityTermsSuspend( + @Query("titles") titles: String, + @Query("wbetlanguage") lang: String + ): MwQueryResponse + @GET(MW_API_PREFIX + "action=wbgetclaims") fun getClaims( @Query("entity") entity: String, @Query("property") property: String? ): Observable + @GET(MW_API_PREFIX + "action=wbgetclaims") + suspend fun getClaimsSuspend( + @Query("entity") entity: String, + @Query("property") property: String? + ): Claims + @GET(MW_API_PREFIX + "action=wbgetentities&props=descriptions|labels|sitelinks") suspend fun getWikidataLabelsAndDescriptions(@Query("ids") idList: String): Entities + @GET(MW_API_PREFIX + "action=wbgetentities&props=descriptions") + suspend fun getWikidataDescription(@Query("titles") titles: String, + @Query("sites") sites: String, + @Query("languages") langCode: String): Entities + @POST(MW_API_PREFIX + "action=wbsetclaim&errorlang=uselang") @FormUrlEncoded fun postSetClaim( @@ -636,7 +720,7 @@ interface Service { @Query("ggtlimit") count: Int ): MwQueryResponse - @GET(MW_API_PREFIX + "action=query&generator=search&gsrsearch=hasrecommendation%3Aimage&gsrnamespace=0&gsrsort=random&prop=growthimagesuggestiondata|revisions&rvprop=ids|timestamp|flags|comment|user|content&rvslots=main&rvsection=0") + @GET(MW_API_PREFIX + "action=query&generator=search&gsrsearch=hasrecommendation%3Aimage&gsrnamespace=0&gsrsort=random&prop=growthimagesuggestiondata|revisions|pageimages&rvprop=ids|timestamp|flags|comment|user|content&rvslots=main&rvsection=0") suspend fun getPagesWithImageRecommendations( @Query("gsrlimit") count: Int ): MwQueryResponse @@ -655,6 +739,10 @@ interface Service { @Query("modules") modules: String ): ParamInfoResponse + @GET(MW_API_PREFIX + "action=templatedata&includeMissingTitles=&converttitles=") + suspend fun getTemplateData(@Query("lang") langCode: String, + @Query("titles") titles: String): TemplateDataResponse + companion object { const val WIKIPEDIA_URL = "https://wikipedia.org/" const val WIKIDATA_URL = "https://www.wikidata.org/" diff --git a/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt b/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt index aef957cd76d..e75ea6c0ebf 100644 --- a/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt +++ b/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt @@ -3,6 +3,7 @@ package org.wikipedia.dataclient import okhttp3.Cookie import okhttp3.CookieJar import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrl import org.wikipedia.settings.Prefs import org.wikipedia.util.log.L @@ -24,8 +25,18 @@ class SharedPreferenceCookieManager( @Synchronized fun getCookieByName(name: String): String? { for (domainSpec in cookieJar.keys) { - for (cookie in cookieJar[domainSpec]!!) { - if (cookie.name == name) { + getCookieByName(name, domainSpec)?.let { + return it + } + } + return null + } + + @Synchronized + fun getCookieByName(name: String, domainSpec: String, matchExactName: Boolean = true): String? { + cookieJar[domainSpec]?.let { cookies -> + for (cookie in cookies) { + if (if (matchExactName) cookie.name == name else cookie.name.contains(name, ignoreCase = false)) { return cookie.value } } @@ -97,6 +108,11 @@ class SharedPreferenceCookieManager( return cookieList } + @Synchronized + fun loadForRequest(url: String): List { + return loadForRequest(url.toHttpUrl()) + } + private fun buildCookieList(outList: MutableList, inList: MutableList, prefix: String?) { val i = inList.iterator() var cookieJarModified = false diff --git a/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfig.kt b/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfig.kt new file mode 100644 index 00000000000..4fe767442f9 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfig.kt @@ -0,0 +1,14 @@ +package org.wikipedia.dataclient.donate + +import kotlinx.serialization.Serializable + +@Suppress("unused") +@Serializable +class DonationConfig( + val version: Int, + val currencyMinimumDonation: Map = emptyMap(), + val currencyMaximumDonation: Map = emptyMap(), + val currencyAmountPresets: Map> = emptyMap(), + val currencyTransactionFees: Map = emptyMap(), + val countryCodeEmailOptInRequired: List = emptyList() +) diff --git a/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfigHelper.kt b/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfigHelper.kt new file mode 100644 index 00000000000..e73077283eb --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfigHelper.kt @@ -0,0 +1,41 @@ +package org.wikipedia.dataclient.donate + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import okhttp3.Request +import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory +import org.wikipedia.json.JsonUtil + +object DonationConfigHelper { + + private const val CONFIG_VERSION = 1 + + private const val CONFIG_URL = "https://donate.wikimedia.org/wiki/MediaWiki:AppsDonationConfig.json?action=raw" + + suspend fun getConfig(): DonationConfig? { + val campaignList = mutableListOf() + + withContext(Dispatchers.IO) { + val url = CONFIG_URL + val request = Request.Builder().url(url).build() + val response = OkHttpConnectionFactory.client.newCall(request).execute() + val configs = JsonUtil.decodeFromString>(response.body?.string()).orEmpty() + + campaignList.addAll(configs.filter { + val proto = JsonUtil.json.decodeFromJsonElement(it) + proto.version == CONFIG_VERSION + }.map { + JsonUtil.json.decodeFromJsonElement(it) + }) + } + return campaignList.firstOrNull() + } + + @Serializable + class ConfigProto( + val version: Int = 0 + ) +} diff --git a/app/src/main/java/org/wikipedia/dataclient/donate/PaymentResponseContainer.kt b/app/src/main/java/org/wikipedia/dataclient/donate/PaymentResponseContainer.kt new file mode 100644 index 00000000000..fc09cddeec1 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/donate/PaymentResponseContainer.kt @@ -0,0 +1,48 @@ +package org.wikipedia.dataclient.donate + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.wikipedia.dataclient.mwapi.MwException +import org.wikipedia.dataclient.mwapi.MwServiceError + +@Suppress("unused") +@Serializable +class PaymentResponseContainer( + val response: PaymentResponse? = null +) + +@Suppress("unused") +@Serializable +class PaymentResponse( + val status: String = "", + @SerialName("error_message") val errorMessage: String = "", + @SerialName("order_id") val orderId: String = "", + @SerialName("gateway_transaction_id") val gatewayTransactionId: String = "", + val paymentMethods: List = emptyList() +) { + init { + if (status == "error") { + throw MwException(MwServiceError("donate_error", errorMessage)) + } + } +} + +@Suppress("unused") +@Serializable +class PaymentMethod( + val name: String = "", + val type: String = "", + val brands: List = emptyList(), + val configuration: PaymentMethodConfiguration? = null +) + +@Suppress("unused") +@Serializable +class PaymentMethodConfiguration( + val merchantId: String = "", + val merchantName: String = "", + val gatewayMerchantId: String = "", + val storeId: String = "", + val region: String = "", + val publicKeyId: String = "" +) diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt index 0a27bd04e99..b0a653e42e6 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt @@ -14,7 +14,9 @@ import org.wikipedia.page.PageTitle import org.wikipedia.settings.SiteInfo import org.wikipedia.util.DateUtil import org.wikipedia.util.StringUtil -import java.time.* +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId import java.util.Date @Serializable @@ -207,7 +209,7 @@ class MwQueryResult { private val minor = false val oldlen = 0 val newlen = 0 - val timestamp: String = "" + private val timestamp: String = "" @SerialName("parsedcomment") val parsedComment: String = "" private val tags: List? = null diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt new file mode 100644 index 00000000000..f196c872058 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt @@ -0,0 +1,65 @@ +package org.wikipedia.dataclient.mwapi + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive +import org.wikipedia.json.JsonUtil +import org.wikipedia.util.log.L + +@Serializable +class TemplateDataResponse : MwResponse() { + + private val pages: Map? = null + + val getTemplateData get() = pages?.values?.toList() ?: emptyList() + + @Serializable + class TemplateData { + val title: String = "" + // When send lang=[langCode], the type of it will become String instead of a Map + val description: String? = null + private val params: JsonElement? = null + val format: String? = null + @SerialName("notemplatedata") val noTemplateData: Boolean = false + + val getParams: Map? get() { + try { + if (params != null && params !is JsonArray) { + return if (noTemplateData) { + JsonUtil.json.decodeFromJsonElement>(params).mapValues { + TemplateDataParam() + } + } else { + JsonUtil.json.decodeFromJsonElement>(params) + } + } + } catch (e: Exception) { + L.d("Error on parsing params $e") + } + return null + } + } + + @Serializable + class TemplateDataParam { + // [label, description, default and example]: The original format of them is in a Map style; + // When you send a target language in the request, it will become a String. + val label: String? = null + val description: String? = null + val default: String? = null + val example: String? = null + val type: String = "" + val required: Boolean = false + val suggested: Boolean = false + @SerialName("autovalue") val autoValue: String? = null + @SerialName("suggestedvalues") val suggestedValues: List = emptyList() + val aliases: List = emptyList() + private val deprecated: JsonElement? = null + + val isDeprecated get() = deprecated != null && !deprecated.jsonPrimitive.contentOrNull.equals("false", true) + } +} diff --git a/app/src/main/java/org/wikipedia/dataclient/okhttp/CommonHeaderRequestInterceptor.kt b/app/src/main/java/org/wikipedia/dataclient/okhttp/CommonHeaderRequestInterceptor.kt index 18a408fdb4e..b4111f34e12 100644 --- a/app/src/main/java/org/wikipedia/dataclient/okhttp/CommonHeaderRequestInterceptor.kt +++ b/app/src/main/java/org/wikipedia/dataclient/okhttp/CommonHeaderRequestInterceptor.kt @@ -15,6 +15,8 @@ internal class CommonHeaderRequestInterceptor : Interceptor { .header("X-WMF-UUID", app.appInstallID) if (chain.request().url.encodedPath.contains(RestService.PAGE_HTML_ENDPOINT)) { builder.header("Accept", RestService.ACCEPT_HEADER_MOBILE_HTML) + } else if (chain.request().url.host.contains("maps.wikimedia.org")) { + builder.header("Referer", "https://maps.wikimedia.org/") } return chain.proceed(builder.build()) } diff --git a/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpConnectionFactory.kt b/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpConnectionFactory.kt index d8f5b65debc..db5f777b10d 100644 --- a/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpConnectionFactory.kt +++ b/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpConnectionFactory.kt @@ -1,13 +1,18 @@ package org.wikipedia.dataclient.okhttp +import android.os.Build import okhttp3.Cache import okhttp3.CacheControl import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import okhttp3.tls.HandshakeCertificates +import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.SharedPreferenceCookieManager import org.wikipedia.settings.Prefs import java.io.File +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit object OkHttpConnectionFactory { @@ -21,7 +26,7 @@ object OkHttpConnectionFactory { val client = createClient() private fun createClient(): OkHttpClient { - return OkHttpClient.Builder() + val builder = OkHttpClient.Builder() .cookieJar(SharedPreferenceCookieManager.instance) .cache(NET_CACHE) .readTimeout(20, TimeUnit.SECONDS) @@ -33,6 +38,17 @@ object OkHttpConnectionFactory { .addInterceptor(TestStubInterceptor()) .addInterceptor(TitleEncodeInterceptor()) .addInterceptor(HttpLoggingInterceptor().setLevel(Prefs.retrofitLogLevel)) + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { + val certFactory = CertificateFactory.getInstance("X.509") + val certificates = HandshakeCertificates.Builder() + .addPlatformTrustedCertificates() + .addTrustedCertificate(certFactory.generateCertificate(WikipediaApp.instance.resources.openRawResource(R.raw.isrg_root_x1)) as X509Certificate) + .addTrustedCertificate(certFactory.generateCertificate(WikipediaApp.instance.resources.openRawResource(R.raw.isrg_root_x2)) as X509Certificate) .build() + builder.sslSocketFactory(certificates.sslSocketFactory(), certificates.trustManager) + } + + return builder.build() } } diff --git a/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpWebViewClient.kt b/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpWebViewClient.kt index 532a7dd089b..08d030a7f88 100644 --- a/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpWebViewClient.kt +++ b/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpWebViewClient.kt @@ -41,7 +41,8 @@ abstract class OkHttpWebViewClient : WebViewClient() { if (!SUPPORTED_SCHEMES.contains(request.url.scheme)) { return null } - if (request.url.toString().contains(RestService.PAGE_HTML_PREVIEW_ENDPOINT)) { + if (request.method == "POST" || + request.url.toString().contains(RestService.PAGE_HTML_PREVIEW_ENDPOINT)) { return null } var response: WebResourceResponse diff --git a/app/src/main/java/org/wikipedia/dataclient/restbase/RbDefinition.kt b/app/src/main/java/org/wikipedia/dataclient/restbase/RbDefinition.kt index 65b4f07f541..31df15d60b0 100644 --- a/app/src/main/java/org/wikipedia/dataclient/restbase/RbDefinition.kt +++ b/app/src/main/java/org/wikipedia/dataclient/restbase/RbDefinition.kt @@ -3,7 +3,7 @@ package org.wikipedia.dataclient.restbase import kotlinx.serialization.Serializable @Serializable -class RbDefinition(val usagesByLang: Map>) { +class RbDefinition { @Serializable class Usage(val partOfSpeech: String = "", val definitions: List) diff --git a/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt b/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt index 12735d80c60..8a25a091888 100644 --- a/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt +++ b/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt @@ -37,6 +37,10 @@ class Entities : MwResponse() { emptyMap() } } + + fun getDescription(langCode: String): String? { + return descriptions[langCode]?.value + } } @Serializable diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt index be16f86d193..17c268e1006 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt @@ -10,6 +10,7 @@ import org.wikipedia.activity.SingleFragmentActivity import org.wikipedia.analytics.eventplatform.ABTest.Companion.GROUP_1 import org.wikipedia.analytics.eventplatform.MachineGeneratedArticleDescriptionsAnalyticsHelper import org.wikipedia.auth.AccountUtil +import org.wikipedia.commons.ImagePreviewDialog import org.wikipedia.extensions.parcelableExtra import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter @@ -19,7 +20,6 @@ import org.wikipedia.settings.Prefs import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.util.DeviceUtil import org.wikipedia.util.ReleaseUtil -import org.wikipedia.views.ImagePreviewDialog import org.wikipedia.views.SuggestedArticleDescriptionsDialog class DescriptionEditActivity : SingleFragmentActivity(), DescriptionEditFragment.Callback { diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditBottomBarView.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditBottomBarView.kt index 3c20fc141c2..5df3a3d0c9b 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditBottomBarView.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditBottomBarView.kt @@ -27,7 +27,7 @@ class DescriptionEditBottomBarView constructor(context: Context, attrs: Attribut fun setSummary(summaryForEdit: PageSummaryForEdit) { setConditionalLayoutDirection(this, summaryForEdit.lang) - binding.viewArticleTitle.text = StringUtil.fromHtml(StringUtil.removeNamespace(summaryForEdit.displayTitle!!)) + binding.viewArticleTitle.text = StringUtil.fromHtml(StringUtil.removeNamespace(summaryForEdit.displayTitle)) if (summaryForEdit.thumbnailUrl.isNullOrEmpty()) { binding.viewImageThumbnail.visibility = GONE } else { diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt index 46d433ecc01..7458a42fcf9 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt @@ -9,6 +9,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -16,7 +17,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R @@ -46,11 +48,13 @@ import org.wikipedia.settings.Prefs import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.suggestededits.SuggestedEditsSurvey import org.wikipedia.suggestededits.SuggestionsActivity -import org.wikipedia.util.* +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ReleaseUtil +import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L import java.io.IOException -import java.lang.Runnable -import java.util.* +import java.util.Date import java.util.concurrent.TimeUnit class DescriptionEditFragment : Fragment() { @@ -140,7 +144,7 @@ class DescriptionEditFragment : Fragment() { val loginIntent = LoginActivity.newIntent(requireActivity(), LoginActivity.SOURCE_EDIT) loginLauncher.launch(loginIntent) } - captchaHandler = CaptchaHandler(requireActivity(), pageTitle.wikiSite, binding.fragmentDescriptionEditView.getCaptchaContainer().root, + captchaHandler = CaptchaHandler(requireActivity() as AppCompatActivity, pageTitle.wikiSite, binding.fragmentDescriptionEditView.getCaptchaContainer().root, binding.fragmentDescriptionEditView.getDescriptionEditTextView(), "", null) return binding.root } @@ -545,6 +549,7 @@ class DescriptionEditFragment : Fragment() { const val SUGGESTED_EDITS_TRANSLATE_DESC_COMMENT = "#suggestededit-translate-desc $SUGGESTED_EDITS_UI_VERSION" const val SUGGESTED_EDITS_ADD_CAPTION_COMMENT = "#suggestededit-add-caption $SUGGESTED_EDITS_UI_VERSION" const val SUGGESTED_EDITS_TRANSLATE_CAPTION_COMMENT = "#suggestededit-translate-caption $SUGGESTED_EDITS_UI_VERSION" + const val SUGGESTED_EDITS_IMAGE_TAGS_COMMENT = "#suggestededit-add-tag $SUGGESTED_EDITS_UI_VERSION" private val DESCRIPTION_TEMPLATES = arrayOf("Short description", "SHORTDESC") // Don't remove the ending escaped `\\}` diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditView.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditView.kt index 53ec90f45be..7cf08ea31a0 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditView.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditView.kt @@ -10,10 +10,12 @@ import android.view.LayoutInflater import android.view.inputmethod.EditorInfo import android.widget.LinearLayout import androidx.appcompat.content.res.AppCompatResources +import androidx.core.graphics.BlendModeColorFilterCompat +import androidx.core.graphics.BlendModeCompat import androidx.core.view.isVisible import androidx.core.widget.ImageViewCompat import androidx.core.widget.addTextChangedListener -import de.mrapp.android.view.drawable.CircularProgressDrawable +import androidx.swiperefreshlayout.widget.CircularProgressDrawable import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.MachineGeneratedArticleDescriptionsAnalyticsHelper @@ -65,7 +67,7 @@ class DescriptionEditView : LinearLayout, MlKitLanguageDetector.Callback { } init { - FeedbackUtil.setButtonLongPressToast(binding.viewDescriptionEditSaveButton, binding.viewDescriptionEditCancelButton) + FeedbackUtil.setButtonTooltip(binding.viewDescriptionEditSaveButton, binding.viewDescriptionEditCancelButton) orientation = VERTICAL mlKitLanguageDetector.callback = this @@ -417,7 +419,11 @@ class DescriptionEditView : LinearLayout, MlKitLanguageDetector.Callback { fun showSuggestedDescriptionsLoadingProgress() { binding.suggestedDescButton.isVisible = true binding.suggestedDescButton.isEnabled = false - binding.suggestedDescButton.chipIcon = CircularProgressDrawable(ResourceUtil.getThemedColor(context, R.attr.primary_color), 1).also { it.start() } + val drawable = CircularProgressDrawable(context) + drawable.strokeWidth = DimenUtil.dpToPx(1.5f) + drawable.colorFilter = BlendModeColorFilterCompat.createBlendModeColorFilterCompat(ResourceUtil.getThemedColor(context, R.attr.primary_color), BlendModeCompat.SRC_IN) + binding.suggestedDescButton.chipIcon = drawable + drawable.start() } fun updateSuggestedDescriptionsButtonVisibility() { diff --git a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsActivity.kt b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsActivity.kt index b8d96f45461..0b656d2577a 100644 --- a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsActivity.kt +++ b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsActivity.kt @@ -4,6 +4,8 @@ import android.content.Context import android.content.Intent import android.os.Bundle import androidx.fragment.app.commit +import org.wikipedia.Constants.INTENT_EXTRA_INVOKE_SOURCE +import org.wikipedia.Constants.InvokeSource import org.wikipedia.activity.BaseActivity import org.wikipedia.databinding.ActivityArticleEditDetailsBinding import org.wikipedia.extensions.parcelableExtra @@ -23,7 +25,7 @@ class ArticleEditDetailsActivity : BaseActivity() { intent.getIntExtra(EXTRA_PAGE_ID, -1), intent.getLongExtra(EXTRA_EDIT_REVISION_FROM, -1), intent.getLongExtra(EXTRA_EDIT_REVISION_TO, -1), - intent.getBooleanExtra(EXTRA_FROM_RECENT_EDITS, false)) + intent.getSerializableExtra(INTENT_EXTRA_INVOKE_SOURCE) as InvokeSource) if (savedInstanceState == null) { supportFragmentManager.commit { add(binding.fragmentContainer.id, fragment) } @@ -35,23 +37,18 @@ class ArticleEditDetailsActivity : BaseActivity() { const val EXTRA_PAGE_ID = "pageId" const val EXTRA_EDIT_REVISION_FROM = "revisionFrom" const val EXTRA_EDIT_REVISION_TO = "revisionTo" - const val EXTRA_FROM_RECENT_EDITS = "fromRecentEdits" fun newIntent(context: Context, title: PageTitle, revisionTo: Long): Intent { return newIntent(context, title, -1, -1, revisionTo) } - fun newIntent(context: Context, title: PageTitle, pageId: Int, revisionTo: Long): Intent { - return newIntent(context, title, pageId, -1, revisionTo) - } - - fun newIntent(context: Context, title: PageTitle, pageId: Int, revisionFrom: Long = -1, revisionTo: Long, fromRecentEdits: Boolean = false): Intent { + fun newIntent(context: Context, title: PageTitle, pageId: Int, revisionFrom: Long = -1, revisionTo: Long, source: InvokeSource = InvokeSource.DIFF_ACTIVITY): Intent { return Intent(context, ArticleEditDetailsActivity::class.java) .putExtra(EXTRA_ARTICLE_TITLE, title) .putExtra(EXTRA_PAGE_ID, pageId) .putExtra(EXTRA_EDIT_REVISION_FROM, revisionFrom) .putExtra(EXTRA_EDIT_REVISION_TO, revisionTo) - .putExtra(EXTRA_FROM_RECENT_EDITS, fromRecentEdits) + .putExtra(INTENT_EXTRA_INVOKE_SOURCE, source) } } } diff --git a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt index e9f994b508b..d8715fe55f4 100644 --- a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt +++ b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt @@ -12,9 +12,7 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView -import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.PopupMenu @@ -29,10 +27,11 @@ import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.appbar.AppBarLayout import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar -import com.google.android.material.textfield.TextInputEditText +import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.activity.FragmentUtil +import org.wikipedia.analytics.eventplatform.EditAttemptStepEvent import org.wikipedia.analytics.eventplatform.EditHistoryInteractionEvent import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent import org.wikipedia.auth.AccountUtil @@ -40,6 +39,7 @@ import org.wikipedia.commons.FilePageActivity import org.wikipedia.databinding.FragmentArticleEditDetailsBinding import org.wikipedia.dataclient.mwapi.MwQueryPage.Revision import org.wikipedia.dataclient.okhttp.HttpStatusException +import org.wikipedia.extensions.parcelableExtra import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.Namespace @@ -53,9 +53,9 @@ import org.wikipedia.suggestededits.SuggestedEditsCardsFragment import org.wikipedia.talk.TalkReplyActivity import org.wikipedia.talk.TalkTopicsActivity import org.wikipedia.talk.UserTalkPopupHelper +import org.wikipedia.talk.template.TalkTemplatesActivity import org.wikipedia.util.ClipboardUtil import org.wikipedia.util.DateUtil -import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.L10nUtil import org.wikipedia.util.Resource @@ -64,6 +64,7 @@ import org.wikipedia.util.ShareUtil import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L +import org.wikipedia.views.SurveyDialog import org.wikipedia.watchlist.WatchlistExpiry import org.wikipedia.watchlist.WatchlistExpiryDialog @@ -85,30 +86,25 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M binding.overlayRevisionDetailsView.isVisible = -verticalOffset > bounds.top } - private val requestWarn = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == TalkReplyActivity.RESULT_EDIT_SUCCESS || it.resultCode == TalkReplyActivity.RESULT_SAVE_TEMPLATE) { - viewModel.revisionTo?.let { revision -> - val pageTitle = PageTitle(UserAliasData.valueFor(viewModel.pageTitle.wikiSite.languageCode), revision.user, viewModel.pageTitle.wikiSite) - val message = if (it.resultCode == TalkReplyActivity.RESULT_EDIT_SUCCESS) { - sendPatrollerExperienceEvent("publish_message_toast", "pt_warning_messages") - R.string.talk_warn_submitted - } else { - sendPatrollerExperienceEvent("publish_message_saved_toast", "pt_warning_messages") - R.string.talk_warn_submitted_and_saved - } - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(message)) - snackbar.setAction(R.string.patroller_tasks_patrol_edit_snackbar_view) { - sendPatrollerExperienceEvent("publish_message_view_click", "pt_warning_messages") - startActivity(TalkTopicsActivity.newIntent(requireContext(), pageTitle, InvokeSource.DIFF_ACTIVITY)) - } - snackbar.show() + private val requestTalk = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == TalkReplyActivity.RESULT_EDIT_SUCCESS || result.resultCode == TalkReplyActivity.RESULT_SAVE_TEMPLATE) { + val pageTitle = result.data?.parcelableExtra(Constants.ARG_TITLE) ?: viewModel.pageTitle + val message = if (result.resultCode == TalkReplyActivity.RESULT_EDIT_SUCCESS) { + PatrollerExperienceEvent.logAction("publish_message_toast", "pt_warning_messages") + R.string.talk_warn_submitted + } else { + PatrollerExperienceEvent.logAction("publish_message_saved_toast", "pt_warning_messages") + R.string.talk_warn_submitted_and_saved } - } - } - - private fun sendPatrollerExperienceEvent(action: String, activeInterface: String, actionData: String = "") { - if (viewModel.fromRecentEdits) { - PatrollerExperienceEvent.logAction(action, activeInterface, actionData) + FeedbackUtil.makeSnackbar(requireActivity(), getString(message)) + .setAction(R.string.patroller_tasks_patrol_edit_snackbar_view) { + if (isAdded) { + PatrollerExperienceEvent.logAction("publish_message_view_click", "pt_warning_messages") + startActivity(TalkTopicsActivity.newIntent(requireContext(), pageTitle, InvokeSource.DIFF_ACTIVITY)) + } + } + .setAnchorView(binding.navTabContainer) + .show() } } @@ -126,7 +122,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M super.onCreateView(inflater, container, savedInstanceState) _binding = FragmentArticleEditDetailsBinding.inflate(inflater, container, false) binding.diffRecyclerView.layoutManager = LinearLayoutManager(requireContext()) - FeedbackUtil.setButtonLongPressToast(binding.newerIdButton, binding.olderIdButton) + FeedbackUtil.setButtonTooltip(binding.newerIdButton, binding.olderIdButton) return binding.root } @@ -136,6 +132,10 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M setLoadingState() requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + if (savedInstanceState == null) { + EditAttemptStepEvent.logInit(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) + } + if (!viewModel.fromRecentEdits) { (requireActivity() as AppCompatActivity).supportActionBar?.title = getString(R.string.revision_diff_compare) binding.articleTitleView.text = StringUtil.fromHtml(viewModel.pageTitle.displayText) @@ -199,6 +199,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M viewModel.undoEditResponse.observe(viewLifecycleOwner) { binding.progressBar.isVisible = false if (it is Resource.Success) { + EditAttemptStepEvent.logSaveSuccess(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) setLoadingState() viewModel.getRevisionDetails(it.data.edit!!.newRevId) sendPatrollerExperienceEvent("undo_success", "pt_edit", @@ -207,6 +208,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M editHistoryInteractionEvent?.logUndoSuccess() callback()?.onUndoSuccess() } else if (it is Resource.Error) { + EditAttemptStepEvent.logSaveFailure(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) it.throwable.printStackTrace() FeedbackUtil.showError(requireActivity(), it.throwable) editHistoryInteractionEvent?.logUndoFail() @@ -226,6 +228,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M viewModel.rollbackResponse.observe(viewLifecycleOwner) { binding.progressBar.isVisible = false if (it is Resource.Success) { + EditAttemptStepEvent.logSaveSuccess(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) setLoadingState() viewModel.getRevisionDetails(it.data.rollback?.revision ?: 0) sendPatrollerExperienceEvent("rollback_success", "pt_edit", @@ -233,6 +236,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M showRollbackSnackbar() callback()?.onRollbackSuccess() } else if (it is Resource.Error) { + EditAttemptStepEvent.logSaveFailure(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) it.throwable.printStackTrace() FeedbackUtil.showError(requireActivity(), it.throwable) } @@ -311,7 +315,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M binding.undoButton.setOnClickListener { val canUndo = viewModel.revisionFrom != null && AccountUtil.isLoggedIn val canRollback = AccountUtil.isLoggedIn && viewModel.hasRollbackRights && !viewModel.canGoForward - + EditAttemptStepEvent.logSaveIntent(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) if (canUndo && canRollback) { PopupMenu(requireContext(), binding.undoLabel, Gravity.END).apply { menuInflater.inflate(R.menu.menu_context_undo, menu) @@ -352,7 +356,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M sendPatrollerExperienceEvent("warn_init", "pt_toolbar") viewModel.revisionTo?.let { revision -> val pageTitle = PageTitle(UserTalkAliasData.valueFor(viewModel.pageTitle.wikiSite.languageCode), revision.user, viewModel.pageTitle.wikiSite) - requestWarn.launch(TalkReplyActivity.newIntent(requireContext(), pageTitle, null, null, invokeSource = InvokeSource.DIFF_ACTIVITY, fromDiff = true)) + requestTalk.launch(TalkTemplatesActivity.newIntent(requireContext(), pageTitle, fromRevisionId = viewModel.revisionFromId, toRevisionId = viewModel.revisionToId)) } } @@ -368,6 +372,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M menu.findItem(R.id.menu_view_edit_history).isVisible = viewModel.fromRecentEdits menu.findItem(R.id.menu_report_feature).isVisible = viewModel.fromRecentEdits menu.findItem(R.id.menu_learn_more).isVisible = viewModel.fromRecentEdits + menu.findItem(R.id.menu_saved_messages).isVisible = viewModel.fromRecentEdits } override fun onMenuItemSelected(item: MenuItem): Boolean { @@ -398,6 +403,12 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M showFeedbackOptionsDialog(true) true } + R.id.menu_saved_messages -> { + sendPatrollerExperienceEvent("diff_saved_init", "pt_warning_messages") + val pageTitle = PageTitle(UserTalkAliasData.valueFor(viewModel.pageTitle.wikiSite.languageCode), viewModel.pageTitle.text, viewModel.pageTitle.wikiSite) + requireActivity().startActivity(TalkTemplatesActivity.newIntent(requireContext(), pageTitle, true)) + true + } else -> false } } @@ -544,52 +555,54 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M updateWatchButton(false) if (!viewModel.isWatched) { sendPatrollerExperienceEvent("unwatch_success_toast", "pt_watchlist") - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.watchlist_page_removed_from_watchlist_snackbar, viewModel.pageTitle.displayText)) - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.watchlist_page_removed_from_watchlist_snackbar, viewModel.pageTitle.displayText)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() } - showFeedbackOptionsDialog() - } - }) - snackbar.show() + }) + .setAnchorView(binding.navTabContainer) + .show() } else if (viewModel.isWatched) { sendPatrollerExperienceEvent("watch_success_toast", "pt_watchlist") - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.watchlist_page_add_to_watchlist_snackbar, viewModel.pageTitle.displayText, getString(WatchlistExpiry.NEVER.stringId))) - snackbar.setAction(R.string.watchlist_page_add_to_watchlist_snackbar_action) { - ExclusiveBottomSheetPresenter.show(childFragmentManager, WatchlistExpiryDialog.newInstance(viewModel.pageTitle, WatchlistExpiry.NEVER)) - } - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return - } - showFeedbackOptionsDialog() + .setAction(R.string.watchlist_page_add_to_watchlist_snackbar_action) { + ExclusiveBottomSheetPresenter.show(childFragmentManager, WatchlistExpiryDialog.newInstance(viewModel.pageTitle, WatchlistExpiry.NEVER)) } - }) - snackbar.show() + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() + } + }) + .setAnchorView(binding.navTabContainer) + .show() } } private fun showThankSnackbar() { - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.thank_success_message, - viewModel.revisionTo?.user)) binding.thankIcon.setImageResource(R.drawable.ic_heart_24) binding.thankButton.isEnabled = false - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.thank_success_message, viewModel.revisionTo?.user)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() } - showFeedbackOptionsDialog() - } - }) + }) + .setAnchorView(binding.navTabContainer) + .show() sendPatrollerExperienceEvent("thank_success", "pt_thank") - snackbar.show() } private fun showThankDialog() { @@ -614,6 +627,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M if (viewModel.fromRecentEdits) InvokeSource.SUGGESTED_EDITS_RECENT_EDITS else null) { text -> viewModel.revisionTo?.let { binding.progressBar.isVisible = true + EditAttemptStepEvent.logSaveAttempt(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) viewModel.undoEdit(viewModel.pageTitle, it.user, text.toString(), viewModel.revisionToId, 0) } } @@ -621,16 +635,17 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M } private fun showUndoSnackbar() { - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.patroller_tasks_patrol_edit_undo_success)) - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.patroller_tasks_patrol_edit_undo_success)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() } - showFeedbackOptionsDialog() - } - }) - snackbar.show() + }) + .setAnchorView(binding.navTabContainer) + .show() } private fun showRollbackDialog() { @@ -640,6 +655,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M sendPatrollerExperienceEvent("rollback_confirm", "pt_edit") binding.progressBar.isVisible = true viewModel.revisionTo?.let { + EditAttemptStepEvent.logSaveAttempt(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) viewModel.postRollback(viewModel.pageTitle, it.user) } } @@ -650,16 +666,17 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M } private fun showRollbackSnackbar() { - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.patroller_tasks_patrol_edit_rollback_success)) - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.patroller_tasks_patrol_edit_rollback_success)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() } - showFeedbackOptionsDialog() - } - }) - snackbar.show() + }) + .setAnchorView(binding.navTabContainer) + .show() } private fun showFeedbackOptionsDialog(skipPreference: Boolean = false) { @@ -669,74 +686,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M if (Prefs.showOneTimeRecentEditsFeedbackForm) { sendPatrollerExperienceEvent("toolbar_first_feedback", "pt_feedback") } - var dialog: AlertDialog? = null - val feedbackView = layoutInflater.inflate(R.layout.dialog_patrol_edit_feedback_options, null) - - val clickListener = View.OnClickListener { - viewModel.feedbackOption = (it as TextView).text.toString() - dialog?.dismiss() - if (viewModel.feedbackOption == getString(R.string.patroller_diff_feedback_dialog_option_satisfied)) { - showFeedbackSnackbarAndTooltip() - } else { - showFeedbackInputDialog() - } - sendPatrollerExperienceEvent("feedback_selection", "pt_feedback", - PatrollerExperienceEvent.getActionDataString(feedbackOption = viewModel.feedbackOption)) - } - - feedbackView.findViewById(R.id.optionSatisfied).setOnClickListener(clickListener) - feedbackView.findViewById(R.id.optionNeutral).setOnClickListener(clickListener) - feedbackView.findViewById(R.id.optionUnsatisfied).setOnClickListener(clickListener) - PatrollerExperienceEvent.logImpression("pt_feedback") - dialog = MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.patroller_diff_feedback_dialog_title) - .setCancelable(false) - .setView(feedbackView) - .show() - } - - private fun showFeedbackInputDialog() { - if (!viewModel.fromRecentEdits) { - return - } - val feedbackView = layoutInflater.inflate(R.layout.dialog_patrol_edit_feedback_input, null) - sendPatrollerExperienceEvent("feedback_input_impression", "pt_feedback") - MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.patroller_diff_feedback_dialog_feedback_title) - .setView(feedbackView) - .setPositiveButton(R.string.patroller_diff_feedback_dialog_submit) { _, _ -> - viewModel.feedbackInput = feedbackView.findViewById(R.id.feedbackInput).text.toString() - sendPatrollerExperienceEvent("feedback_submit", "pt_feedback", - PatrollerExperienceEvent.getActionDataString(feedbackText = viewModel.feedbackInput)) - showFeedbackSnackbarAndTooltip() - } - .show() - } - - private fun showFeedbackSnackbarAndTooltip() { - if (!viewModel.fromRecentEdits) { - return - } - FeedbackUtil.showMessage(this@ArticleEditDetailsFragment, R.string.patroller_diff_feedback_submitted_snackbar) - sendPatrollerExperienceEvent("feedback_submit_toast", "pt_feedback") - requireActivity().window.decorView.postDelayed({ - val anchorView = requireActivity().findViewById(R.id.more_options) - if (!requireActivity().isDestroyed && anchorView != null && Prefs.showOneTimeRecentEditsFeedbackForm) { - sendPatrollerExperienceEvent("tooltip_impression", "pt_feedback") - FeedbackUtil.getTooltip( - requireActivity(), - getString(R.string.patroller_diff_feedback_tooltip), - arrowAnchorPadding = -DimenUtil.roundedDpToPx(7f), - topOrBottomMargin = 0, - aboveOrBelow = false, - autoDismiss = false, - showDismissButton = true - ).apply { - showAlignBottom(anchorView) - Prefs.showOneTimeRecentEditsFeedbackForm = false - } - } - }, 100) + SurveyDialog.showFeedbackOptionsDialog(requireActivity(), InvokeSource.SUGGESTED_EDITS_RECENT_EDITS) } private fun updateActionButtons() { @@ -762,18 +712,24 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M FeedbackUtil.showMessage(this, R.string.address_copied) } + private fun sendPatrollerExperienceEvent(action: String, activeInterface: String, actionData: String = "") { + if (viewModel.fromRecentEdits) { + PatrollerExperienceEvent.logAction(action, activeInterface, actionData) + } + } + private fun callback(): Callback? { return FragmentUtil.getCallback(this, Callback::class.java) } companion object { - fun newInstance(title: PageTitle, pageId: Int, revisionFrom: Long, revisionTo: Long, fromRecentEdits: Boolean): ArticleEditDetailsFragment { + fun newInstance(title: PageTitle, pageId: Int, revisionFrom: Long, revisionTo: Long, source: InvokeSource): ArticleEditDetailsFragment { return ArticleEditDetailsFragment().apply { arguments = bundleOf(ArticleEditDetailsActivity.EXTRA_ARTICLE_TITLE to title, ArticleEditDetailsActivity.EXTRA_PAGE_ID to pageId, ArticleEditDetailsActivity.EXTRA_EDIT_REVISION_FROM to revisionFrom, ArticleEditDetailsActivity.EXTRA_EDIT_REVISION_TO to revisionTo, - ArticleEditDetailsActivity.EXTRA_FROM_RECENT_EDITS to fromRecentEdits) + Constants.INTENT_EXTRA_INVOKE_SOURCE to source) } } } diff --git a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt index aab9395e668..bc095b12162 100644 --- a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt +++ b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt @@ -9,6 +9,8 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.async import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.Constants.InvokeSource import org.wikipedia.analytics.eventplatform.WatchlistAnalyticsHelper import org.wikipedia.dataclient.Service import org.wikipedia.dataclient.ServiceFactory @@ -30,6 +32,8 @@ import org.wikipedia.watchlist.WatchlistExpiry class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { + private val invokeSource = bundle.getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as InvokeSource + val watchedStatus = MutableLiveData>() val rollbackRights = MutableLiveData>() val revisionDetails = MutableLiveData>() @@ -40,9 +44,7 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { val undoEditResponse = SingleLiveData>() val rollbackResponse = SingleLiveData>() - var watchlistExpiryChanged = false - - val fromRecentEdits = bundle.getBoolean(ArticleEditDetailsActivity.EXTRA_FROM_RECENT_EDITS, false) + val fromRecentEdits = invokeSource == InvokeSource.SUGGESTED_EDITS_RECENT_EDITS var pageTitle = bundle.parcelable(ArticleEditDetailsActivity.EXTRA_ARTICLE_TITLE)!! private set @@ -56,9 +58,6 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { var hasRollbackRights = false var isWatched = false - var feedbackOption = "" - var feedbackInput = "" - val diffSize get() = if (revisionFrom != null) revisionTo!!.size - revisionFrom!!.size else revisionTo!!.size init { @@ -228,7 +227,7 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { val undoMessage = msgResponse.query?.allmessages?.find { it.name == "undo-summary" }?.content var summary = if (undoMessage != null) "$undoMessage $comment" else comment if (fromRecentEdits) { - summary += DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_UNDO + summary += ", " + DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_UNDO } val token = ServiceFactory.get(title.wikiSite).getToken().query!!.csrfToken()!! val undoResponse = ServiceFactory.get(title.wikiSite).postUndoEdit(title.prefixedText, summary, @@ -241,10 +240,14 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> rollbackResponse.postValue(Resource.Error(throwable)) }) { + + val rollbackSummaryMsg = ServiceFactory.get(title.wikiSite).getMessages("revertpage", null) + .query?.allmessages?.firstOrNull { it.name == "revertpage" }?.content + val rollbackToken = ServiceFactory.get(title.wikiSite).getToken("rollback").query!!.rollbackToken()!! - var summary: String? = null + var summary = rollbackSummaryMsg if (fromRecentEdits) { - summary = DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_ROLLBACK + summary += ", " + DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_ROLLBACK } val rollbackPostResponse = ServiceFactory.get(title.wikiSite).postRollback(title.prefixedText, summary, user, rollbackToken) rollbackResponse.postValue(Resource.Success(rollbackPostResponse)) diff --git a/app/src/main/java/org/wikipedia/edit/EditHandler.kt b/app/src/main/java/org/wikipedia/edit/EditHandler.kt index 48633d32772..fb2e7b6880d 100644 --- a/app/src/main/java/org/wikipedia/edit/EditHandler.kt +++ b/app/src/main/java/org/wikipedia/edit/EditHandler.kt @@ -41,7 +41,7 @@ class EditHandler(private val fragment: PageFragment, bridge: CommunicationBridg val menu = PopupMenu(fragment.requireContext(), tempView, 0, 0, R.style.PagePopupMenu) menu.menuInflater.inflate(R.menu.menu_page_header_edit, menu.menu) menu.setOnMenuItemClickListener(EditMenuClickListener()) - menu.setOnDismissListener { (fragment.view as ViewGroup).removeView(tempView) } + menu.setOnDismissListener { (fragment.view as? ViewGroup)?.removeView(tempView) } menu.show() } else { startEditingSection(sectionId, null) diff --git a/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt b/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt index 6b52fdf34e8..45df88692d4 100644 --- a/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt +++ b/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt @@ -75,7 +75,7 @@ import org.wikipedia.views.ViewUtil import java.io.IOException import java.util.concurrent.TimeUnit -class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, LinkPreviewDialog.LoadPageCallback { +class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPreviewFragment.Callback, LinkPreviewDialog.LoadPageCallback { private lateinit var binding: ActivityEditSectionBinding private lateinit var textWatcher: TextWatcher private lateinit var captchaHandler: CaptchaHandler @@ -464,6 +464,7 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, LinkPre else -> { // we must be showing the editing window, so show the Preview. DeviceUtil.hideSoftKeyboard(this) + binding.editSectionContainer.isVisible = false editPreviewFragment.showPreview(pageTitle, binding.editSectionText.text.toString()) EditAttemptStepEvent.logSaveIntent(pageTitle) supportActionBar?.title = getString(R.string.edit_preview) @@ -601,7 +602,8 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, LinkPre editSummaryFragment.hide() } if (editPreviewFragment.isActive) { - editPreviewFragment.hide(binding.editSectionContainer) + editPreviewFragment.hide() + binding.editSectionContainer.isVisible = true } } @@ -705,11 +707,19 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, LinkPre binding.editSectionText.highlightText(highlightText) } - fun showProgressBar(enable: Boolean) { - binding.viewProgressBar.isVisible = enable + override fun getParentPageTitle(): PageTitle { + return pageTitle + } + + override fun showProgressBar(visible: Boolean) { + binding.viewProgressBar.isVisible = visible invalidateOptionsMenu() } + override fun isNewPage(): Boolean { + return false + } + override fun onBackPressed() { val addImageTitle = intent.parcelableExtra(InsertMediaActivity.EXTRA_IMAGE_TITLE) val addImageSource = intent.getStringExtra(InsertMediaActivity.EXTRA_IMAGE_SOURCE) @@ -738,7 +748,8 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, LinkPre recommendationSourceProjects = addImageSourceProjects.orEmpty(), acceptanceState = "accepted", captionAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION).isNullOrEmpty(), altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), pageTitle.wikiSite.languageCode) - editPreviewFragment.hide(binding.editSectionContainer) + editPreviewFragment.hide() + binding.editSectionContainer.isVisible = true supportActionBar?.title = null // If we came from the Image Recommendations workflow, bring back the Add Image activity. diff --git a/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt b/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt index e3d81417de3..a3838cc4bc3 100644 --- a/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt +++ b/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt @@ -63,8 +63,8 @@ class FindInEditorActionProvider(private val scrollView: View, currentResultIndex = 0 resultPositions.clear() - searchQuery?.let { - resultPositions += it.toRegex(StringUtil.SEARCH_REGEX_OPTIONS).findAll(textView.text) + searchQuery?.let { query -> + resultPositions += query.toRegex(StringUtil.SEARCH_REGEX_OPTIONS).findAll(textView.text) .map { it.range.first } } scrollToCurrentResult() @@ -72,11 +72,15 @@ class FindInEditorActionProvider(private val scrollView: View, private fun scrollToCurrentResult() { setMatchesResults(currentResultIndex, resultPositions.size) - val textPosition = resultPositions.getOrElse(currentResultIndex) { 0 } - textView.setSelection(textPosition, textPosition + searchQuery.orEmpty().length) + var highlightLength = searchQuery.orEmpty().length + val textPosition = resultPositions.getOrElse(currentResultIndex) { + highlightLength = 0 + 0 + } + textView.setSelection(textPosition, textPosition + highlightLength) val r = Rect() textView.getFocusedRect(r) scrollView.scrollTo(0, r.top - DimenUtil.roundedDpToPx(32f)) - syntaxHighlighter.setSearchQueryInfo(resultPositions, searchQuery.orEmpty().length, currentResultIndex) + syntaxHighlighter.setSearchQueryInfo(resultPositions, highlightLength, currentResultIndex) } } diff --git a/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt b/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt index 675508a74a3..9616134b297 100644 --- a/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt +++ b/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt @@ -7,7 +7,9 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import org.wikipedia.Constants +import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent import org.wikipedia.edit.insertmedia.InsertMediaActivity +import org.wikipedia.edit.templates.TemplatesSearchActivity import org.wikipedia.extensions.parcelableExtra import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter @@ -27,6 +29,7 @@ class SyntaxHighlightViewAdapter( private val wikiTextKeyboardHeadingsView: WikiTextKeyboardHeadingsView, private val invokeSource: Constants.InvokeSource, private val requestInsertMedia: ActivityResultLauncher, + private val isFromDiff: Boolean = false, showUserMention: Boolean = false ) : WikiTextKeyboardView.Callback { @@ -61,6 +64,15 @@ class SyntaxHighlightViewAdapter( } } + private val requestInsertTemplate = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == TemplatesSearchActivity.RESULT_INSERT_TEMPLATE_SUCCESS) { + it.data?.let { data -> + val newWikiText = data.getStringExtra(TemplatesSearchActivity.RESULT_WIKI_TEXT) + editText.inputConnection?.commitText(newWikiText, 1) + } + } + } + override fun onPreviewLink(title: String) { val dialog = LinkPreviewDialog.newInstance(HistoryEntry(PageTitle(title, pageTitle.wikiSite), HistoryEntry.SOURCE_INTERNAL_LINK)) ExclusiveBottomSheetPresenter.show(activity.supportFragmentManager, dialog) @@ -81,6 +93,14 @@ class SyntaxHighlightViewAdapter( invokeSource)) } + override fun onRequestInsertTemplate() { + if (isFromDiff) { + val activeInterface = if (invokeSource == Constants.InvokeSource.TALK_REPLY_ACTIVITY) "pt_talk" else "pt_edit" + PatrollerExperienceEvent.logAction("template_init", activeInterface) + } + requestInsertTemplate.launch(TemplatesSearchActivity.newIntent(activity, pageTitle.wikiSite, isFromDiff, invokeSource)) + } + override fun onRequestInsertLink() { requestLinkFromSearch.launch(SearchActivity.newIntent(activity, invokeSource, null, true)) } diff --git a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardFormattingView.kt b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardFormattingView.kt index d3badf69a2f..302d0254ed2 100644 --- a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardFormattingView.kt +++ b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardFormattingView.kt @@ -20,7 +20,7 @@ class WikiTextKeyboardFormattingView : FrameLayout { binding.closeButton.setOnClickListener { callback?.onSyntaxOverlayCollapse() } - FeedbackUtil.setButtonLongPressToast(binding.closeButton) + FeedbackUtil.setButtonTooltip(binding.closeButton) binding.wikitextButtonBold.setOnClickListener { editText?.inputConnection?.let { WikiTextKeyboardView.toggleSyntaxAroundCurrentSelection(editText, it, "'''", "'''") diff --git a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardHeadingsView.kt b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardHeadingsView.kt index 6eaba4967be..42568fe51c9 100644 --- a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardHeadingsView.kt +++ b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardHeadingsView.kt @@ -47,7 +47,7 @@ class WikiTextKeyboardHeadingsView : FrameLayout { } } - FeedbackUtil.setButtonLongPressToast(binding.closeButton, binding.wikitextButtonH2, binding.wikitextButtonH3, + FeedbackUtil.setButtonTooltip(binding.closeButton, binding.wikitextButtonH2, binding.wikitextButtonH3, binding.wikitextButtonH4, binding.wikitextButtonH5) } } diff --git a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardView.kt b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardView.kt index eb813ba1151..c2f72ea7dfa 100644 --- a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardView.kt +++ b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardView.kt @@ -19,6 +19,7 @@ class WikiTextKeyboardView constructor(context: Context, attrs: AttributeSet?) : fun onPreviewLink(title: String) fun onRequestInsertMedia() fun onRequestInsertLink() + fun onRequestInsertTemplate() fun onRequestHeading() fun onRequestFormatting() fun onSyntaxOverlayCollapse() @@ -64,9 +65,7 @@ class WikiTextKeyboardView constructor(context: Context, attrs: AttributeSet?) : } binding.wikitextButtonTemplate.setOnClickListener { - editText?.inputConnection?.let { - toggleSyntaxAroundCurrentSelection(editText, it, "{{", "}}") - } + callback?.onRequestInsertTemplate() } binding.wikitextButtonRef.setOnClickListener { diff --git a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaSettingsFragment.kt b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaSettingsFragment.kt index fed345548e3..373807a626f 100644 --- a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaSettingsFragment.kt +++ b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaSettingsFragment.kt @@ -17,6 +17,7 @@ import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent +import org.wikipedia.commons.ImagePreviewDialog import org.wikipedia.databinding.FragmentInsertMediaSettingsBinding import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.LinkMovementMethodExt @@ -27,7 +28,6 @@ import org.wikipedia.util.DeviceUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.StringUtil import org.wikipedia.views.AppTextViewWithImages -import org.wikipedia.views.ImagePreviewDialog import org.wikipedia.views.ViewUtil class InsertMediaSettingsFragment : Fragment() { diff --git a/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt b/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt index 6fc5d37bf3d..fb857516740 100644 --- a/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt +++ b/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt @@ -7,11 +7,11 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.webkit.WebView -import androidx.core.app.ActivityCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.wikipedia.R +import org.wikipedia.activity.FragmentUtil import org.wikipedia.bridge.CommunicationBridge import org.wikipedia.bridge.CommunicationBridge.CommunicationBridgeListener import org.wikipedia.bridge.JavaScriptActionHandler @@ -20,12 +20,17 @@ import org.wikipedia.dataclient.RestService import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.okhttp.OkHttpWebViewClient -import org.wikipedia.edit.EditSectionActivity +import org.wikipedia.diff.ArticleEditDetailsActivity import org.wikipedia.history.HistoryEntry import org.wikipedia.json.JsonUtil -import org.wikipedia.page.* +import org.wikipedia.page.ExclusiveBottomSheetPresenter +import org.wikipedia.page.LinkHandler +import org.wikipedia.page.PageActivity +import org.wikipedia.page.PageTitle +import org.wikipedia.page.PageViewModel import org.wikipedia.page.references.PageReferences import org.wikipedia.page.references.ReferenceDialog +import org.wikipedia.staticdata.MainPageNameData import org.wikipedia.util.DeviceUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.ResourceUtil @@ -33,6 +38,12 @@ import org.wikipedia.util.UriUtil class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDialog.Callback { + interface Callback { + fun getParentPageTitle(): PageTitle + fun showProgressBar(visible: Boolean) + fun isNewPage(): Boolean + } + private var _binding: FragmentPreviewEditBinding? = null private val binding get() = _binding!! @@ -51,7 +62,7 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentPreviewEditBinding.inflate(layoutInflater, container, false) bridge = CommunicationBridge(this) - val pageTitle = (requireActivity() as EditSectionActivity).pageTitle + val pageTitle = callback().getParentPageTitle() model.title = pageTitle model.curEntry = HistoryEntry(pageTitle, HistoryEntry.SOURCE_INTERNAL_LINK) linkHandler = EditLinkHandler(requireContext()) @@ -70,12 +81,15 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi */ fun showPreview(title: PageTitle, wikiText: String) { DeviceUtil.hideSoftKeyboard(requireActivity()) - (requireActivity() as EditSectionActivity).showProgressBar(true) - val url = ServiceFactory.getRestBasePath(model.title!!.wikiSite) + - RestService.PAGE_HTML_PREVIEW_ENDPOINT + UriUtil.encodeURL(title.prefixedText) + callback().showProgressBar(true) + + // Workaround for T363781 + // The preview endpoint requires the target page to exist, so if it doesn't exist yet, + // we will base the preview on the Main Page of the wiki. + val url = ServiceFactory.getRestBasePath(model.title!!.wikiSite) + RestService.PAGE_HTML_PREVIEW_ENDPOINT + + UriUtil.encodeURL(if (callback().isNewPage()) MainPageNameData.valueFor(title.wikiSite.languageCode) else title.prefixedText) val postData = "wikitext=" + UriUtil.encodeURL(wikiText) binding.editPreviewWebview.postUrl(url, postData.toByteArray()) - ActivityCompat.requireViewById(requireActivity(), R.id.edit_section_container).isVisible = false binding.editPreviewContainer.isVisible = true requireActivity().invalidateOptionsMenu() } @@ -95,7 +109,7 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi } bridge.onMetadataReady() bridge.execute(JavaScriptActionHandler.setMargins(16, 0, 16, 16 + DimenUtil.roundedPxToDp(binding.licenseText.height.toFloat()))) - (requireActivity() as EditSectionActivity).showProgressBar(false) + callback().showProgressBar(false) requireActivity().invalidateOptionsMenu() } } @@ -132,19 +146,22 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi * Hides (fades out) the Preview fragment. * When fade-out completes, the state of the actionbar button(s) is updated. */ - fun hide(toView: View) { + fun hide() { binding.editPreviewContainer.isVisible = false - toView.isVisible = true requireActivity().invalidateOptionsMenu() } - inner class EditLinkHandler constructor(context: Context) : LinkHandler(context) { + private fun callback(): Callback { + return FragmentUtil.getCallback(this, Callback::class.java)!! + } + + inner class EditLinkHandler(context: Context) : LinkHandler(context) { override fun onPageLinkClicked(anchor: String, linkText: String) { // TODO: also need to handle references, issues, disambig, ... in preview eventually } override fun onInternalLinkClicked(title: PageTitle) { - showLeavingEditDialogue { + showLeavingEditDialog { startActivity( PageActivity.newIntentForCurrentTab( context, @@ -155,7 +172,7 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi } override fun onExternalLinkClicked(uri: Uri) { - showLeavingEditDialogue { UriUtil.handleExternalLink(context, uri) } + showLeavingEditDialog { UriUtil.handleExternalLink(context, uri) } } override fun onMediaLinkClicked(title: PageTitle) { @@ -163,7 +180,9 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi } override fun onDiffLinkClicked(title: PageTitle, revisionId: Long) { - // ignore + showLeavingEditDialog { + startActivity(ArticleEditDetailsActivity.newIntent(requireContext(), title, revisionId)) + } } /** @@ -172,7 +191,7 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi * * @param runnable The runnable that is run if the user chooses to leave. */ - private fun showLeavingEditDialogue(runnable: Runnable) { + private fun showLeavingEditDialog(runnable: Runnable) { // Ask the user if they really meant to leave the edit workflow MaterialAlertDialogBuilder(requireActivity()) .setMessage(R.string.dialog_message_leaving_edit) diff --git a/app/src/main/java/org/wikipedia/edit/richtext/SyntaxHighlighter.kt b/app/src/main/java/org/wikipedia/edit/richtext/SyntaxHighlighter.kt index 50c4083a0ed..af5583aedb7 100644 --- a/app/src/main/java/org/wikipedia/edit/richtext/SyntaxHighlighter.kt +++ b/app/src/main/java/org/wikipedia/edit/richtext/SyntaxHighlighter.kt @@ -23,6 +23,7 @@ class SyntaxHighlighter( private val highlightDelayMillis: Long = HIGHLIGHT_DELAY_MILLIS) { private val syntaxRules = listOf( + SyntaxRule("{{{", "}}}", SyntaxRuleStyle.PRE_TEMPLATE), SyntaxRule("{{", "}}", SyntaxRuleStyle.TEMPLATE), SyntaxRule("[[", "]]", SyntaxRuleStyle.INTERNAL_LINK), SyntaxRule("[", "]", SyntaxRuleStyle.EXTERNAL_LINK), diff --git a/app/src/main/java/org/wikipedia/edit/richtext/SyntaxRuleStyle.kt b/app/src/main/java/org/wikipedia/edit/richtext/SyntaxRuleStyle.kt index d8e1e3c6bb8..334b9ac166d 100644 --- a/app/src/main/java/org/wikipedia/edit/richtext/SyntaxRuleStyle.kt +++ b/app/src/main/java/org/wikipedia/edit/richtext/SyntaxRuleStyle.kt @@ -9,6 +9,11 @@ import org.wikipedia.R import org.wikipedia.util.ResourceUtil enum class SyntaxRuleStyle { + PRE_TEMPLATE { + override fun createSpan(ctx: Context, spanStart: Int, syntaxItem: SyntaxRule): SpanExtents { + return ColorSpanEx(ResourceUtil.getThemedColor(ctx, R.attr.success_color), Color.TRANSPARENT, spanStart, syntaxItem) + } + }, TEMPLATE { override fun createSpan(ctx: Context, spanStart: Int, syntaxItem: SyntaxRule): SpanExtents { return ColorSpanEx(ResourceUtil.getThemedColor(ctx, R.attr.placeholder_color), Color.TRANSPARENT, spanStart, syntaxItem) diff --git a/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt b/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt new file mode 100644 index 00000000000..50c91ebee37 --- /dev/null +++ b/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt @@ -0,0 +1,134 @@ +package org.wikipedia.edit.templates + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import com.google.android.material.textfield.TextInputLayout +import org.wikipedia.R +import org.wikipedia.databinding.FragmentInsertTemplateBinding +import org.wikipedia.databinding.ItemInsertTemplateBinding +import org.wikipedia.dataclient.mwapi.TemplateDataResponse +import org.wikipedia.page.LinkMovementMethodExt +import org.wikipedia.page.PageTitle +import org.wikipedia.util.StringUtil +import org.wikipedia.util.UriUtil +import org.wikipedia.views.PlainPasteEditText + +class InsertTemplateFragment : Fragment() { + + private lateinit var activity: TemplatesSearchActivity + private var _binding: FragmentInsertTemplateBinding? = null + private val binding get() = _binding!! + val isActive get() = binding.root.visibility == View.VISIBLE + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentInsertTemplateBinding.inflate(layoutInflater, container, false) + activity = (requireActivity() as TemplatesSearchActivity) + activity.supportActionBar?.title = null + return binding.root + } + + private fun buildParamsInputFields(templateData: TemplateDataResponse.TemplateData) { + activity.updateInsertButton(true) + binding.templateDataParamsContainer.removeAllViews() + templateData.getParams?.filter { + !it.value.isDeprecated + }?.forEach { + val itemBinding = ItemInsertTemplateBinding.inflate(layoutInflater) + val labelText = it.value.label.orEmpty().ifEmpty { StringUtil.capitalize(it.key) } + itemBinding.root.tag = false + if (it.value.required) { + itemBinding.textInputLayout.hint = labelText + itemBinding.editText.addTextChangedListener { + if (!activity.isDestroyed) { + checkRequiredParams() + } + } + itemBinding.root.tag = true + // Make the insert button disable when require param shows up. + activity.updateInsertButton(false) + } else if (it.value.suggested) { + itemBinding.textInputLayout.hint = getString(R.string.templates_param_suggested_hint, labelText) + } else { + itemBinding.textInputLayout.hint = getString(R.string.templates_param_optional_hint, labelText) + } + itemBinding.textInputLayout.tag = it.key + val hintText = it.value.suggestedValues.firstOrNull() + if (!hintText.isNullOrEmpty()) { + itemBinding.textInputLayout.placeholderText = getString(R.string.templates_param_suggested_value, hintText) + } + itemBinding.textInputLayout.helperText = it.value.description + binding.templateDataParamsContainer.addView(itemBinding.root) + } + } + + private fun checkRequiredParams() { + val allRequiredParamsFilled = !binding.templateDataParamsContainer.children + .any { it.tag == true && it.findViewById(R.id.editText).text.toString().trim().isEmpty() } + activity.updateInsertButton(allRequiredParamsFilled) + } + + fun show(pageTitle: PageTitle, templateData: TemplateDataResponse.TemplateData) { + activity.sendPatrollerExperienceEvent("search_success", "pt_templates") + binding.root.isVisible = true + binding.templateDataTitle.text = StringUtil.removeNamespace(pageTitle.displayText) + binding.templateDataDescription.text = StringUtil.fromHtml(getTemplateDescription(templateData)) + binding.templateDataDescription.isVisible = !binding.templateDataDescription.text.isNullOrEmpty() + binding.templateDataMissing.isVisible = templateData.noTemplateData + binding.templateDataMissingText.text = StringUtil.fromHtml(getString(R.string.templates_description_missing_data, + getString(R.string.template_parameters_url), getString(R.string.autogenerated_parameters_url))) + binding.templateDataMissingText.movementMethod = LinkMovementMethodExt.getExternalLinkMovementMethod() + binding.templateDataLearnMoreButton.setOnClickListener { + activity.sendPatrollerExperienceEvent("learn_click", "pt_templates") + UriUtil.visitInExternalBrowser(requireContext(), Uri.parse(pageTitle.uri)) + } + buildParamsInputFields(templateData) + } + + private fun getTemplateDescription(templateData: TemplateDataResponse.TemplateData): String { + return if (templateData.description.isNullOrEmpty()) { + getString(R.string.templates_description_empty, templateData.title) + } else { + templateData.description + "
" + getString(R.string.templates_description_incomplete) + "" + } + } + + fun hide() { + binding.root.isVisible = false + activity.invalidateOptionsMenu() + } + + fun collectParamsInfoAndBuildWikiText(): String { + var wikiText = "{{" + wikiText += binding.templateDataTitle.text + binding.templateDataParamsContainer.children.iterator().forEach { + var label = it.findViewById(R.id.textInputLayout).tag as String + label = if (label.toIntOrNull() != null) "" else "$label=" + val editText = it.findViewById(R.id.editText).text.toString().trim() + if (editText.isNotEmpty()) { + wikiText += "|$label$editText" + } + } + wikiText += "}}" + return wikiText + } + + fun handleBackPressed(): Boolean { + if (isActive) { + hide() + return true + } + return false + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt new file mode 100644 index 00000000000..7962a467eb2 --- /dev/null +++ b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt @@ -0,0 +1,228 @@ +package org.wikipedia.edit.templates + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.R +import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent +import org.wikipedia.databinding.ActivityTemplatesSearchBinding +import org.wikipedia.databinding.ItemTemplatesSearchBinding +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.mwapi.TemplateDataResponse +import org.wikipedia.page.PageTitle +import org.wikipedia.settings.Prefs +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.StringUtil + +class TemplatesSearchActivity : BaseActivity() { + private lateinit var binding: ActivityTemplatesSearchBinding + private lateinit var insertTemplateFragment: InsertTemplateFragment + + private var templatesSearchAdapter: TemplatesSearchAdapter? = null + + val viewModel: TemplatesSearchViewModel by viewModels { TemplatesSearchViewModel.Factory(intent.extras!!) } + + private val searchCloseListener = SearchView.OnCloseListener { + closeSearch() + false + } + + private val searchQueryListener = object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(queryText: String): Boolean { + DeviceUtil.hideSoftKeyboard(this@TemplatesSearchActivity) + return true + } + + override fun onQueryTextChange(queryText: String): Boolean { + binding.searchCabView.setCloseButtonVisibility(queryText) + startSearch(queryText.trim()) + return true + } + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityTemplatesSearchBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + sendPatrollerExperienceEvent("search_init", "pt_templates") + initSearchView() + + templatesSearchAdapter = TemplatesSearchAdapter() + binding.templateRecyclerView.layoutManager = LinearLayoutManager(this) + binding.templateRecyclerView.adapter = templatesSearchAdapter + + insertTemplateFragment = supportFragmentManager.findFragmentById(R.id.insertTemplateFragment) as InsertTemplateFragment + + binding.insertTemplateButton.setOnClickListener { + viewModel.selectedPageTitle?.let { + sendPatrollerExperienceEvent("template_insert_click", "pt_templates") + Prefs.addRecentUsedTemplates(setOf(it)) + val wikiText = insertTemplateFragment.collectParamsInfoAndBuildWikiText() + val intent = Intent().putExtra(RESULT_WIKI_TEXT, wikiText) + setResult(RESULT_INSERT_TEMPLATE_SUCCESS, intent) + finish() + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.searchTemplatesFlow.collectLatest { + templatesSearchAdapter?.submitData(it) + } + } + launch { + templatesSearchAdapter?.loadStateFlow?.collectLatest { + binding.searchProgressBar.isVisible = it.append is LoadState.Loading || it.refresh is LoadState.Loading + val showEmpty = (it.append is LoadState.NotLoading && it.append.endOfPaginationReached && templatesSearchAdapter?.itemCount == 0) + binding.emptyMessage.isVisible = showEmpty + } + } + launch { + viewModel.uiState.collect { + when (it) { + is TemplatesSearchViewModel.UiState.LoadTemplateData -> { + showInsertTemplateFragment(it.pageTitle, it.templateData) + } + is TemplatesSearchViewModel.UiState.LoadError -> { + FeedbackUtil.showError(this@TemplatesSearchActivity, it.throwable) + } + } + } + } + } + } + } + + private fun initSearchView() { + binding.searchCabView.setOnQueryTextListener(searchQueryListener) + binding.searchCabView.setOnCloseListener(searchCloseListener) + binding.searchCabView.setSearchHintTextColor(ResourceUtil.getThemedColor(this, R.attr.secondary_color)) + binding.searchCabView.queryHint = getString(R.string.templates_search_hint) + val searchEditPlate = binding.searchCabView.findViewById(androidx.appcompat.R.id.search_plate) + searchEditPlate.setBackgroundColor(Color.TRANSPARENT) + } + + private fun startSearch(term: String?) { + viewModel.searchQuery = term + templatesSearchAdapter?.refresh() + } + + private fun closeSearch() { + DeviceUtil.hideSoftKeyboard(this) + } + + private fun showInsertTemplateFragment(pageTitle: PageTitle, templateData: TemplateDataResponse.TemplateData) { + binding.searchCabView.isVisible = false + binding.insertTemplateButton.isVisible = true + updateToolbarElevation(false) + insertTemplateFragment.show(pageTitle, templateData) + } + + fun updateInsertButton(enabled: Boolean) { + binding.insertTemplateButton.isEnabled = enabled + binding.insertTemplateButton.setTextColor(ResourceUtil.getThemedColor(this, if (enabled) R.attr.progressive_color else R.attr.inactive_color)) + } + + private fun updateToolbarElevation(enabled: Boolean) { + binding.toolbarContainer.elevation = if (enabled) DimenUtil.dpToPx(1f) else 0f + } + + fun sendPatrollerExperienceEvent(action: String, activeInterface: String, actionData: String = "") { + if (viewModel.isFromDiff) { + PatrollerExperienceEvent.logAction(action, activeInterface, actionData) + } + } + + override fun onBackPressed() { + if (insertTemplateFragment.handleBackPressed()) { + if (templatesSearchAdapter != null) { + binding.searchCabView.isVisible = true + binding.insertTemplateButton.isVisible = false + supportActionBar?.title = null + updateToolbarElevation(true) + } else { + finish() + } + return + } + super.onBackPressed() + } + + private inner class TemplatesSearchDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: PageTitle, newItem: PageTitle): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: PageTitle, newItem: PageTitle): Boolean { + return oldItem.prefixedText == newItem.prefixedText && oldItem.namespace == newItem.namespace + } + } + + private inner class TemplatesSearchAdapter : PagingDataAdapter(TemplatesSearchDiffCallback()) { + override fun onCreateViewHolder(parent: ViewGroup, pos: Int): TemplatesSearchItemHolder { + return TemplatesSearchItemHolder(ItemTemplatesSearchBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + getItem(position)?.let { + (holder as TemplatesSearchItemHolder).bindItem(it) + } + } + } + + private inner class TemplatesSearchItemHolder(val binding: ItemTemplatesSearchBinding) : RecyclerView.ViewHolder(binding.root) { + fun bindItem(pageTitle: PageTitle) { + binding.itemTitle.text = StringUtil.removeNamespace(pageTitle.displayText) + binding.itemDescription.isVisible = !pageTitle.description.isNullOrEmpty() + binding.itemDescription.text = pageTitle.description + StringUtil.boldenKeywordText(binding.itemTitle, binding.itemTitle.text.toString(), viewModel.searchQuery) + + if (viewModel.searchQuery.isNullOrEmpty()) { + binding.itemTitle.typeface = Typeface.DEFAULT_BOLD + } else { + binding.itemTitle.typeface = Typeface.DEFAULT + } + + itemView.setOnClickListener { + viewModel.loadTemplateData(pageTitle) + viewModel.selectedPageTitle = pageTitle + } + } + } + + companion object { + const val EXTRA_FROM_DIFF = "isFromDiff" + const val RESULT_INSERT_TEMPLATE_SUCCESS = 100 + const val RESULT_WIKI_TEXT = "resultWikiText" + fun newIntent(context: Context, wikiSite: WikiSite, isFromDiff: Boolean, invokeSource: Constants.InvokeSource): Intent { + return Intent(context, TemplatesSearchActivity::class.java) + .putExtra(Constants.ARG_WIKISITE, wikiSite) + .putExtra(EXTRA_FROM_DIFF, isFromDiff) + .putExtra(Constants.INTENT_EXTRA_INVOKE_SOURCE, invokeSource) + } + } +} diff --git a/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchViewModel.kt b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchViewModel.kt new file mode 100644 index 00000000000..c977c6fa8e3 --- /dev/null +++ b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchViewModel.kt @@ -0,0 +1,91 @@ +package org.wikipedia.edit.templates + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.cachedIn +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.mwapi.TemplateDataResponse +import org.wikipedia.extensions.parcelable +import org.wikipedia.page.Namespace +import org.wikipedia.page.PageTitle +import org.wikipedia.settings.Prefs + +class TemplatesSearchViewModel(bundle: Bundle) : ViewModel() { + + val invokeSource = bundle.getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as Constants.InvokeSource + val wikiSite = bundle.parcelable(Constants.ARG_WIKISITE)!! + val isFromDiff = bundle.getBoolean(TemplatesSearchActivity.EXTRA_FROM_DIFF, false) + var searchQuery: String? = null + var selectedPageTitle: PageTitle? = null + val searchTemplatesFlow = Pager(PagingConfig(pageSize = 10)) { + SearchTemplatesFlowSource(searchQuery, wikiSite) + }.flow.cachedIn(viewModelScope) + + val uiState = MutableStateFlow(UiState()) + + fun loadTemplateData(pageTitle: PageTitle) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + uiState.value = UiState.LoadError(throwable) + }) { + val response = ServiceFactory.get(pageTitle.wikiSite).getTemplateData(pageTitle.wikiSite.languageCode, pageTitle.prefixedText) + uiState.value = UiState.LoadTemplateData(pageTitle, response.getTemplateData.first()) + } + } + + class SearchTemplatesFlowSource(val searchQuery: String?, val wikiSite: WikiSite) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + if (searchQuery.isNullOrEmpty()) { + val recentUsedTemplates = Prefs.recentUsedTemplates.filter { it.wikiSite == wikiSite } + return LoadResult.Page(recentUsedTemplates, null, null) + } + val query = Namespace.TEMPLATE.name + ":" + searchQuery + val response = ServiceFactory.get(wikiSite) + .fullTextSearchTemplates("$query*", params.loadSize, params.key) + + return response.query?.pages?.let { list -> + val partition = list.partition { it.title.equals(query, true) }.apply { + second.sortedBy { it.index } + } + val results = partition.toList().flatten().map { + val pageTitle = PageTitle(wikiSite = wikiSite, _text = it.title, description = it.description) + pageTitle.displayText = it.displayTitle(wikiSite.languageCode) + pageTitle + } + LoadResult.Page(results, null, response.continuation?.gsroffset) + } ?: run { + LoadResult.Page(emptyList(), null, null) + } + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return null + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return TemplatesSearchViewModel(bundle) as T + } + } + + open class UiState { + data class LoadTemplateData(val pageTitle: PageTitle, val templateData: TemplateDataResponse.TemplateData) : UiState() + data class LoadError(val throwable: Throwable) : UiState() + } +} diff --git a/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt b/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt index 07cab03f492..827aabb0595 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt @@ -1,9 +1,11 @@ package org.wikipedia.feed import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wikipedia.WikipediaApp import org.wikipedia.feed.aggregated.AggregatedFeedContentClient import org.wikipedia.feed.announcement.AnnouncementClient @@ -40,12 +42,12 @@ class FeedCoordinator internal constructor(context: Context) : FeedCoordinatorBa companion object { fun postCardsToCallback(cb: FeedClient.Callback, cards: List) { - Completable.fromAction { - val delayMillis = 150L - Thread.sleep(delayMillis) - }.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { cb.success(cards) } + CoroutineScope(Dispatchers.Default).launch { + delay(150L) + withContext(Dispatchers.Main) { + cb.success(cards) + } + } } } } diff --git a/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt b/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt index 994ab113278..4dd9cc609e8 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt @@ -21,7 +21,7 @@ import org.wikipedia.settings.Prefs import org.wikipedia.util.DeviceUtil import org.wikipedia.util.ThrowableUtil import org.wikipedia.util.log.L -import java.util.* +import java.util.Collections abstract class FeedCoordinatorBase(private val context: Context) { @@ -149,7 +149,7 @@ abstract class FeedCoordinatorBase(private val context: Context) { if (pendingClients.isNotEmpty()) { pendingClients.removeAt(0) } - if (lastCard !is ProgressCard && shouldShowProgressCard(pendingClients[0])) { + if (lastCard !is ProgressCard && shouldShowProgressCard(pendingClients.getOrNull(0))) { requestProgressCard() } requestCard(wiki) @@ -262,10 +262,11 @@ abstract class FeedCoordinatorBase(private val context: Context) { card is FeaturedImageCard } - private fun shouldShowProgressCard(pendingClient: FeedClient): Boolean { + private fun shouldShowProgressCard(pendingClient: FeedClient?): Boolean { return pendingClient is SuggestedEditsFeedClient || pendingClient is AnnouncementClient || - pendingClient is BecauseYouReadClient + pendingClient is BecauseYouReadClient || + pendingClient == null } companion object { diff --git a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt index 1b684911180..f1d478133d2 100644 --- a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt +++ b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt @@ -9,10 +9,10 @@ import org.wikipedia.feed.onthisday.OnThisDay import org.wikipedia.feed.topread.TopRead @Serializable -class AggregatedFeedContent { - val tfa: PageSummary? = null - val news: List? = null - @SerialName("mostread") val topRead: TopRead? = null - @SerialName("image") val potd: FeaturedImage? = null +class AggregatedFeedContent( + val tfa: PageSummary? = null, + val news: List? = null, + @SerialName("mostread") val topRead: TopRead? = null, + @SerialName("image") val potd: FeaturedImage? = null, val onthisday: List? = null -} +) diff --git a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt index 93ec1aaaa42..481ff8e72a3 100644 --- a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt +++ b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt @@ -1,13 +1,18 @@ package org.wikipedia.feed.aggregated import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.wikipedia.Constants import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.feed.FeedContentType import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient @@ -23,7 +28,7 @@ import org.wikipedia.util.log.L class AggregatedFeedContentClient { private val aggregatedResponses = mutableMapOf() private var aggregatedResponseAge = -1 - private val disposables = CompositeDisposable() + var clientJob: Job? = null class OnThisDayFeed(aggregatedClient: AggregatedFeedContentClient) : BaseClient(aggregatedClient) { @@ -110,10 +115,6 @@ class AggregatedFeedContentClient { aggregatedResponseAge = -1 } - fun cancel() { - disposables.clear() - } - abstract class BaseClient internal constructor(private val aggregatedClient: AggregatedFeedContentClient) : FeedClient { private lateinit var cb: FeedClient.Callback private lateinit var wiki: WikiSite @@ -137,31 +138,64 @@ class AggregatedFeedContentClient { override fun cancel() {} private fun requestAggregated() { - aggregatedClient.cancel() + aggregatedClient.clientJob?.cancel() val date = DateUtil.getUtcRequestDateFor(age) - aggregatedClient.disposables.add(Observable.fromIterable(FeedContentType.aggregatedLanguages) - .flatMap({ lang -> - ServiceFactory.getRest(WikiSite.forLanguageCode(lang)) - .getAggregatedFeed(date.year, date.month, date.day) - .subscribeOn(Schedulers.io()) - }, { first, second -> Pair(first, second) }) - .toList() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ pairList -> - val cards = mutableListOf() - for (pair in pairList) { - val content = pair.second ?: continue - aggregatedClient.aggregatedResponses[WikiSite.forLanguageCode(pair.first).languageCode] = content - aggregatedClient.aggregatedResponseAge = age - } - if (aggregatedClient.aggregatedResponses.containsKey(wiki.languageCode)) { - getCardFromResponse(aggregatedClient.aggregatedResponses, wiki, age, cards) - } - FeedCoordinator.postCardsToCallback(cb, cards) - }) { caught -> + aggregatedClient.clientJob = CoroutineScope(Dispatchers.Main).launch( + CoroutineExceptionHandler { _, caught -> L.v(caught) cb.error(caught) - }) + } + ) { + val cards = mutableListOf() + FeedContentType.aggregatedLanguages.forEach { langCode -> + val wikiSite = WikiSite.forLanguageCode(langCode) + val hasParentLanguageCode = !WikipediaApp.instance.languageState.getDefaultLanguageCode(langCode).isNullOrEmpty() + var feedContentResponse = ServiceFactory.getRest(wikiSite).getFeedFeatured(date.year, date.month, date.day) + + // TODO: This is a temporary fix for T355192 + if (hasParentLanguageCode) { + // TODO: Needs to update tfa and most read + feedContentResponse.tfa?.let { + val tfaResponse = getPageSummaryForLanguageVariant(it, wikiSite) + feedContentResponse = AggregatedFeedContent( + tfa = tfaResponse, + news = feedContentResponse.news, + topRead = feedContentResponse.topRead, + potd = feedContentResponse.potd, + onthisday = feedContentResponse.onthisday + ) + } + } + + aggregatedClient.aggregatedResponses[langCode] = feedContentResponse + aggregatedClient.aggregatedResponseAge = age + } + if (aggregatedClient.aggregatedResponses.containsKey(wiki.languageCode)) { + getCardFromResponse(aggregatedClient.aggregatedResponses, wiki, age, cards) + } + FeedCoordinator.postCardsToCallback(cb, cards) + } + } + + // TODO: This is a temporary fix for T355192 + private suspend fun getPageSummaryForLanguageVariant(pageSummary: PageSummary, wikiSite: WikiSite): PageSummary { + var newPageSummary = pageSummary + withContext(Dispatchers.IO) { + // First, get the correct description from Wikidata directly. + val wikiDataResponse = async { + ServiceFactory.get(Constants.wikidataWikiSite) + .getWikidataDescription(titles = pageSummary.apiTitle, sites = wikiSite.dbName(), langCode = wikiSite.languageCode) + } + // Second, fetch PageSummary endpoint instead of using the one with incorrect language variant (mostly from the feed endpoint). + val pageSummaryResponse = async { + ServiceFactory.getRest(wikiSite).getPageSummary(null, pageSummary.apiTitle) + } + + newPageSummary = pageSummaryResponse.await().apply { + description = wikiDataResponse.await().first?.getDescription(wikiSite.languageCode) ?: description + } + } + return newPageSummary } } } diff --git a/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt b/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt index 28bfef857de..a30d841fd1c 100644 --- a/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt +++ b/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt @@ -2,9 +2,11 @@ package org.wikipedia.feed.announcement import android.content.Context import androidx.annotation.VisibleForTesting -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil import org.wikipedia.dataclient.ServiceFactory @@ -20,23 +22,23 @@ import java.util.Date class AnnouncementClient : FeedClient { - private val disposables = CompositeDisposable() + private var clientJob: Job? = null override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { cancel() - disposables.add(ServiceFactory.getRest(wiki).announcements - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ list -> - FeedCoordinator.postCardsToCallback(cb, buildCards(list.items)) - }) { throwable -> - L.v(throwable) - cb.error(throwable) - }) + clientJob = CoroutineScope(Dispatchers.Main).launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.error(caught) + } + ) { + val announcementsResponse = ServiceFactory.getRest(wiki).getAnnouncements() + FeedCoordinator.postCardsToCallback(cb, buildCards(announcementsResponse.items)) + } } override fun cancel() { - disposables.clear() + clientJob?.cancel() } companion object { diff --git a/app/src/main/java/org/wikipedia/feed/becauseyouread/BecauseYouReadClient.kt b/app/src/main/java/org/wikipedia/feed/becauseyouread/BecauseYouReadClient.kt index 1b2b8d75b59..7117ac598bd 100644 --- a/app/src/main/java/org/wikipedia/feed/becauseyouread/BecauseYouReadClient.kt +++ b/app/src/main/java/org/wikipedia/feed/becauseyouread/BecauseYouReadClient.kt @@ -1,10 +1,11 @@ package org.wikipedia.feed.becauseyouread import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -14,68 +15,59 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient -import org.wikipedia.history.HistoryEntry import org.wikipedia.util.StringUtil +import org.wikipedia.util.log.L class BecauseYouReadClient : FeedClient { - - private val disposables = CompositeDisposable() - + private var clientJob: Job? = null override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { cancel() - disposables.add( - Observable.fromCallable { - AppDatabase.instance.historyEntryWithImageDao().findEntryForReadMore(age, - context.resources.getInteger(R.integer.article_engagement_threshold_sec)) + clientJob = CoroutineScope(Dispatchers.Main).launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.success(emptyList()) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ entries -> - if (entries.size <= age) cb.success(emptyList()) else getCardForHistoryEntry(entries[age], cb) - }) { cb.success(emptyList()) }) - } + ) { + val entries = AppDatabase.instance.historyEntryWithImageDao().findEntryForReadMore(age, context.resources.getInteger(R.integer.article_engagement_threshold_sec)) + if (entries.size <= age) { + cb.success(emptyList()) + } else { + val entry = entries[age] + val langCode = entry.title.wikiSite.languageCode + // If the language code has a parent language code, it means set "Accept-Language" will slow down the loading time of /page/related + // TODO: remove when https://phabricator.wikimedia.org/T271145 is resolved. + val hasParentLanguageCode = !WikipediaApp.instance.languageState.getDefaultLanguageCode(langCode).isNullOrEmpty() + val searchTerm = StringUtil.removeUnderscores(entry.title.prefixedText) + val relatedPages = mutableListOf() - override fun cancel() { - disposables.clear() - } + val moreLikeResponse = ServiceFactory.get(entry.title.wikiSite).searchMoreLike("morelike:$searchTerm", + Constants.SUGGESTION_REQUEST_ITEMS, Constants.SUGGESTION_REQUEST_ITEMS) - private fun getCardForHistoryEntry(entry: HistoryEntry, cb: FeedClient.Callback) { + val headerPage = PageSummary(entry.title.displayText, entry.title.prefixedText, entry.title.description, + entry.title.extract, entry.title.thumbUrl, langCode) - // If the language code has a parent language code, it means set "Accept-Language" will slow down the loading time of /page/related - // TODO: remove when https://phabricator.wikimedia.org/T271145 is resolved. - val hasParentLanguageCode = !WikipediaApp.instance.languageState.getDefaultLanguageCode(entry.title.wikiSite.languageCode).isNullOrEmpty() - val searchTerm = StringUtil.removeUnderscores(entry.title.prefixedText) - disposables.add(ServiceFactory.get(entry.title.wikiSite) - .searchMoreLike("morelike:$searchTerm", - Constants.SUGGESTION_REQUEST_ITEMS, Constants.SUGGESTION_REQUEST_ITEMS) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .flatMap { response -> - val relatedPages = mutableListOf() - val langCode = entry.title.wikiSite.languageCode - relatedPages.add(PageSummary(entry.title.displayText, entry.title.prefixedText, entry.title.description, - entry.title.extract, entry.title.thumbUrl, langCode)) - response.query?.pages?.forEach { + moreLikeResponse.query?.pages?.forEach { if (it.title != searchTerm) { - relatedPages.add(PageSummary(it.displayTitle(langCode), it.title, it.description, - it.extract, it.thumbUrl(), langCode)) + if (hasParentLanguageCode) { + val pageSummary = ServiceFactory.getRest(entry.title.wikiSite).getPageSummary(entry.referrer, it.title) + relatedPages.add(pageSummary) + } else { + relatedPages.add(PageSummary(it.displayTitle(langCode), it.title, it.description, + it.extract, it.thumbUrl(), langCode)) + } } } - Observable.fromIterable(relatedPages) - } - .concatMap { pageSummary -> - if (hasParentLanguageCode) ServiceFactory.getRest(entry.title.wikiSite).getSummary(entry.referrer, pageSummary.apiTitle) - else Observable.just(pageSummary) - } - .observeOn(AndroidSchedulers.mainThread()) - .toList() - .subscribe({ list -> - val headerPage = list.removeAt(0) + FeedCoordinator.postCardsToCallback(cb, - if (list.isEmpty()) emptyList() - else listOf(toBecauseYouReadCard(list, headerPage, entry.title.wikiSite)) + if (relatedPages.isEmpty()) emptyList() + else listOf(toBecauseYouReadCard(relatedPages, headerPage, entry.title.wikiSite)) ) - }) { caught -> cb.error(caught) }) + } + } + } + + override fun cancel() { + clientJob?.cancel() } private fun toBecauseYouReadCard(results: List, pageSummary: PageSummary, wikiSite: WikiSite): BecauseYouReadCard { diff --git a/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleCardView.kt b/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleCardView.kt index c4e1d9c1326..d4cf7d15b9c 100644 --- a/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleCardView.kt +++ b/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleCardView.kt @@ -11,7 +11,7 @@ import org.wikipedia.history.HistoryEntry import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.LongPressMenu import org.wikipedia.readinglist.database.ReadingListPage -import org.wikipedia.settings.SiteInfoClient +import org.wikipedia.staticdata.MainPageNameData import org.wikipedia.views.ImageZoomHelper @Suppress("LeakingThis") @@ -111,7 +111,7 @@ open class FeaturedArticleCardView(context: Context) : DefaultFeedCardView(context) { @@ -38,7 +38,7 @@ class MainPageCardView(context: Context) : DefaultFeedCardView(con private fun goToMainPage() { card?.let { - callback?.onSelectPage(it, HistoryEntry(PageTitle(getMainPageForLang(it.wikiSite().languageCode), it.wikiSite()), + callback?.onSelectPage(it, HistoryEntry(PageTitle(MainPageNameData.valueFor(it.wikiSite().languageCode), it.wikiSite()), HistoryEntry.SOURCE_FEED_MAIN_PAGE), false) } } diff --git a/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt b/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt index 7f28c05195c..3409a759c1d 100644 --- a/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt +++ b/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt @@ -1,14 +1,14 @@ package org.wikipedia.feed.random import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite -import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.feed.FeedContentType import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient @@ -17,37 +17,35 @@ import org.wikipedia.util.log.L class RandomClient : FeedClient { - private val disposables = CompositeDisposable() + private var clientJob: Job? = null override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { cancel() - disposables.add( - Observable.fromIterable(FeedContentType.aggregatedLanguages) - .flatMap({ lang -> getRandomSummaryObservable(lang) }, { first, second -> Pair(first, second) }) - .observeOn(AndroidSchedulers.mainThread()) - .toList() - .subscribe({ pairs -> - val list = pairs.map { RandomCard(it.second, age, WikiSite.forLanguageCode(it.first)) } - FeedCoordinator.postCardsToCallback(cb, list) - }) { t -> - L.v(t) - cb.error(t) - }) - } - - private fun getRandomSummaryObservable(lang: String): Observable { - return ServiceFactory.getRest(WikiSite.forLanguageCode(lang)) - .randomSummary - .subscribeOn(Schedulers.io()) - .onErrorResumeNext { throwable -> - Observable.fromCallable { - val page = AppDatabase.instance.readingListPageDao().getRandomPage() ?: throw throwable as Exception - ReadingListPage.toPageSummary(page) + clientJob = CoroutineScope(Dispatchers.Main).launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.error(caught) + } + ) { + val list = mutableListOf() + FeedContentType.aggregatedLanguages.forEach { lang -> + val wikiSite = WikiSite.forLanguageCode(lang) + val randomSummary = try { + ServiceFactory.getRest(wikiSite).getRandomSummary() + } catch (e: Exception) { + AppDatabase.instance.readingListPageDao().getRandomPage()?.let { + ReadingListPage.toPageSummary(it) + } ?: run { + throw e + } } + list.add(RandomCard(randomSummary, age, wikiSite)) } + FeedCoordinator.postCardsToCallback(cb, list) + } } override fun cancel() { - disposables.clear() + clientJob?.cancel() } } diff --git a/app/src/main/java/org/wikipedia/feed/searchbar/SearchCardView.kt b/app/src/main/java/org/wikipedia/feed/searchbar/SearchCardView.kt index ad56366419d..a15a6d7c3e5 100644 --- a/app/src/main/java/org/wikipedia/feed/searchbar/SearchCardView.kt +++ b/app/src/main/java/org/wikipedia/feed/searchbar/SearchCardView.kt @@ -20,7 +20,7 @@ class SearchCardView(context: Context) : DefaultFeedCardView(context init { val binding = ViewSearchBarBinding.inflate(LayoutInflater.from(context), this, true) binding.searchContainer.setCardBackgroundColor(ResourceUtil.getThemedColor(context, R.attr.background_color)) - FeedbackUtil.setButtonLongPressToast(binding.voiceSearchButton) + FeedbackUtil.setButtonTooltip(binding.voiceSearchButton) binding.searchContainer.setOnClickListener { callback?.onSearchRequested(it) } binding.voiceSearchButton.setOnClickListener { callback?.onVoiceSearchRequested() } diff --git a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt index a877917bac3..10955b7bd08 100644 --- a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt @@ -21,7 +21,10 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.descriptions.DescriptionEditActivity import org.wikipedia.descriptions.DescriptionEditActivity.Action -import org.wikipedia.descriptions.DescriptionEditActivity.Action.* +import org.wikipedia.descriptions.DescriptionEditActivity.Action.ADD_CAPTION +import org.wikipedia.descriptions.DescriptionEditActivity.Action.ADD_IMAGE_TAGS +import org.wikipedia.descriptions.DescriptionEditActivity.Action.TRANSLATE_CAPTION +import org.wikipedia.descriptions.DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION import org.wikipedia.descriptions.DescriptionEditReviewView.Companion.ARTICLE_EXTRACT_MAX_LINE_WITHOUT_IMAGE import org.wikipedia.descriptions.DescriptionEditReviewView.Companion.ARTICLE_EXTRACT_MAX_LINE_WITH_IMAGE import org.wikipedia.extensions.parcelable @@ -178,8 +181,8 @@ class SuggestedEditsCardItemFragment : Fragment() { binding.articleDescriptionPlaceHolder2.visibility = VISIBLE binding.viewArticleTitle.visibility = VISIBLE binding.divider.visibility = VISIBLE - binding.viewArticleTitle.text = StringUtil.fromHtml(sourceSummaryForEdit!!.displayTitle!!) - binding.viewArticleExtract.text = StringUtil.fromHtml(sourceSummaryForEdit!!.extract) + binding.viewArticleTitle.text = StringUtil.fromHtml(sourceSummaryForEdit?.displayTitle) + binding.viewArticleExtract.text = StringUtil.fromHtml(sourceSummaryForEdit?.extract) binding.viewArticleExtract.maxLines = ARTICLE_EXTRACT_MAX_LINE_WITHOUT_IMAGE showItemImage() } diff --git a/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt b/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt index e9c51dfaba0..fbfba2688fb 100644 --- a/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt +++ b/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt @@ -184,7 +184,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener } .subscribe({ response -> mediaPage = response.query?.firstPage() - if (FileUtil.isVideo(mediaListItem.type)) { + if (FileUtil.isVideo(mediaPage?.imageInfo()?.mime.orEmpty())) { loadVideo() } else { loadImage(ImageUrlUtil.getUrlForPreferredSize(mediaInfo!!.thumbUrl, @@ -197,7 +197,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener } private fun getMediaInfoDisposable(title: String, lang: String): Observable { - return if (FileUtil.isVideo(mediaListItem.type)) { + return if (mediaListItem.isVideo) { ServiceFactory.get(if (mediaListItem.isInCommons) Constants.commonsWikiSite else pageTitle!!.wikiSite).getVideoInfo(title, lang) } else { @@ -209,13 +209,12 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener private val videoThumbnailClickListener: View.OnClickListener = object : View.OnClickListener { private var loading = false override fun onClick(v: View) { - val derivative = mediaInfo?.getBestDerivativeForSize(Constants.PREFERRED_GALLERY_IMAGE_SIZE) - if (loading || derivative == null) { + if (loading) { return } - val bestDerivative = derivative.src + val bestUrl = mediaInfo?.getBestDerivativeForSize(Constants.PREFERRED_GALLERY_IMAGE_SIZE)?.src ?: mediaInfo?.originalUrl ?: return loading = true - L.d("Loading video from url: $bestDerivative") + L.d("Loading video from url: $bestUrl") binding.videoView.visibility = View.VISIBLE mediaController = MediaController(requireActivity()) if (!DeviceUtil.isNavigationBarShowing) { @@ -244,7 +243,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener loading = false true } - binding.videoView.setVideoURI(Uri.parse(bestDerivative)) + binding.videoView.setVideoURI(Uri.parse(bestUrl)) } } @@ -282,17 +281,15 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener private fun shareImage() { mediaInfo?.let { - object : ImagePipelineBitmapGetter(ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl, - Constants.PREFERRED_GALLERY_IMAGE_SIZE)) { - override fun onSuccess(bitmap: Bitmap?) { - if (!isAdded) { - return - } - imageTitle?.let { title -> - callback()?.onShare(this@GalleryItemFragment, bitmap, shareSubject, title) - } + val imageUrl = ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl, Constants.PREFERRED_GALLERY_IMAGE_SIZE) + ImagePipelineBitmapGetter(requireContext(), imageUrl) { bitmap -> + if (!isAdded) { + return@ImagePipelineBitmapGetter } - }[requireContext()] + imageTitle?.let { title -> + callback()?.onShare(this@GalleryItemFragment, bitmap, shareSubject, title) + } + } } } diff --git a/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt b/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt index 88ce9749d09..61628f9fba9 100644 --- a/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt +++ b/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt @@ -7,19 +7,19 @@ import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition -abstract class ImagePipelineBitmapGetter(private val imageUrl: String?) { - - abstract fun onSuccess(bitmap: Bitmap?) +class ImagePipelineBitmapGetter(context: Context, imageUrl: String?, callback: Callback) { + fun interface Callback { + fun onSuccess(bitmap: Bitmap) + } - operator fun get(context: Context) { + init { Glide.with(context) .asBitmap() .load(imageUrl) .into(object : CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { - onSuccess(resource) + callback.onSuccess(resource) } - override fun onLoadCleared(placeholder: Drawable?) {} }) } diff --git a/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt b/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt index 977b8d2cba0..67020882895 100644 --- a/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt +++ b/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt @@ -19,6 +19,8 @@ class MediaListItem constructor(val title: String = "", val isInCommons get() = srcSets.firstOrNull()?.src?.contains(Service.URL_FRAGMENT_FROM_COMMONS) == true + val isVideo get() = type == "video" + fun getImageUrl(deviceScale: Float): String { var imageUrl = srcSets[0].src var lastScale = 1.0f diff --git a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt index 432ef118690..19f31be7ed1 100644 --- a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt +++ b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt @@ -68,6 +68,7 @@ class HistoryEntry( const val SOURCE_LANGUAGE_LINK = 6 const val SOURCE_RANDOM = 7 const val SOURCE_MAIN_PAGE = 8 + const val SOURCE_PLACES = 9 const val SOURCE_DISAMBIG = 10 const val SOURCE_READING_LIST = 11 const val SOURCE_FEED_BECAUSE_YOU_READ = 13 diff --git a/app/src/main/java/org/wikipedia/history/HistoryFragment.kt b/app/src/main/java/org/wikipedia/history/HistoryFragment.kt index 971c9ecdd32..fd881df0346 100644 --- a/app/src/main/java/org/wikipedia/history/HistoryFragment.kt +++ b/app/src/main/java/org/wikipedia/history/HistoryFragment.kt @@ -300,7 +300,7 @@ class HistoryFragment : Fragment(), BackPressedHandler { deleteSelectedPages() } } - FeedbackUtil.setButtonLongPressToast(historyFilterButton, clearHistoryButton) + FeedbackUtil.setButtonTooltip(historyFilterButton, clearHistoryButton) adjustSearchCardView(searchCardView) } } diff --git a/app/src/main/java/org/wikipedia/language/AppLanguageState.kt b/app/src/main/java/org/wikipedia/language/AppLanguageState.kt index 072d1dbce4f..6889408d8e5 100644 --- a/app/src/main/java/org/wikipedia/language/AppLanguageState.kt +++ b/app/src/main/java/org/wikipedia/language/AppLanguageState.kt @@ -36,8 +36,8 @@ class AppLanguageState(context: Context) { val appLanguageCode: String get() = appLanguageCodes.first() - val remainingAvailableLanguageCodes: List - get() = LanguageUtil.availableLanguages.filter { !_appLanguageCodes.contains(it) && appLanguageLookUpTable.isSupportedCode(it) } + val remainingSuggestedLanguageCodes: List + get() = LanguageUtil.suggestedLanguagesFromSystem.filter { !_appLanguageCodes.contains(it) && appLanguageLookUpTable.isSupportedCode(it) } val systemLanguageCode: String get() { @@ -143,7 +143,7 @@ class AppLanguageState(context: Context) { private fun initAppLanguageCodes() { if (_appLanguageCodes.isEmpty()) { if (Prefs.isInitialOnboardingEnabled) { - setAppLanguageCodes(remainingAvailableLanguageCodes) + setAppLanguageCodes(remainingSuggestedLanguageCodes) } else { // If user has never changed app language before addAppLanguageCode(systemLanguageCode) diff --git a/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt b/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt index b44ac6fbf1a..dd37d893bcb 100644 --- a/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt +++ b/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt @@ -5,7 +5,8 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.ServiceFactory @@ -13,7 +14,7 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.SiteMatrix import org.wikipedia.extensions.parcelable import org.wikipedia.page.PageTitle -import org.wikipedia.settings.SiteInfoClient +import org.wikipedia.staticdata.MainPageNameData import org.wikipedia.util.Resource import org.wikipedia.util.SingleLiveData import org.wikipedia.util.StringUtil @@ -84,7 +85,7 @@ class LangLinksViewModel(bundle: Bundle) : ViewModel() { // remove the language code and replace it with its variants it.remove() for (variant in languageVariants) { - it.add(PageTitle(if (pageTitle.isMainPage) SiteInfoClient.getMainPageForLang(variant) else link.prefixedText, + it.add(PageTitle(if (pageTitle.isMainPage) MainPageNameData.valueFor(variant) else link.prefixedText, WikiSite.forLanguageCode(variant))) } } @@ -122,15 +123,17 @@ class LangLinksViewModel(bundle: Bundle) : ViewModel() { } companion object { - @JvmStatic fun addVariantEntriesIfNeeded(language: AppLanguageState, title: PageTitle, languageEntries: MutableList) { + if (languageEntries.isEmpty()) { + return + } val parentLanguageCode = language.getDefaultLanguageCode(title.wikiSite.languageCode) if (parentLanguageCode != null) { val languageVariants = language.getLanguageVariants(parentLanguageCode) if (languageVariants != null) { for (languageCode in languageVariants) { if (!title.wikiSite.languageCode.contains(languageCode)) { - val pageTitle = PageTitle(if (title.isMainPage) SiteInfoClient.getMainPageForLang(languageCode) else title.displayText, WikiSite.forLanguageCode(languageCode)) + val pageTitle = PageTitle(if (title.isMainPage) MainPageNameData.valueFor(languageCode) else title.displayText, WikiSite.forLanguageCode(languageCode)) pageTitle.text = StringUtil.removeNamespace(title.prefixedText) languageEntries.add(pageTitle) } diff --git a/app/src/main/java/org/wikipedia/language/LanguageUtil.kt b/app/src/main/java/org/wikipedia/language/LanguageUtil.kt index f73ba6d003c..cabe0ddb764 100644 --- a/app/src/main/java/org/wikipedia/language/LanguageUtil.kt +++ b/app/src/main/java/org/wikipedia/language/LanguageUtil.kt @@ -7,15 +7,16 @@ import androidx.core.os.LocaleListCompat import org.apache.commons.lang3.StringUtils import org.wikipedia.WikipediaApp import org.wikipedia.util.StringUtil -import java.util.* +import java.util.Locale object LanguageUtil { + private const val MAX_SUGGESTED_LANGUAGES = 8 private const val HONG_KONG_COUNTRY_CODE = "HK" private const val MACAU_COUNTRY_CODE = "MO" private val TRADITIONAL_CHINESE_COUNTRY_CODES = listOf(Locale.TAIWAN.country, HONG_KONG_COUNTRY_CODE, MACAU_COUNTRY_CODE) - val availableLanguages: List + val suggestedLanguagesFromSystem: List get() { val languages = mutableListOf() @@ -76,7 +77,7 @@ object LanguageUtil { } } } - return languages + return languages.take(MAX_SUGGESTED_LANGUAGES) } @JvmStatic diff --git a/app/src/main/java/org/wikipedia/language/LanguagesListViewModel.kt b/app/src/main/java/org/wikipedia/language/LanguagesListViewModel.kt index 1e508732392..832c27ffabd 100644 --- a/app/src/main/java/org/wikipedia/language/LanguagesListViewModel.kt +++ b/app/src/main/java/org/wikipedia/language/LanguagesListViewModel.kt @@ -16,7 +16,7 @@ import org.wikipedia.util.log.L class LanguagesListViewModel : ViewModel() { - private val suggestedLanguageCodes = WikipediaApp.instance.languageState.remainingAvailableLanguageCodes + private val suggestedLanguageCodes = WikipediaApp.instance.languageState.remainingSuggestedLanguageCodes private val nonSuggestedLanguageCodes = WikipediaApp.instance.languageState.appMruLanguageCodes.filterNot { suggestedLanguageCodes.contains(it) || WikipediaApp.instance.languageState.appLanguageCodes.contains(it) } diff --git a/app/src/main/java/org/wikipedia/main/MainFragment.kt b/app/src/main/java/org/wikipedia/main/MainFragment.kt index 731074e1e7a..73d67a4492c 100644 --- a/app/src/main/java/org/wikipedia/main/MainFragment.kt +++ b/app/src/main/java/org/wikipedia/main/MainFragment.kt @@ -6,7 +6,6 @@ import android.app.ActivityOptions import android.content.ActivityNotFoundException import android.content.Intent import android.content.pm.PackageManager -import android.graphics.Bitmap import android.os.Build import android.os.Bundle import android.speech.RecognizerIntent @@ -22,6 +21,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.core.view.MenuProvider import androidx.core.view.descendants +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback @@ -34,6 +34,7 @@ import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.FragmentUtil.getCallback +import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.analytics.eventplatform.ReadingListsAnalyticsHelper import org.wikipedia.auth.AccountUtil import org.wikipedia.commons.FilePageActivity @@ -68,7 +69,7 @@ import org.wikipedia.search.SearchActivity import org.wikipedia.search.SearchFragment import org.wikipedia.settings.Prefs import org.wikipedia.settings.SettingsActivity -import org.wikipedia.settings.SiteInfoClient.getMainPageForLang +import org.wikipedia.staticdata.MainPageNameData import org.wikipedia.staticdata.UserAliasData import org.wikipedia.staticdata.UserTalkAliasData import org.wikipedia.suggestededits.SuggestedEditsTasksFragment @@ -130,7 +131,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. it.maxLines = 2 } - FeedbackUtil.setButtonLongPressToast(binding.navMoreContainer) + FeedbackUtil.setButtonTooltip(binding.navMoreContainer) binding.navMoreContainer.setOnClickListener { ExclusiveBottomSheetPresenter.show(childFragmentManager, MenuNavTabDialog.newInstance()) } @@ -169,7 +170,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. downloadReceiver.register(requireContext(), downloadReceiverCallback) // reset the last-page-viewed timer Prefs.pageLastShown = 0 - maybeShowWatchlistTooltip() + maybeShowPlacesTooltip() } override fun onDestroyView() { @@ -200,7 +201,8 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. return } if (resultCode == TabActivity.RESULT_NEW_TAB) { - val entry = HistoryEntry(PageTitle(getMainPageForLang(WikipediaApp.instance.appOrSystemLanguageCode), + val entry = HistoryEntry(PageTitle( + MainPageNameData.valueFor(WikipediaApp.instance.appOrSystemLanguageCode), WikipediaApp.instance.wikiSite), HistoryEntry.SOURCE_MAIN_PAGE) startActivity(PageActivity.newIntentForNewTab(requireContext(), entry, entry.title)) } else if (resultCode == TabActivity.RESULT_LOAD_FROM_BACKSTACK) { @@ -270,7 +272,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. tabCountsView!!.contentDescription = getString(R.string.menu_page_show_tabs) tabsItem.actionView = tabCountsView tabsItem.expandActionView() - FeedbackUtil.setButtonLongPressToast(tabCountsView!!) + FeedbackUtil.setButtonTooltip(tabCountsView!!) showTabCountsAnimation = false } val notificationMenuItem = menu.findItem(R.id.menu_notifications) @@ -285,7 +287,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. notificationButtonView.contentDescription = getString(R.string.notifications_activity_title) notificationMenuItem.actionView = notificationButtonView notificationMenuItem.expandActionView() - FeedbackUtil.setButtonLongPressToast(notificationButtonView) + FeedbackUtil.setButtonTooltip(notificationButtonView) } else { notificationMenuItem.isVisible = false } @@ -369,16 +371,13 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. override fun onFeedShareImage(card: FeaturedImageCard) { val thumbUrl = card.baseImage().thumbnailUrl val fullSizeUrl = card.baseImage().original.source - object : ImagePipelineBitmapGetter(thumbUrl) { - override fun onSuccess(bitmap: Bitmap?) { - if (bitmap != null) { - ShareUtil.shareImage(requireContext(), bitmap, File(thumbUrl).name, - ShareUtil.getFeaturedImageShareSubject(requireContext(), card.age()), fullSizeUrl) - } else { - FeedbackUtil.showMessage(this@MainFragment, getString(R.string.gallery_share_error, card.baseImage().title)) - } + ImagePipelineBitmapGetter(requireContext(), thumbUrl) { bitmap -> + if (!isAdded) { + return@ImagePipelineBitmapGetter } - }[requireContext()] + ShareUtil.shareImage(requireContext(), bitmap, File(thumbUrl).name, + ShareUtil.getFeaturedImageShareSubject(requireContext(), card.age()), fullSizeUrl) + } } override fun onFeedDownloadImage(image: FeaturedImage) { @@ -455,7 +454,8 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. } fun setBottomNavVisible(visible: Boolean) { - binding.mainNavTabContainer.visibility = if (visible) View.VISIBLE else View.GONE + binding.mainNavTabBorder.isVisible = visible + binding.mainNavTabContainer.isVisible = visible } fun onGoOffline() { @@ -551,14 +551,15 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. } } - private fun maybeShowWatchlistTooltip() { - if (Prefs.isWatchlistPageOnboardingTooltipShown && - !Prefs.isWatchlistMainOnboardingTooltipShown && AccountUtil.isLoggedIn) { + private fun maybeShowPlacesTooltip() { + if (Prefs.showOneTimePlacesMainNavOnboardingTooltip && Prefs.exploreFeedVisitCount > SHOW_PLACES_MAIN_NAV_TOOLTIP) { enqueueTooltip { - FeedbackUtil.showTooltip(requireActivity(), binding.navMoreContainer, R.layout.view_watchlist_main_tooltip, 0, 0, aboveOrBelow = true, autoDismiss = false) - .setOnBalloonDismissListener { - Prefs.isWatchlistMainOnboardingTooltipShown = true - } + PlacesEvent.logImpression("main_nav_tooltip") + FeedbackUtil.showTooltip(requireActivity(), binding.navMoreContainer, + getString(R.string.places_nav_tab_tooltip_message), aboveOrBelow = true, autoDismiss = false, showDismissButton = true).setOnBalloonDismissListener { + Prefs.showOneTimePlacesMainNavOnboardingTooltip = false + PlacesEvent.logAction("dismiss_click", "main_nav_tooltip") + } } } } @@ -606,6 +607,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. companion object { // Actually shows on the 3rd time of using the app. The Pref.incrementExploreFeedVisitCount() gets call after MainFragment.onResume() private const val SHOW_EDITS_SNACKBAR_COUNT = 2 + private const val SHOW_PLACES_MAIN_NAV_TOOLTIP = 1 fun newInstance(): MainFragment { return MainFragment().apply { diff --git a/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt b/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt index a0c9001d688..9cf8f48cce5 100644 --- a/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt +++ b/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt @@ -12,9 +12,11 @@ import org.wikipedia.WikipediaApp import org.wikipedia.activity.FragmentUtil import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.auth.AccountUtil import org.wikipedia.databinding.ViewMainDrawerBinding import org.wikipedia.page.ExtendedBottomSheetDialogFragment +import org.wikipedia.places.PlacesActivity import org.wikipedia.util.CustomTabsUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.ResourceUtil.getThemedColorStateList @@ -57,6 +59,12 @@ class MenuNavTabDialog : ExtendedBottomSheetDialogFragment() { dismiss() } + binding.mainDrawerPlacesContainer.setOnClickListener { + PlacesEvent.logAction("places_click", "main_nav_tab") + requireActivity().startActivity(PlacesActivity.newIntent(requireActivity())) + dismiss() + } + binding.mainDrawerSettingsContainer.setOnClickListener { BreadCrumbLogEvent.logClick(requireActivity(), binding.mainDrawerSettingsContainer) callback()?.settingsClick() diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt b/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt index 631085c774b..be57fd24a8a 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt @@ -54,6 +54,7 @@ import org.wikipedia.util.DeviceUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.L10nUtil +import org.wikipedia.util.Resource import org.wikipedia.util.ResourceUtil import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil @@ -138,8 +139,8 @@ class NotificationActivity : BaseActivity() { repeatOnLifecycle(Lifecycle.State.CREATED) { viewModel.uiState.collect { when (it) { - is NotificationViewModel.UiState.Success -> onNotificationsComplete(it.notifications, it.fromContinuation) - is NotificationViewModel.UiState.Error -> setErrorState(it.throwable) + is Resource.Success -> onNotificationsComplete(it.data.first, it.data.second) + is Resource.Error -> setErrorState(it.throwable) } } } @@ -534,7 +535,7 @@ class NotificationActivity : BaseActivity() { resultLauncher.launch(NotificationFilterActivity.newIntent(it.context)) } - FeedbackUtil.setButtonLongPressToast(notificationFilterButton) + FeedbackUtil.setButtonTooltip(notificationFilterButton) } fun updateFilterIconAndCount() { diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt b/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt index 76f06c09310..483939ed994 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt @@ -7,11 +7,11 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.notifications.db.Notification import org.wikipedia.notifications.db.NotificationDao -class NotificationRepository constructor(private val notificationDao: NotificationDao) { +class NotificationRepository(private val notificationDao: NotificationDao) { fun getAllNotifications() = notificationDao.getAllNotifications() - fun insertNotifications(notifications: List) { + private fun insertNotifications(notifications: List) { notificationDao.insertNotifications(notifications) } @@ -19,10 +19,6 @@ class NotificationRepository constructor(private val notificationDao: Notificati notificationDao.updateNotification(notification) } - suspend fun deleteNotification(notification: Notification) { - notificationDao.deleteNotification(notification) - } - suspend fun fetchUnreadWikiDbNames(): Map { val response = ServiceFactory.get(Constants.commonsWikiSite).unreadNotificationWikis() return response.query?.unreadNotificationWikis!! diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationViewModel.kt b/app/src/main/java/org/wikipedia/notifications/NotificationViewModel.kt index 6c2a6a30678..befbdb80a02 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationViewModel.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationViewModel.kt @@ -13,14 +13,16 @@ import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.WikiSite import org.wikipedia.notifications.db.Notification import org.wikipedia.settings.Prefs +import org.wikipedia.util.Resource import org.wikipedia.util.StringUtil -import java.util.* +import java.util.Date +import java.util.Random class NotificationViewModel : ViewModel() { private val notificationRepository = NotificationRepository(AppDatabase.instance.notificationDao()) private val handler = CoroutineExceptionHandler { _, throwable -> - _uiState.value = UiState.Error(throwable) + _uiState.value = Resource.Error(throwable) } private val notificationList = mutableListOf() private var dbNameMap = mapOf() @@ -31,7 +33,7 @@ class NotificationViewModel : ViewModel() { var mentionsUnreadCount: Int = 0 var allUnreadCount: Int = 0 - private val _uiState = MutableStateFlow(UiState()) + private val _uiState = MutableStateFlow(Resource, Boolean>>()) val uiState = _uiState.asStateFlow() init { @@ -42,8 +44,8 @@ class NotificationViewModel : ViewModel() { } private fun filterAndPostNotifications() { - _uiState.value = UiState.Success(processList(notificationRepository.getAllNotifications()), - !currentContinueStr.isNullOrEmpty()) + val pair = Pair(processList(notificationRepository.getAllNotifications()), !currentContinueStr.isNullOrEmpty()) + _uiState.value = Resource.Success(pair) } private fun processList(list: List): List { @@ -191,10 +193,4 @@ class NotificationViewModel : ViewModel() { filterAndPostNotifications() } } - - open class UiState { - class Success(val notifications: List, - val fromContinuation: Boolean) : UiState() - class Error(val throwable: Throwable) : UiState() - } } diff --git a/app/src/main/java/org/wikipedia/page/ExtendedBottomSheetDialogFragment.kt b/app/src/main/java/org/wikipedia/page/ExtendedBottomSheetDialogFragment.kt index cc762bc21e4..1e068a9314d 100644 --- a/app/src/main/java/org/wikipedia/page/ExtendedBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/wikipedia/page/ExtendedBottomSheetDialogFragment.kt @@ -5,25 +5,15 @@ import android.content.Context import android.os.Bundle import android.view.MotionEvent import androidx.annotation.StyleRes -import androidx.core.view.WindowInsetsControllerCompat import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment import org.wikipedia.R -import org.wikipedia.WikipediaApp import org.wikipedia.analytics.BreadcrumbsContextHelper import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent import org.wikipedia.util.DeviceUtil import org.wikipedia.util.ResourceUtil open class ExtendedBottomSheetDialogFragment : BottomSheetDialogFragment() { - protected fun disableBackgroundDim() { - requireDialog().window?.let { - WindowInsetsControllerCompat(it, it.decorView).run { - isAppearanceLightStatusBars = !WikipediaApp.instance.currentTheme.isDark - } - it.setDimAmount(0f) - } - } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) diff --git a/app/src/main/java/org/wikipedia/page/LinkHandler.kt b/app/src/main/java/org/wikipedia/page/LinkHandler.kt index 66f1b61aff6..66dd66766f8 100644 --- a/app/src/main/java/org/wikipedia/page/LinkHandler.kt +++ b/app/src/main/java/org/wikipedia/page/LinkHandler.kt @@ -1,5 +1,6 @@ package org.wikipedia.page +import android.app.Activity import android.content.Context import android.net.Uri import kotlinx.serialization.json.JsonObject @@ -7,6 +8,8 @@ import kotlinx.serialization.json.jsonPrimitive import org.wikipedia.bridge.CommunicationBridge.JSEventListener import org.wikipedia.dataclient.WikiSite import org.wikipedia.page.LinkMovementMethodExt.UrlHandlerWithText +import org.wikipedia.places.PlacesActivity +import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L @@ -99,6 +102,12 @@ abstract class LinkHandler(protected val context: Context) : JSEventListener, Ur } open fun onExternalLinkClicked(uri: Uri) { + if (uri.authority.orEmpty().contains("geohack") && context is Activity) { + StringUtil.geoHackToLocation(uri.getQueryParameter("params"))?.let { + context.startActivity(PlacesActivity.newIntent(context, null, it)) + return + } + } UriUtil.handleExternalLink(context, uri) } diff --git a/app/src/main/java/org/wikipedia/page/PageActionTabLayout.kt b/app/src/main/java/org/wikipedia/page/PageActionTabLayout.kt index cd8de303e10..93667a4b33c 100644 --- a/app/src/main/java/org/wikipedia/page/PageActionTabLayout.kt +++ b/app/src/main/java/org/wikipedia/page/PageActionTabLayout.kt @@ -27,12 +27,14 @@ class PageActionTabLayout constructor(context: Context, attrs: AttributeSet? = n fun update() { removeAllViews() val typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL) + val tintColor = ResourceUtil.getThemedColorStateList(context, R.attr.primary_color) + val backgroundSource = ResourceUtil.getThemedAttributeId(context, androidx.appcompat.R.attr.selectableItemBackgroundBorderless) Prefs.customizeToolbarOrder.forEach { val view = MaterialTextView(context) view.gravity = Gravity.CENTER view.setPadding(DimenUtil.roundedDpToPx(2f), DimenUtil.roundedDpToPx(12f), DimenUtil.roundedDpToPx(2f), 0) - view.setBackgroundResource(ResourceUtil.getThemedAttributeId(context, androidx.appcompat.R.attr.selectableItemBackgroundBorderless)) - view.setTextColor(ResourceUtil.getThemedColor(context, R.attr.placeholder_color)) + view.setBackgroundResource(backgroundSource) + view.setTextColor(tintColor) view.textAlignment = TEXT_ALIGNMENT_CENTER view.setTypeface(typeface, Typeface.NORMAL) view.setTextSize(TypedValue.COMPLEX_UNIT_PX, resources.getDimension(R.dimen.bottom_nav_label_text_size)) @@ -43,8 +45,8 @@ class PageActionTabLayout constructor(context: Context, attrs: AttributeSet? = n view.id = item.viewId view.text = context.getString(item.titleResId) view.contentDescription = view.text - FeedbackUtil.setButtonLongPressToast(view) - TextViewCompat.setCompoundDrawableTintList(view, ResourceUtil.getThemedColorStateList(context, R.attr.placeholder_color)) + FeedbackUtil.setButtonTooltip(view) + TextViewCompat.setCompoundDrawableTintList(view, tintColor) view.setCompoundDrawablesWithIntrinsicBounds(0, item.iconResId, 0, 0) view.compoundDrawablePadding = -DimenUtil.roundedDpToPx(4f) view.setOnClickListener { v -> diff --git a/app/src/main/java/org/wikipedia/page/PageActivity.kt b/app/src/main/java/org/wikipedia/page/PageActivity.kt index 57c950b3efc..4e5404c0659 100644 --- a/app/src/main/java/org/wikipedia/page/PageActivity.kt +++ b/app/src/main/java/org/wikipedia/page/PageActivity.kt @@ -33,6 +33,7 @@ import org.wikipedia.activity.SingleWebViewActivity import org.wikipedia.analytics.eventplatform.ArticleLinkPreviewInteractionEvent import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.analytics.metricsplatform.ArticleLinkPreviewInteraction import org.wikipedia.auth.AccountUtil import org.wikipedia.commons.FilePageActivity @@ -57,7 +58,7 @@ import org.wikipedia.page.tabs.TabActivity import org.wikipedia.readinglist.ReadingListActivity import org.wikipedia.search.SearchActivity import org.wikipedia.settings.Prefs -import org.wikipedia.settings.SiteInfoClient +import org.wikipedia.staticdata.MainPageNameData import org.wikipedia.staticdata.UserTalkAliasData import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.suggestededits.SuggestedEditsImageTagEditActivity @@ -166,23 +167,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo super.onCreate(savedInstanceState) PreferenceManager.setDefaultValues(this, R.xml.preferences, false) binding = ActivityPageBinding.inflate(layoutInflater) - - try { - setContentView(binding.root) - } catch (e: Exception) { - if (!e.message.isNullOrEmpty() && e.message!!.lowercase(Locale.getDefault()).contains(EXCEPTION_MESSAGE_WEBVIEW) || - !ThrowableUtil.getInnermostThrowable(e).message.isNullOrEmpty() && - ThrowableUtil.getInnermostThrowable(e).message!!.lowercase(Locale.getDefault()).contains(EXCEPTION_MESSAGE_WEBVIEW)) { - // If the system failed to inflate our activity because of the WebView (which could - // be one of several types of exceptions), it likely means that the system WebView - // is in the process of being updated. In this case, show the user a message and - // bail immediately. - Toast.makeText(app, R.string.error_webview_updating, Toast.LENGTH_LONG).show() - finish() - return - } - throw e - } + setContentView(binding.root) disposables.add(app.bus.subscribe(EventBusConsumer())) updateProgressBar(false) @@ -205,7 +190,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo requestBrowseTabLauncher.launch(TabActivity.newIntentFromPageActivity(this)) } toolbarHideHandler = ViewHideHandler(binding.pageToolbarContainer, null, Gravity.TOP) { isTooltipShowing } - FeedbackUtil.setButtonLongPressToast(binding.pageToolbarButtonNotifications, binding.pageToolbarButtonTabs, binding.pageToolbarButtonShowOverflowMenu) + FeedbackUtil.setButtonTooltip(binding.pageToolbarButtonNotifications, binding.pageToolbarButtonTabs, binding.pageToolbarButtonShowOverflowMenu) binding.pageToolbarButtonShowOverflowMenu.setOnClickListener { pageFragment.showOverflowMenu(it) pageFragment.articleInteractionEvent?.logMoreClick() @@ -249,10 +234,6 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo loadMainPage(TabPosition.EXISTING_TAB) } - if (AccountUtil.isLoggedIn) { - Prefs.loggedInPageActivityVisitCount++ - } - if (savedInstanceState == null) { // if there's no savedInstanceState, and we're not coming back from a Theme change, // then we must have been launched with an Intent, so... handle it! @@ -260,6 +241,24 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo } } + override fun onStart() { + try { + super.onStart() + } catch (e: Exception) { + if (e.message.orEmpty().contains(EXCEPTION_MESSAGE_WEBVIEW, true) || + ThrowableUtil.getInnermostThrowable(e).message.orEmpty().contains(EXCEPTION_MESSAGE_WEBVIEW, true)) { + // If the system failed to inflate our activity because of the WebView (which could + // be one of several types of exceptions), it likely means that the system WebView + // is in the process of being updated. In this case, show the user a message and + // bail immediately. + Toast.makeText(app, R.string.error_webview_updating, Toast.LENGTH_LONG).show() + finish() + return + } + throw e + } + } + override fun onPrepareOptionsMenu(menu: Menu): Boolean { if (!isDestroyed) { binding.pageToolbarButtonTabs.updateTabCount(false) @@ -365,8 +364,8 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo override fun onPageLoadComplete() { removeTransitionAnimState() - maybeShowWatchlistTooltip() maybeShowThemeTooltip() + maybeShowPlacesTooltip() } override fun onPageDismissBottomSheet() { @@ -485,13 +484,13 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo return } // Special cases: - // If the link is to a page in the "donate." or "thankyou." domains (e.g. a "thank you" page - // after having donated), then bounce it out to an external browser, since we don't have - // the same cookie state as the browser does. + // If the subdomain of the URL is not a "language" subdomain as we expect, then + // bounce it out to an external browser. This can be links to the "donate." or + // "thankyou." subdomains, or the Wikiquote "quote." subdomain, and possibly others. val language = wiki.languageCode.lowercase(Locale.getDefault()) - val isDonationRelated = language == "donate" || language == "thankyou" - if (isDonationRelated || (title.isSpecial && !title.isContributions)) { - // Stop bouncing out if the URL is from the Android app customTab. + if (Constants.NON_LANGUAGE_SUBDOMAINS.contains(language) || (title.isSpecial && !title.isContributions)) { + // ...Except if the URL came as a result of a successful donation, in which case + // open it in a Custom Tab. val utmCampaign = uri.getQueryParameter("utm_campaign") if (utmCampaign != null && utmCampaign == "Android") { // TODO: need to verify if the page can be displayed and logged properly. @@ -530,11 +529,6 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo val title = PageTitle(query, app.wikiSite) val historyEntry = HistoryEntry(title, HistoryEntry.SOURCE_SEARCH) loadPage(title, historyEntry, TabPosition.EXISTING_TAB) - } else if (intent.hasExtra(Constants.INTENT_FEATURED_ARTICLE_FROM_WIDGET)) { - intent.parcelableExtra(Constants.ARG_TITLE)?.let { - val historyEntry = HistoryEntry(it, HistoryEntry.SOURCE_WIDGET) - loadPage(it, historyEntry, TabPosition.EXISTING_TAB) - } } else if (ACTION_CREATE_NEW_TAB == intent.action) { loadMainPage(TabPosition.NEW_TAB_FOREGROUND) } else { @@ -595,7 +589,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo } private fun loadMainPage(position: TabPosition) { - val title = PageTitle(SiteInfoClient.getMainPageForLang(app.appOrSystemLanguageCode), app.wikiSite) + val title = PageTitle(MainPageNameData.valueFor(app.appOrSystemLanguageCode), app.wikiSite) val historyEntry = HistoryEntry(title, HistoryEntry.SOURCE_MAIN_PAGE) loadPage(title, historyEntry, position) } @@ -668,16 +662,31 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo .show() } - private fun maybeShowWatchlistTooltip() { - pageFragment.historyEntry?.let { - if (!Prefs.isWatchlistPageOnboardingTooltipShown && AccountUtil.isLoggedIn && - it.source != HistoryEntry.SOURCE_SUGGESTED_EDITS && - Prefs.loggedInPageActivityVisitCount >= 3) { - enqueueTooltip { - Prefs.isWatchlistPageOnboardingTooltipShown = true - FeedbackUtil.showTooltip(this, binding.pageToolbarButtonShowOverflowMenu, - R.layout.view_watchlist_page_tooltip, -32, -8, aboveOrBelow = false, autoDismiss = false) + private fun maybeShowPlacesTooltip() { + if (!Prefs.showOneTimePlacesPageOnboardingTooltip || + pageFragment.page?.pageProperties?.geo == null || isTooltipShowing) { + return + } + enqueueTooltip { + FeedbackUtil.getTooltip( + this, + StringUtil.fromHtml(getString(R.string.places_article_menu_tooltip_message)), + arrowAnchorPadding = -DimenUtil.roundedDpToPx(7f), + topOrBottomMargin = -8, + aboveOrBelow = false, + autoDismiss = false, + showDismissButton = true + ).apply { + PlacesEvent.logImpression("article_more_tooltip") + setOnBalloonDismissListener { + PlacesEvent.logAction("dismiss_click", "article_more_tooltip") + isTooltipShowing = false + Prefs.showOneTimePlacesPageOnboardingTooltip = false } + isTooltipShowing = true + BreadCrumbLogEvent.logTooltipShown(this@PageActivity, binding.pageToolbarButtonShowOverflowMenu) + showAlignBottom(binding.pageToolbarButtonShowOverflowMenu) + setCurrentTooltip(this) } } } diff --git a/app/src/main/java/org/wikipedia/page/PageFragment.kt b/app/src/main/java/org/wikipedia/page/PageFragment.kt index 59b18ce4395..c10f38d5460 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragment.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragment.kt @@ -19,12 +19,14 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.animation.doOnEnd import androidx.core.app.ActivityCompat import androidx.core.app.ActivityOptionsCompat import androidx.core.graphics.Insets import androidx.core.view.forEach +import androidx.core.widget.TextViewCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener @@ -53,6 +55,7 @@ import org.wikipedia.analytics.eventplatform.ArticleFindInPageInteractionEvent import org.wikipedia.analytics.eventplatform.ArticleInteractionEvent import org.wikipedia.analytics.eventplatform.DonorExperienceEvent import org.wikipedia.analytics.eventplatform.EventPlatformClient +import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.analytics.eventplatform.WatchlistAnalyticsHelper import org.wikipedia.analytics.metricsplatform.ArticleFindInPageInteraction import org.wikipedia.analytics.metricsplatform.ArticleToolbarInteraction @@ -91,6 +94,7 @@ import org.wikipedia.page.references.PageReferences import org.wikipedia.page.references.ReferenceDialog import org.wikipedia.page.shareafact.ShareHandler import org.wikipedia.page.tabs.Tab +import org.wikipedia.places.PlacesActivity import org.wikipedia.readinglist.LongPressMenu import org.wikipedia.readinglist.ReadingListBehaviorsUtil import org.wikipedia.readinglist.database.ReadingListPage @@ -101,10 +105,8 @@ import org.wikipedia.theme.ThemeChooserDialog import org.wikipedia.util.ActiveTimer import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil -import org.wikipedia.util.GeoUtil import org.wikipedia.util.ResourceUtil import org.wikipedia.util.ShareUtil -import org.wikipedia.util.StringUtil import org.wikipedia.util.ThrowableUtil import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L @@ -398,7 +400,8 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi onPageSetupEvent() bridge.onMetadataReady() bridge.onPcsReady() - bridge.execute(JavaScriptActionHandler.mobileWebChromeShim()) + bridge.execute(JavaScriptActionHandler.mobileWebChromeShim(DimenUtil.roundedPxToDp(((requireActivity() as AppCompatActivity).supportActionBar?.height ?: 0).toFloat()), + DimenUtil.roundedPxToDp(binding.pageActionsTabLayout.height.toFloat()))) } } } @@ -839,8 +842,12 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } "coordinate" -> { model.page?.let { page -> - page.pageProperties.geo?.let { geo -> - GeoUtil.sendGeoIntent(requireActivity(), geo, StringUtil.fromHtml(page.displayTitle).toString()) + val location = page.pageProperties.geo + if (location != null) { + PlacesEvent.logAction("places_click", "article_footer") + requireActivity().startActivity(PlacesActivity.newIntent(requireContext(), page.title, location)) + } else { + FeedbackUtil.showMessage(this@PageFragment, getString(R.string.action_item_view_on_map_unavailable)) } } } @@ -1021,16 +1028,29 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi binding.pageActionsTabLayout.forEach { it as MaterialTextView val pageActionItem = PageActionItem.find(it.id) val enabled = model.page != null && (!model.shouldLoadAsMobileWeb || (model.shouldLoadAsMobileWeb && pageActionItem.isAvailableOnMobileWeb)) - it.isEnabled = enabled - it.alpha = if (enabled) 1f else 0.5f - - if (pageActionItem == PageActionItem.SAVE) { - it.setCompoundDrawablesWithIntrinsicBounds(0, PageActionItem.readingListIcon(model.isInReadingList), 0, 0) - } else if (pageActionItem == PageActionItem.ADD_TO_WATCHLIST) { - it.setText(if (model.isWatched) R.string.menu_page_unwatch else R.string.menu_page_watch) - it.setCompoundDrawablesWithIntrinsicBounds(0, PageActionItem.watchlistIcon(model.isWatched, model.hasWatchlistExpiry), 0, 0) - it.isEnabled = enabled && AccountUtil.isLoggedIn - it.alpha = if (it.isEnabled) 1f else 0.5f + when (pageActionItem) { + PageActionItem.ADD_TO_WATCHLIST -> { + it.setText(if (model.isWatched) R.string.menu_page_unwatch else R.string.menu_page_watch) + it.setCompoundDrawablesWithIntrinsicBounds(0, PageActionItem.watchlistIcon(model.isWatched, model.hasWatchlistExpiry), 0, 0) + it.isEnabled = enabled && AccountUtil.isLoggedIn + it.alpha = if (it.isEnabled) 1f else 0.5f + } + PageActionItem.SAVE -> { + it.setCompoundDrawablesWithIntrinsicBounds(0, PageActionItem.readingListIcon(model.isInReadingList), 0, 0) + } + PageActionItem.EDIT_ARTICLE -> { + it.setCompoundDrawablesRelativeWithIntrinsicBounds(0, PageActionItem.editArticleIcon(model.page?.pageProperties?.canEdit != true), 0, 0) + } + PageActionItem.VIEW_ON_MAP -> { + val geoAvailable = model.page?.pageProperties?.geo != null + val tintColor = ResourceUtil.getThemedColorStateList(requireContext(), if (geoAvailable) R.attr.primary_color else R.attr.inactive_color) + it.setTextColor(tintColor) + TextViewCompat.setCompoundDrawableTintList(it, tintColor) + } + else -> { + it.isEnabled = enabled + it.alpha = if (enabled) 1f else 0.5f + } } } sidePanelHandler.setEnabled(false) @@ -1341,10 +1361,6 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi val anchor = if (Prefs.customizeToolbarOrder.contains(PageActionItem.SAVE.id)) binding.pageActionsTabLayout else (requireActivity() as PageActivity).getOverflowMenu() LongPressMenu(anchor, existsInAnyList = false, callback = object : LongPressMenu.Callback { - override fun onOpenLink(entry: HistoryEntry) { } - - override fun onOpenInNewTab(entry: HistoryEntry) { } - override fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) { title?.run { ReadingListBehaviorsUtil.addToDefaultList(requireActivity(), this, addToDefault, InvokeSource.BOOKMARK_BUTTON) @@ -1467,6 +1483,18 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi metricsPlatformArticleEventToolbarInteraction.logEditArticleClick() } + override fun onViewOnMapSelected() { + title?.let { + val location = page?.pageProperties?.geo + if (location != null) { + PlacesEvent.logAction("places_click", "article_more_menu") + requireActivity().startActivity(PlacesActivity.newIntent(requireContext(), it, location)) + } else { + FeedbackUtil.showMessage(this@PageFragment, getString(R.string.action_item_view_on_map_unavailable)) + } + } + } + override fun forwardClick() { goForward() articleInteractionEvent?.logForwardClick() diff --git a/app/src/main/java/org/wikipedia/page/PageTitle.kt b/app/src/main/java/org/wikipedia/page/PageTitle.kt index a86f335f573..d97b891b584 100644 --- a/app/src/main/java/org/wikipedia/page/PageTitle.kt +++ b/app/src/main/java/org/wikipedia/page/PageTitle.kt @@ -7,8 +7,8 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.wikipedia.dataclient.WikiSite import org.wikipedia.language.LanguageUtil -import org.wikipedia.settings.SiteInfoClient import org.wikipedia.staticdata.ContributionsNameData +import org.wikipedia.staticdata.MainPageNameData import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil import java.util.* @@ -72,7 +72,7 @@ data class PageTitle( val isMainPage: Boolean get() { - val mainPageTitle = SiteInfoClient.getMainPageForLang(wikiSite.languageCode) + val mainPageTitle = MainPageNameData.valueFor(wikiSite.languageCode) return mainPageTitle == displayText } @@ -131,7 +131,7 @@ data class PageTitle( constructor(title: String?, wiki: WikiSite, thumbUrl: String? = null) : this(null, wiki, title.orEmpty(), null, thumbUrl, null, null, null) { // FIXME: Does not handle mainspace articles with a colon in the title well at all - var text = title.orEmpty().ifEmpty { SiteInfoClient.getMainPageForLang(wiki.languageCode) } + var text = title.orEmpty().ifEmpty { MainPageNameData.valueFor(wiki.languageCode) } // Split off any fragment (#...) from the title var parts = text.split("#".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray() diff --git a/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt b/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt index 5def032eb80..7346d006011 100644 --- a/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt +++ b/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt @@ -12,6 +12,7 @@ enum class PageActionItem constructor(val id: Int, @DrawableRes val iconResId: Int = R.drawable.ic_settings_black_24dp, val isAvailableOnMobileWeb: Boolean = true, val isExternalLink: Boolean = false) : EnumCode { + // TODO: Need to add the newly added item to the default const lists below SAVE(0, R.id.page_save, R.string.article_menu_bar_save_button, R.drawable.ic_bookmark_border_white_24dp, false) { override fun select(cb: Callback) { cb.onSaveSelected() @@ -76,6 +77,11 @@ enum class PageActionItem constructor(val id: Int, override fun select(cb: Callback) { cb.onEditArticleSelected() } + }, + VIEW_ON_MAP(13, R.id.page_view_on_map, R.string.action_item_view_on_map, R.drawable.baseline_location_on_24, false) { + override fun select(cb: Callback) { + cb.onViewOnMapSelected() + } }; abstract fun select(cb: Callback) @@ -100,10 +106,14 @@ enum class PageActionItem constructor(val id: Int, fun onExploreSelected() fun onCategoriesSelected() fun onEditArticleSelected() + fun onViewOnMapSelected() fun forwardClick() } companion object { + val DEFAULT_TOOLBAR_LIST = listOf(SAVE, LANGUAGE, FIND_IN_ARTICLE, THEME, CONTENTS).map { it.id } + val DEFAULT_OVERFLOW_MENU_LIST = listOf(SHARE, ADD_TO_WATCHLIST, VIEW_TALK_PAGE, VIEW_EDIT_HISTORY, VIEW_ON_MAP, NEW_TAB, EXPLORE, CATEGORIES, EDIT_ARTICLE).map { it.id } + fun find(id: Int): PageActionItem { return entries.find { id == it.id || id == it.viewId } ?: entries[0] } diff --git a/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt index f8837149f87..ab951e035fa 100644 --- a/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt +++ b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt @@ -46,7 +46,7 @@ class CampaignDialogView(context: Context) : FrameLayout(context) { binding.closeButton.setOnClickListener { callback?.onClose() } - FeedbackUtil.setButtonLongPressToast(binding.closeButton) + FeedbackUtil.setButtonTooltip(binding.closeButton) // TODO: think about optimizing the usage of actions array try { diff --git a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt index 8f4e68cacb6..296e7132654 100644 --- a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt +++ b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt @@ -389,7 +389,7 @@ class EditHistoryListActivity : BaseActivity() { showFilterOverflowMenu() } - FeedbackUtil.setButtonLongPressToast(binding.filterByButton) + FeedbackUtil.setButtonTooltip(binding.filterByButton) binding.root.isVisible = true } @@ -432,7 +432,7 @@ class EditHistoryListActivity : BaseActivity() { toggleSelectState() } else { startActivity(ArticleEditDetailsActivity.newIntent(this@EditHistoryListActivity, - viewModel.pageTitle, viewModel.pageId, revision.revId)) + viewModel.pageTitle, viewModel.pageId, revisionTo = revision.revId)) } } diff --git a/app/src/main/java/org/wikipedia/page/leadimages/LeadImagesHandler.kt b/app/src/main/java/org/wikipedia/page/leadimages/LeadImagesHandler.kt index 5989a61b5de..f5921d988c1 100644 --- a/app/src/main/java/org/wikipedia/page/leadimages/LeadImagesHandler.kt +++ b/app/src/main/java/org/wikipedia/page/leadimages/LeadImagesHandler.kt @@ -2,10 +2,12 @@ package org.wikipedia.page.leadimages import android.net.Uri import androidx.core.app.ActivityOptionsCompat -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wikipedia.Constants import org.wikipedia.Constants.ImageEditType import org.wikipedia.Constants.InvokeSource @@ -45,7 +47,7 @@ class LeadImagesHandler(private val parentFragment: PageFragment, private val title get() = parentFragment.title private val page get() = parentFragment.page private val activity get() = parentFragment.requireActivity() - private val disposables = CompositeDisposable() + private var handlerJob: Job? = null private val isLeadImageEnabled get() = Prefs.isImageDownloadEnabled && !DimenUtil.isLandscape(activity) && displayHeightDp >= MIN_SCREEN_HEIGHT_DP && !isMainPage && !leadImageUrl.isNullOrEmpty() private val leadImageWidth get() = page?.run { pageProperties.leadImageWidth } ?: pageHeaderView.imageView.width @@ -94,7 +96,7 @@ class LeadImagesHandler(private val parentFragment: PageFragment, private fun updateCallToAction() { dispose() pageHeaderView.callToActionText = null - if (!AccountUtil.isLoggedIn || leadImageUrl == null || !leadImageUrl!!.contains(Service.URL_FRAGMENT_FROM_COMMONS) || page == null) { + if (!AccountUtil.isLoggedIn || leadImageUrl?.contains(Service.URL_FRAGMENT_FROM_COMMONS) != true || page == null) { return } title?.let { @@ -104,44 +106,39 @@ class LeadImagesHandler(private val parentFragment: PageFragment, finalizeCallToAction() return } - disposables.add(ServiceFactory.get(Constants.commonsWikiSite).getProtectionInfo(imageTitle) - .subscribeOn(Schedulers.io()) - .map { response -> response.query?.isEditProtected ?: false } - .flatMap { isProtected -> - if (isProtected) Observable.empty() else Observable.zip(ServiceFactory.get(Constants.commonsWikiSite).getEntitiesByTitle(imageTitle, Constants.COMMONS_DB_NAME), - ServiceFactory.get(Constants.commonsWikiSite).getImageInfo(imageTitle, WikipediaApp.instance.appOrSystemLanguageCode)) { first, second -> Pair(first, second) } - } - .flatMap { pair -> - val labelMap = pair.first.first?.labels?.values?.associate { v -> v.language to v.value }.orEmpty() - val depicts = ImageTagsProvider.getDepictsClaims(pair.first.first?.getStatements().orEmpty()) - captionSourcePageTitle = PageTitle(imageTitle, WikiSite(Service.COMMONS_URL, it.wikiSite.languageCode)) - captionSourcePageTitle!!.description = labelMap[it.wikiSite.languageCode] - imagePage = pair.second.query?.firstPage() - imageEditType = null // Need to clear value from precious call - if (!labelMap.containsKey(it.wikiSite.languageCode)) { - imageEditType = ImageEditType.ADD_CAPTION - return@flatMap Observable.just(depicts) - } - if (WikipediaApp.instance.languageState.appLanguageCodes.size >= Constants.MIN_LANGUAGES_TO_UNLOCK_TRANSLATION) { - for (lang in WikipediaApp.instance.languageState.appLanguageCodes) { - if (!labelMap.containsKey(lang)) { + handlerJob = parentFragment.lifecycleScope.launch { + withContext(Dispatchers.Main) { + lastImageTitleForCallToAction = imageTitle + val isProtected = ServiceFactory.get(Constants.commonsWikiSite) + .getProtectionInfoSuspend(imageTitle).query?.isEditProtected ?: false + if (!isProtected) { + val firstEntity = async { + ServiceFactory.get(Constants.commonsWikiSite).getEntitiesByTitleSuspend(imageTitle, Constants.COMMONS_DB_NAME).first + } + val firstImageInfo = async { + ServiceFactory.get(Constants.commonsWikiSite).getImageInfoSuspend(imageTitle, Constants.COMMONS_DB_NAME).query?.firstPage() + } + val labelMap = firstEntity.await()?.labels?.values?.associate { v -> v.language to v.value }.orEmpty() + val depicts = ImageTagsProvider.getDepictsClaims(firstEntity.await()?.getStatements().orEmpty()) + imagePage = firstImageInfo.await() + captionSourcePageTitle = PageTitle(imageTitle, WikiSite(Service.COMMONS_URL, it.wikiSite.languageCode)) + captionSourcePageTitle!!.description = labelMap[it.wikiSite.languageCode] + if (!labelMap.containsKey(it.wikiSite.languageCode)) { + imageEditType = ImageEditType.ADD_CAPTION + } + if (WikipediaApp.instance.languageState.appLanguageCodes.size >= Constants.MIN_LANGUAGES_TO_UNLOCK_TRANSLATION) { + WikipediaApp.instance.languageState.appLanguageCodes.firstOrNull { lang -> !labelMap.containsKey(lang) }?.run { imageEditType = ImageEditType.ADD_CAPTION_TRANSLATION - captionTargetPageTitle = PageTitle(imageTitle, WikiSite(Service.COMMONS_URL, lang)) - break + captionTargetPageTitle = PageTitle(imageTitle, WikiSite(Service.COMMONS_URL, this)) } } - } - Observable.just(depicts) - } - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { depicts -> - if (imageEditType != ImageEditType.ADD_CAPTION && depicts.isEmpty()) { - imageEditType = ImageEditType.ADD_TAGS + if (imageEditType != ImageEditType.ADD_CAPTION && depicts.isEmpty()) { + imageEditType = ImageEditType.ADD_TAGS + } } finalizeCallToAction() - lastImageTitleForCallToAction = imageTitle } - ) + } } } @@ -239,7 +236,7 @@ class LeadImagesHandler(private val parentFragment: PageFragment, } fun dispose() { - disposables.clear() + handlerJob?.cancel() callToActionSourceSummary = null callToActionTargetSummary = null callToActionIsTranslation = false diff --git a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewContents.kt b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewContents.kt index 54398481a92..a4e0b438745 100644 --- a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewContents.kt +++ b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewContents.kt @@ -5,8 +5,9 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.util.L10nUtil -class LinkPreviewContents constructor(pageSummary: PageSummary, wiki: WikiSite) { +class LinkPreviewContents(pageSummary: PageSummary, wiki: WikiSite) { val title = pageSummary.getPageTitle(wiki) + val ns = pageSummary.namespace val isDisambiguation = pageSummary.type == PageSummary.TYPE_DISAMBIGUATION val extract = if (isDisambiguation) "

" + L10nUtil.getStringForArticleLanguage(title, R.string.link_preview_disambiguation_description) + "

" + pageSummary.extractHtml diff --git a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt index 12d1ae50d02..c218b38a07c 100644 --- a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt +++ b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt @@ -23,10 +23,13 @@ import org.wikipedia.R import org.wikipedia.activity.BaseActivity import org.wikipedia.activity.FragmentUtil.getCallback import org.wikipedia.analytics.eventplatform.ArticleLinkPreviewInteractionEvent +import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.analytics.metricsplatform.ArticleLinkPreviewInteraction import org.wikipedia.bridge.JavaScriptActionHandler import org.wikipedia.databinding.DialogLinkPreviewBinding import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.edit.EditHandler +import org.wikipedia.edit.EditSectionActivity import org.wikipedia.gallery.GalleryActivity import org.wikipedia.gallery.GalleryThumbnailScrollView.GalleryViewListener import org.wikipedia.history.HistoryEntry @@ -35,6 +38,7 @@ import org.wikipedia.page.ExtendedBottomSheetDialogFragment import org.wikipedia.page.Namespace import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle +import org.wikipedia.places.PlacesActivity import org.wikipedia.readinglist.LongPressMenu import org.wikipedia.readinglist.ReadingListBehaviorsUtil import org.wikipedia.readinglist.database.ReadingListPage @@ -56,10 +60,15 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV fun onLinkPreviewLoadPage(title: PageTitle, entry: HistoryEntry, inNewTab: Boolean) } + interface DismissCallback { + fun onLinkPreviewDismiss() + } + private var _binding: DialogLinkPreviewBinding? = null private val binding get() = _binding!! private val loadPageCallback get() = getCallback(this, LoadPageCallback::class.java) + private val dismissCallback get() = getCallback(this, DismissCallback::class.java) private var articleLinkPreviewInteractionEvent: ArticleLinkPreviewInteractionEvent? = null private var linkPreviewInteraction: ArticleLinkPreviewInteraction? = null @@ -79,29 +88,32 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV true } R.id.menu_link_preview_watch -> { + sendPlacesEvent("watch_click", "detail_overflow_menu") viewModel.watchOrUnwatch(viewModel.isWatched) true } R.id.menu_link_preview_open_in_new_tab -> { + sendPlacesEvent("new_tab_click", "detail_overflow_menu") goToLinkedPage(true) true } R.id.menu_link_preview_copy_link -> { + sendPlacesEvent("copy_link_click", "detail_overflow_menu") ClipboardUtil.setPlainText(requireActivity(), text = viewModel.pageTitle.uri) FeedbackUtil.showMessage(requireActivity(), R.string.address_copied) dismiss() true } R.id.menu_link_preview_view_on_map -> { + PlacesEvent.logAction("places_click", "article_preview_more_menu") viewModel.location?.let { - // TODO: implement this in Places branch - // startActivity(PlacesActivity.newIntent(requireContext(), viewModel.pageTitle, it)) - GeoUtil.sendGeoIntent(requireActivity(), it, StringUtil.fromHtml(viewModel.pageTitle.displayText).toString()) + startActivity(PlacesActivity.newIntent(requireContext(), viewModel.pageTitle, it)) } dismiss() true } R.id.menu_link_preview_get_directions -> { + sendPlacesEvent("directions_click", "detail_overflow_menu") viewModel.location?.let { GeoUtil.sendGeoIntent(requireActivity(), it, StringUtil.fromHtml(viewModel.pageTitle.displayText).toString()) } @@ -111,6 +123,12 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV } } + private fun sendPlacesEvent(action: String, activeInterface: String) { + if (viewModel.historyEntry.source == HistoryEntry.SOURCE_PLACES) { + PlacesEvent.logAction(action, activeInterface) + } + } + private val galleryViewListener = GalleryViewListener { view, thumbUrl, imageName -> var options: ActivityOptionsCompat? = null view.drawable?.let { @@ -129,12 +147,26 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV } } + private val requestStubArticleEditLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == EditHandler.RESULT_REFRESH_PAGE) { + overlayView?.let { overlay -> + FeedbackUtil.makeSnackbar(overlay.rootView, getString(R.string.stub_article_edit_saved_successfully)) + .setAnchorView(overlay.secondaryButtonView).show() + } + } + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = DialogLinkPreviewBinding.inflate(inflater, container, false) binding.linkPreviewToolbar.setOnClickListener { goToLinkedPage(false) } binding.linkPreviewOverflowButton.setOnClickListener { setupOverflowMenu() } + binding.linkPreviewEditButton.setOnClickListener { + viewModel.pageTitle.run { + requestStubArticleEditLauncher.launch(EditSectionActivity.newIntent(requireContext(), -1, null, this, Constants.InvokeSource.LINK_PREVIEW_MENU, null)) + } + } L10nUtil.setConditionalLayoutDirection(binding.root, viewModel.pageTitle.wikiSite.languageCode) lifecycleScope.launch { @@ -222,13 +254,6 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV showError(throwable) } - override fun onStart() { - super.onStart() - if (viewModel.fromPlaces) { - disableBackgroundDim() - } - } - override fun onResume() { super.onResume() val containerView = requireDialog().findViewById(R.id.container) @@ -277,6 +302,7 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV override fun onDismiss(dialogInterface: DialogInterface) { super.onDismiss(dialogInterface) + dismissCallback?.onLinkPreviewDismiss() if (!navigateSuccess) { articleLinkPreviewInteractionEvent?.logCancel() linkPreviewInteraction?.logCancel() @@ -314,10 +340,6 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV private fun showReadingListPopupMenu(anchorView: View) { if (viewModel.isInReadingList) { LongPressMenu(anchorView, existsInAnyList = false, callback = object : LongPressMenu.Callback { - override fun onOpenLink(entry: HistoryEntry) { } - - override fun onOpenInNewTab(entry: HistoryEntry) { } - override fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) { ReadingListBehaviorsUtil.addToDefaultList(requireActivity(), viewModel.pageTitle, addToDefault, Constants.InvokeSource.LINK_PREVIEW_MENU) dismiss() @@ -368,23 +390,25 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV } private fun setPreviewContents(contents: LinkPreviewContents) { - if (!contents.extract.isNullOrEmpty()) { - binding.linkPreviewExtractWebview.setBackgroundColor(Color.TRANSPARENT) - val colorHex = ResourceUtil.colorToCssString( - ResourceUtil.getThemedColor( - requireContext(), - android.R.attr.textColorPrimary - ) - ) - val dir = if (L10nUtil.isLangRTL(viewModel.pageTitle.wikiSite.languageCode)) "rtl" else "ltr" - binding.linkPreviewExtractWebview.loadDataWithBaseURL( - null, - "${JavaScriptActionHandler.getCssStyles(viewModel.pageTitle.wikiSite)}
${contents.extract}
", - "text/html", - "UTF-8", - null + binding.linkPreviewExtractWebview.setBackgroundColor(Color.TRANSPARENT) + val colorHex = ResourceUtil.colorToCssString( + ResourceUtil.getThemedColor( + requireContext(), + android.R.attr.textColorPrimary ) - } + ) + val dir = if (L10nUtil.isLangRTL(viewModel.pageTitle.wikiSite.languageCode)) "rtl" else "ltr" + val editVisibility = contents.extract.isNullOrBlank() && contents.ns?.id == Namespace.MAIN.code() + binding.linkPreviewEditButton.isVisible = editVisibility + binding.linkPreviewThumbnailGallery.isVisible = !editVisibility + val extract = if (editVisibility) "" + getString(R.string.link_preview_stub_placeholder_text) + "" else contents.extract + binding.linkPreviewExtractWebview.loadDataWithBaseURL( + null, + "${JavaScriptActionHandler.getCssStyles(viewModel.pageTitle.wikiSite)}
$extract
", + "text/html", + "UTF-8", + null + ) contents.title.thumbUrl?.let { binding.linkPreviewThumbnail.visibility = View.VISIBLE ViewUtil.loadImage(binding.linkPreviewThumbnail, it) @@ -443,16 +467,19 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV private inner class OverlayViewPlacesCallback : LinkPreviewOverlayView.Callback { override fun onPrimaryClick() { + sendPlacesEvent("share_click", "detail_toolbar") ShareUtil.shareText(requireContext(), viewModel.pageTitle) } override fun onSecondaryClick() { overlayView?.let { + sendPlacesEvent("save_click", "detail_toolbar") showReadingListPopupMenu(it.secondaryButtonView) } } override fun onTertiaryClick() { + sendPlacesEvent("read_click", "detail_toolbar") goToLinkedPage(false) } } @@ -461,15 +488,13 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV const val ARG_ENTRY = "entry" const val ARG_LOCATION = "location" const val ARG_LAST_KNOWN_LOCATION = "lastKnownLocation" - const val ARG_FROM_PLACES = "fromPlaces" - fun newInstance(entry: HistoryEntry, location: Location? = null, lastKnownLocation: Location? = null, fromPlaces: Boolean = false): LinkPreviewDialog { + fun newInstance(entry: HistoryEntry, location: Location? = null, lastKnownLocation: Location? = null): LinkPreviewDialog { return LinkPreviewDialog().apply { arguments = bundleOf( ARG_ENTRY to entry, ARG_LOCATION to location, - ARG_LAST_KNOWN_LOCATION to lastKnownLocation, - ARG_FROM_PLACES to fromPlaces + ARG_LAST_KNOWN_LOCATION to lastKnownLocation ) } } diff --git a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewViewModel.kt b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewViewModel.kt index 58ead3af395..7d7ec9fe267 100644 --- a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewViewModel.kt +++ b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewViewModel.kt @@ -6,10 +6,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.wikipedia.analytics.eventplatform.WatchlistAnalyticsHelper +import org.wikipedia.auth.AccountUtil import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.extensions.parcelable @@ -25,7 +27,7 @@ class LinkPreviewViewModel(bundle: Bundle) : ViewModel() { val historyEntry = bundle.parcelable(LinkPreviewDialog.ARG_ENTRY)!! var pageTitle = historyEntry.title var location = bundle.parcelable(LinkPreviewDialog.ARG_LOCATION) - val fromPlaces = bundle.getBoolean(LinkPreviewDialog.ARG_FROM_PLACES, false) + val fromPlaces = historyEntry.source == HistoryEntry.SOURCE_PLACES val lastKnownLocation = bundle.parcelable(LinkPreviewDialog.ARG_LAST_KNOWN_LOCATION) var isInReadingList = false @@ -40,9 +42,12 @@ class LinkPreviewViewModel(bundle: Bundle) : ViewModel() { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> _uiState.value = LinkPreviewViewState.Error(throwable) }) { - val response = ServiceFactory.getRest(pageTitle.wikiSite) - .getSummaryResponseSuspend(pageTitle.prefixedText, null, null, null, null, null) + val summaryCall = async { ServiceFactory.getRest(pageTitle.wikiSite) + .getSummaryResponseSuspend(pageTitle.prefixedText, null, null, null, null, null) } + val watchedCall = async { if (fromPlaces && AccountUtil.isLoggedIn) ServiceFactory.get(pageTitle.wikiSite).getWatchedStatus(pageTitle.prefixedText) else null } + + val response = summaryCall.await() val summary = response.body()!! // Rebuild our PageTitle, since it may have been redirected or normalized. val oldFragment = pageTitle.fragment @@ -60,9 +65,7 @@ class LinkPreviewViewModel(bundle: Bundle) : ViewModel() { } if (fromPlaces) { - val watchStatus = ServiceFactory.get(pageTitle.wikiSite).getWatchedStatus(pageTitle.prefixedText).query?.firstPage() - isWatched = watchStatus?.watched ?: false - + isWatched = watchedCall.await()?.query?.firstPage()?.watched ?: false val readingList = AppDatabase.instance.readingListPageDao().findPageInAnyList(pageTitle) isInReadingList = readingList != null } diff --git a/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt b/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt index 20d94bc38a0..a28b3aea6ae 100644 --- a/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt +++ b/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt @@ -12,6 +12,7 @@ import androidx.core.view.drawToBitmap import androidx.core.view.isVisible import com.google.android.material.dialog.MaterialAlertDialogBuilder import de.mrapp.android.tabswitcher.Animation +import de.mrapp.android.tabswitcher.SwipeAnimation import de.mrapp.android.tabswitcher.TabSwitcher import de.mrapp.android.tabswitcher.TabSwitcherDecorator import de.mrapp.android.tabswitcher.TabSwitcherListener @@ -47,7 +48,7 @@ class TabActivity : BaseActivity() { setContentView(binding.root) binding.tabCountsView.updateTabCount(false) binding.tabCountsView.setOnClickListener { onBackPressed() } - FeedbackUtil.setButtonLongPressToast(binding.tabCountsView, binding.tabButtonNotifications) + FeedbackUtil.setButtonTooltip(binding.tabCountsView, binding.tabButtonNotifications) binding.tabSwitcher.setPreserveState(false) binding.tabSwitcher.decorator = object : TabSwitcherDecorator() { override fun onInflateView(inflater: LayoutInflater, parent: ViewGroup?, viewType: Int): View { @@ -111,6 +112,15 @@ class TabActivity : BaseActivity() { binding.tabSwitcher.logLevel = LogLevel.OFF binding.tabSwitcher.addListener(tabListener) binding.tabSwitcher.showSwitcher() + + binding.tabSwitcher.addCloseTabListener { tabSwitcher, tab -> + tabSwitcher.removeTab(tab, SwipeAnimation.Builder() + .setDuration(0) + .setRelocateAnimationDuration(100) + .setInterpolator(null).create()) + false + } + launchedFromPageActivity = intent.hasExtra(LAUNCHED_FROM_PAGE_ACTIVITY) setStatusBarColor(ResourceUtil.getThemedColor(this, android.R.attr.colorBackground)) setNavigationBarColor(ResourceUtil.getThemedColor(this, android.R.attr.colorBackground)) diff --git a/app/src/main/java/org/wikipedia/places/PlacesActivity.kt b/app/src/main/java/org/wikipedia/places/PlacesActivity.kt new file mode 100644 index 00000000000..95d692fc3de --- /dev/null +++ b/app/src/main/java/org/wikipedia/places/PlacesActivity.kt @@ -0,0 +1,24 @@ +package org.wikipedia.places + +import android.content.Context +import android.content.Intent +import android.location.Location +import org.wikipedia.Constants +import org.wikipedia.activity.SingleFragmentActivity +import org.wikipedia.extensions.parcelableExtra +import org.wikipedia.page.PageTitle + +class PlacesActivity : SingleFragmentActivity() { + public override fun createFragment(): PlacesFragment { + return PlacesFragment.newInstance(intent.parcelableExtra(Constants.ARG_TITLE), intent.parcelableExtra(EXTRA_LOCATION)) + } + + companion object { + const val EXTRA_LOCATION = "location" + fun newIntent(context: Context, pageTitle: PageTitle? = null, location: Location? = null): Intent { + return Intent(context, PlacesActivity::class.java) + .putExtra(Constants.ARG_TITLE, pageTitle) + .putExtra(EXTRA_LOCATION, location) + } + } +} diff --git a/app/src/main/java/org/wikipedia/places/PlacesFilterActivity.kt b/app/src/main/java/org/wikipedia/places/PlacesFilterActivity.kt new file mode 100644 index 00000000000..34dc34e8dc8 --- /dev/null +++ b/app/src/main/java/org/wikipedia/places/PlacesFilterActivity.kt @@ -0,0 +1,152 @@ +package org.wikipedia.places + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.wikipedia.Constants +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.eventplatform.PlacesEvent +import org.wikipedia.databinding.ActivityPlacesFiltersBinding +import org.wikipedia.databinding.ViewPlacesFilterItemBinding +import org.wikipedia.settings.Prefs +import org.wikipedia.settings.languages.WikipediaLanguagesActivity +import org.wikipedia.util.ResourceUtil +import org.wikipedia.views.DefaultViewHolder + +class PlacesFilterActivity : BaseActivity() { + + private lateinit var binding: ActivityPlacesFiltersBinding + private var initLanguage: String = Prefs.placesWikiCode + private var filtersList = mutableListOf() + + val addLanguageLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + // Check if places wiki language code was deleted + if (!WikipediaApp.instance.languageState.appLanguageCodes.contains(Prefs.placesWikiCode)) { + Prefs.placesWikiCode = WikipediaApp.instance.appOrSystemLanguageCode + } + setUpRecyclerView() + binding.placesFiltersRecyclerView.adapter?.notifyDataSetChanged() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityPlacesFiltersBinding.inflate(layoutInflater) + + setUpRecyclerView() + setStatusBarColor(ResourceUtil.getThemedColor(this, R.attr.background_color)) + setNavigationBarColor(ResourceUtil.getThemedColor(this, R.attr.background_color)) + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + setContentView(binding.root) + } + + override fun finish() { + setResult(RESULT_OK, Intent() + .putExtra(EXTRA_LANG_CHANGED, initLanguage != Prefs.placesWikiCode)) + super.finish() + } + + private fun setUpRecyclerView() { + filtersList.clear() + filtersList.add(HEADER) + filtersList.addAll(WikipediaApp.instance.languageState.appLanguageCodes) + filtersList.add(FOOTER) + binding.placesFiltersRecyclerView.layoutManager = LinearLayoutManager(this) + binding.placesFiltersRecyclerView.adapter = PlacesLangListFilterAdapter(this) + } + + private inner class PlacesLangListFilterAdapter(val context: Context) : + RecyclerView.Adapter>(), PlacesFilterItemViewHolder.Callback { + + override fun onCreateViewHolder(parent: ViewGroup, type: Int): DefaultViewHolder<*> { + return when (type) { + VIEW_TYPE_HEADER -> { + PlacesFilterHeaderViewHolder(layoutInflater.inflate(R.layout.view_watchlist_filter_header, parent, false)) + } + VIEW_TYPE_FOOTER -> { + PlacesFilterFooterViewHolder(layoutInflater.inflate(R.layout.view_places_filters_footer, parent, false)) + } + else -> { + PlacesFilterItemViewHolder(ViewPlacesFilterItemBinding.inflate(layoutInflater), this) + } + } + } + + override fun getItemCount(): Int { + return filtersList.size + } + + override fun getItemViewType(position: Int): Int { + return if (filtersList[position] == HEADER) VIEW_TYPE_HEADER + else if (filtersList[position] == FOOTER) VIEW_TYPE_FOOTER + else VIEW_TYPE_ITEM + } + + override fun onBindViewHolder(holder: DefaultViewHolder<*>, position: Int) { + when (holder) { + is PlacesFilterHeaderViewHolder -> holder.bindItem(getString(R.string.watchlist_filter_wiki_filter_header)) + is PlacesFilterFooterViewHolder -> holder.bindItem() + else -> (holder as PlacesFilterItemViewHolder).bindItem(filtersList[position]) + } + } + + override fun onLangSelected() { + notifyDataSetChanged() + } + } + + inner class PlacesFilterHeaderViewHolder(itemView: View) : DefaultViewHolder(itemView) { + private val headerText = itemView.findViewById(R.id.filter_header_title)!! + + fun bindItem(filterHeader: String) { + headerText.setTextColor(ResourceUtil.getThemedColor(this@PlacesFilterActivity, R.attr.primary_color)) + headerText.text = filterHeader + } + } + inner class PlacesFilterFooterViewHolder(itemView: View) : DefaultViewHolder(itemView) { + fun bindItem() { + itemView.setOnClickListener { + addLanguageLauncher.launch(WikipediaLanguagesActivity.newIntent(itemView.context, + Constants.InvokeSource.PLACES)) + } + } + } + class PlacesFilterItemViewHolder(private val itemViewBinding: ViewPlacesFilterItemBinding, val callback: Callback) : DefaultViewHolder(itemViewBinding.root) { + interface Callback { + fun onLangSelected() + } + + fun bindItem(languageCode: String) { + itemViewBinding.placesFilterTitle.text = WikipediaApp.instance.languageState.getAppLanguageCanonicalName(languageCode) + itemViewBinding.placesFilterLangCode.setLangCode(languageCode) + itemViewBinding.placesFilterRadio.isVisible = languageCode == Prefs.placesWikiCode + itemViewBinding.root.setOnClickListener { + if (languageCode != Prefs.placesWikiCode) { + PlacesEvent.logAction("filter_change_save", "filter_view") + } + Prefs.placesWikiCode = languageCode + callback.onLangSelected() + } + } + } + companion object { + private const val VIEW_TYPE_HEADER = 0 + private const val VIEW_TYPE_FOOTER = 1 + private const val VIEW_TYPE_ITEM = 2 + private const val HEADER = "header" + private const val FOOTER = "footer" + const val EXTRA_LANG_CHANGED = "langChanged" + fun newIntent(context: Context): Intent { + return Intent(context, PlacesFilterActivity::class.java) + } + } +} diff --git a/app/src/main/java/org/wikipedia/places/PlacesFragment.kt b/app/src/main/java/org/wikipedia/places/PlacesFragment.kt new file mode 100644 index 00000000000..81ee31f84b5 --- /dev/null +++ b/app/src/main/java/org/wikipedia/places/PlacesFragment.kt @@ -0,0 +1,863 @@ +package org.wikipedia.places + +import android.Manifest +import android.annotation.SuppressLint +import android.app.Activity.RESULT_OK +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.Rect +import android.graphics.RectF +import android.graphics.drawable.BitmapDrawable +import android.location.Location +import android.os.Bundle +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.app.ActivityCompat +import androidx.core.app.ActivityOptionsCompat +import androidx.core.content.ContextCompat +import androidx.core.graphics.Insets +import androidx.core.graphics.applyCanvas +import androidx.core.os.bundleOf +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.mapbox.mapboxsdk.Mapbox +import com.mapbox.mapboxsdk.camera.CameraPosition +import com.mapbox.mapboxsdk.camera.CameraUpdateFactory +import com.mapbox.mapboxsdk.geometry.LatLng +import com.mapbox.mapboxsdk.location.LocationComponentActivationOptions +import com.mapbox.mapboxsdk.location.modes.CameraMode +import com.mapbox.mapboxsdk.location.modes.RenderMode +import com.mapbox.mapboxsdk.maps.MapboxMap +import com.mapbox.mapboxsdk.maps.MapboxMap.CancelableCallback +import com.mapbox.mapboxsdk.maps.Style +import com.mapbox.mapboxsdk.module.http.HttpRequestImpl +import com.mapbox.mapboxsdk.plugins.annotation.ClusterOptions +import com.mapbox.mapboxsdk.plugins.annotation.Symbol +import com.mapbox.mapboxsdk.plugins.annotation.SymbolManager +import com.mapbox.mapboxsdk.plugins.annotation.SymbolOptions +import com.mapbox.mapboxsdk.style.expressions.Expression +import com.mapbox.mapboxsdk.style.expressions.Expression.get +import com.mapbox.mapboxsdk.style.layers.PropertyFactory.circleColor +import com.mapbox.mapboxsdk.style.layers.PropertyFactory.circleStrokeColor +import com.mapbox.mapboxsdk.style.layers.PropertyFactory.circleStrokeWidth +import com.mapbox.mapboxsdk.style.layers.PropertyFactory.textAllowOverlap +import com.mapbox.mapboxsdk.style.layers.PropertyFactory.textFont +import com.mapbox.mapboxsdk.style.layers.PropertyFactory.textIgnorePlacement +import org.wikipedia.Constants +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.eventplatform.PlacesEvent +import org.wikipedia.databinding.FragmentPlacesBinding +import org.wikipedia.databinding.ItemPlacesListBinding +import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory +import org.wikipedia.extensions.parcelable +import org.wikipedia.extensions.parcelableExtra +import org.wikipedia.gallery.ImagePipelineBitmapGetter +import org.wikipedia.history.HistoryEntry +import org.wikipedia.page.ExclusiveBottomSheetPresenter +import org.wikipedia.page.LinkMovementMethodExt +import org.wikipedia.page.PageActivity +import org.wikipedia.page.PageTitle +import org.wikipedia.page.linkpreview.LinkPreviewDialog +import org.wikipedia.page.tabs.TabActivity +import org.wikipedia.readinglist.LongPressMenu +import org.wikipedia.readinglist.ReadingListBehaviorsUtil +import org.wikipedia.readinglist.database.ReadingListPage +import org.wikipedia.search.SearchActivity +import org.wikipedia.settings.Prefs +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.GeoUtil +import org.wikipedia.util.Resource +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.StringUtil +import org.wikipedia.util.TabUtil +import org.wikipedia.util.log.L +import org.wikipedia.views.DrawableItemDecoration +import org.wikipedia.views.SurveyDialog +import org.wikipedia.views.ViewUtil +import java.util.Locale +import kotlin.math.abs + +class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPreviewDialog.DismissCallback, MapboxMap.OnMapClickListener { + + private var _binding: FragmentPlacesBinding? = null + private val binding get() = _binding!! + private var statusBarInsets: Insets? = null + private var navBarInsets: Insets? = null + + private val viewModel: PlacesFragmentViewModel by viewModels { PlacesFragmentViewModel.Factory(requireArguments()) } + + private var mapboxMap: MapboxMap? = null + private var symbolManager: SymbolManager? = null + + private val annotationCache = ArrayDeque() + private var lastCheckedId = R.id.mapViewButton + private var lastLocation: Location? = null + private var lastLocationQueried: Location? = null + private var lastZoom = 15.0 + private var lastZoomQueried = 0.0 + + private lateinit var markerBitmapBase: Bitmap + private lateinit var markerPaintSrc: Paint + private lateinit var markerPaintSrcIn: Paint + private lateinit var markerBorderPaint: Paint + private val markerRect = Rect(0, 0, MARKER_SIZE, MARKER_SIZE) + private val searchRadius + get() = mapboxMap?.let { + latitudeDiffToMeters(it.projection.visibleRegion.latLngBounds.latitudeSpan / 2) + } ?: 50 + private var magnifiedMarker: Symbol? = null + + private val locationPermissionRequest = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions -> + when { + permissions.getOrDefault(Manifest.permission.ACCESS_FINE_LOCATION, false) || + permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { + PlacesEvent.logAction("location_permission_granted", "map_view") + startLocationTracking() + goToLocation(viewModel.location) + } + else -> { + PlacesEvent.logAction("location_permission_denied", "map_view") + FeedbackUtil.showMessage(requireActivity(), R.string.places_permissions_denied) + } + } + } + + private val placesSearchLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == SearchActivity.RESULT_LINK_SUCCESS) { + val location = it.data?.parcelableExtra(PlacesActivity.EXTRA_LOCATION)!! + val pageTitle = it.data?.parcelableExtra(SearchActivity.EXTRA_RETURN_LINK_TITLE)!! + viewModel.highlightedPageTitle = pageTitle + Prefs.placesWikiCode = pageTitle.wikiSite.languageCode + goToLocation(preferredLocation = location, zoom = 15.9) + viewModel.fetchNearbyPages(location.latitude, location.longitude, searchRadius, ITEMS_PER_REQUEST) + } + } + + private val filterLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + val languageChanged = it.data?.getBooleanExtra(PlacesFilterActivity.EXTRA_LANG_CHANGED, false) ?: false + if (languageChanged) { + clearAnnotationCache() + viewModel.highlightedPageTitle = null + symbolManager?.deleteAll() + viewModel.fetchNearbyPages(lastLocation?.latitude ?: 0.0, + lastLocation?.longitude ?: 0.0, searchRadius, ITEMS_PER_REQUEST) + goToLocation(lastLocation, lastZoom) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setupMarkerPaints() + markerBitmapBase = Bitmap.createBitmap(MARKER_SIZE, MARKER_SIZE, Bitmap.Config.ARGB_8888).applyCanvas { + drawMarker(this) + } + + Mapbox.getInstance(requireActivity().applicationContext) + + HttpRequestImpl.setOkHttpClient(OkHttpConnectionFactory.client) + + requireArguments().parcelable(Constants.ARG_TITLE)?.let { + Prefs.placesWikiCode = it.wikiSite.languageCode + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + _binding = FragmentPlacesBinding.inflate(inflater, container, false) + + binding.root.setOnApplyWindowInsetsListener { view, windowInsets -> + val insetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets, view) + val newStatusBarInsets = insetsCompat.getInsets(WindowInsetsCompat.Type.statusBars()) + val newNavBarInsets = insetsCompat.getInsets(WindowInsetsCompat.Type.navigationBars()) + + var params = binding.controlsContainer.layoutParams as ViewGroup.MarginLayoutParams + params.topMargin = newStatusBarInsets.top + newNavBarInsets.top + params.leftMargin = newStatusBarInsets.left + newNavBarInsets.left + params.rightMargin = newStatusBarInsets.right + newNavBarInsets.right + params.bottomMargin = newStatusBarInsets.bottom + newNavBarInsets.bottom + + params = binding.myLocationButton.layoutParams as ViewGroup.MarginLayoutParams + params.bottomMargin = newNavBarInsets.bottom + DimenUtil.roundedDpToPx(16f) + params.leftMargin = newStatusBarInsets.left + newNavBarInsets.left + DimenUtil.roundedDpToPx(16f) + params.rightMargin = newStatusBarInsets.right + newNavBarInsets.right + DimenUtil.roundedDpToPx(16f) + binding.myLocationButton.layoutParams = params + + statusBarInsets = newStatusBarInsets + navBarInsets = newNavBarInsets + WindowInsetsCompat.Builder() + .setInsets(WindowInsetsCompat.Type.navigationBars(), navBarInsets!!) + .build().toWindowInsets() ?: windowInsets + } + + binding.tabsButton.setOnClickListener { + PlacesEvent.logAction("tabs_view_click", "search_bar_view") + if (WikipediaApp.instance.tabCount == 1) { + startActivity(PageActivity.newIntent(requireActivity())) + } else { + startActivity(TabActivity.newIntent(requireActivity())) + } + } + + binding.searchTextView.setOnClickListener { + PlacesEvent.logAction("search_view_click", "search_bar_view") + val intent = SearchActivity.newIntent(requireActivity(), Constants.InvokeSource.PLACES, + viewModel.highlightedPageTitle?.displayText, true) + val options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), + binding.searchContainer.getChildAt(0), getString(R.string.transition_search_bar)) + placesSearchLauncher.launch(intent, options) + } + + binding.backButton.setOnClickListener { + PlacesEvent.logAction("back_click", "search_bar_view") + requireActivity().finish() + } + + binding.langCodeButton.setOnClickListener { + PlacesEvent.logAction("filter_click", "search_bar_view") + filterLauncher.launch(PlacesFilterActivity.newIntent(requireActivity())) + } + + binding.searchCloseBtn.setOnClickListener { + PlacesEvent.logAction("search_clear_click", "search_bar_view") + updateSearchText() + } + + binding.myLocationButton.setOnClickListener { + PlacesEvent.logAction("current_location_click", "map_view") + if (haveLocationPermissions()) { + goToLocation() + } else { + locationPermissionRequest.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) + } + } + + binding.viewButtonsGroup.addOnButtonCheckedListener { _, checkedId, isChecked -> + if (!isChecked) { + return@addOnButtonCheckedListener + } + lastCheckedId = checkedId + val mapViewChecked = checkedId == R.id.mapViewButton + updateToggleViews(mapViewChecked) + + val progressColor = ResourceUtil.getThemedColorStateList(requireContext(), R.attr.progressive_color) + val additionColor = ResourceUtil.getThemedColorStateList(requireContext(), R.attr.addition_color) + val placeholderColor = ResourceUtil.getThemedColorStateList(requireContext(), R.attr.placeholder_color) + val paperColor = ResourceUtil.getThemedColorStateList(requireContext(), R.attr.paper_color) + val backgroundColor = ResourceUtil.getThemedColorStateList(requireContext(), R.attr.background_color) + if (mapViewChecked) { + binding.mapViewButton.setTextColor(progressColor) + binding.mapViewButton.backgroundTintList = additionColor + binding.mapViewButton.strokeColor = paperColor + binding.listViewButton.setTextColor(placeholderColor) + binding.listViewButton.backgroundTintList = paperColor + binding.listViewButton.strokeColor = paperColor + } else { + binding.mapViewButton.setTextColor(placeholderColor) + binding.mapViewButton.backgroundTintList = backgroundColor + binding.mapViewButton.strokeColor = backgroundColor + binding.listViewButton.setTextColor(progressColor) + binding.listViewButton.backgroundTintList = additionColor + binding.listViewButton.strokeColor = backgroundColor + } + } + + binding.listRecyclerView.layoutManager = LinearLayoutManager(requireContext()) + binding.listRecyclerView.addItemDecoration(DrawableItemDecoration(requireContext(), R.attr.list_divider, drawStart = true, skipSearchBar = true)) + binding.listEmptyMessage.text = StringUtil.fromHtml(getString(R.string.places_empty_list)) + binding.listEmptyMessage.movementMethod = LinkMovementMethodExt { _ -> + binding.viewButtonsGroup.check(R.id.mapViewButton) + } + + return binding.root + } + + private fun updateSearchText(searchText: String = "") { + if (searchText.isEmpty()) { + binding.searchTextView.text = getString(R.string.places_search_hint) + binding.searchCloseBtn.isVisible = false + resetMagnifiedSymbol() + } else { + binding.searchCloseBtn.isVisible = true + binding.searchTextView.text = StringUtil.fromHtml(searchText) + } + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.mapView.onCreate(savedInstanceState) + + PlacesEvent.logImpression("map_view") + + binding.mapView.getMapAsync { map -> + val assetForTheme = if (WikipediaApp.instance.currentTheme.isDark) "asset://mapstyle-dark.json" else "asset://mapstyle.json" + map.setStyle(Style.Builder().fromUri(assetForTheme)) { style -> + mapboxMap = map + + style.addImage(MARKER_DRAWABLE, markerBitmapBase) + + map.setMaxZoomPreference(20.0) + + map.uiSettings.isLogoEnabled = false + val defMargin = DimenUtil.roundedDpToPx(16f) + + val navBarLeft = navBarInsets?.left ?: 0 + val navBarRight = navBarInsets?.right ?: 0 + val navBarTop = navBarInsets?.top ?: 0 + val navBarBottom = navBarInsets?.bottom ?: 0 + val statusBarLeft = statusBarInsets?.left ?: 0 + val statusBarRight = statusBarInsets?.right ?: 0 + val statusBarTop = statusBarInsets?.top ?: 0 + val statusBarBottom = statusBarInsets?.bottom ?: 0 + + map.uiSettings.setCompassImage(AppCompatResources.getDrawable(requireContext(), R.drawable.ic_compass_with_bg)!!) + map.uiSettings.compassGravity = Gravity.TOP or Gravity.END + map.uiSettings.attributionGravity = Gravity.BOTTOM or Gravity.START + map.uiSettings.setAttributionTintColor(ResourceUtil.getThemedColor(requireContext(), R.attr.placeholder_color)) + + map.uiSettings.setCompassMargins(defMargin + navBarLeft + statusBarLeft, + defMargin + navBarTop + statusBarTop + binding.searchContainer.height, + DimenUtil.roundedDpToPx(12f) + navBarRight + statusBarRight, defMargin) + + map.uiSettings.setAttributionMargins(defMargin + navBarLeft + statusBarLeft, + 0, defMargin + navBarRight + statusBarRight, + navBarBottom + statusBarBottom + DimenUtil.roundedDpToPx(36f)) + + map.addOnCameraIdleListener { + mapboxMap?.cameraPosition?.target?.let { + onUpdateCameraPosition(it) + } + } + + map.addOnMapClickListener(this) + + setUpSymbolManagerWithClustering(map, style) + + symbolManager?.iconAllowOverlap = true + symbolManager?.textAllowOverlap = true + symbolManager?.addClickListener { symbol -> + L.d(">>>> clicked: " + symbol.latLng.latitude + ", " + symbol.latLng.longitude) + PlacesEvent.logAction("marker_click", "map_view") + annotationCache.find { it.annotation == symbol }?.let { + val location = Location("").apply { + latitude = symbol.latLng.latitude + longitude = symbol.latLng.longitude + } + resetMagnifiedSymbol() + setMagnifiedSymbol(it.annotation) + viewModel.highlightedPageTitle = it.pageTitle + symbolManager?.update(it.annotation) + showLinkPreview(it.pageTitle, location) + } + true + } + + if (haveLocationPermissions()) { + startLocationTracking() + } + + viewModel.location?.let { + goToLocation(it) + } ?: run { + val lastLocationAndZoomLevel = Prefs.placesLastLocationAndZoomLevel + goToLocation(lastLocationAndZoomLevel?.first, lastLocationAndZoomLevel?.second ?: lastZoom) + + if (!haveLocationPermissions()) { + locationPermissionRequest.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)) + } + } + } + } + + viewModel.nearbyPagesLiveData.observe(viewLifecycleOwner) { + if (it is Resource.Success) { + updateMapMarkers(it.data) + } else if (it is Resource.Error) { + FeedbackUtil.showError(requireActivity(), it.throwable) + } + } + + maybeShowSurvey() + } + + private fun updateToggleViews(isMapVisible: Boolean) { + if ((binding.listRecyclerView.isVisible || binding.listEmptyContainer.isVisible) && isMapVisible) { + PlacesEvent.logAction("map_view_click", "map_view") + } + if (binding.mapView.isVisible && !isMapVisible) { + PlacesEvent.logAction("list_view_click", "list_view") + } + val tintColor = ResourceUtil.getThemedColorStateList(requireContext(), if (isMapVisible) R.attr.paper_color else R.attr.background_color) + binding.mapView.isVisible = isMapVisible + binding.listRecyclerView.isVisible = !isMapVisible && (binding.listRecyclerView.adapter?.itemCount ?: 0) > 0 + binding.listEmptyContainer.isVisible = !isMapVisible && (binding.listRecyclerView.adapter?.itemCount ?: 0) == 0 + binding.searchContainer.backgroundTintList = tintColor + binding.myLocationButton.isVisible = isMapVisible + } + + private fun showLinkPreview(pageTitle: PageTitle, location: Location) { + PlacesEvent.logImpression("detail_view") + val entry = HistoryEntry(pageTitle, HistoryEntry.SOURCE_PLACES) + updateSearchText(pageTitle.displayText) + + ExclusiveBottomSheetPresenter.show(childFragmentManager, + LinkPreviewDialog.newInstance(entry, location, getLastKnownUserLocation())) + } + + private fun resetMagnifiedSymbol() { + // Reset the magnified marker to regular size + magnifiedMarker?.let { + it.iconSize = 1.0f + symbolManager?.update(it) + } + viewModel.highlightedPageTitle = null + } + + private fun setMagnifiedSymbol(symbol: Symbol?) { + magnifiedMarker?.symbolSortKey = 0f + magnifiedMarker = symbol + magnifiedMarker?.iconSize = 1.75f + magnifiedMarker?.symbolSortKey = 1f + } + + private fun setupMarkerPaints() { + markerPaintSrc = Paint().apply { + isAntiAlias = true + color = ResourceUtil.getThemedColor(requireContext(), R.attr.success_color) + xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) + } + markerPaintSrcIn = Paint().apply { + isAntiAlias = true + xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN) + } + markerBorderPaint = Paint().apply { + style = Paint.Style.STROKE + strokeWidth = MARKER_BORDER_SIZE + color = ResourceUtil.getThemedColor(requireContext(), R.attr.paper_color) + isAntiAlias = true + } + } + + private fun updateSearchCardViews() { + val tabsCount = WikipediaApp.instance.tabCount + binding.tabsButton.isVisible = tabsCount != 0 + binding.tabsButton.updateTabCount(false) + + if (!WikipediaApp.instance.languageState.appLanguageCodes.contains(Prefs.placesWikiCode)) { + Prefs.placesWikiCode = WikipediaApp.instance.appOrSystemLanguageCode + } + binding.langCodeButton.setLangCode(Prefs.placesWikiCode) + + FeedbackUtil.setButtonTooltip(binding.tabsButton, binding.langCodeButton) + } + + private fun setUpSymbolManagerWithClustering(mapboxMap: MapboxMap, style: Style) { + val clusterOptions = ClusterOptions() + .withClusterRadius(60) + .withTextSize(Expression.literal(16f)) + .withTextField(Expression.toString(get(POINT_COUNT))) + .withTextColor(Expression.color(ResourceUtil.getThemedColor(requireContext(), R.attr.paper_color))) + + symbolManager = SymbolManager(binding.mapView, mapboxMap, style, null, null, clusterOptions) + + // Clustering with SymbolManager doesn't expose a few style specifications we need. + // Accessing the styles in a fail-safe manner + try { + style.getLayer(CLUSTER_TEXT_LAYER_ID)?.apply { + this.setProperties( + textFont(CLUSTER_FONT_STACK), + textIgnorePlacement(true), + textAllowOverlap(true) + ) + } + style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.apply { + this.setProperties( + circleColor(ContextCompat.getColor(requireActivity(), ResourceUtil.getThemedAttributeId(requireContext(), R.attr.success_color))), + circleStrokeColor(ResourceUtil.getThemedColor(requireContext(), R.attr.paper_color)), + circleStrokeWidth(2.0f), + ) + } + } catch (e: Exception) { + L.e(e) + } + } + + override fun onStart() { + super.onStart() + binding.mapView.onStart() + } + + override fun onPause() { + super.onPause() + binding.mapView.onPause() + } + + override fun onResume() { + super.onResume() + activity?.window?.let { window -> + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.TRANSPARENT + window.navigationBarColor = Color.TRANSPARENT + } + + binding.mapView.onResume() + updateSearchCardViews() + updateToggleViews(lastCheckedId == R.id.mapViewButton) + ExclusiveBottomSheetPresenter.dismiss(childFragmentManager) + } + + override fun onStop() { + super.onStop() + binding.mapView.onStop() + } + + override fun onLowMemory() { + super.onLowMemory() + binding.mapView.onLowMemory() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + binding.mapView.onSaveInstanceState(outState) + } + + override fun onDestroyView() { + lastLocation?.let { + Prefs.placesLastLocationAndZoomLevel = Pair(it, lastZoom) + } + binding.mapView.onDestroy() + _binding = null + + clearAnnotationCache() + markerBitmapBase.recycle() + super.onDestroyView() + } + + private fun clearAnnotationCache() { + annotationCache.forEach { + if (it.bitmap != null) { + Glide.get(requireContext()).bitmapPool.put(it.bitmap!!) + } + } + annotationCache.clear() + } + + private fun onUpdateCameraPosition(latLng: LatLng) { + lastLocation = Location("").also { + it.latitude = latLng.latitude + it.longitude = latLng.longitude + } + + lastZoom = mapboxMap?.cameraPosition?.zoom ?: 15.0 + + if (lastZoom < 3.0) { + // Don't fetch pages if the map is zoomed out too far. + return + } + + // Fetch new pages within the current viewport, but only if the map has moved a significant distance. + val latEpsilon = (mapboxMap?.projection?.visibleRegion?.latLngBounds?.latitudeSpan ?: 0.0) * 0.2 + val lngEpsilon = (mapboxMap?.projection?.visibleRegion?.latLngBounds?.longitudeSpan ?: 0.0) * 0.2 + if (lastLocationQueried != null && + abs(latLng.latitude - (lastLocationQueried?.latitude ?: 0.0)) < latEpsilon && + abs(latLng.longitude - (lastLocationQueried?.longitude ?: 0.0)) < lngEpsilon && + abs(lastZoom - lastZoomQueried) < 0.5) { + return + } + lastLocationQueried = lastLocation + lastZoomQueried = lastZoom + + L.d(">>> requesting update: " + latLng.latitude + ", " + latLng.longitude + ", " + mapboxMap?.cameraPosition?.zoom) + viewModel.fetchNearbyPages(latLng.latitude, latLng.longitude, searchRadius, ITEMS_PER_REQUEST) + } + + private fun updateMapMarkers(pages: List) { + symbolManager?.let { manager -> + + pages.filter { + annotationCache.find { item -> item.pageId == it.pageId } == null + }.forEach { + it.annotation = manager.create( + SymbolOptions() + .withLatLng(LatLng(it.latitude, it.longitude)) + .withTextFont(MARKER_FONT_STACK) + .withIconImage(MARKER_DRAWABLE) + ) + if (viewModel.highlightedPageTitle?.prefixedText.orEmpty() == it.pageTitle.prefixedText) { + setMagnifiedSymbol(it.annotation) + } + annotationCache.addFirst(it) + manager.update(it.annotation) + + queueImageForAnnotation(it) + + if (annotationCache.size > MAX_ANNOTATIONS) { + val removed = annotationCache.removeLast() + manager.delete(removed.annotation) + if (!removed.pageTitle.thumbUrl.isNullOrEmpty()) { + mapboxMap?.style?.removeImage(removed.pageTitle.thumbUrl!!) + } + if (removed.bitmap != null) { + Glide.get(requireContext()).bitmapPool.put(removed.bitmap!!) + } + } + } + } + binding.listRecyclerView.adapter = RecyclerViewAdapter(pages) + } + + private fun haveLocationPermissions(): Boolean { + return ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED && + ActivityCompat.checkSelfPermission(requireContext(), Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + } + + private fun getLastKnownUserLocation(): Location? { + return if (mapboxMap?.locationComponent?.isLocationComponentActivated == true) + mapboxMap?.locationComponent?.lastKnownLocation else null + } + + @SuppressLint("MissingPermission") + private fun startLocationTracking() { + mapboxMap?.let { + it.locationComponent.activateLocationComponent(LocationComponentActivationOptions.builder(requireContext(), it.style!!).build()) + it.locationComponent.isLocationComponentEnabled = true + it.locationComponent.cameraMode = CameraMode.NONE + it.locationComponent.renderMode = RenderMode.COMPASS + } + } + + private fun goToLocation(preferredLocation: Location? = null, zoom: Double = 15.0) { + binding.viewButtonsGroup.check(R.id.mapViewButton) + mapboxMap?.let { + viewModel.lastKnownLocation = getLastKnownUserLocation() + var currentLatLngLoc: LatLng? = null + viewModel.lastKnownLocation?.let { loc -> currentLatLngLoc = LatLng(loc.latitude, loc.longitude) } + val location = preferredLocation?.let { loc -> LatLng(loc.latitude, loc.longitude) } + val targetLocation = location ?: currentLatLngLoc + targetLocation?.let { target -> + it.moveCamera(CameraUpdateFactory.newLatLngZoom(target, zoom), object : CancelableCallback { + override fun onCancel() { } + + override fun onFinish() { + if (isAdded && preferredLocation != null && viewModel.highlightedPageTitle != null) { + showLinkPreview(viewModel.highlightedPageTitle!!, preferredLocation) + } + } + }) + } + } + } + + private fun queueImageForAnnotation(page: PlacesFragmentViewModel.NearbyPage) { + val url = page.pageTitle.thumbUrl + if (!Prefs.isImageDownloadEnabled || url.isNullOrEmpty()) { + return + } + + ImagePipelineBitmapGetter(requireContext(), url) { bitmap -> + if (!isAdded) { + return@ImagePipelineBitmapGetter + } + annotationCache.find { it.pageId == page.pageId }?.let { + val bmp = getMarkerBitmap(bitmap) + it.bitmap = bmp + + mapboxMap?.style?.addImage(url, BitmapDrawable(resources, bmp)) + + it.annotation?.let { annotation -> + annotation.iconImage = url + symbolManager?.update(annotation) + } + } + } + } + + private fun getMarkerBitmap(thumbnailBitmap: Bitmap): Bitmap { + + // Retrieve an unused bitmap from the pool + val result = Glide.get(requireContext()).bitmapPool + .getDirty(MARKER_SIZE, MARKER_SIZE, Bitmap.Config.ARGB_8888) + + result.applyCanvas { + this.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR) + drawMarker(this, thumbnailBitmap) + } + return result + } + + private fun drawMarker(canvas: Canvas, thumbnailBitmap: Bitmap? = null) { + val radius = MARKER_SIZE / 2f + canvas.drawCircle(radius, radius, radius, markerPaintSrc) + thumbnailBitmap?.let { + val thumbnailRect = Rect(0, 0, it.width, it.height) + canvas.drawBitmap(it, thumbnailRect, markerRect, markerPaintSrcIn) + } + canvas.drawCircle(radius, radius, radius - MARKER_BORDER_SIZE / 2, markerBorderPaint) + } + + override fun onLinkPreviewLoadPage(title: PageTitle, entry: HistoryEntry, inNewTab: Boolean) { + if (inNewTab) { + TabUtil.openInNewBackgroundTab(entry) + requireActivity().invalidateOptionsMenu() + binding.tabsButton.isVisible = WikipediaApp.instance.tabCount > 0 + binding.tabsButton.updateTabCount(true) + } else { + startActivity(PageActivity.newIntentForNewTab(requireActivity(), entry, entry.title)) + } + } + + override fun onLinkPreviewDismiss() { + updateSearchText() + } + + override fun onMapClick(point: LatLng): Boolean { + mapboxMap?.let { + val screenPoint = it.projection.toScreenLocation(point) + val rect = RectF(screenPoint.x - 10, screenPoint.y - 10, screenPoint.x + 10, screenPoint.y + 10) + + updateSearchText() + // Zoom-in 2 levels on click of a cluster circle. Do not handle other click events + val featureList = it.queryRenderedFeatures(rect, CLUSTER_CIRCLE_LAYER_ID) + if (featureList.isNotEmpty()) { + PlacesEvent.logAction("cluster_click", "map_view") + val cameraPosition = CameraPosition.Builder() + .target(point) + .zoom(it.cameraPosition.zoom + 2) + .build() + it.easeCamera(CameraUpdateFactory.newCameraPosition(cameraPosition), ZOOM_IN_ANIMATION_DURATION) + return true + } + } + return false + } + + private fun maybeShowSurvey() { + binding.root.postDelayed({ + if (isAdded && Prefs.shouldShowOneTimePlacesSurvey == 1) { + Prefs.shouldShowOneTimePlacesSurvey++ + SurveyDialog.showFeedbackOptionsDialog(requireActivity(), Constants.InvokeSource.PLACES) + } + }, 1000) + } + + private inner class RecyclerViewAdapter(val nearbyPages: List) : RecyclerView.Adapter() { + override fun getItemCount(): Int { + return nearbyPages.size + } + + override fun onCreateViewHolder(parent: ViewGroup, type: Int): RecyclerViewItemHolder { + return RecyclerViewItemHolder(ItemPlacesListBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: RecyclerViewItemHolder, position: Int) { + holder.bindItem(nearbyPages[position], viewModel.lastKnownLocation) + } + } + + private inner class RecyclerViewItemHolder(val binding: ItemPlacesListBinding) : + RecyclerView.ViewHolder(binding.root), View.OnClickListener, View.OnLongClickListener { + + private lateinit var page: PlacesFragmentViewModel.NearbyPage + + init { + itemView.setOnClickListener(this) + itemView.setOnLongClickListener(this) + DeviceUtil.setContextClickAsLongClick(itemView) + } + + fun bindItem(page: PlacesFragmentViewModel.NearbyPage, locationForDistance: Location?) { + this.page = page + binding.listItemTitle.text = StringUtil.fromHtml(page.pageTitle.displayText) + binding.listItemDescription.text = StringUtil.fromHtml(page.pageTitle.description) + binding.listItemDescription.isVisible = !page.pageTitle.description.isNullOrEmpty() + locationForDistance?.let { + binding.listItemDistance.text = GeoUtil.getDistanceWithUnit(it, page.location, Locale.getDefault()) + } + page.pageTitle.thumbUrl?.let { + ViewUtil.loadImage(binding.listItemThumbnail, it, circleShape = true) + binding.listItemThumbnail.isVisible = true + } ?: run { + binding.listItemThumbnail.isVisible = false + } + } + + override fun onClick(v: View) { + PlacesEvent.logAction("read_click", "list_view") + val entry = HistoryEntry(page.pageTitle, HistoryEntry.SOURCE_PLACES) + startActivity(PageActivity.newIntentForNewTab(requireActivity(), entry, entry.title)) + } + + override fun onLongClick(v: View): Boolean { + val entry = HistoryEntry(page.pageTitle, HistoryEntry.SOURCE_PLACES) + val location = page.location + LongPressMenu(v, menuRes = R.menu.menu_places_long_press, location = location, callback = object : LongPressMenu.Callback { + override fun onOpenInNewTab(entry: HistoryEntry) { + onLinkPreviewLoadPage(entry.title, entry, true) + } + + override fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) { + ReadingListBehaviorsUtil.addToDefaultList(requireActivity(), entry.title, addToDefault, Constants.InvokeSource.PLACES) + } + + override fun onMoveRequest(page: ReadingListPage?, entry: HistoryEntry) { + page?.let { + ReadingListBehaviorsUtil.moveToList(requireActivity(), it.listId, entry.title, Constants.InvokeSource.PLACES) + } + } + }).show(entry) + return true + } + } + + companion object { + const val MARKER_DRAWABLE = "markerDrawable" + const val POINT_COUNT = "point_count" + const val MAX_ANNOTATIONS = 250 + const val THUMB_SIZE = 160 + const val ITEMS_PER_REQUEST = 75 + const val CLUSTER_TEXT_LAYER_ID = "mapbox-android-cluster-text" + const val CLUSTER_CIRCLE_LAYER_ID = "mapbox-android-cluster-circle0" + const val ZOOM_IN_ANIMATION_DURATION = 1000 + const val SURVEY_NOT_INITIALIZED = -1 + + val CLUSTER_FONT_STACK = arrayOf("Open Sans Semibold") + val MARKER_FONT_STACK = arrayOf("Open Sans Regular") + val MARKER_SIZE = DimenUtil.roundedDpToPx(40f) + val MARKER_BORDER_SIZE = DimenUtil.dpToPx(2f) + + fun newInstance(pageTitle: PageTitle?, location: Location?): PlacesFragment { + return PlacesFragment().apply { + arguments = bundleOf( + Constants.ARG_TITLE to pageTitle, + PlacesActivity.EXTRA_LOCATION to location + ) + } + } + + /** + * Rough conversion of latitude degrees to meters, bounded by the limits accepted by the API. + */ + fun latitudeDiffToMeters(latitudeDiff: Double): Int { + return (111132 * latitudeDiff).toInt().coerceIn(10, 10000) + } + } +} diff --git a/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt b/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt new file mode 100644 index 00000000000..3bd7a3354e2 --- /dev/null +++ b/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt @@ -0,0 +1,79 @@ +package org.wikipedia.places + +import android.graphics.Bitmap +import android.location.Location +import android.os.Bundle +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.mapbox.mapboxsdk.plugins.annotation.Symbol +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.extensions.parcelable +import org.wikipedia.page.PageTitle +import org.wikipedia.settings.Prefs +import org.wikipedia.util.ImageUrlUtil +import org.wikipedia.util.Resource + +class PlacesFragmentViewModel(bundle: Bundle) : ViewModel() { + + val wikiSite: WikiSite get() = WikiSite.forLanguageCode(Prefs.placesWikiCode) + var location: Location? = bundle.parcelable(PlacesActivity.EXTRA_LOCATION) + var highlightedPageTitle: PageTitle? = bundle.parcelable(Constants.ARG_TITLE) + + var lastKnownLocation: Location? = null + val nearbyPagesLiveData = MutableLiveData>>() + + init { + Prefs.shouldShowOneTimePlacesSurvey++ + } + + fun fetchNearbyPages(latitude: Double, longitude: Double, radius: Int, maxResults: Int) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + nearbyPagesLiveData.postValue(Resource.Error(throwable)) + }) { + val response = ServiceFactory.get(wikiSite).getGeoSearch("$latitude|$longitude", radius, maxResults, maxResults) + val pages = response.query?.pages.orEmpty() + .filter { it.coordinates != null } + .map { + NearbyPage(it.pageId, PageTitle(it.title, wikiSite, + if (it.thumbUrl().isNullOrEmpty()) null else ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl()!!, PlacesFragment.THUMB_SIZE), + it.description, it.displayTitle(wikiSite.languageCode)), it.coordinates!![0].lat, it.coordinates[0].lon) + } + .sortedBy { + lastKnownLocation?.run { + it.location.distanceTo(this) + } + } + nearbyPagesLiveData.postValue(Resource.Success(pages)) + } + } + + class NearbyPage( + val pageId: Int, + val pageTitle: PageTitle, + val latitude: Double, + val longitude: Double, + var annotation: Symbol? = null, + var bitmap: Bitmap? = null + ) { + + private val lat = latitude + private val lng = longitude + val location get() = Location("").apply { + latitude = lat + longitude = lng + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return PlacesFragmentViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/random/RandomFragment.kt b/app/src/main/java/org/wikipedia/random/RandomFragment.kt index 74052eb5e65..e2f0abe1511 100644 --- a/app/src/main/java/org/wikipedia/random/RandomFragment.kt +++ b/app/src/main/java/org/wikipedia/random/RandomFragment.kt @@ -9,21 +9,20 @@ import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.functions.Consumer -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp -import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentRandomBinding import org.wikipedia.dataclient.WikiSite import org.wikipedia.events.ArticleSavedOrDeletedEvent -import org.wikipedia.extensions.parcelable import org.wikipedia.history.HistoryEntry import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle @@ -33,47 +32,25 @@ import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.util.AnimationUtil.PagerTransformer import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.Resource import org.wikipedia.util.log.L import org.wikipedia.views.PositionAwareFragmentStateAdapter class RandomFragment : Fragment() { - companion object { - const val DEFAULT_PAGER_TAB = 0 - const val PAGER_OFFSCREEN_PAGE_LIMIT = 2 - const val ENABLED_BACK_BUTTON_ALPHA = 1f - const val DISABLED_BACK_BUTTON_ALPHA = 0.5f - - fun newInstance(wikiSite: WikiSite, invokeSource: InvokeSource) = RandomFragment().apply { - arguments = bundleOf( - Constants.ARG_WIKISITE to wikiSite, - Constants.INTENT_EXTRA_INVOKE_SOURCE to invokeSource - ) - } - } - private var _binding: FragmentRandomBinding? = null private val binding get() = _binding!! - private val disposables = CompositeDisposable() - private val viewPagerListener: ViewPagerListener = ViewPagerListener() - - private lateinit var wikiSite: WikiSite + private val viewModel: RandomViewModel by viewModels { RandomViewModel.Factory(requireArguments()) } + private val viewPagerListener = ViewPagerListener() private val topTitle get() = getTopChild()?.title - private var saveButtonState = false - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) _binding = FragmentRandomBinding.inflate(inflater, container, false) val view = binding.root - FeedbackUtil.setButtonLongPressToast(binding.randomNextButton, binding.randomSaveButton) - - wikiSite = requireArguments().parcelable(Constants.ARG_WIKISITE)!! + FeedbackUtil.setButtonTooltip(binding.randomNextButton, binding.randomSaveButton) binding.randomItemPager.offscreenPageLimit = 2 binding.randomItemPager.adapter = RandomItemAdapter(this) @@ -84,13 +61,23 @@ class RandomFragment : Fragment() { binding.randomBackButton.setOnClickListener { onBackClick() } binding.randomSaveButton.setOnClickListener { onSaveShareClick() } - disposables.add(WikipediaApp.instance.bus.subscribe(EventBusConsumer())) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + val eventBus = WikipediaApp.instance.bus.subscribe(EventBusConsumer()) + viewModel.uiState.collect { + when (it) { + is Resource.Success -> setSaveButton() + is Resource.Error -> L.w(it.throwable) + } + } + } + } - updateSaveShareButton() + updateSaveButton() updateBackButton(DEFAULT_PAGER_TAB) if (savedInstanceState != null && binding.randomItemPager.currentItem == DEFAULT_PAGER_TAB && topTitle != null) { - updateSaveShareButton(topTitle) + updateSaveButton(topTitle) } return view @@ -98,11 +85,10 @@ class RandomFragment : Fragment() { override fun onResume() { super.onResume() - updateSaveShareButton(topTitle) + updateSaveButton(topTitle) } override fun onDestroyView() { - disposables.clear() binding.randomItemPager.unregisterOnPageChangeCallback(viewPagerListener) _binding = null super.onDestroyView() @@ -128,33 +114,25 @@ class RandomFragment : Fragment() { private fun onSaveShareClick() { val title = topTitle ?: return - if (saveButtonState) { + if (viewModel.saveButtonState) { LongPressMenu(binding.randomSaveButton, existsInAnyList = false, callback = object : LongPressMenu.Callback { - override fun onOpenLink(entry: HistoryEntry) { - // ignore - } - - override fun onOpenInNewTab(entry: HistoryEntry) { - // ignore - } - override fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) { ReadingListBehaviorsUtil.addToDefaultList(requireActivity(), title, addToDefault, InvokeSource.RANDOM_ACTIVITY) { - updateSaveShareButton(title) + updateSaveButton(title) } } override fun onMoveRequest(page: ReadingListPage?, entry: HistoryEntry) { page?.let { ReadingListBehaviorsUtil.moveToList(requireActivity(), page.listId, title, InvokeSource.RANDOM_ACTIVITY) { - updateSaveShareButton() + updateSaveButton() } } } }).show(HistoryEntry(title, HistoryEntry.SOURCE_RANDOM)) } else { ReadingListBehaviorsUtil.addToDefaultList(requireActivity(), title, true, InvokeSource.RANDOM_ACTIVITY) { - updateSaveShareButton(title) + updateSaveButton(title) } } } @@ -171,10 +149,7 @@ class RandomFragment : Fragment() { intent.putExtra(Constants.INTENT_EXTRA_HAS_TRANSITION_ANIM, true) } - startActivity( - intent, - if (DimenUtil.isLandscape(requireContext()) || sharedElements.isEmpty()) null else options.toBundle() - ) + startActivity(intent, if (DimenUtil.isLandscape(requireContext()) || sharedElements.isEmpty()) null else options.toBundle()) } private fun updateBackButton(pagerPosition: Int) { @@ -183,38 +158,24 @@ class RandomFragment : Fragment() { if (pagerPosition == DEFAULT_PAGER_TAB) DISABLED_BACK_BUTTON_ALPHA else ENABLED_BACK_BUTTON_ALPHA } - private fun updateSaveShareButton(title: PageTitle?) { - if (title == null) { - return + private fun updateSaveButton(title: PageTitle? = null) { + title?.let { + viewModel.findPageInAnyList(title) + } ?: run { + val enable = getTopChild()?.isLoadComplete ?: false + binding.randomSaveButton.isClickable = enable + binding.randomSaveButton.alpha = + if (enable) ENABLED_BACK_BUTTON_ALPHA else DISABLED_BACK_BUTTON_ALPHA } - - val d = Observable.fromCallable { - AppDatabase.instance.readingListPageDao().findPageInAnyList(title) != null - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ exists: Boolean -> - saveButtonState = exists - val img = - if (saveButtonState) R.drawable.ic_bookmark_white_24dp else R.drawable.ic_bookmark_border_white_24dp - binding.randomSaveButton.setImageResource(img) - }, { t -> - L.w(t) - }) - - disposables.add(d) } - private fun updateSaveShareButton() { - val enable = getTopChild()?.isLoadComplete ?: false - - binding.randomSaveButton.isClickable = enable - binding.randomSaveButton.alpha = - if (enable) ENABLED_BACK_BUTTON_ALPHA else DISABLED_BACK_BUTTON_ALPHA + private fun setSaveButton() { + val imageSource = if (viewModel.saveButtonState) R.drawable.ic_bookmark_white_24dp else R.drawable.ic_bookmark_border_white_24dp + binding.randomSaveButton.setImageResource(imageSource) } fun onChildLoaded() { - updateSaveShareButton() + updateSaveButton() } private fun getTopChild(): RandomItemFragment? { @@ -229,7 +190,7 @@ class RandomFragment : Fragment() { } override fun createFragment(position: Int): Fragment { - return RandomItemFragment.newInstance(wikiSite) + return RandomItemFragment.newInstance(viewModel.wikiSite) } } @@ -243,12 +204,17 @@ class RandomFragment : Fragment() { override fun onPageSelected(position: Int) { updateBackButton(position) - updateSaveShareButton(topTitle) + updateSaveButton(topTitle) nextPageSelectedAutomatic = false prevPosition = position - updateSaveShareButton() + updateSaveButton() + + val storedOffScreenPagesCount = binding.randomItemPager.offscreenPageLimit * 2 + 1 + if (position >= storedOffScreenPagesCount) { + (binding.randomItemPager.adapter as RandomItemAdapter).removeFragmentAt(position - storedOffScreenPagesCount) + } } } @@ -260,10 +226,23 @@ class RandomFragment : Fragment() { } for (page in event.pages) { if (page.apiTitle == topTitle?.prefixedText && page.wiki.languageCode == topTitle?.wikiSite?.languageCode) { - updateSaveShareButton(topTitle) + updateSaveButton(topTitle) } } } } } + + companion object { + const val DEFAULT_PAGER_TAB = 0 + const val ENABLED_BACK_BUTTON_ALPHA = 1f + const val DISABLED_BACK_BUTTON_ALPHA = 0.5f + + fun newInstance(wikiSite: WikiSite, invokeSource: InvokeSource) = RandomFragment().apply { + arguments = bundleOf( + Constants.ARG_WIKISITE to wikiSite, + Constants.INTENT_EXTRA_INVOKE_SOURCE to invokeSource + ) + } + } } diff --git a/app/src/main/java/org/wikipedia/random/RandomItemFragment.kt b/app/src/main/java/org/wikipedia/random/RandomItemFragment.kt index 60ecfd4d533..e97c0b3d1db 100644 --- a/app/src/main/java/org/wikipedia/random/RandomItemFragment.kt +++ b/app/src/main/java/org/wikipedia/random/RandomItemFragment.kt @@ -6,55 +6,36 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.databinding.FragmentRandomItemBinding -import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.page.PageSummary -import org.wikipedia.extensions.parcelable import org.wikipedia.page.PageTitle import org.wikipedia.util.ImageUrlUtil.getUrlForPreferredSize import org.wikipedia.util.L10nUtil +import org.wikipedia.util.Resource import org.wikipedia.util.log.L class RandomItemFragment : Fragment() { - companion object { - private const val EXTRACT_MAX_LINES = 4 - - fun newInstance(wikiSite: WikiSite) = RandomItemFragment().apply { - arguments = bundleOf(Constants.ARG_WIKISITE to wikiSite) - } - } - private var _binding: FragmentRandomItemBinding? = null private val binding get() = _binding!! + private val viewModel: RandomItemViewModel by viewModels { RandomItemViewModel.Factory(requireArguments()) } - private val disposables = CompositeDisposable() - - private lateinit var wikiSite: WikiSite - private var summary: PageSummary? = null - - val isLoadComplete: Boolean get() = summary != null - val title: PageTitle? get() = summary?.getPageTitle(wikiSite) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - wikiSite = requireArguments().parcelable(Constants.ARG_WIKISITE)!! - - retainInstance = true - } + val isLoadComplete: Boolean get() = viewModel.summary != null + val title: PageTitle? get() = viewModel.summary?.getPageTitle(viewModel.wikiSite) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) _binding = FragmentRandomItemBinding.inflate(inflater, container, false) - val view = binding.root binding.randomItemWikiArticleCardView.setOnClickListener { title?.let { title -> @@ -68,74 +49,69 @@ class RandomItemFragment : Fragment() { binding.randomItemErrorView.retryClickListener = View.OnClickListener { binding.randomItemProgress.visibility = View.VISIBLE - getRandomPage() + viewModel.getRandomPage() } - updateContents() - - if (summary == null) { - getRandomPage() + L10nUtil.setConditionalLayoutDirection(binding.root, viewModel.wikiSite.languageCode) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> { + binding.randomItemProgress.isVisible = true + } + is Resource.Success -> updateContents(it.data) + is Resource.Error -> setErrorState(it.throwable) + } + } + } } - L10nUtil.setConditionalLayoutDirection(view, wikiSite.languageCode) - return view + return binding.root } override fun onDestroyView() { - disposables.clear() _binding = null - super.onDestroyView() } - private fun getRandomPage() { - val d = ServiceFactory.getRest(wikiSite).randomSummary - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ pageSummary -> - summary = pageSummary - updateContents() - parent().onChildLoaded() - }, { t -> - setErrorState(t) - }) - - disposables.add(d) - } - private fun setErrorState(t: Throwable) { L.e(t) - binding.randomItemErrorView.setError(t) - binding.randomItemErrorView.visibility = View.VISIBLE - binding.randomItemProgress.visibility = View.GONE - binding.randomItemWikiArticleCardView.visibility = View.GONE + binding.randomItemErrorView.isVisible = true + binding.randomItemProgress.isVisible = false + binding.randomItemWikiArticleCardView.isVisible = false } - private fun updateContents() { - binding.randomItemErrorView.visibility = View.GONE - - binding.randomItemWikiArticleCardView.visibility = - if (summary == null) View.GONE else View.VISIBLE + private fun updateContents(summary: PageSummary?) { + binding.randomItemErrorView.isVisible = false + binding.randomItemProgress.isVisible = false + binding.randomItemWikiArticleCardView.isVisible = summary != null + summary?.run { + binding.randomItemWikiArticleCardView.setTitle(displayTitle) + binding.randomItemWikiArticleCardView.setDescription(description) + binding.randomItemWikiArticleCardView.setExtract(extract, EXTRACT_MAX_LINES) - binding.randomItemProgress.visibility = - if (summary == null) View.VISIBLE else View.GONE + var imageUri: Uri? = null - val summary = summary ?: return - - binding.randomItemWikiArticleCardView.setTitle(summary.displayTitle) - binding.randomItemWikiArticleCardView.setDescription(summary.description) - binding.randomItemWikiArticleCardView.setExtract(summary.extract, EXTRACT_MAX_LINES) - - var imageUri: Uri? = null - - summary.thumbnailUrl.takeUnless { it.isNullOrBlank() }?.let { thumbnailUrl -> - imageUri = Uri.parse(getUrlForPreferredSize(thumbnailUrl, Constants.PREFERRED_CARD_THUMBNAIL_SIZE)) + thumbnailUrl.takeUnless { it.isNullOrBlank() }?.let { thumbnailUrl -> + imageUri = Uri.parse(getUrlForPreferredSize(thumbnailUrl, Constants.PREFERRED_CARD_THUMBNAIL_SIZE)) + } + binding.randomItemWikiArticleCardView.setImageUri(imageUri, false) } - binding.randomItemWikiArticleCardView.setImageUri(imageUri, false) + parent().onChildLoaded() } private fun parent(): RandomFragment { return requireActivity().supportFragmentManager.fragments[0] as RandomFragment } + + companion object { + private const val EXTRACT_MAX_LINES = 4 + + fun newInstance(wikiSite: WikiSite) = RandomItemFragment().apply { + arguments = bundleOf(Constants.ARG_WIKISITE to wikiSite) + } + } } diff --git a/app/src/main/java/org/wikipedia/random/RandomItemViewModel.kt b/app/src/main/java/org/wikipedia/random/RandomItemViewModel.kt new file mode 100644 index 00000000000..be1ab2caf55 --- /dev/null +++ b/app/src/main/java/org/wikipedia/random/RandomItemViewModel.kt @@ -0,0 +1,47 @@ +package org.wikipedia.random + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.extensions.parcelable +import org.wikipedia.util.Resource + +class RandomItemViewModel(bundle: Bundle) : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + val wikiSite: WikiSite = bundle.parcelable(Constants.ARG_WIKISITE)!! + var summary: PageSummary? = null + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + getRandomPage() + } + + fun getRandomPage() { + _uiState.value = Resource.Loading() + viewModelScope.launch(handler) { + summary = ServiceFactory.getRest(wikiSite).getRandomSummary() + _uiState.value = Resource.Success(summary) + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return RandomItemViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/random/RandomViewModel.kt b/app/src/main/java/org/wikipedia/random/RandomViewModel.kt new file mode 100644 index 00000000000..8e43d5b6cf3 --- /dev/null +++ b/app/src/main/java/org/wikipedia/random/RandomViewModel.kt @@ -0,0 +1,43 @@ +package org.wikipedia.random + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.database.AppDatabase +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.extensions.parcelable +import org.wikipedia.page.PageTitle +import org.wikipedia.util.Resource + +class RandomViewModel(bundle: Bundle) : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + val wikiSite: WikiSite = bundle.parcelable(Constants.ARG_WIKISITE)!! + var saveButtonState = false + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + fun findPageInAnyList(title: PageTitle) { + viewModelScope.launch(handler) { + val inAnyList = AppDatabase.instance.readingListPageDao().findPageInAnyList(title) != null + saveButtonState = inAnyList + _uiState.value = Resource.Success(inAnyList) + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return RandomViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt b/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt index dc3d4e751fc..7d064fec246 100644 --- a/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt +++ b/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt @@ -27,7 +27,6 @@ import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.ReadingListTitleDialog.readingListTitleDialog import org.wikipedia.readinglist.database.ReadingList import org.wikipedia.settings.Prefs -import org.wikipedia.settings.SiteInfoClient import org.wikipedia.util.DimenUtil.getDimension import org.wikipedia.util.DimenUtil.roundedDpToPx import org.wikipedia.util.FeedbackUtil.makeSnackbar @@ -123,8 +122,8 @@ open class AddToReadingListDialog : ExtendedBottomSheetDialogFragment() { } private fun addAndDismiss(readingList: ReadingList, titles: List?) { - if (readingList.pages.size + titles!!.size > SiteInfoClient.maxPagesPerReadingList) { - val message = getString(R.string.reading_list_article_limit_message, readingList.title, SiteInfoClient.maxPagesPerReadingList) + if (readingList.pages.size + titles!!.size > Constants.MAX_READING_LIST_ARTICLE_LIMIT) { + val message = getString(R.string.reading_list_article_limit_message, readingList.title, Constants.MAX_READING_LIST_ARTICLE_LIMIT) makeSnackbar(requireActivity(), message).show() dismiss() return diff --git a/app/src/main/java/org/wikipedia/readinglist/LongPressMenu.kt b/app/src/main/java/org/wikipedia/readinglist/LongPressMenu.kt index 06d28d944e3..ebaa0871ea1 100644 --- a/app/src/main/java/org/wikipedia/readinglist/LongPressMenu.kt +++ b/app/src/main/java/org/wikipedia/readinglist/LongPressMenu.kt @@ -2,11 +2,11 @@ package org.wikipedia.readinglist import android.content.ContextWrapper import android.icu.text.ListFormatter +import android.location.Location import android.os.Build import android.view.Gravity import android.view.MenuItem import android.view.View -import androidx.annotation.MenuRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.PopupMenu import kotlinx.coroutines.CoroutineScope @@ -14,29 +14,32 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent +import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.database.AppDatabase import org.wikipedia.history.HistoryEntry import org.wikipedia.readinglist.database.ReadingList import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.util.ClipboardUtil import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.GeoUtil import org.wikipedia.util.ShareUtil +import org.wikipedia.util.StringUtil class LongPressMenu( private val anchorView: View, private val existsInAnyList: Boolean = true, + private var menuRes: Int = R.menu.menu_long_press, + private val location: Location? = null, private val callback: Callback? = null ) { interface Callback { - fun onOpenLink(entry: HistoryEntry) - fun onOpenInNewTab(entry: HistoryEntry) + fun onOpenLink(entry: HistoryEntry) { /* ignore by default */ } + fun onOpenInNewTab(entry: HistoryEntry) { /* ignore by default */ } fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) fun onMoveRequest(page: ReadingListPage?, entry: HistoryEntry) fun onRemoveRequest() { /* ignore by default */ } } - @MenuRes - private val menuRes: Int = if (existsInAnyList) R.menu.menu_long_press else R.menu.menu_reading_list_page_toggle private var listsContainingPage: List? = null private var entry: HistoryEntry? = null @@ -50,6 +53,9 @@ class LongPressMenu( return@launch } this@LongPressMenu.entry = it + if (!existsInAnyList) { + this@LongPressMenu.menuRes = R.menu.menu_reading_list_page_toggle + } showMenu() } } @@ -83,6 +89,7 @@ class LongPressMenu( saveItem.isVisible = it.isEmpty() saveItem.isEnabled = it.isEmpty() } + menu.menu.findItem(R.id.menu_long_press_get_directions)?.isVisible = location != null menu.show() } } @@ -125,39 +132,63 @@ class LongPressMenu( true } R.id.menu_long_press_open_in_new_tab -> { + sendPlacesEvent("new_tab_click") entry?.let { callback?.onOpenInNewTab(it) } true } R.id.menu_long_press_add_to_default_list -> { + sendPlacesEvent("save_click") entry?.let { callback?.onAddRequest(it, true) } true } R.id.menu_long_press_add_to_another_list -> { + sendPlacesEvent("add_to_another_list_click") listsContainingPage?.let { entry?.let { callback?.onAddRequest(it, false) } } true } R.id.menu_long_press_move_from_list_to_another_list -> { + sendPlacesEvent("move_from_list_to_another_list_click") listsContainingPage?.let { list -> entry?.let { callback?.onMoveRequest(list[0].pages[0], it) } } true } R.id.menu_long_press_remove_from_lists -> { + sendPlacesEvent("remove_from_list_click") deleteOrShowDialog() callback?.onRemoveRequest() true } R.id.menu_long_press_share_page -> { + sendPlacesEvent("share_click") entry?.let { ShareUtil.shareText(getActivity(), it.title) } true } R.id.menu_long_press_copy_page -> { + sendPlacesEvent("copy_link_click") entry?.let { ClipboardUtil.setPlainText(getActivity(), text = it.title.uri) FeedbackUtil.showMessage((getActivity()), R.string.address_copied) } true } + R.id.menu_long_press_get_directions -> { + sendPlacesEvent("directions_click") + location?.let { + entry?.let { + GeoUtil.sendGeoIntent(getActivity(), location, StringUtil.fromHtml(it.title.displayText).toString()) + } + } + true + } else -> false } } } + + private fun sendPlacesEvent(action: String) { + entry?.let { + if (it.source == HistoryEntry.SOURCE_PLACES) { + PlacesEvent.logAction(action, "list_view_menu") + } + } + } } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt index eb53efd046d..818ae126578 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt @@ -51,7 +51,6 @@ import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.readinglist.sync.ReadingListSyncEvent import org.wikipedia.settings.Prefs import org.wikipedia.settings.RemoteConfig -import org.wikipedia.settings.SiteInfoClient.maxPagesPerReadingList import org.wikipedia.util.* import org.wikipedia.util.log.L import org.wikipedia.views.* @@ -288,8 +287,8 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial if (!toolbarExpanded) { binding.readingListToolbarContainer.title = it.title } - if (!articleLimitMessageShown && it.pages.size >= maxPagesPerReadingList) { - val message = getString(R.string.reading_list_article_limit_message, readingList.title, maxPagesPerReadingList) + if (!articleLimitMessageShown && it.pages.size >= Constants.MAX_READING_LIST_ARTICLE_LIMIT) { + val message = getString(R.string.reading_list_article_limit_message, readingList.title, Constants.MAX_READING_LIST_ARTICLE_LIMIT) FeedbackUtil.makeSnackbar(requireActivity(), message).show() articleLimitMessageShown = true } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt index 471766a2421..dc7877a09a9 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt @@ -103,7 +103,7 @@ class ReadingListItemView : ConstraintLayout { } } - FeedbackUtil.setButtonLongPressToast(binding.itemShareButton, binding.itemOverflowMenu) + FeedbackUtil.setButtonTooltip(binding.itemShareButton, binding.itemOverflowMenu) } fun setReadingList(readingList: ReadingList, description: Description, selectMode: Boolean = false, newImport: Boolean = false) { diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListPreviewSaveDialogView.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListPreviewSaveDialogView.kt index 86e62535604..9012d4c1308 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListPreviewSaveDialogView.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListPreviewSaveDialogView.kt @@ -21,7 +21,7 @@ import org.wikipedia.util.StringUtil import org.wikipedia.views.DefaultViewHolder import org.wikipedia.views.DrawableItemDecoration import org.wikipedia.views.ViewUtil -import java.util.* +import java.util.Date class ReadingListPreviewSaveDialogView : FrameLayout { @@ -91,7 +91,7 @@ class ReadingListPreviewSaveDialogView : FrameLayout { itemBinding.articleName.text = StringUtil.fromHtml(readingListPage.displayTitle) itemBinding.articleDescription.isVisible = !readingListPage.description.isNullOrEmpty() itemBinding.articleDescription.text = StringUtil.fromHtml(readingListPage.description) - ViewUtil.loadImage(itemBinding.articleThumbnail, readingListPage.thumbUrl, true) + ViewUtil.loadImage(itemBinding.articleThumbnail, readingListPage.thumbUrl, roundedCorners = true) itemBinding.container.setOnClickListener(this) itemBinding.checkbox.setOnClickListener(this) updateState() diff --git a/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListSyncAdapter.kt b/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListSyncAdapter.kt index 83c87401792..d239afbc1a8 100644 --- a/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListSyncAdapter.kt +++ b/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListSyncAdapter.kt @@ -80,6 +80,27 @@ class ReadingListSyncAdapter : JobIntentService() { if (lastSyncTime.isEmpty()) { syncEverything = true } + + if (!syncEverything) { + try { + L.d("Fetching changes from server, since $lastSyncTime") + val allChanges = client.getChangesSince(lastSyncTime) + allChanges.lists?.let { + remoteListsModified = it as MutableList + } + allChanges.entries?.let { + remoteEntriesModified = it as MutableList + } + } catch (t: Throwable) { + if (client.isErrorType(t, "too-old")) { + // If too much time has elapsed between syncs, then perform a full sync. + syncEverything = true + } else { + throw t + } + } + } + if (syncEverything) { if (allLocalLists == null) { allLocalLists = AppDatabase.instance.readingListDao().getAllLists().toMutableList() @@ -88,14 +109,6 @@ class ReadingListSyncAdapter : JobIntentService() { if (allLocalLists == null) { allLocalLists = AppDatabase.instance.readingListDao().getAllListsWithUnsyncedPages().toMutableList() } - L.d("Fetching changes from server, since $lastSyncTime") - val allChanges = client.getChangesSince(lastSyncTime) - allChanges.lists?.let { - remoteListsModified = it as MutableList - } - allChanges.entries?.let { - remoteEntriesModified = it as MutableList - } } // Perform a quick check for whether we'll need to sync all lists diff --git a/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt b/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt index 7db11d37458..fcda69e823f 100644 --- a/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt +++ b/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt @@ -21,11 +21,9 @@ import androidx.core.text.HtmlCompat import androidx.core.text.getSpans import androidx.core.text.parseAsHtml import androidx.core.text.toSpanned -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition import org.wikipedia.dataclient.Service import org.wikipedia.dataclient.WikiSite +import org.wikipedia.gallery.ImagePipelineBitmapGetter import org.wikipedia.util.DimenUtil import org.wikipedia.util.ResourceUtil import org.wikipedia.util.WhiteBackgroundTransformation @@ -148,21 +146,15 @@ class CustomHtmlParser(private val handler: TagHandler) : TagHandler, ContentHan uri = Service.COMMONS_URL + uri.replace("./", "") } - Glide.with(view) - .asBitmap() - .load(uri) - .into(object : CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - if (!drawable.bitmap.isRecycled) { - drawable.bitmap.applyCanvas { - drawBitmap(resource, Rect(0, 0, resource.width, resource.height), drawable.bounds, null) - } - WhiteBackgroundTransformation.maybeDimImage(drawable.bitmap) - view.postInvalidate() - } + ImagePipelineBitmapGetter(view.context, uri) { bitmap -> + if (!drawable.bitmap.isRecycled) { + drawable.bitmap.applyCanvas { + drawBitmap(bitmap, Rect(0, 0, bitmap.width, bitmap.height), drawable.bounds, null) } - override fun onLoadCleared(placeholder: Drawable?) { } - }) + WhiteBackgroundTransformation.maybeDimImage(drawable.bitmap) + view.postInvalidate() + } + } } } } else if (tag == "a") { diff --git a/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt b/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt index ce93b0f154f..099bcc73316 100644 --- a/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt +++ b/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt @@ -22,7 +22,7 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryResult import org.wikipedia.page.Namespace import org.wikipedia.search.db.RecentSearch -import org.wikipedia.util.FeedbackUtil.setButtonLongPressToast +import org.wikipedia.util.FeedbackUtil.setButtonTooltip import org.wikipedia.util.ResourceUtil import org.wikipedia.util.log.L import org.wikipedia.views.SwipeableItemTouchHelperCallback @@ -36,7 +36,7 @@ class RecentSearchesFragment : Fragment() { } private var _binding: FragmentSearchRecentBinding? = null - private val binding get() = _binding!! + val binding get() = _binding!! private val namespaceHints = listOf(Namespace.USER, Namespace.PORTAL, Namespace.HELP) private val namespaceMap = ConcurrentHashMap>() private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> L.e(throwable) } @@ -66,7 +66,7 @@ class RecentSearchesFragment : Fragment() { touchCallback.swipeableEnabled = true val itemTouchHelper = ItemTouchHelper(touchCallback) itemTouchHelper.attachToRecyclerView(binding.recentSearchesRecycler) - setButtonLongPressToast(binding.recentSearchesDeleteButton) + setButtonTooltip(binding.recentSearchesDeleteButton) return binding.root } diff --git a/app/src/main/java/org/wikipedia/search/SearchFragment.kt b/app/src/main/java/org/wikipedia/search/SearchFragment.kt index 0b7dfebac95..0b18ee199a6 100644 --- a/app/src/main/java/org/wikipedia/search/SearchFragment.kt +++ b/app/src/main/java/org/wikipedia/search/SearchFragment.kt @@ -3,6 +3,7 @@ package org.wikipedia.search import android.app.Activity.RESULT_OK import android.content.Intent import android.graphics.Color +import android.location.Location import android.os.Build import android.os.Bundle import android.view.LayoutInflater @@ -12,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineExceptionHandler @@ -20,12 +22,14 @@ import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentSearchBinding import org.wikipedia.history.HistoryEntry import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle +import org.wikipedia.places.PlacesActivity import org.wikipedia.readinglist.ReadingListBehaviorsUtil import org.wikipedia.search.db.RecentSearch import org.wikipedia.settings.Prefs @@ -107,12 +111,16 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche recentSearchesFragment.callback = this searchResultsFragment = childFragmentManager.findFragmentById( R.id.fragment_search_results) as SearchResultsFragment + searchResultsFragment.setInvokeSource(invokeSource) (activity as? AppCompatActivity)?.setSupportActionBar(binding.searchToolbar) binding.searchToolbar.setNavigationOnClickListener { requireActivity().supportFinishAfterTransition() } initialLanguageList = JsonUtil.encodeToString(app.languageState.appLanguageCodes).orEmpty() binding.searchContainer.setOnClickListener { onSearchContainerClick() } binding.searchLangButton.setOnClickListener { onLangButtonClick() } initSearchView() + if (invokeSource == InvokeSource.PLACES) { + PlacesEvent.logImpression("search_view") + } return binding.root } @@ -121,6 +129,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche setUpLanguageScroll(Prefs.selectedLanguagePositionInSearch) startSearch(query, langBtnClicked) binding.searchCabView.setCloseButtonVisibility(query) + recentSearchesFragment.binding.namespacesContainer.isVisible = invokeSource != InvokeSource.PLACES if (!query.isNullOrEmpty()) { showPanel(PANEL_SEARCH_RESULTS) } @@ -194,20 +203,24 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche binding.searchCabView.setQuery(text, false) } - override fun navigateToTitle(item: PageTitle, inNewTab: Boolean, position: Int) { + override fun navigateToTitle(item: PageTitle, inNewTab: Boolean, position: Int, location: Location?) { if (!isAdded) { return } if (returnLink) { + if (invokeSource == InvokeSource.PLACES) { + PlacesEvent.logAction("search_result_click", "search_view") + } val intent = Intent().putExtra(SearchActivity.EXTRA_RETURN_LINK_TITLE, item) + .putExtra(PlacesActivity.EXTRA_LOCATION, location) requireActivity().setResult(SearchActivity.RESULT_LINK_SUCCESS, intent) requireActivity().finish() } else { val historyEntry = HistoryEntry(item, HistoryEntry.SOURCE_SEARCH) startActivity(if (inNewTab) PageActivity.newIntentForNewTab(requireContext(), historyEntry, historyEntry.title) else PageActivity.newIntentForCurrentTab(requireContext(), historyEntry, historyEntry.title, false)) - closeSearch() } + closeSearch() } override fun onSearchAddPageToList(entry: HistoryEntry, addToDefault: Boolean) { @@ -253,7 +266,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche return@postDelayed } searchResultsFragment.startSearch(term, force) - }, if (invokeSource == InvokeSource.VOICE || invokeSource == InvokeSource.INTENT_SHARE || invokeSource == InvokeSource.INTENT_PROCESS_TEXT) INTENT_DELAY_MILLIS else 0) + }, if (invokeSource == InvokeSource.PLACES || invokeSource == InvokeSource.VOICE || invokeSource == InvokeSource.INTENT_SHARE || invokeSource == InvokeSource.INTENT_PROCESS_TEXT) INTENT_DELAY_MILLIS else 0) } private fun openSearch() { @@ -300,6 +313,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche binding.searchCabView.setOnCloseListener(searchCloseListener) binding.searchCabView.setSearchHintTextColor(ResourceUtil.getThemedColor(requireContext(), R.attr.secondary_color)) + binding.searchCabView.queryHint = getString(if (invokeSource == InvokeSource.PLACES) R.string.places_search_hint else R.string.search_hint) // remove focus line from search plate val searchEditPlate = binding.searchCabView @@ -309,7 +323,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche private fun initLangButton() { binding.searchLangButton.setLangCode(app.languageState.appLanguageCode.uppercase(Locale.ENGLISH)) - FeedbackUtil.setButtonLongPressToast(binding.searchLangButton) + FeedbackUtil.setButtonTooltip(binding.searchLangButton) } private fun addRecentSearch(title: String?) { diff --git a/app/src/main/java/org/wikipedia/search/SearchResult.kt b/app/src/main/java/org/wikipedia/search/SearchResult.kt index ef29ebcb9ce..f481cc1cdc2 100644 --- a/app/src/main/java/org/wikipedia/search/SearchResult.kt +++ b/app/src/main/java/org/wikipedia/search/SearchResult.kt @@ -1,5 +1,6 @@ package org.wikipedia.search +import android.location.Location import kotlinx.serialization.Serializable import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryPage @@ -8,17 +9,26 @@ import org.wikipedia.page.PageTitle @Serializable data class SearchResult(val pageTitle: PageTitle, val redirectFrom: String?, - val type: SearchResultType) { + val type: SearchResultType, + val coordinates: List? = null) { @Serializable enum class SearchResultType { SEARCH, HISTORY, READING_LIST, TAB_LIST } - constructor(page: MwQueryPage, wiki: WikiSite) : this(PageTitle(page.title, + constructor(page: MwQueryPage, wiki: WikiSite, coordinates: List? = null) : this(PageTitle(page.title, wiki, page.thumbUrl(), page.description, page.displayTitle(wiki.languageCode)), - page.redirectFrom, SearchResultType.SEARCH) + page.redirectFrom, SearchResultType.SEARCH, coordinates) constructor(pageTitle: PageTitle, searchResultType: SearchResultType) : this(pageTitle, null, searchResultType) + + val location: Location? get() { + return if (coordinates.isNullOrEmpty()) null else + Location("").also { + it.latitude = coordinates[0].lat + it.longitude = coordinates[0].lon + } + } } diff --git a/app/src/main/java/org/wikipedia/search/SearchResults.kt b/app/src/main/java/org/wikipedia/search/SearchResults.kt index b02b6a3fb2f..2092c686fdf 100644 --- a/app/src/main/java/org/wikipedia/search/SearchResults.kt +++ b/app/src/main/java/org/wikipedia/search/SearchResults.kt @@ -10,7 +10,7 @@ data class SearchResults constructor(var results: MutableList = mu var continuation: MwQueryResponse.Continuation? = null) { constructor(pages: List, wiki: WikiSite, continuation: MwQueryResponse.Continuation?) : this() { // Sort the array based on the "index" property - results.addAll(pages.sortedBy { it.index }.map { SearchResult(it, wiki) }) + results.addAll(pages.sortedBy { it.index }.map { SearchResult(it, wiki, it.coordinates) }) this.continuation = continuation } } diff --git a/app/src/main/java/org/wikipedia/search/SearchResultsFragment.kt b/app/src/main/java/org/wikipedia/search/SearchResultsFragment.kt index 5fcf2a268ec..472890c2064 100644 --- a/app/src/main/java/org/wikipedia/search/SearchResultsFragment.kt +++ b/app/src/main/java/org/wikipedia/search/SearchResultsFragment.kt @@ -1,5 +1,6 @@ package org.wikipedia.search +import android.location.Location import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -17,10 +18,12 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import org.wikipedia.Constants import org.wikipedia.LongPressHandler import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.FragmentUtil.getCallback +import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.databinding.FragmentSearchResultsBinding import org.wikipedia.databinding.ItemSearchNoResultsBinding import org.wikipedia.databinding.ItemSearchResultBinding @@ -39,7 +42,7 @@ class SearchResultsFragment : Fragment() { fun onSearchAddPageToList(entry: HistoryEntry, addToDefault: Boolean) fun onSearchMovePageToList(sourceReadingListId: Long, entry: HistoryEntry) fun onSearchProgressBar(enabled: Boolean) - fun navigateToTitle(item: PageTitle, inNewTab: Boolean, position: Int) + fun navigateToTitle(item: PageTitle, inNewTab: Boolean, position: Int, location: Location? = null) fun setSearchText(text: CharSequence) } @@ -81,7 +84,6 @@ class SearchResultsFragment : Fragment() { } } } - return binding.root } @@ -187,6 +189,9 @@ class SearchResultsFragment : Fragment() { private val accentColorStateList = getThemedColorStateList(requireContext(), R.attr.progressive_color) private val secondaryColorStateList = getThemedColorStateList(requireContext(), R.attr.secondary_color) fun bindItem(position: Int, resultsCount: Int) { + if (resultsCount == 0 && viewModel.invokeSource == Constants.InvokeSource.PLACES) { + PlacesEvent.logAction("no_results_impression", "search_view") + } val langCode = WikipediaApp.instance.languageState.appLanguageCodes[position] itemBinding.resultsText.text = if (resultsCount == 0) getString(R.string.search_results_count_zero) else resources.getQuantityString(R.plurals.search_results_count, resultsCount, resultsCount) itemBinding.resultsText.setTextColor(if (resultsCount == 0) secondaryColorStateList else accentColorStateList) @@ -231,7 +236,7 @@ class SearchResultsFragment : Fragment() { view.isLongClickable = true view.setOnClickListener { - callback()?.navigateToTitle(searchResult.pageTitle, false, position) + callback()?.navigateToTitle(searchResult.pageTitle, false, position, searchResult.location) } view.setOnCreateContextMenuListener(LongPressHandler(view, HistoryEntry.SOURCE_SEARCH, SearchResultsFragmentLongPressHandler(position), pageTitle)) @@ -242,6 +247,10 @@ class SearchResultsFragment : Fragment() { return getCallback(this, Callback::class.java) } + fun setInvokeSource(invokeSource: Constants.InvokeSource) { + viewModel.invokeSource = invokeSource + } + private val searchLanguageCode get() = if (isAdded) (requireParentFragment() as SearchFragment).searchLanguageCode else WikipediaApp.instance.languageState.appLanguageCode } diff --git a/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt b/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt index 7f3b5b8b117..10dccdd32ed 100644 --- a/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt +++ b/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt @@ -2,10 +2,21 @@ package org.wikipedia.search import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.paging.* -import kotlinx.coroutines.* +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.map +import kotlinx.coroutines.withContext +import org.wikipedia.Constants import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory @@ -21,10 +32,11 @@ class SearchResultsViewModel : ViewModel() { var resultsCount = mutableListOf() var searchTerm: String? = null var languageCode: String? = null + lateinit var invokeSource: Constants.InvokeSource @OptIn(FlowPreview::class) // TODO: revisit if the debounce method changed. val searchResultsFlow = Pager(PagingConfig(pageSize = batchSize, initialLoadSize = batchSize)) { - SearchResultsPagingSource(searchTerm, languageCode, resultsCount, totalResults) + SearchResultsPagingSource(searchTerm, languageCode, resultsCount, totalResults, invokeSource) }.flow.debounce(delayMillis).map { pagingData -> pagingData.filter { searchResult -> totalResults.find { it.pageTitle.prefixedText == searchResult.pageTitle.prefixedText } == null @@ -43,7 +55,8 @@ class SearchResultsViewModel : ViewModel() { private val searchTerm: String?, private val languageCode: String?, private var resultsCount: MutableList?, - private var totalResults: MutableList? + private var totalResults: MutableList?, + private var invokeSource: Constants.InvokeSource ) : PagingSource() { private var prefixSearch = true @@ -59,7 +72,7 @@ class SearchResultsViewModel : ViewModel() { var response: MwQueryResponse? = null val resultList = mutableListOf() if (prefixSearch) { - if (searchTerm.length > 2) { + if (searchTerm.length > 2 && invokeSource != Constants.InvokeSource.PLACES) { withContext(Dispatchers.IO) { listOf(async { getSearchResultsFromTabs(searchTerm) @@ -78,7 +91,9 @@ class SearchResultsViewModel : ViewModel() { } resultList.addAll(response?.query?.pages?.let { list -> - list.sortedBy { it.index }.map { SearchResult(it, wikiSite) } + (if (invokeSource == Constants.InvokeSource.PLACES) + list.filter { it.coordinates != null } else list).sortedBy { it.index } + .map { SearchResult(it, wikiSite, it.coordinates) } } ?: emptyList()) if (resultList.size < params.loadSize) { @@ -87,7 +102,9 @@ class SearchResultsViewModel : ViewModel() { continuation = response.continuation?.gsroffset resultList.addAll(response.query?.pages?.let { list -> - list.sortedBy { it.index }.map { SearchResult(it, wikiSite) } + (if (invokeSource == Constants.InvokeSource.PLACES) + list.filter { it.coordinates != null } else list).sortedBy { it.index } + .map { SearchResult(it, wikiSite, it.coordinates) } } ?: emptyList()) } diff --git a/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt b/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt index 3ca1f854505..e87c0651a6f 100644 --- a/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt +++ b/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt @@ -3,6 +3,7 @@ package org.wikipedia.settings import android.app.Activity import android.content.Context import android.util.AttributeSet +import android.view.View import android.widget.Button import android.widget.TextView import androidx.preference.Preference @@ -10,7 +11,9 @@ import androidx.preference.PreferenceViewHolder import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.activity.SingleWebViewActivity import org.wikipedia.auth.AccountUtil +import org.wikipedia.util.StringUtil @Suppress("unused") class LogoutPreference : Preference { @@ -28,7 +31,7 @@ class LogoutPreference : Preference { super.onBindViewHolder(holder) holder.itemView.isClickable = false holder.itemView.findViewById(R.id.accountName).text = AccountUtil.userName - holder.itemView.findViewById