diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e9136bb4b92..1bb14271ea8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -3,4 +3,4 @@ # For more information: https://docs.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners -* @cooltey @dbrant @sharvaniharan +* @cooltey @dbrant @Williamrai diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..033f2f3f4c0 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,25 @@ +# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "daily" + registries: "*" + labels: [ "dependencies" ] + groups: + kotlin-ksp: + patterns: + - "org.jetbrains.kotlin:*" + - "org.jetbrains.kotlin.jvm" + - "com.google.devtools.ksp" + open-pull-requests-limit: 20 +registries: + maven-google: + type: "maven-repository" + url: "https://maven.google.com" + replaces-base: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..96272d6714c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,8 @@ +### What does this do? + + +### Why is this needed? + + +**Phabricator:** +https://phabricator.wikimedia.org/T... diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 2dd0d5130a4..7558cf1faa6 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -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.13.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 29be6c00b28..427f9b29758 100644 --- a/.github/workflows/android_branch.yml +++ b/.github/workflows/android_branch.yml @@ -21,7 +21,7 @@ jobs: run: ./gradlew clean assembleAlphaRelease - name: List run: ls -alR ./app/build/outputs/apk/ - - uses: r0adkll/sign-android-release@v1 + - uses: kevin-david/zipalign-sign-android-release@v2 name: Sign APK id: build_signed with: @@ -31,7 +31,7 @@ jobs: keyStorePassword: ${{ secrets.KEY_STORE_PASSWORD }} keyPassword: ${{ secrets.KEY_PASSWORD }} env: - # override default build-tools version (29.0.3) -- optional + # override default build-tools version (33.0.0) -- optional BUILD_TOOLS_VERSION: "34.0.0" - uses: actions/upload-artifact@v4 name: Upload APK artifact diff --git a/.github/workflows/android_phab.yml b/.github/workflows/android_phab.yml index e27ccfe91ca..0b69c34d8b0 100644 --- a/.github/workflows/android_phab.yml +++ b/.github/workflows/android_phab.yml @@ -21,4 +21,5 @@ jobs: -d transactions[0][type]=comment \ -d transactions[0][value]="${message}" \ -d objectIdentifier=${line} + sleep 10 done diff --git a/.github/workflows/android_pr.yml b/.github/workflows/android_pr.yml index bf5855f1d79..a33f7f9e93a 100644 --- a/.github/workflows/android_pr.yml +++ b/.github/workflows/android_pr.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: gradle/wrapper-validation-action@v2 + - uses: gradle/actions/wrapper-validation@v4 - uses: actions/setup-java@v4 with: distribution: 'temurin' diff --git a/.gitignore b/.gitignore index cc865eed048..6ceecf6cbdd 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,8 @@ scripts/backup.ab # miscellaneous all-apks.sh device*.png +*.salive +*keystore* + +# Log Files +*.log \ No newline at end of file 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 09c43f7e58d..824f88efa21 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,7 +20,7 @@ static def computeVersionName(versionCode, label) { final JavaVersion JAVA_VERSION = JavaVersion.VERSION_17 android { - compileSdk 34 + compileSdk 35 compileOptions { coreLibraryDesugaringEnabled true @@ -36,8 +36,8 @@ android { defaultConfig { applicationId 'org.wikipedia' minSdk 21 - targetSdk 34 - versionCode 50479 + targetSdk 35 + versionCode 50515 testApplicationId 'org.wikipedia.test' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments clearPackageData: 'true' @@ -71,11 +71,10 @@ android { 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()) @@ -172,105 +171,98 @@ dependencies { // use http://gradleplease.appspot.com/ or http://search.maven.org/. // Debug with ./gradlew -q app:dependencies --configuration compile - String okHttpVersion = '4.12.0' - String retrofitVersion = '2.9.0' - String glideVersion = '4.16.0' - String mockitoVersion = '5.2.0' - String leakCanaryVersion = '2.13' - String kotlinCoroutinesVersion = '1.7.3' - String firebaseMessagingVersion = '23.4.1' - String mlKitVersion = '17.0.5' - String roomVersion = "2.6.1" - String espressoVersion = '3.5.1' - String serialization_version = '1.6.2' - String metricsVersion = '2.4' - - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' - - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlinCoroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinCoroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$serialization_version" - - 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.constraintlayout:constraintlayout:2.1.4' - implementation "androidx.fragment:fragment-ktx:1.6.2" - implementation "androidx.paging:paging-runtime-ktx:3.2.1" - implementation "androidx.palette:palette-ktx:1.0.0" - implementation "androidx.preference:preference-ktx:1.2.1" - implementation "androidx.recyclerview:recyclerview:1.3.2" - implementation "androidx.viewpager2:viewpager2:1.0.0" - implementation 'com.google.android.flexbox:flexbox:3.0.0' - implementation 'com.android.installreferrer:installreferrer:2.2' - implementation 'androidx.drawerlayout:drawerlayout:1.2.0' - implementation 'androidx.work:work-runtime-ktx:2.9.0' - implementation "org.wikimedia.metrics:metrics-platform:$metricsVersion" - - implementation ('com.github.michael-rapp:chrome-like-tab-switcher:0.4.6') { - exclude group: 'org.jetbrains' - } - - implementation "com.github.bumptech.glide:glide:$glideVersion" - 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" - implementation "io.reactivex.rxjava3:rxjava:3.1.8" - implementation "io.reactivex.rxjava3:rxandroid:3.0.2" - implementation 'org.apache.commons:commons-lang3:3.14.0' - implementation 'org.jsoup:jsoup:1.17.2' - implementation 'com.github.chrisbanes:PhotoView:2.3.0' - 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.2.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" - implementation("androidx.room:room-ktx:$roomVersion") - implementation "androidx.room:room-rxjava3:$roomVersion" + coreLibraryDesugaring libs.desugar.jdk.libs + + implementation libs.kotlin.stdlib.jdk8 + implementation libs.kotlinx.coroutines.core + implementation libs.kotlinx.coroutines.android + implementation libs.kotlinx.serialization.json + + implementation libs.material + implementation libs.appcompat + implementation libs.core.ktx + implementation libs.browser + implementation libs.constraintlayout + implementation libs.fragment.ktx + implementation libs.paging.runtime.ktx + implementation libs.palette.ktx + implementation libs.preference.ktx + implementation libs.recyclerview + implementation libs.viewpager2 + implementation libs.flexbox + implementation libs.drawerlayout + implementation libs.swiperefreshlayout + implementation libs.work.runtime.ktx + implementation libs.metrics.platform + + implementation libs.glide + implementation libs.okhttp3.integration + ksp libs.glide.ksp + + implementation libs.okhttp.tls + implementation libs.okhttp3.logging.interceptor + implementation libs.retrofit + implementation libs.commons.lang3 + implementation libs.jsoup + implementation libs.photoview + implementation libs.balloon + implementation libs.retrofit2.kotlinx.serialization.converter + + implementation libs.android.sdk + implementation libs.android.plugin.annotation.v9 + + implementation libs.androidx.room.runtime + annotationProcessor libs.androidx.room.compiler + ksp libs.androidx.room.compiler + implementation libs.androidx.room.ktx // For language detection during editing - prodImplementation "com.google.mlkit:language-id:$mlKitVersion" - betaImplementation "com.google.mlkit:language-id:$mlKitVersion" - alphaImplementation "com.google.mlkit:language-id:$mlKitVersion" - devImplementation "com.google.mlkit:language-id:$mlKitVersion" - customImplementation "com.google.mlkit:language-id:$mlKitVersion" + prodImplementation libs.com.google.mlkit.language.id + betaImplementation libs.com.google.mlkit.language.id + alphaImplementation libs.com.google.mlkit.language.id + devImplementation libs.com.google.mlkit.language.id + customImplementation libs.com.google.mlkit.language.id // For receiving push notifications for logged-in users. - prodImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" - betaImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" - alphaImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" - devImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" - customImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" - - 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 "com.squareup.okhttp3:okhttp:$okHttpVersion" - testImplementation "com.squareup.okhttp3:mockwebserver:$okHttpVersion" - testImplementation 'org.hamcrest:hamcrest:2.2' - testImplementation "androidx.room:room-testing:$roomVersion" - - androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion" - androidTestImplementation "androidx.test.espresso:espresso-contrib:$espressoVersion" - 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.3.0' - androidTestImplementation "androidx.room:room-testing:$roomVersion" - androidTestUtil 'androidx.test:orchestrator:1.4.2' + prodImplementation libs.com.google.firebase.firebase.messaging.ktx3 + betaImplementation libs.com.google.firebase.firebase.messaging.ktx3 + alphaImplementation libs.com.google.firebase.firebase.messaging.ktx3 + devImplementation libs.com.google.firebase.firebase.messaging.ktx3 + customImplementation libs.com.google.firebase.firebase.messaging.ktx3 + + // For integrating with Google Pay for donations + prodImplementation libs.com.google.android.gms.play.services.wallet2 + betaImplementation libs.com.google.android.gms.play.services.wallet2 + alphaImplementation libs.com.google.android.gms.play.services.wallet2 + devImplementation libs.com.google.android.gms.play.services.wallet2 + customImplementation libs.com.google.android.gms.play.services.wallet2 + + // For InstallReferrer Library + prodImplementation libs.installreferrer + betaImplementation libs.installreferrer + alphaImplementation libs.installreferrer + devImplementation libs.installreferrer + customImplementation libs.installreferrer + + debugImplementation libs.leakcanary.android + implementation libs.plumber.android + + testImplementation libs.junit + testImplementation libs.mockito.inline + testImplementation libs.robolectric + testImplementation libs.okhttp3.okhttp + testImplementation libs.mockwebserver + testImplementation libs.hamcrest + testImplementation libs.room.testing + + androidTestImplementation libs.espresso.core + androidTestImplementation libs.espresso.contrib + androidTestImplementation libs.androidx.espresso.intents + androidTestImplementation libs.espresso.web + androidTestImplementation libs.androidx.junit + androidTestImplementation libs.androidx.uiautomator + androidTestImplementation libs.room.testing + androidTestUtil libs.androidx.orchestrator } private setSigningConfigKey(config, Properties props) { diff --git a/app/lint.xml b/app/lint.xml index 2e4fb49d272..636987862e3 100644 --- a/app/lint.xml +++ b/app/lint.xml @@ -25,4 +25,5 @@ + \ No newline at end of file diff --git a/app/src/androidTest/java/org/wikipedia/EspressoLogger.kt b/app/src/androidTest/java/org/wikipedia/EspressoLogger.kt new file mode 100644 index 00000000000..b9e9ce6663d --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/EspressoLogger.kt @@ -0,0 +1,11 @@ +package org.wikipedia + +import android.util.Log + +object EspressoLogger { + private const val TAG = "EspressoError" + + fun logError(message: String) { + Log.e(TAG, message) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/FakeData.kt b/app/src/androidTest/java/org/wikipedia/FakeData.kt new file mode 100644 index 00000000000..ed1323dd2a9 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/FakeData.kt @@ -0,0 +1,24 @@ +package org.wikipedia + +import android.location.Location +import android.net.Uri +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.history.HistoryEntry +import org.wikipedia.page.PageTitle + +object FakeData { + val site = WikiSite( + uri = Uri.parse("https://en.wikipedia.org") + ) + val title = PageTitle( + _displayText = "Hopf_fibration", + _text = "Hopf fibration", + description = "Fiber bundle of the 3-sphere over the 2-sphere, with 1-spheres as fibers", + thumbUrl = "https://upload.wikimedia.org/wikipedia/commons/thumb/b/b9/Hopf_Fibration.png/320px-Hopf_Fibration.png", + wikiSite = site + ) + val inNewTab = false + val position = 0 + val location: Location? = null + val historyEntry = HistoryEntry(title, HistoryEntry.SOURCE_SEARCH) +} diff --git a/app/src/androidTest/java/org/wikipedia/TestConstants.kt b/app/src/androidTest/java/org/wikipedia/TestConstants.kt new file mode 100644 index 00000000000..40b976c5b45 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/TestConstants.kt @@ -0,0 +1,13 @@ +package org.wikipedia + +object TestConstants { + const val FEATURED_ARTICLE = "Featured article" + const val TODAY_ON_WIKIPEDIA_MAIN_PAGE = "Today on Wikipedia" + const val TOP_READ_ARTICLES = "Top read" + const val PICTURE_OF_DAY = "Picture of the day" + const val BECAUSE_YOU_READ = "Because you read" + const val NEWS_CARD = "In the news" + const val ON_THIS_DAY_CARD = "On this day" + const val RANDOM_CARD = "Random article" + const val SUGGESTED_EDITS = "Suggested edits" +} diff --git a/app/src/androidTest/java/org/wikipedia/TestLogRule.kt b/app/src/androidTest/java/org/wikipedia/TestLogRule.kt new file mode 100644 index 00000000000..88c533d6d57 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/TestLogRule.kt @@ -0,0 +1,33 @@ +package org.wikipedia + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +class TestLogRule : TestRule { + override fun apply(base: Statement, description: Description): Statement { + return object : Statement() { + override fun evaluate() { + try { + base.evaluate() + } catch (t: Throwable) { + val locationErrorLog = t.stackTrace + .filter { it.className.contains("org.wikipedia") } + .take(3) + .joinToString("\n") { + "\u2551 at ${it.fileName}:${it.lineNumber} --> ${it.methodName}()" + } + val errorLog = buildString { + appendLine("════ TEST FAILURE ════") + append(locationErrorLog) + appendLine("Stack Trace: ") + append(t.localizedMessage) + appendLine("\n══════════════════════") + } + EspressoLogger.logError(errorLog) + throw t + } + } + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/base/AssertJavascriptAction.kt b/app/src/androidTest/java/org/wikipedia/base/AssertJavascriptAction.kt new file mode 100644 index 00000000000..287bd4132ba --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/base/AssertJavascriptAction.kt @@ -0,0 +1,55 @@ +package org.wikipedia.base + +import android.view.View +import android.webkit.ValueCallback +import android.webkit.WebView +import androidx.test.espresso.PerformException +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.espresso.util.HumanReadables +import org.hamcrest.Matcher +import java.util.concurrent.atomic.AtomicBoolean + +class AssertJavascriptAction(val script: String, val expectedResult: String) : ViewAction, ValueCallback { + private var result: String? = null + private val evaluateFinished = AtomicBoolean(false) + private val exception = PerformException.Builder() + .withActionDescription(this.description) + + override fun getConstraints(): Matcher { + return isAssignableFrom(WebView::class.java) + } + + override fun getDescription(): String { + return "Evaluate Javascript" + } + + override fun perform(uiController: UiController, view: View) { + uiController.loopMainThreadUntilIdle() + + val webView = view as WebView + exception.withViewDescription(HumanReadables.describe(view)) + + webView.evaluateJavascript(script, this) + + val maxTime = System.currentTimeMillis() + 5000 + while (!evaluateFinished.get()) { + if (System.currentTimeMillis() > maxTime) { + throw exception + .withCause(RuntimeException("Evaluating Javascript timed out.")) + .build() + } + uiController.loopMainThreadForAtLeast(50) + } + } + + override fun onReceiveValue(value: String) { + evaluateFinished.set(true) + if (value != expectedResult) { + throw exception + .withCause(RuntimeException("Expected: $expectedResult, but got: $value")) + .build() + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/base/BaseRobot.kt b/app/src/androidTest/java/org/wikipedia/base/BaseRobot.kt new file mode 100644 index 00000000000..040a5c124b6 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/base/BaseRobot.kt @@ -0,0 +1,522 @@ +package org.wikipedia.base + +import android.app.Activity +import android.graphics.Rect +import android.util.Log +import android.view.View +import android.widget.HorizontalScrollView +import android.widget.ListView +import android.widget.ScrollView +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.annotation.IdRes +import androidx.core.widget.NestedScrollView +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.Espresso.pressBack +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.ViewAssertion +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.action.ViewActions.swipeLeft +import androidx.test.espresso.action.ViewActions.swipeRight +import androidx.test.espresso.action.ViewActions.swipeUp +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.contrib.RecyclerViewActions.scrollToPosition +import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.RootMatchers.isDialog +import androidx.test.espresso.matcher.RootMatchers.withDecorView +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.espresso.web.assertion.WebViewAssertions +import androidx.test.espresso.web.sugar.Web.onWebView +import androidx.test.espresso.web.webdriver.DriverAtoms +import androidx.test.espresso.web.webdriver.DriverAtoms.findElement +import androidx.test.espresso.web.webdriver.DriverAtoms.webClick +import androidx.test.espresso.web.webdriver.Locator +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.not +import org.hamcrest.TypeSafeMatcher +import org.wikipedia.R +import org.wikipedia.TestUtil +import org.wikipedia.TestUtil.waitOnId +import java.util.concurrent.TimeUnit + +abstract class BaseRobot { + + protected fun clickOnViewWithIdAndContainsString(@IdRes viewId: Int, text: String) { + onView(allOf( + withId(viewId), + hasText(text), + )).perform(scrollAndClick()) + } + + protected fun clickOnViewWithId(@IdRes viewId: Int) { + onView(withId(viewId)).perform(click()) + } + + protected fun clickOnDisplayedView(@IdRes viewId: Int) { + onView(allOf(withId(viewId), isDisplayed())).perform(click()) + } + + protected fun clicksOnDisplayedViewWithText(@IdRes viewId: Int, text: String) { + onView(allOf(withId(viewId), withText(text), isDisplayed())).perform(click()) + } + + protected fun clickOnDisplayedViewWithContentDescription(description: String) { + onView(allOf(withContentDescription(description), isDisplayed())).perform(click()) + } + + protected fun clickOnDisplayedViewWithIdAnContentDescription( + @IdRes viewId: Int, + description: String + ) { + onView(allOf(withId(viewId), withContentDescription(description), isDisplayed())).perform( + click() + ) + } + + protected fun clickOnViewWithText(text: String) { + onView(withText(text)).perform(click()) + } + + protected fun typeTextInView(@IdRes viewId: Int, text: String) { + onView(allOf(withId(viewId), isDisplayed())) + .perform(replaceText(text), closeSoftKeyboard()) + } + + protected fun clickOnItemInList(@IdRes listId: Int, position: Int) { + onView(withId(listId)) + .perform( + RecyclerViewActions.actionOnItemAtPosition( + position, + click() + ) + ) + } + + protected fun scrollToView(@IdRes viewId: Int) { + onView(withId(viewId)).perform(scrollTo()) + } + + protected fun scrollToViewAndClick(@IdRes viewId: Int) { + onView(withId(viewId)).perform(scrollTo(), click()) + } + + protected fun scrollToTextAndClick(text: String) { + onView(allOf(withText(text))).perform(scrollTo(), click()) + } + + protected fun checkViewExists(@IdRes viewId: Int) { + onView(withId(viewId)).check(matches(isDisplayed())) + } + + protected fun checkViewDoesNotExist(@IdRes viewId: Int) { + onView(withId(viewId)).check(matches(not(isDisplayed()))) + } + + protected fun checkViewWithTextDisplayed(text: String) { + onView(withText(text)).check(matches(isDisplayed())) + } + + protected fun checkTextDoesNotExist(text: String) { + onView(withText(text)).check(matches(not(isDisplayed()))) + } + + protected fun checkViewWithIdDisplayed(@IdRes viewId: Int) { + onView(withId(viewId)).check(matches(isDisplayed())) + } + + protected fun isViewWithTextVisible(text: String): Boolean { + var isDisplayed = false + onView(withText(text)).check { view, noViewFoundException -> + isDisplayed = noViewFoundException == null && view.isShown + } + return isDisplayed + } + + protected fun isViewWithIdDisplayed(@IdRes viewId: Int): Boolean { + var isDisplayed = false + onView(withId(viewId)).check { view, noViewFoundException -> + isDisplayed = noViewFoundException == null && view.isShown + } + return isDisplayed + } + + protected fun checkViewWithIdAndText(@IdRes viewId: Int, text: String) { + onView(allOf(withId(viewId), withText(text))).check(matches(isDisplayed())) + } + + protected fun delay(seconds: Long) { + onView(isRoot()).perform(waitOnId(TimeUnit.SECONDS.toMillis(seconds))) + } + + protected fun checkWithTextIsDisplayed(@IdRes viewId: Int, text: String) { + onView(allOf(withId(viewId), withText(text), isDisplayed())) + .check(matches(withText(text))) + } + + protected fun checkIfViewIsDisplayingText(@IdRes viewId: Int, text: String) { + onView(allOf(withId(viewId), isDisplayed())) + .check(matches(withText(text))) + } + + protected fun swipeLeft(@IdRes viewId: Int) { + onView(withId(viewId)).perform(swipeLeft()) + } + + protected fun swipeRight(@IdRes viewId: Int) { + onView(withId(viewId)).perform(swipeRight()) + } + + protected fun goBack() { + pressBack() + } + + protected fun clickWebLink(linkTitle: String) = apply { + onWebView() + .withElement(findElement(Locator.CSS_SELECTOR, "a[title='$linkTitle']")) + .perform(webClick()) + } + + protected fun verifyH1Title(expectedTitle: String) = apply { + onWebView() + .withElement(findElement(Locator.CSS_SELECTOR, "h1")) + .check( + WebViewAssertions.webMatches( + DriverAtoms.getText(), + Matchers.`is`(expectedTitle) + ) + ) + } + + protected fun verifyWithMatcher(@IdRes viewId: Int, matcher: Matcher) { + onView(withId(viewId)) + .check(matches(matcher)) + } + + protected fun swipeDownOnTheWebView(@IdRes viewId: Int) { + onView(withId(viewId)).perform(TestUtil.swipeDownWebView()) + delay(TestConfig.DELAY_LARGE) + } + + protected fun performIfDialogShown( + dialogText: String, + action: () -> Unit + ) { + try { + onView(withText(dialogText)) + .inRoot(isDialog()) + .check(matches(isDisplayed())) + action() + } catch (e: Exception) { + // Dialog not shown or text not found + Log.e("error", "") + } + } + + protected fun swipeUp(@IdRes viewId: Int) { + onView(allOf(withId(viewId))).perform(swipeUp()) + } + + protected fun clickRecyclerViewItemAtPosition(@IdRes viewId: Int, position: Int) { + onView(withId(viewId)) + .perform( + RecyclerViewActions.actionOnItemAtPosition( + position, + click() + ) + ) + } + + protected fun scrollToPositionInRecyclerView(@IdRes viewId: Int, position: Int) { + onView(withId(viewId)) + .perform( + RecyclerViewActions.scrollToPosition(position) + ) + } + + protected fun makeViewVisibleAndLongClick(@IdRes viewId: Int, @IdRes parentViewId: Int) { + onView(allOf(withId(viewId), isDescendantOfA(withId(parentViewId)))) + .perform(scrollAndLongClick()) + } + + private fun scrollAndLongClick() = object : ViewAction { + override fun getConstraints(): Matcher { + return isDisplayingAtLeast(10) + } + + override fun getDescription(): String { + return "Scroll item into view and long click" + } + + override fun perform(uiController: UiController, view: View) { + if (!isDisplayingAtLeast(90).matches(view)) { + view.requestRectangleOnScreen( + Rect(0, 0, view.width, view.height), + true + ) + uiController.loopMainThreadForAtLeast(500) + } + + view.performLongClick() + uiController.loopMainThreadForAtLeast(1000) + } + } + + protected fun clickIfVisible(): ViewAction { + return object : ViewAction { + override fun getConstraints(): Matcher { + return isDisplayingAtLeast(90) + } + + override fun getDescription(): String { + return "Click if Visible" + } + + override fun perform(uiController: UiController, view: View) { + if (view.isShown && view.isEnabled) { + view.performClick() + uiController.loopMainThreadForAtLeast(500) + } + } + } + } + + protected fun dismissTooltipIfAny(activity: Activity, @IdRes viewId: Int) = apply { + onView(allOf(withId(viewId))).inRoot(withDecorView(not(Matchers.`is`(activity.window.decorView)))) + .perform(click()) + } + + protected fun scrollToRecyclerViewInsideNestedScrollView(@IdRes recyclerViewId: Int, position: Int, viewAction: ViewAction) { + onView(withId(recyclerViewId)) + .perform(NestedScrollViewExtension()) + onView(withId(recyclerViewId)) + .perform(RecyclerViewActions.actionOnItemAtPosition(position, viewAction)) + } + + protected fun scrollToViewInsideNestedScrollView(@IdRes viewId: Int) { + onView(withId(viewId)).perform(NestedScrollViewExtension()) + } + + protected fun makeViewVisibleAndClick(@IdRes viewId: Int, @IdRes parentViewId: Int) { + onView(allOf(withId(viewId), isDescendantOfA(withId(parentViewId)))) + .perform(scrollAndClick()) + } + + fun scrollToRecyclerView( + recyclerViewId: Int = R.id.feed_view, + title: String, + textViewId: Int = R.id.view_card_header_title, + verticalOffset: Int = 200 + ) = apply { + var currentOccurrence = 0 + onView(withId(recyclerViewId)) + .perform( + RecyclerViewActions.scrollTo( + hasDescendant( + object : BoundedMatcher(View::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("Scroll to Card View with title: $title") + } + + override fun matchesSafely(item: View?): Boolean { + val titleView = item?.findViewById(textViewId) + if (titleView?.text?.toString() == title) { + if (currentOccurrence == 0) { + currentOccurrence++ + return true + } + currentOccurrence++ + } + return false + } + } + ) + ) + ).also { view -> + if (verticalOffset != 0) { + view.perform(object : ViewAction { + override fun getConstraints(): Matcher = + Matchers.any(View::class.java) + + override fun getDescription(): String = "Scroll" + + override fun perform(uiController: UiController, view: View) { + (view as RecyclerView).scrollBy(0, verticalOffset) + uiController.loopMainThreadUntilIdle() + } + }) + } + } + } + + private fun scrollAndClick() = object : ViewAction { + override fun getConstraints(): Matcher { + return isDisplayingAtLeast(10) + } + + override fun getDescription(): String { + return "Scroll item into view and click" + } + + override fun perform(uiController: UiController, view: View) { + if (!isDisplayingAtLeast(90).matches(view)) { + view.requestRectangleOnScreen( + Rect(0, 0, view.width, view.height), + true + ) + uiController.loopMainThreadForAtLeast(500) + } + + view.performClick() + uiController.loopMainThreadForAtLeast(1000) + } + } + + protected fun verifyTextViewColor( + @IdRes textViewId: Int, + @ColorRes colorResId: Int + ) { + onView(withId(textViewId)) + .check(ColorAssertions.hasColor(colorResId, ColorAssertions.ColorType.TextColor)) + } + + protected fun verifyBackgroundColor( + @IdRes viewId: Int, + @ColorRes colorResId: Int + ) { + onView(withId(viewId)) + .check(ColorAssertions.hasColor(colorResId, ColorAssertions.ColorType.BackgroundColor)) + } + + protected fun verifyTintColor( + @IdRes viewId: Int, + colorResOrAttr: Int, + isAttr: Boolean = false + ) { + onView(withId(viewId)) + .check((matches(ColorMatchers.withTintColor(colorResOrAttr, isAttr)))) + } + + protected fun checkRTLDirectionOfAView(@IdRes viewId: Int) { + onView(withId(viewId),) + .check(matches(isLayoutDirectionRTL())) + } + + protected fun checkRTLDirectionOfRecyclerViewItem(@IdRes recyclerViewId: Int) { + onView(withId(recyclerViewId)) + .perform(scrollToPosition(0)) + .check(matches(atPosition(0, isLayoutDirectionRTL()))) + } + + private fun atPosition(position: Int, matcher: Matcher) = object : BoundedMatcher(RecyclerView::class.java) { + override fun describeTo(description: Description) { + description.appendText("has item at position $position") + } + + override fun matchesSafely(recylerView: RecyclerView): Boolean { + val viewHolder = recylerView.findViewHolderForAdapterPosition(position) + ?: return false + return matcher.matches(viewHolder.itemView) + } + } + + private fun isLayoutDirectionRTL() = object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("with layout direction RTL") + } + + override fun matchesSafely(view: View): Boolean { + return view.layoutDirection == View.LAYOUT_DIRECTION_RTL + } + } + + protected fun clickOnSpecificItemInList(@IdRes listId: Int, @IdRes itemId: Int, position: Int) { + onView(withId(listId)) + .perform( + RecyclerViewActions.scrollToPosition(position), + RecyclerViewActions.actionOnItemAtPosition( + position, + clickChildViewWithId(itemId) + ) + ) + } + + protected fun assertColorForChildItemInAList( + @IdRes listId: Int, + @IdRes childItemId: Int, + @ColorRes colorResId: Int, + position: Int, + colorType: ColorAssertions.ColorType = ColorAssertions.ColorType.TextColor + ) { + onView(withId(listId)) + .perform(RecyclerViewActions.scrollToPosition(position)) + .check(matchesAtPosition(position, targetViewId = childItemId, assertion = { view -> + ColorAssertions.hasColor(colorResId, colorType) + .check(view, null) + })) + } + + private fun clickChildViewWithId(@IdRes id: Int) = object : ViewAction { + override fun getConstraints() = null + + override fun getDescription() = "Click on a child view with specified id." + + override fun perform(uiController: UiController, view: View) { + val v = view.findViewById(id) + v?.performClick() + } + } + + private fun matchesAtPosition(position: Int, @IdRes targetViewId: Int, assertion: (View) -> Unit): ViewAssertion { + return ViewAssertion { view, noViewFoundException -> + if (view !is RecyclerView) { + throw IllegalStateException("The asserted view is not RecyclerView") + } + + val itemView = view.findViewHolderForAdapterPosition(position)?.itemView + ?: throw IllegalStateException("No view with id: $targetViewId") + val targetView = itemView.findViewById(targetViewId) + ?: throw IllegalStateException("No view with id: $targetViewId") + assertion(targetView) + } + } + + private fun hasText(text: String) = object : BoundedMatcher(TextView::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("contains text $text") + } + + override fun matchesSafely(item: TextView): Boolean { + return item.text.toString().contains(text, ignoreCase = true) + } + } +} + +class NestedScrollViewExtension(scrollToAction: ViewAction = ViewActions.scrollTo()) : ViewAction by scrollToAction { + override fun getConstraints(): Matcher { + return Matchers.allOf( + ViewMatchers.withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE), + ViewMatchers.isDescendantOfA(Matchers.anyOf( + ViewMatchers.isAssignableFrom(NestedScrollView::class.java), + ViewMatchers.isAssignableFrom(ScrollView::class.java), + ViewMatchers.isAssignableFrom(HorizontalScrollView::class.java), + ViewMatchers.isAssignableFrom(ListView::class.java)))) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/base/BaseTest.kt b/app/src/androidTest/java/org/wikipedia/base/BaseTest.kt new file mode 100644 index 00000000000..cb7af43194a --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/base/BaseTest.kt @@ -0,0 +1,80 @@ +package org.wikipedia.base + +import android.content.Context +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import androidx.test.espresso.IdlingPolicies +import androidx.test.espresso.intent.Intents +import androidx.test.ext.junit.rules.ActivityScenarioRule +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.wikipedia.TestLogRule +import org.wikipedia.settings.Prefs +import java.util.concurrent.TimeUnit + +object TestConfig { + const val DELAY_SHORT = 1L + const val DELAY_MEDIUM = 2L + const val DELAY_LARGE = 5L + const val DELAY_SWIPE_TO_REFRESH = 8L + const val SEARCH_TERM = "hopf fibration" + const val ARTICLE_TITLE = "Hopf fibration" + const val ARTICLE_TITLE_ESPANOL = "Fibración de Hopf" +} + +data class DataInjector( + val isInitialOnboardingEnabled: Boolean = false, + val overrideEditsContribution: Int? = null, + val intentBuilder: (Intent.() -> Unit)? = null +) + +abstract class BaseTest( + activityClass: Class, + dataInjector: DataInjector = DataInjector() +) { + @get:Rule + val testLogRule = TestLogRule() + + @get:Rule + var activityScenarioRule: ActivityScenarioRule + + protected lateinit var activity: T + protected lateinit var device: UiDevice + protected var context: Context = InstrumentationRegistry.getInstrumentation().targetContext + + init { + val intent = Intent(context, activityClass) + activityScenarioRule = ActivityScenarioRule(intent) + Prefs.isInitialOnboardingEnabled = dataInjector.isInitialOnboardingEnabled + dataInjector.overrideEditsContribution?.let { + Prefs.overrideSuggestedEditContribution = it + } + dataInjector.intentBuilder?.let { + val newIntent = Intent(context, activityClass).apply(it) + activityScenarioRule = ActivityScenarioRule(newIntent) + } + } + + @Before + open fun setup() { + Intents.init() + device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + IdlingPolicies.setMasterPolicyTimeout(20, TimeUnit.SECONDS) + activityScenarioRule.scenario.onActivity { + activity = it + } + } + + protected fun setDeviceOrientation(isLandscape: Boolean) { + if (isLandscape) device.setOrientationRight() else device.setOrientationNatural() + Thread.sleep(TestConfig.DELAY_MEDIUM) + } + + @After + open fun tearDown() { + Intents.release() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/base/ColorAssertions.kt b/app/src/androidTest/java/org/wikipedia/base/ColorAssertions.kt new file mode 100644 index 00000000000..65986cf5bc7 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/base/ColorAssertions.kt @@ -0,0 +1,45 @@ +package org.wikipedia.base + +import android.graphics.drawable.ColorDrawable +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.ColorRes +import androidx.core.content.ContextCompat +import androidx.core.widget.ImageViewCompat +import androidx.test.espresso.ViewAssertion +import com.google.android.material.imageview.ShapeableImageView +import org.junit.Assert.assertTrue + +object ColorAssertions { + sealed class ColorType { + data object TextColor : ColorType() + data object BackgroundColor : ColorType() + data object Tint : ColorType() + data object ShapeableImageViewColor : ColorType() + } + + fun hasColor( + @ColorRes colorResId: Int, + colorType: ColorType = ColorType.TextColor + ) = ViewAssertion { view, _ -> + val context = view.context + val expectedColor = ContextCompat.getColor(context, colorResId) + val actualColor = when (colorType) { + ColorType.BackgroundColor -> (view.background as ColorDrawable).color + ColorType.TextColor -> (view as TextView).currentTextColor + ColorType.Tint -> ImageViewCompat.getImageTintList(view as ImageView)?.defaultColor + ColorType.ShapeableImageViewColor -> { + val targetView = (view as ShapeableImageView) + val colorDrawable = targetView.drawable as? ColorDrawable + colorDrawable?.color + } + } + + assertTrue( + "expectedColor: $expectedColor, actualColor: $actualColor", + expectedColor.toHexString() == actualColor?.toHexString() + ) + } + + private fun Int.toHexString() = String.format("#%06x", this) +} diff --git a/app/src/androidTest/java/org/wikipedia/base/ColorMatchers.kt b/app/src/androidTest/java/org/wikipedia/base/ColorMatchers.kt new file mode 100644 index 00000000000..9f53c110f88 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/base/ColorMatchers.kt @@ -0,0 +1,98 @@ +package org.wikipedia.base + +import android.content.Context +import android.util.TypedValue +import android.view.View +import android.widget.ImageView +import android.widget.TextView +import androidx.core.content.ContextCompat +import androidx.core.widget.ImageViewCompat +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.TypeSafeMatcher + +object ColorMatchers { + sealed class ColorTarget { + data object TextColor : ColorTarget() + data object BackgroundColor : ColorTarget() + data object Tint : ColorTarget() + } + + fun withTextColor(colorResOrAttr: Int, isAttr: Boolean = false): Matcher = withColor(colorResOrAttr, isAttr, ColorTarget.TextColor) + + fun withBackgroundColor(colorResOrAttr: Int, isAttr: Boolean = false): Matcher = withColor(colorResOrAttr, isAttr, ColorTarget.BackgroundColor) + + fun withTintColor(colorResOrAttr: Int, isAttr: Boolean = false): Matcher = withColor(colorResOrAttr, isAttr, ColorTarget.Tint) + + private fun withColor( + colorResOrAttr: Int, + isAttr: Boolean = false, + target: ColorTarget + ): Matcher { + return object : TypeSafeMatcher() { + override fun describeTo(description: Description) { + description.appendText("with color ${getColorTargetDescription(target)}: $colorResOrAttr") + } + + override fun matchesSafely(view: View): Boolean { + val expectedColor = resolveColor(view.context, colorResOrAttr, isAttr) + return when (target) { + ColorTarget.BackgroundColor -> matchBackgroundColor(view, expectedColor) + ColorTarget.TextColor -> matchTextColor(view, expectedColor) + ColorTarget.Tint -> matchTintColor(view, expectedColor) + } + } + } + } + + private fun resolveColor(context: Context, colorResOrAttr: Int, isAttr: Boolean): Int { + return if (isAttr) { + val typedValue = TypedValue() + context.theme.resolveAttribute(colorResOrAttr, typedValue, true) + ContextCompat.getColor(context, typedValue.resourceId) + } else { + ContextCompat.getColor(context, colorResOrAttr) + } + } + + private fun matchTextColor(view: View, expectedColor: Int): Boolean { + return when (view) { + is TextView -> view.currentTextColor == expectedColor + else -> false + } + } + + private fun matchBackgroundColor(view: View, expectedColor: Int): Boolean { + val background = view.background + return when { + background != null -> { + val backgroundColor = TypedValue() + view.context.theme.resolveAttribute( + android.R.attr.colorBackground, + backgroundColor, + true + ) + backgroundColor.data == expectedColor + } + else -> false + } + } + + private fun matchTintColor(view: View, expectedColor: Int): Boolean { + return when (view) { + is ImageView -> { + val tintList = ImageViewCompat.getImageTintList(view) + tintList?.defaultColor == expectedColor + } + else -> false + } + } + + private fun getColorTargetDescription(target: ColorTarget): String { + return when (target) { + ColorTarget.BackgroundColor -> "text color" + ColorTarget.TextColor -> "background color" + ColorTarget.Tint -> "tint color" + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/base/TestWikipediaColors.kt b/app/src/androidTest/java/org/wikipedia/base/TestWikipediaColors.kt new file mode 100644 index 00000000000..a66f94a2037 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/base/TestWikipediaColors.kt @@ -0,0 +1,115 @@ +package org.wikipedia.base + +import androidx.annotation.ColorRes +import org.wikipedia.R +import org.wikipedia.theme.Theme + +/** + * Each type represents a specific use case for colors across different themes + */ +enum class TestThemeColorType { + PAPER, + BACKGROUND, + BORDER, + INACTIVE, + PLACEHOLDER, + SECONDARY, + PRIMARY, + PROGRESSIVE, + SUCCESS, + DESTRUCTIVE, + WARNING, + HIGHLIGHT, + FOCUS, + ADDITION, + OVERLAY +} + +object TestWikipediaColors { + + @ColorRes + fun getGetColor(theme: Theme, colorType: TestThemeColorType): Int { + return when (theme) { + Theme.LIGHT -> getLightThemeColor(colorType) + Theme.DARK -> getDarkThemeColor(colorType) + Theme.BLACK -> getBlackThemeColor(colorType) + Theme.SEPIA -> getSepiaThemeColor(colorType) + } + } + + @ColorRes + private fun getLightThemeColor(colorType: TestThemeColorType): Int = when (colorType) { + TestThemeColorType.PAPER -> R.color.white + TestThemeColorType.BACKGROUND -> R.color.gray100 + TestThemeColorType.BORDER -> R.color.gray200 + TestThemeColorType.INACTIVE -> R.color.gray400 + TestThemeColorType.PLACEHOLDER -> R.color.gray500 + TestThemeColorType.SECONDARY -> R.color.gray600 + TestThemeColorType.PRIMARY -> R.color.gray700 + TestThemeColorType.PROGRESSIVE -> R.color.blue600 + TestThemeColorType.SUCCESS -> R.color.green700 + TestThemeColorType.DESTRUCTIVE -> R.color.red700 + TestThemeColorType.WARNING -> R.color.yellow700 + TestThemeColorType.HIGHLIGHT -> R.color.yellow500 + TestThemeColorType.FOCUS -> R.color.orange500 + TestThemeColorType.ADDITION -> R.color.blue300_15 + TestThemeColorType.OVERLAY -> R.color.black_30 + } + + @ColorRes + private fun getDarkThemeColor(colorType: TestThemeColorType): Int = when (colorType) { + TestThemeColorType.PAPER -> R.color.gray700 + TestThemeColorType.BACKGROUND -> R.color.gray675 + TestThemeColorType.BORDER -> R.color.gray650 + TestThemeColorType.INACTIVE -> R.color.gray500 + TestThemeColorType.PLACEHOLDER -> R.color.gray400 + TestThemeColorType.SECONDARY -> R.color.gray300 + TestThemeColorType.PRIMARY -> R.color.gray200 + TestThemeColorType.PROGRESSIVE -> R.color.blue300 + TestThemeColorType.SUCCESS -> R.color.green600 + TestThemeColorType.DESTRUCTIVE -> R.color.red500 + TestThemeColorType.WARNING -> R.color.orange500 + TestThemeColorType.HIGHLIGHT -> R.color.yellow500_40 + TestThemeColorType.FOCUS -> R.color.orange500_50 + TestThemeColorType.ADDITION -> R.color.blue600_30 + TestThemeColorType.OVERLAY -> R.color.black_70 + } + + @ColorRes + private fun getSepiaThemeColor(colorType: TestThemeColorType): Int = when (colorType) { + TestThemeColorType.PAPER -> R.color.beige100 + TestThemeColorType.BACKGROUND -> R.color.beige300 + TestThemeColorType.BORDER -> R.color.beige400 + TestThemeColorType.INACTIVE -> R.color.taupe200 + TestThemeColorType.PLACEHOLDER -> R.color.taupe600 + TestThemeColorType.SECONDARY -> R.color.gray600 + TestThemeColorType.PRIMARY -> R.color.gray700 + TestThemeColorType.PROGRESSIVE -> R.color.blue600 + TestThemeColorType.SUCCESS -> R.color.green700 + TestThemeColorType.DESTRUCTIVE -> R.color.red700 + TestThemeColorType.WARNING -> R.color.yellow700 + TestThemeColorType.HIGHLIGHT -> R.color.yellow500 + TestThemeColorType.FOCUS -> R.color.orange500 + TestThemeColorType.ADDITION -> R.color.blue300_15 + TestThemeColorType.OVERLAY -> R.color.black_30 + } + + @ColorRes + private fun getBlackThemeColor(colorType: TestThemeColorType): Int = when (colorType) { + TestThemeColorType.PAPER -> R.color.black + TestThemeColorType.BACKGROUND -> R.color.gray700 + TestThemeColorType.BORDER -> R.color.gray675 + TestThemeColorType.INACTIVE -> R.color.gray500 + TestThemeColorType.PLACEHOLDER -> R.color.gray400 + TestThemeColorType.SECONDARY -> R.color.gray300 + TestThemeColorType.PRIMARY -> R.color.gray200 + TestThemeColorType.PROGRESSIVE -> R.color.blue300 + TestThemeColorType.SUCCESS -> R.color.green600 + TestThemeColorType.DESTRUCTIVE -> R.color.red500 + TestThemeColorType.WARNING -> R.color.orange500 + TestThemeColorType.HIGHLIGHT -> R.color.yellow500_40 + TestThemeColorType.FOCUS -> R.color.orange500_50 + TestThemeColorType.ADDITION -> R.color.blue600_30 + TestThemeColorType.OVERLAY -> R.color.black_70 + } +} diff --git a/app/src/androidTest/java/org/wikipedia/database/UpgradeFromPreRoomTest.kt b/app/src/androidTest/java/org/wikipedia/database/UpgradeFromPreRoomTest.kt index 85fd270f7a8..9ef956a710a 100644 --- a/app/src/androidTest/java/org/wikipedia/database/UpgradeFromPreRoomTest.kt +++ b/app/src/androidTest/java/org/wikipedia/database/UpgradeFromPreRoomTest.kt @@ -4,7 +4,7 @@ import androidx.room.Room import androidx.room.testing.MigrationTestHelper import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry -import kotlinx.coroutines.flow.count +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.nullValue @@ -37,7 +37,7 @@ class UpgradeFromPreRoomTest(private val fromVersion: Int) { @Before fun createDb() { - val helper = MigrationTestHelper(InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java.canonicalName) + val helper = MigrationTestHelper(InstrumentationRegistry.getInstrumentation(), AppDatabase::class.java) var helperDb = helper.createDatabase(DB_NAME, fromVersion) InstrumentationRegistry.getInstrumentation().context.assets.open("database/wikipedia_v$fromVersion.sql").bufferedReader().lines().forEach { @@ -45,12 +45,12 @@ class UpgradeFromPreRoomTest(private val fromVersion: Int) { } helperDb.close() - helperDb = helper.runMigrationsAndValidate(DB_NAME, 23, true, - AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_22_23) + helperDb = helper.runMigrationsAndValidate(DB_NAME, DATABASE_VERSION, true, + AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.MIGRATION_25_26) helperDb.close() db = Room.databaseBuilder(ApplicationProvider.getApplicationContext(), AppDatabase::class.java, DB_NAME) - .addMigrations(AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_22_23) + .addMigrations(AppDatabase.MIGRATION_19_20, AppDatabase.MIGRATION_20_21, AppDatabase.MIGRATION_21_22, AppDatabase.MIGRATION_22_23, AppDatabase.MIGRATION_23_24, AppDatabase.MIGRATION_24_25, AppDatabase.MIGRATION_25_26) .fallbackToDestructiveMigration() .build() recentSearchDao = db.recentSearchDao() @@ -121,11 +121,12 @@ class UpgradeFromPreRoomTest(private val fromVersion: Int) { val historyEntry = historyDao.findEntryBy("ru.wikipedia.org", "ru", "Обама,_Барак")!! assertThat(historyEntry.displayTitle, equalTo("Обама, Барак")) + val talkPageSeen = talkPageSeenDao.getAll().first() if (fromVersion == 22) { - assertThat(talkPageSeenDao.getAll().count(), equalTo(2)) + assertThat(talkPageSeen.count(), equalTo(2)) assertThat(offlineObjectDao.getOfflineObject("https://en.wikipedia.org/api/rest_v1/page/summary/Joe_Biden")!!.path, equalTo("/data/user/0/org.wikipedia.dev/files/offline_files/481b1ef996728fd9994bd97ab19733d8")) } else { - assertThat(talkPageSeenDao.getAll().count(), equalTo(0)) + assertThat(talkPageSeen.count(), equalTo(0)) assertThat(offlineObjectDao.getOfflineObject("https://en.wikipedia.org/api/rest_v1/page/summary/Joe_Biden"), nullValue()) } } diff --git a/app/src/androidTest/java/org/wikipedia/main/LoggedInTests.kt b/app/src/androidTest/java/org/wikipedia/main/LoggedInTests.kt deleted file mode 100644 index dbadd622088..00000000000 --- a/app/src/androidTest/java/org/wikipedia/main/LoggedInTests.kt +++ /dev/null @@ -1,71 +0,0 @@ -package org.wikipedia.main - -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.action.ViewActions.* -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.* -import androidx.test.ext.junit.rules.ActivityScenarioRule -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.filters.LargeTest -import org.hamcrest.Matchers.allOf -import org.hamcrest.Matchers.`is` -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 - -@LargeTest -@RunWith(AndroidJUnit4::class) -class LoggedInTests { - - @Rule - @JvmField - var mActivityTestRule = ActivityScenarioRule(MainActivity::class.java) - - @Test - fun loggedInTest() { - - // Skip over onboarding screens - onView(allOf(withId(R.id.fragment_onboarding_skip_button), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - // Click the More menu - onView(allOf(withId(R.id.nav_more_container), isDisplayed())) - .perform(click()) - - TestUtil.delay(1) - - // 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) - - // Type in an incorrect username and password - onView(allOf(TestUtil.withGrandparent(withId(R.id.login_username_text)), withClassName(`is`("org.wikipedia.views.PlainPasteEditText")))) - .perform(replaceText(BuildConfig.TEST_LOGIN_USERNAME), closeSoftKeyboard()) - - onView(allOf(TestUtil.withGrandparent(withId(R.id.login_password_input)), withClassName(`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) - - // Verify that a snackbar appears (because the login failed.) - onView(withId(com.google.android.material.R.id.snackbar_text)) - .check(matches(isDisplayed())) - } -} diff --git a/app/src/androidTest/java/org/wikipedia/main/SmokeTests.kt b/app/src/androidTest/java/org/wikipedia/main/SmokeTests.kt deleted file mode 100644 index 5c0e463dfad..00000000000 --- a/app/src/androidTest/java/org/wikipedia/main/SmokeTests.kt +++ /dev/null @@ -1,1242 +0,0 @@ -package org.wikipedia.main - -import android.graphics.Color -import android.os.Build -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.* -import androidx.test.espresso.web.assertion.WebViewAssertions -import androidx.test.espresso.web.sugar.Web.onWebView -import androidx.test.espresso.web.webdriver.DriverAtoms -import androidx.test.espresso.web.webdriver.DriverAtoms.findElement -import androidx.test.espresso.web.webdriver.DriverAtoms.webClick -import androidx.test.espresso.web.webdriver.Locator -import androidx.test.ext.junit.rules.ActivityScenarioRule -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.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.TestUtil.childAtPosition -import org.wikipedia.TestUtil.isDisplayed -import org.wikipedia.navtab.NavTab -import java.util.concurrent.TimeUnit - -@LargeTest -@RunWith(AndroidJUnit4::class) -class SmokeTests { - - @Rule - @JvmField - var mActivityTestRule = ActivityScenarioRule(MainActivity::class.java) - private lateinit var activity: MainActivity - - @Before - fun setActivity() { - mActivityTestRule.scenario.onActivity { - activity = it - } - } - - @Test - fun mainActivityTest() { - val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) - val screenWidth = device.displayWidth - val screenHeight = device.displayHeight - - IdlingPolicies.setMasterPolicyTimeout(20, TimeUnit.SECONDS) - - TestUtil.delay(1) - - // Flip through the initial onboarding screens... - onView(allOf(withId(R.id.fragment_onboarding_forward_button), isDisplayed())) - .perform(click()) - - onView(allOf(withId(R.id.fragment_onboarding_forward_button), isDisplayed())) - .perform(click()) - - onView(allOf(withId(R.id.fragment_onboarding_forward_button), isDisplayed())) - .perform(click()) - - // 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) - - // 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) - - // Click the Search box - onView(allOf(withId(R.id.search_container), isDisplayed())) - .perform(click()) - - TestUtil.delay(1) - - // Type in our search term - onView(allOf(withId(androidx.appcompat.R.id.search_src_text), isDisplayed())) - .perform(replaceText(SEARCH_TERM), closeSoftKeyboard()) - - // Give the API plenty of time to return results - TestUtil.delay(5) - - // Make sure one of the results matches the title that we expect - onView(allOf(withId(R.id.page_list_item_title), withText(ARTICLE_TITLE), isDisplayed())) - .check(matches(withText(ARTICLE_TITLE))) - - // Rotate the device - device.setOrientationRight() - - TestUtil.delay(2) - - // Dismiss keyboard - pressBack() - - TestUtil.delay(1) - - // Make sure the same title appears in the new screen orientation - onView(allOf(withId(R.id.page_list_item_title), withText(ARTICLE_TITLE), isDisplayed())) - .check(matches(withText(ARTICLE_TITLE))) - - // 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())) - - // Give the page plenty of time to load fully - TestUtil.delay(5) - - // Dismiss tooltip - onView(allOf(withId(R.id.buttonView))).inRoot(withDecorView(not(Matchers.`is`(activity.window.decorView)))) - .perform(click()) - - TestUtil.delay(2) - - // Click on a link to load a Link Preview dialog - onWebView().withElement(findElement(Locator.CSS_SELECTOR, "a[title='3-sphere']")) - .perform(webClick()) - - TestUtil.delay(3) - - // Click through the preview to load a new article - onView(allOf(withId(R.id.link_preview_toolbar))) - .perform(click()) - - TestUtil.delay(3) - - // Click on another link - onWebView().withElement(findElement(Locator.CSS_SELECTOR, "a[title='Sphere']")) - .perform(webClick()) - - TestUtil.delay(3) - - // Open it in a new tab - onView(allOf(withId(R.id.link_preview_secondary_button))) - .perform(click()) - - TestUtil.delay(2) - - // Ensure that there are now two tabs - onView(allOf(withId(R.id.tabsCountText))) - .check(matches(withText("2"))) - - // Go back to the original article - pressBack() - - TestUtil.delay(2) - - // Ensure the header view (with lead image) is displayed - onView(allOf(withId(R.id.page_header_view))) - .check(matches(isDisplayed())) - - // Click on the lead image to launch the full-screen gallery - onView(allOf(withId(R.id.view_page_header_image))) - .perform(click()) - - TestUtil.delay(3) - - // 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() - - onWebView().forceJavascriptEnabled() - - // Ensure the article title matches what we expect - onWebView().withElement(findElement(Locator.CSS_SELECTOR, "h1")) - .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 - onView(allOf(withId(R.id.page_header_view))) - .check(matches(TestUtil.isNotVisible())) - - // Make sure the article title still matches what we expect - onWebView().withElement(findElement(Locator.CSS_SELECTOR, "h1")) - .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()) - - TestUtil.delay(2) - - // Switch off the "match system theme" option - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { - onView(withId(R.id.theme_chooser_match_system_theme_switch)) - .check(matches(TestUtil.isNotVisible())) - } else { - onView(withId(R.id.theme_chooser_match_system_theme_switch)) - .perform(scrollTo(), click()) - - TestUtil.delay(1) - } - - // Select the Black theme - onView(withId(R.id.button_theme_black)).perform(scrollTo(), click()) - - TestUtil.delay(2) - - pressBack() - - TestUtil.delay(1) - - // Make sure the background is 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)) - .perform(click()) - - TestUtil.delay(1) - - onView(withId(R.id.button_theme_light)).perform(scrollTo(), click()) - - TestUtil.delay(2) - - pressBack() - - // Click the edit pencil at the top of the article - onWebView().withElement(findElement(Locator.CSS_SELECTOR, "a[data-id='0'].pcs-edit-section-link")) - .perform(webClick()) - - TestUtil.delay(1) - - // Click the "edit introduction" menu item - onView(allOf(withId(R.id.title), withText("Edit introduction"), isDisplayed())) - .perform(click()) - - TestUtil.delay(3) - - // Click on the fonts and theme icon - onView(allOf(withId(R.id.menu_edit_theme), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - // Increase text size by clicking on increase text icon - onView(allOf(withId(R.id.buttonIncreaseTextSize))).perform(scrollTo(), click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.buttonIncreaseTextSize))).perform(scrollTo(), click()) - - TestUtil.delay(2) - - // Exit bottom sheet - pressBack() - - TestUtil.delay(4) - - onView(allOf(withId(R.id.menu_edit_theme), isDisplayed())) - .perform(click()) - - TestUtil.delay(3) - - // Decrease text size by clicking on decrease text icon - onView(allOf(withId(R.id.buttonDecreaseTextSize))).perform(scrollTo(), click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.buttonDecreaseTextSize))).perform(scrollTo(), click()) - - TestUtil.delay(2) - - // Exit bottom sheet - pressBack() - - TestUtil.delay(3) - - // Type in some stuff into the edit window - 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()) - - // 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()) - - TestUtil.delay(3) - - // Click one of the default edit summary choices - 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()) - - TestUtil.delay(1) - - onView(allOf(withContentDescription("Navigate up"), isDisplayed())).perform(click()) - - TestUtil.delay(1) - - 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()) - - TestUtil.delay(1) - - pressBack() - - TestUtil.delay(1) - - // Choose to leave the editing workflow - onView(allOf(withId(android.R.id.button1), withText("Yes"))) - .perform(scrollTo(), click()) - - TestUtil.delay(2) - - // Click on the Tabs button to launch the tabs screen - onView(allOf(withId(R.id.page_toolbar_button_tabs), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - // Create a new tab (which should load the Main Page) - onView(allOf(withContentDescription("New tab"), isDisplayed())) - .perform(click()) - - TestUtil.delay(5) - - // Open the Tabs screen again - onView(allOf(withId(R.id.page_toolbar_button_tabs), isDisplayed())) - .perform(click()) - - TestUtil.delay(2) - - // Click on the previous tab in the list - device.click(screenWidth / 2, screenHeight * 50 / 100) - - TestUtil.delay(2) - - // Swipe down on the WebView to reload the contents - onView(withId(R.id.page_contents_container)) - .perform(TestUtil.swipeDownWebView()) - - TestUtil.delay(5) - - // 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))) - - // Swipe left to show the table of contents - onView(allOf(withId(R.id.page_web_view))) - .perform(swipeLeft()) - - // Make sure the topmost item in the table of contents is the article title - onView(allOf(withId(R.id.page_toc_item_text), withText(ARTICLE_TITLE))) - .check(matches(isDisplayed())) - - // Swipe the table of contents to go all the way to the bottom - onView(allOf(withId(R.id.toc_list))) - .perform(swipeUp()) - - // Select the "About this article" item - onView(allOf(withId(R.id.page_toc_item_text), withText("About this article"))) - .perform(click()) - - TestUtil.delay(2) - - // Go to the Talk page for this article - onWebView().withElement(findElement(Locator.CSS_SELECTOR, "a[title='View talk page']")) - .perform(webClick()) - - TestUtil.delay(4) - - // Click on the 3rd topic - onView(withId(R.id.talkRecyclerView)) - .perform(actionOnItemAtPosition(2, click())) - - // Give the page plenty of time to load fully - TestUtil.delay(5) - - // Go back out of the Talk interface - pressBack() - - // Get back to article screen - pressBack() - - TestUtil.delay(2) - - // Click on the Save button to add article to reading list - onView(withId(R.id.page_save)).perform(click()) - - TestUtil.delay(1) - - // Access article in a different language - onView(allOf(withId(R.id.page_language), withContentDescription("Language"), isDisplayed())).perform(click()) - - TestUtil.delay(2) - - onView(allOf(withId(R.id.langlinks_recycler))).perform(actionOnItemAtPosition(3, click())) - - 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) - - onView(withId(R.id.page_toolbar_button_show_overflow_menu)).perform(click()) - - TestUtil.delay(1) - - // Navigate back to Explore feed - onView(withText("Explore")).perform(click()) - - TestUtil.delay(2) - - // 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(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()) - - TestUtil.delay(1) - - // Click on first item in the list - onView(withId(R.id.recycler_view)) - .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(Matchers.`is`(activity.window.decorView)))).perform(click()) - - TestUtil.delay(1) - - // Make sure one of the list item matches the title that we expect - 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()) - - TestUtil.delay(5) - - // Click on bookmark icon and open the menu - onView(withId(R.id.page_save)).perform(click()) - - TestUtil.delay(2) - - // 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 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/androidTest/java/org/wikipedia/robots/AppThemeRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/AppThemeRobot.kt new file mode 100644 index 00000000000..0ce195ffaa7 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/AppThemeRobot.kt @@ -0,0 +1,108 @@ +package org.wikipedia.robots + +import android.os.Build +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.withId +import com.google.android.apps.common.testing.accessibility.framework.utils.contrast.Color +import org.wikipedia.R +import org.wikipedia.TestUtil +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class AppThemeRobot : BaseRobot() { + fun toggleTheme() = apply { + clickOnDisplayedView(R.id.page_theme) + delay(TestConfig.DELAY_MEDIUM) + } + + fun switchOffMatchSystemTheme() = apply { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + checkViewDoesNotExist(R.id.theme_chooser_match_system_theme_switch) + } else { + scrollToViewAndClick(R.id.theme_chooser_match_system_theme_switch) + } + delay(TestConfig.DELAY_SHORT) + } + + fun selectBlackTheme() = apply { + scrollToViewAndClick(R.id.button_theme_black) + delay(TestConfig.DELAY_MEDIUM) + } + + fun verifyBackgroundIsBlack() = apply { + onView(withId(R.id.page_actions_tab_layout)).check(matches(TestUtil.hasBackgroundColor(Color.BLACK))) + } + + fun goBackToLightTheme() = apply { + clickOnViewWithId(R.id.page_theme) + delay(TestConfig.DELAY_SHORT) + scrollToViewAndClick(R.id.button_theme_light) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickThemeIconOnEditPage() = apply { + clickOnDisplayedView(R.id.menu_edit_theme) + delay(TestConfig.DELAY_MEDIUM) + } + + fun increaseTextSize() = apply { + scrollToViewAndClick(R.id.buttonIncreaseTextSize) + delay(TestConfig.DELAY_MEDIUM) + } + + fun decreaseTextSize() = apply { + scrollToViewAndClick(R.id.buttonDecreaseTextSize) + delay(TestConfig.DELAY_MEDIUM) + } + + fun applySerif() = apply { + scrollToViewAndClick(R.id.button_font_family_serif) + delay(TestConfig.DELAY_MEDIUM) + } + + fun applySansSerif() = apply { + scrollToViewAndClick(R.id.button_font_family_sans_serif) + delay(TestConfig.DELAY_MEDIUM) + } + + fun toggleReadingFocusMode() = apply { + scrollToViewAndClick(R.id.theme_chooser_reading_focus_mode_switch) + delay(TestConfig.DELAY_MEDIUM) + } + + fun applySepiaTheme() = apply { + scrollToViewAndClick(R.id.button_theme_sepia) + delay(TestConfig.DELAY_MEDIUM) + } + + fun applyLightTheme() = apply { + scrollToViewAndClick(R.id.button_theme_light) + delay(TestConfig.DELAY_MEDIUM) + } + + fun applyDarkTheme() = apply { + scrollToViewAndClick(R.id.button_theme_dark) + delay(TestConfig.DELAY_MEDIUM) + } + + fun applyBlackTheme() = apply { + scrollToViewAndClick(R.id.button_theme_black) + delay(TestConfig.DELAY_MEDIUM) + } + + fun toggleMatchSystemTheme() = apply { + scrollToViewAndClick(R.id.theme_chooser_match_system_theme_switch) + delay(TestConfig.DELAY_MEDIUM) + } + + fun backToHomeScreen() = apply { + pressBack() + pressBack() + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_SHORT) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/DialogRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/DialogRobot.kt new file mode 100644 index 00000000000..37629f28636 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/DialogRobot.kt @@ -0,0 +1,23 @@ +package org.wikipedia.robots + +import android.util.Log +import org.wikipedia.base.BaseRobot + +class DialogRobot : BaseRobot() { + + fun dismissContributionDialog() = apply { + try { + clickOnViewWithText(text = "No, thanks") + } catch (e: Exception) { + Log.d("DialogRobot: ", "No Contribution dialog shown.") + } + } + + fun dismissBigEnglishDialog() = apply { + try { + clickOnViewWithText(text = "Maybe later") + } catch (e: Exception) { + Log.d("DialogRobot: ", "No Big English dialog shown.") + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/SystemRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/SystemRobot.kt new file mode 100644 index 00000000000..3c7a243f1b8 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/SystemRobot.kt @@ -0,0 +1,64 @@ +package org.wikipedia.robots + +import android.content.Context +import android.util.Log +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.matcher.ViewMatchers.isRoot +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiSelector +import org.wikipedia.TestUtil +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class SystemRobot : BaseRobot() { + fun turnOnAirplaneMode() = apply { + TestUtil.setAirplaneMode(true) + delay(TestConfig.DELAY_MEDIUM) + } + + fun turnOffAirplaneMode() = apply { + TestUtil.setAirplaneMode(false) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickOnSystemDialogWithText(text: String) = apply { + try { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + val allowButton = device.findObject(UiSelector().text(text)) + if (allowButton.exists()) { + allowButton.click() + } + delay(TestConfig.DELAY_SHORT) + } catch (e: Exception) { + Log.d("dialog", "Dialog did not appear or couldn't be clicked.") + } + delay(TestConfig.DELAY_MEDIUM) + } + + fun enableDarkMode(context: Context) = apply { + try { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.waitForIdle() + device.executeShellCommand("cmd uimode night yes") + TestUtil.delay(3) + device.waitForIdle() + onView(isRoot()).perform(TestUtil.waitOnId(1000)) + } catch (e: Exception) { + Log.e("SystemRobot", "Error while enabling dark mode", e) + } + } + + fun disableDarkMode(context: Context) = apply { + try { + val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + device.waitForIdle() + device.executeShellCommand("cmd uimode night no") + TestUtil.delay(3) + device.waitForIdle() + onView(isRoot()).perform(TestUtil.waitOnId(1000)) + } catch (e: Exception) { + Log.e("SystemRobot", "Error while disabling dark mode", e) + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/EditorRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/EditorRobot.kt new file mode 100644 index 00000000000..bf49057a994 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/EditorRobot.kt @@ -0,0 +1,48 @@ +package org.wikipedia.robots.feature + +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class EditorRobot : BaseRobot() { + fun clickEditIntroductionMenuItem() = apply { + clicksOnDisplayedViewWithText(viewId = R.id.title, text = "Edit introduction") + delay(TestConfig.DELAY_LARGE) + } + + fun typeInEditWindow() = apply { + typeTextInView(R.id.edit_section_text, "abc") + delay(TestConfig.DELAY_MEDIUM) + } + + fun tapNext() = apply { + // can be flaky + clickOnDisplayedView(R.id.edit_actionbar_button_text) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickDefaultEditSummaryChoices() = apply { + scrollToTextAndClick("Fixed typo") + delay(TestConfig.DELAY_MEDIUM) + } + + fun navigateUp() = apply { + clickOnDisplayedViewWithContentDescription("Navigate up") + delay(TestConfig.DELAY_SHORT) + } + + fun remainInEditWorkflow() = apply { + clicksOnDisplayedViewWithText(android.R.id.button2, "No") + delay(TestConfig.DELAY_SHORT) + } + + fun leaveEditWorkflow() = apply { + clicksOnDisplayedViewWithText(android.R.id.button1, "Yes") + delay(TestConfig.DELAY_SHORT) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_SHORT) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/ExploreFeedRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/ExploreFeedRobot.kt new file mode 100644 index 00000000000..274a064d5cd --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/ExploreFeedRobot.kt @@ -0,0 +1,254 @@ +package org.wikipedia.robots.feature + +import android.util.Log +import android.view.View +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.NoMatchingViewException +import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItemAtPosition +import androidx.test.espresso.contrib.RecyclerViewActions.scrollTo +import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import com.google.android.material.imageview.ShapeableImageView +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers.allOf +import org.wikipedia.R +import org.wikipedia.TestConstants +import org.wikipedia.TestConstants.SUGGESTED_EDITS +import org.wikipedia.TestUtil.childAtPosition +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.ColorAssertions +import org.wikipedia.base.TestConfig +import org.wikipedia.base.TestThemeColorType +import org.wikipedia.base.TestWikipediaColors +import org.wikipedia.theme.Theme + +class ExploreFeedRobot : BaseRobot() { + fun clickOnThisDayCard() = apply { + 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(click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickRandomArticle() = apply { + // Random article card seen and saved to reading lists + makeViewVisibleAndClick( + viewId = R.id.view_featured_article_card_content_container, + parentViewId = R.id.feed_view + ) + delay(TestConfig.DELAY_MEDIUM) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_LARGE) + } + + fun navigateUp() = apply { + clickOnDisplayedViewWithContentDescription("Navigate up") + } + + fun clickTopReadArticle() = apply { + try { + onView( + allOf( + withId(R.id.view_list_card_list), + childAtPosition(withId(R.id.view_list_card_list_container), 0) + ) + ).perform(actionOnItemAtPosition(1, click())) + .perform() + pressBack() + delay(TestConfig.DELAY_MEDIUM) + } catch (e: NoMatchingViewException) { + Log.e("clickError", "") + } + } + + fun clickBecauseYouReadArticle() = apply { + onView( + allOf( + withId(R.id.view_list_card_list), + childAtPosition(withId(R.id.view_list_card_list_container), 0) + ) + ) + .perform(actionOnItemAtPosition(0, click())) + } + + fun clickNewsArticle() = apply { + onView( + allOf( + withId(R.id.news_cardview_recycler_view), + childAtPosition(withId(R.id.rtl_container), 1) + ) + ) + .perform(actionOnItemAtPosition(0, click())) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickAddArticleDescription() = apply { + clickOnDisplayedViewWithContentDescription(description = "Add article descriptions") + } + + fun openOverflowMenuItem() = apply { + clickOnViewWithId(R.id.page_toolbar_button_show_overflow_menu) + delay(TestConfig.DELAY_SHORT) + } + + fun verifyFeaturedArticleImageIsNotVisible() = apply { + checkViewDoesNotExist(viewId = R.id.articleImage) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickPictureOfTheDay() = apply { + clickOnViewWithId(R.id.view_featured_image_card_content_container) + delay(TestConfig.DELAY_SHORT) + } + + fun clickTodayOnWikipedia() = apply { + clickOnViewWithIdAndContainsString(R.id.footerActionButton, text = "View main page") + delay(TestConfig.DELAY_LARGE) + } + + fun clickOnFeaturedArticle() = apply { + makeViewVisibleAndClick( + viewId = R.id.view_featured_article_card_content_container, + parentViewId = R.id.feed_view + ) + delay(TestConfig.DELAY_MEDIUM) + } + + fun stayOnFeaturedArticleFor(milliseconds: Long) = apply { + makeViewVisibleAndClick( + viewId = R.id.view_featured_article_card_content_container, + parentViewId = R.id.feed_view + ) + Thread.sleep(milliseconds) + } + + fun scrollToSuggestedEditsIfVisible() = apply { + try { + scrollToRecyclerView(title = SUGGESTED_EDITS) + clickAddArticleDescription() + pressBack() + } catch (e: Exception) { + Log.e("ScrollError:", "Suggested edits not visible or espresso cannot find it.") + } + } + + private fun changWatchListArticleExpiryFromTheSnackBar() = apply { + clickOnDisplayedViewWithIdAnContentDescription( + viewId = com.google.android.material.R.id.snackbar_action, + "Change" + ) + clickOnViewWithId(R.id.watchlistExpiryOneMonth) + delay(TestConfig.DELAY_SHORT) + } + + fun scrollToCardWithTitle(title: String, @IdRes viewId: Int = R.id.view_card_header_title) = + apply { + onView(withId(R.id.feed_view)) + .perform( + scrollTo( + hasDescendant( + scrollToCardViewWithTitle(title, viewId) + ) + ) + ) + .perform() + delay(TestConfig.DELAY_MEDIUM) + } + + fun swipeToRefresh() = apply { + onView(withId(R.id.swipe_refresh_layout)) + .perform(ViewActions.swipeDown()) + delay(TestConfig.DELAY_SWIPE_TO_REFRESH) + } + + fun scrollToItem( + recyclerViewId: Int = R.id.feed_view, + title: String, + textViewId: Int = R.id.view_card_header_title, + verticalOffset: Int = 200 + ) = apply { + scrollToRecyclerView( + recyclerViewId, + title, + textViewId, + verticalOffset + ) + } + + fun assertFeaturedArticleTitleColor(theme: Theme) = apply { + val color = TestWikipediaColors.getGetColor(theme, colorType = TestThemeColorType.PRIMARY) + onView(allOf( + withId(R.id.view_card_header_title), + withText(TestConstants.FEATURED_ARTICLE) + )).check(ColorAssertions.hasColor(color, ColorAssertions.ColorType.TextColor)) + } + + fun assertTopReadTitleColor(theme: Theme) = apply { + val color = TestWikipediaColors.getGetColor(theme, colorType = TestThemeColorType.PRIMARY) + onView(allOf( + withId(R.id.view_card_header_title), + withText(TestConstants.TOP_READ_ARTICLES) + )).check(ColorAssertions.hasColor(color, ColorAssertions.ColorType.TextColor)) + } + + private fun scrollToCardViewWithTitle( + title: String, + @IdRes textViewId: Int = R.id.view_card_header_title, + ): Matcher { + var currentOccurrence = 0 + return object : BoundedMatcher(View::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("Scroll to Card View with title: $title") + } + + override fun matchesSafely(item: View?): Boolean { + val titleView = item?.findViewById(textViewId) + if (titleView?.text?.toString() == title) { + if (currentOccurrence == 0) { + currentOccurrence++ + return true + } + currentOccurrence++ + } + return false + } + } + } + + fun verifyTopReadArticleIsGreyedOut(theme: Theme) = apply { + delay(TestConfig.DELAY_MEDIUM) + onView(withId(R.id.view_list_card_list)) + .check { view, _ -> + val recyclerView = view as RecyclerView + val viewHolder = recyclerView.findViewHolderForAdapterPosition(1) + ?: throw AssertionError("No viewHolder found at position 0") + val imageView = viewHolder.itemView.findViewById(R.id.view_list_card_item_image) + ?: throw AssertionError("No ImageView found with id view_list_card_item_image") + val color = TestWikipediaColors.getGetColor(theme, TestThemeColorType.BORDER) + ColorAssertions.hasColor( + colorResId = color, + colorType = ColorAssertions.ColorType.ShapeableImageViewColor + ).check(imageView, null) + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/LoginRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/LoginRobot.kt new file mode 100644 index 00000000000..07a0f81857e --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/LoginRobot.kt @@ -0,0 +1,68 @@ +package org.wikipedia.robots.feature + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.closeSoftKeyboard +import androidx.test.espresso.action.ViewActions.replaceText +import androidx.test.espresso.matcher.ViewMatchers.withClassName +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.hamcrest.Matchers +import org.hamcrest.Matchers.allOf +import org.wikipedia.BuildConfig +import org.wikipedia.R +import org.wikipedia.TestUtil +import org.wikipedia.auth.AccountUtil +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class LoginRobot : BaseRobot() { + + fun loginState( + loggedIn: () -> Unit, + loggedOut: () -> Unit + ) = apply { + if (AccountUtil.isLoggedIn) loggedIn.invoke() + else loggedOut.invoke() + } + + fun logInUser() = apply { + clickLoginButton() + setLoginUserNameFromBuildConfig() + setPasswordFromBuildConfig() + loginUser() + } + + private fun clickLoginButton() = apply { + clicksOnDisplayedViewWithText(viewId = R.id.create_account_login_button, text = "Log in") + delay(TestConfig.DELAY_MEDIUM) + } + + private fun setLoginUserNameFromBuildConfig() = apply { + onView( + allOf( + TestUtil.withGrandparent(withId(R.id.login_username_text)), withClassName( + Matchers.`is`("org.wikipedia.views.PlainPasteEditText")) + ) + ) + .perform(replaceText(BuildConfig.TEST_LOGIN_USERNAME), closeSoftKeyboard()) + } + + private fun setPasswordFromBuildConfig() = apply { + onView(allOf(TestUtil.withGrandparent(withId(R.id.login_password_input)), withClassName(Matchers.`is`("org.wikipedia.views.PlainPasteEditText")))) + .perform(replaceText(BuildConfig.TEST_LOGIN_PASSWORD), closeSoftKeyboard()) + } + + private fun loginUser() = apply { + scrollToViewAndClick(R.id.login_button) + delay(TestConfig.DELAY_LARGE) + } + + fun verifyLoginFailed() = apply { + checkViewExists(com.google.android.material.R.id.snackbar_text) + delay(TestConfig.DELAY_SHORT) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_SHORT) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/OnboardingRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/OnboardingRobot.kt new file mode 100644 index 00000000000..1f5806e44b4 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/OnboardingRobot.kt @@ -0,0 +1,82 @@ +package org.wikipedia.robots.feature + +import android.content.res.Resources +import android.view.View +import androidx.test.espresso.matcher.BoundedMatcher +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig +import org.wikipedia.base.TestThemeColorType +import org.wikipedia.base.TestWikipediaColors +import org.wikipedia.theme.Theme + +class OnboardingRobot : BaseRobot() { + + fun checkPrimaryTextViewColor(theme: Theme) = apply { + val color = TestWikipediaColors.getGetColor(theme = theme, colorType = TestThemeColorType.PRIMARY) + verifyTextViewColor(textViewId = R.id.primaryTextView, colorResId = color) + delay(TestConfig.DELAY_MEDIUM) + } + + fun checkSecondaryTextViewColor(theme: Theme) = apply { + val color = TestWikipediaColors.getGetColor(theme = theme, colorType = TestThemeColorType.SECONDARY) + verifyTextViewColor(textViewId = R.id.secondaryTextView, colorResId = color) + delay(TestConfig.DELAY_MEDIUM) + } + + fun moveAllTheWayToEndUsingTapButton() = apply { + repeat(3) { + clickOnDisplayedView(R.id.fragment_onboarding_forward_button) + } + delay(TestConfig.DELAY_SHORT) + } + + fun checkWelcomeScreenViewsForVisibility() = apply { + checkViewExists(R.id.imageViewCentered) + checkViewExists(R.id.primaryTextView) + checkViewExists(R.id.secondaryTextView) + checkViewExists(R.id.addLanguageButton) + checkViewExists(R.id.fragment_onboarding_skip_button) + checkViewExists(R.id.fragment_onboarding_forward_button) + delay(TestConfig.DELAY_SHORT) + } + + fun swipeAllTheWayToEnd() = apply { + repeat(3) { + swipeLeft(R.id.fragment_pager) + } + delay(TestConfig.DELAY_SHORT) + } + + fun swipeBackToWelcomeScreen() = apply { + repeat(3) { + swipeRight(R.id.fragment_pager) + } + delay(TestConfig.DELAY_SHORT) + } + + fun skipWelcomeScreen() = apply { + clickOnDisplayedView(R.id.fragment_onboarding_skip_button) + delay(TestConfig.DELAY_SHORT) + } + + fun verifyAppLanguageMatchesDeviceLanguage() = apply { + verifyWithMatcher(viewId = R.id.primaryTextView, matcher = matchesDeviceLanguage()) + } + + private fun matchesDeviceLanguage(): Matcher { + return object : BoundedMatcher (View::class.java) { + override fun describeTo(description: Description?) { + description?.appendText("with locale matching device locale") + } + + override fun matchesSafely(item: View?): Boolean { + val deviceLocale = Resources.getSystem().configuration.locales[0] + val appLocale = item?.resources?.configuration?.locales?.get(0) + return appLocale == deviceLocale + } + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/PageRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/PageRobot.kt new file mode 100644 index 00000000000..db59d5671a5 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/PageRobot.kt @@ -0,0 +1,222 @@ +package org.wikipedia.robots.feature + +import android.annotation.SuppressLint +import android.app.Activity +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.web.sugar.Web.onWebView +import androidx.test.espresso.web.webdriver.DriverAtoms.findElement +import androidx.test.espresso.web.webdriver.DriverAtoms.webClick +import androidx.test.espresso.web.webdriver.DriverAtoms.webScrollIntoView +import androidx.test.espresso.web.webdriver.Locator +import org.wikipedia.R +import org.wikipedia.base.AssertJavascriptAction +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class PageRobot : BaseRobot() { + + fun clickEditPencilAtTopOfArticle() = apply { + onWebView() + .withElement(findElement(Locator.CSS_SELECTOR, "a[data-id='0'].pcs-edit-section-link")) + .perform(webClick()) + delay(TestConfig.DELAY_SHORT) + } + + fun clickLink(linkTitle: String) = apply { + clickWebLink(linkTitle) + delay(TestConfig.DELAY_MEDIUM) + } + + fun verifyArticleTitle(expectedTitle: String) = apply { + verifyH1Title(expectedTitle) + } + + fun previewArticle() = apply { + clickOnDisplayedView(R.id.link_preview_toolbar) + delay(TestConfig.DELAY_MEDIUM) + } + + fun openInNewTab() = apply { + clickOnDisplayedView(R.id.link_preview_secondary_button) + delay(TestConfig.DELAY_MEDIUM) + } + + fun verifyTabCount(count: String) = apply { + checkWithTextIsDisplayed(R.id.tabsCountText, count) + } + + fun dismissTooltip(activity: Activity) = apply { + dismissTooltipIfAny(activity, viewId = R.id.buttonView) + delay(TestConfig.DELAY_SHORT) + } + + fun verifyHeaderViewWithLeadImage() = apply { + checkViewExists(R.id.page_header_view) + } + + fun clickLeadImage() = apply { + clickOnDisplayedView(R.id.view_page_header_image) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickOverflowMenu(description: String) = apply { + clickOnDisplayedViewWithContentDescription(description) + delay(TestConfig.DELAY_MEDIUM) + } + + fun visitImagePage() = apply { + clicksOnDisplayedViewWithText(viewId = R.id.title, text = "Go to image page") + delay(TestConfig.DELAY_MEDIUM) + } + + fun verifyLeadImageIsNotVisible() = apply { + checkViewDoesNotExist(R.id.page_header_view) + } + + fun swipePagerLeft() = apply { + swipeLeft(R.id.pager) + delay(TestConfig.DELAY_MEDIUM) + } + + fun swipeLeftToShowTableOfContents() = apply { + swipeLeft(R.id.page_web_view) + delay(TestConfig.DELAY_MEDIUM) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_SHORT) + } + + fun goBackToGalleryView() = apply { + goBack() + delay(TestConfig.DELAY_MEDIUM) + } + + fun goBackToOriginalArticle() = apply { + goBack() + delay(TestConfig.DELAY_MEDIUM) + } + + fun enableJavaScript() = apply { + onWebView().forceJavascriptEnabled() + } + + fun launchTabsScreen() = apply { + clickOnDisplayedView(R.id.page_toolbar_button_tabs) + delay(TestConfig.DELAY_MEDIUM) + } + + fun createNewTabWithContentDescription(text: String) = apply { + clickOnDisplayedViewWithContentDescription(text) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickOnPreviewTabInTheList(position: Int) = apply { + clickRecyclerViewItemAtPosition(R.id.tabRecyclerView, position) + } + + fun swipeDownOnTheWebView() = apply { + swipeDownOnTheWebView(R.id.page_contents_container) + } + + fun verifyTopMostItemInTableOfContentIs(text: String) = apply { + checkViewWithIdAndText(viewId = R.id.page_toc_item_text, text) + } + + fun swipeTableOfContentsAllTheWayToBottom() = apply { + swipeUp(R.id.toc_list) + } + + fun clickAboutThisArticleText() = apply { + clickOnViewWithText("About this article") + delay(TestConfig.DELAY_MEDIUM) + } + + fun goToTalkPage() = apply { + onWebView().withElement(findElement(Locator.CSS_SELECTOR, "a[title='View talk page']")) + .perform(webClick()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickThirdTopic() = apply { + clickRecyclerViewItemAtPosition(R.id.talkRecyclerView, 2) + delay(TestConfig.DELAY_MEDIUM) + } + + fun openLanguageSelector() = apply { + clickOnDisplayedViewWithIdAnContentDescription(R.id.page_language, "Language") + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickLanguageListedAtFourthPosition() = apply { + clickRecyclerViewItemAtPosition(R.id.langlinks_recycler, 3) + delay(TestConfig.DELAY_MEDIUM) + } + + fun openOverflowMenu() = apply { + clickOnViewWithId(R.id.page_toolbar_button_show_overflow_menu) + delay(TestConfig.DELAY_SHORT) + } + + fun navigateBackToExploreFeed() = apply { + clickOnViewWithText("Explore") + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickOnBookmarkIcon() = apply { + clickOnViewWithId(R.id.page_save) + delay(TestConfig.DELAY_MEDIUM) + } + + fun removeArticleFromReadingList() = apply { + clickOnViewWithText("Remove from Saved") + delay(TestConfig.DELAY_LARGE) + } + + private fun assertElementVisibility(elementSelector: String, isVisible: Boolean) { + onView(withId(R.id.page_web_view)) + .perform(AssertJavascriptAction("(function() { return document.querySelector(\"$elementSelector\").checkVisibility() })();", isVisible.toString())) + } + + fun verifyPreviewDialogAppears() = apply { + checkViewExists(R.id.link_preview_title) + delay(TestConfig.DELAY_SHORT) + } + + fun scrollToCollapsingTables() = apply { + onWebView() + .withElement(findElement(Locator.CSS_SELECTOR, ".pcs-table-infobox")) + .perform(webScrollIntoView()) + delay(TestConfig.DELAY_MEDIUM) + } + + @SuppressLint("CheckResult") + fun verifyTableIsCollapsed() = apply { + // checking if this class name exists + // tried multiple methods but was not able to check the style for the collapsed/expanded + // state of the table so instead using this className which is used when table is + // collapsed + onWebView() + .withElement(findElement(Locator.CLASS_NAME, "pcs-collapse-table-expanded")) + } + + @SuppressLint("CheckResult") + fun verifyTableIsExpanded() = apply { + // checking if this class name exists + // tried multiple methods but was not able to check the style for the collapsed/expanded + // state of the table so instead using this className which is used when table is + // expanded + onWebView() + .withElement(findElement(Locator.CLASS_NAME, "pcs-collapse-table-collapsed")) + } + + fun assertEditPencilVisibility(isVisible: Boolean) = apply { + assertElementVisibility("a[data-id='0'].pcs-edit-section-link", isVisible) + } + + fun assertCollapsingTableIsVisible(isVisible: Boolean) = apply { + assertElementVisibility(".pcs-collapse-table-content", isVisible) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/ReadingListRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/ReadingListRobot.kt new file mode 100644 index 00000000000..5dca153f986 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/ReadingListRobot.kt @@ -0,0 +1,133 @@ +package org.wikipedia.robots.feature + +import android.app.Activity +import android.content.Context +import android.util.Log +import android.view.View +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.BoundedMatcher +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.hamcrest.Description +import org.hamcrest.Matcher +import org.hamcrest.Matchers.allOf +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class ReadingListRobot : BaseRobot() { + fun saveArticleToReadingList() = apply { + clickOnViewWithId(R.id.page_save) + delay(TestConfig.DELAY_SHORT) + } + + fun addToReadingList(context: Context) = apply { + clickOnViewWithText(context.getString(R.string.reading_list_add_to_list_button)) + delay(TestConfig.DELAY_SHORT) + } + + fun typeNameOfTheList(title: String) = apply { + typeTextInView(viewId = R.id.text_input, title) + delay(TestConfig.DELAY_MEDIUM) + } + + fun saveTheList(context: Context) = apply { + clickOnViewWithText(context.getString(R.string.text_input_dialog_ok_button_text)) + delay(TestConfig.DELAY_MEDIUM) + } + + fun viewTheList(context: Context) = apply { + clickOnViewWithText(context.getString(R.string.reading_list_added_view_button)) + delay(TestConfig.DELAY_MEDIUM) + } + + fun dismissTooltip(activity: Activity) = apply { + dismissTooltipIfAny(activity, viewId = R.id.buttonView) + } + + fun clickOnGotIt() = apply { + clickOnViewWithText("Got it") + delay(TestConfig.DELAY_MEDIUM) + } + + fun verifyArticleHasNotDownloaded() = apply { + delay(TestConfig.DELAY_MEDIUM) + onView(withId(R.id.reading_list_recycler_view)) + .perform(RecyclerViewActions.scrollToPosition(1)) + .check( + matches( + atPosition( + 1, + hasDescendant( + allOf( + withId(R.id.page_list_item_action), + withEffectiveVisibility(ViewMatchers.Visibility.VISIBLE) + ) + ) + ) + ) + ) + } + + fun verifyArticleHasDownloaded() = apply { + delay(TestConfig.DELAY_MEDIUM) + onView(withId(R.id.reading_list_recycler_view)) + .perform(RecyclerViewActions.scrollToPosition(1)) + .check( + matches( + atPosition( + 1, + hasDescendant( + allOf( + withId(R.id.page_list_item_action), + withEffectiveVisibility(ViewMatchers.Visibility.GONE) + ) + ) + ) + ) + ) + } + + private fun atPosition(position: Int, itemMatcher: Matcher): Matcher { + return object : BoundedMatcher(RecyclerView::class.java) { + override fun describeTo(description: Description) { + description.appendText("has item at position $position") + itemMatcher.describeTo(description) + } + + override fun matchesSafely(item: RecyclerView): Boolean { + val viewHolder = item.findViewHolderForAdapterPosition(position) ?: return false + return itemMatcher.matches(viewHolder.itemView) + } + } + } + + fun navigateUp() = apply { + clickOnDisplayedViewWithContentDescription("Navigate up") + delay(TestConfig.DELAY_SHORT) + } + + fun clickNoThanks(context: Context) = apply { + try { + clickOnViewWithText(context.getString(R.string.reading_list_prompt_turned_sync_on_dialog_no_thanks)) + delay(TestConfig.DELAY_MEDIUM) + } catch (e: Exception) { + Log.e("ReadingListRobot: ", "${e.message}") + } + } + + fun clickCreateList() = apply { + clickOnViewWithId(R.id.create_button) + delay(TestConfig.DELAY_MEDIUM) + } + + fun pressBack() = apply { + delay(TestConfig.DELAY_MEDIUM) + goBack() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/SearchRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/SearchRobot.kt new file mode 100644 index 00000000000..af45bcfd3af --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/SearchRobot.kt @@ -0,0 +1,85 @@ +package org.wikipedia.robots.feature + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class SearchRobot : BaseRobot() { + + fun tapSearchView() = apply { + // Click the Search box + clickOnViewWithText("Search Wikipedia") + delay(TestConfig.DELAY_SHORT) + } + + fun clickSearchContainer() = apply { + // Click the Search box + clickOnDisplayedView(R.id.search_container) + delay(TestConfig.DELAY_SHORT) + } + + fun typeTextInView(searchTerm: String) = apply { + // Type in our search term + typeTextInView(androidx.appcompat.R.id.search_src_text, searchTerm) + + // Give the API plenty of time to return results + delay(TestConfig.DELAY_LARGE) + } + + fun verifySearchResult(expectedTitle: String) = apply { + // Make sure one of the results matches the title that we expect + checkWithTextIsDisplayed(R.id.page_list_item_title, expectedTitle) + } + + fun removeTextByTappingTrashIcon() = apply { + onView(withId(androidx.appcompat.R.id.search_close_btn)) + .check(matches(isDisplayed())) + .perform(click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun verifySearchTermIsCleared() = apply { + checkViewWithIdAndText(viewId = androidx.appcompat.R.id.search_src_text, text = "") + } + + fun clickOnItemFromSearchList(position: Int) = apply { + clickOnItemInList(R.id.search_results_list, 0) + delay(TestConfig.DELAY_LARGE) + } + + fun verifyRecentSearchesAppears() = apply { + checkViewWithTextDisplayed("Recent searches:") + } + + fun navigateUp() = apply { + clickOnDisplayedViewWithContentDescription("Navigate up") + } + + fun checkLanguageAvailability(language: String) = apply { + checkViewWithIdAndText(viewId = R.id.language_label, text = language) + } + + fun clickLanguage(language: String) = apply { + clicksOnDisplayedViewWithText(viewId = R.id.language_label, text = language) + delay(TestConfig.DELAY_MEDIUM) + } + + fun checkSearchListItemHasRTLDirection() = apply { + checkRTLDirectionOfRecyclerViewItem(R.id.search_results_list) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_SHORT) + } + + fun goBackToSearchScreen() = apply { + pressBack() + pressBack() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/feature/SettingsRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/feature/SettingsRobot.kt new file mode 100644 index 00000000000..a1af4477c3a --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/feature/SettingsRobot.kt @@ -0,0 +1,172 @@ +package org.wikipedia.robots.feature + +import android.content.Context +import android.util.Log +import androidx.annotation.IdRes +import androidx.recyclerview.widget.RecyclerView +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.assertion.ViewAssertions.doesNotExist +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.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withParent +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.hamcrest.Matchers.allOf +import org.junit.Assert.assertTrue +import org.wikipedia.R +import org.wikipedia.TestUtil.childAtPosition +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class SettingsRobot : BaseRobot() { + + fun clickExploreFeedSettingItem() = apply { + // Click on `Explore feed` option + onView( + allOf( + withId(R.id.recycler_view), + childAtPosition(withId(android.R.id.list_container), 0) + ) + ) + .perform(actionOnItemAtPosition(2, click())) + + delay(TestConfig.DELAY_MEDIUM) + } + + fun openMoreOptionsToolbar() = apply { + onView(allOf( + withContentDescription("More options"), + childAtPosition(childAtPosition(withId(R.id.toolbar), 2), 0), isDisplayed() + )) + .perform(click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun hideAllExploreFeeds() = apply { + // 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()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun showAllExploreFeeds() = apply { + onView(allOf(withId(R.id.title), withText("Show all"), + childAtPosition(childAtPosition(withId(androidx.appcompat.R.id.content), 0), 0), isDisplayed())) + .perform(click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickAboutWikipediaAppOptionItem() = apply { + scrollToSettingsPreferenceItem(R.string.about_description, click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun activateDeveloperMode() = apply { + // 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()) + delay(TestConfig.DELAY_MEDIUM) + } + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickDeveloperMode() = apply { + // 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()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun assertWeAreInDeveloperSettings() = apply { + 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"))) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickLanguages() = apply { + scrollToSettingsPreferenceItem(R.string.preference_title_language, click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickExploreFeed() = apply { + scrollToSettingsPreferenceItem(R.string.preference_title_customize_explore_feed, click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun toggleShowLinkPreviews() = apply { + scrollToSettingsPreferenceItem(R.string.preference_title_show_link_previews, click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun toggleCollapseTables() = apply { + scrollToSettingsPreferenceItem(R.string.preference_title_collapse_tables, click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickAppTheme() = apply { + scrollToSettingsPreferenceItem(R.string.preference_title_app_theme, click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun toggleDownloadReadingList() = apply { + scrollToSettingsPreferenceItem(R.string.preference_title_download_reading_list_articles, click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun toggleShowImages() = apply { + scrollToSettingsPreferenceItem(R.string.preference_title_show_images, click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun verifyExploreFeedIsEmpty(context: Context) = apply { + try { + checkViewWithTextDisplayed(text = context.getString(R.string.feed_empty_message)) + delay(TestConfig.DELAY_SHORT) + } catch (e: AssertionError) { + Log.d("SettingsRobot: ", "Assertion error due to offline mode") + // checks offline card is visible + checkViewWithTextDisplayed(context.getString(R.string.view_offline_card_text)) + // test the feed is empty + onView(withId(R.id.feed_view)) + .check { view, noViewFoundException -> + val expectedCount = 2 + val recyclerView = view as RecyclerView + val itemCount = recyclerView.adapter?.itemCount ?: 0 + assertTrue("ExpectedCount: $expectedCount, Actual: $itemCount", itemCount == expectedCount) + } + onView(withText("Featured article")).check(doesNotExist()) + } + } + + fun verifyExploreFeedIsNotEmpty(context: Context) = apply { + checkTextDoesNotExist(context.getString(R.string.feed_empty_message)) + delay(TestConfig.DELAY_SHORT) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_MEDIUM) + } + + private fun scrollToSettingsPreferenceItem(@IdRes preferenceTitle: Int, viewAction: ViewAction) = apply { + onView(withId(androidx.preference.R.id.recycler_view)) + .perform( + RecyclerViewActions.actionOnItem + (hasDescendant(withText(preferenceTitle)), viewAction)) + delay(TestConfig.DELAY_MEDIUM) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/navigation/BottomNavRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/navigation/BottomNavRobot.kt new file mode 100644 index 00000000000..f2923605e7f --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/navigation/BottomNavRobot.kt @@ -0,0 +1,89 @@ +package org.wikipedia.robots.navigation + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withContentDescription +import androidx.test.espresso.matcher.ViewMatchers.withId +import org.hamcrest.Matchers.allOf +import org.wikipedia.R +import org.wikipedia.TestUtil.childAtPosition +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig +import org.wikipedia.usercontrib.ContributionsDashboardHelper + +class BottomNavRobot : BaseRobot() { + fun navigateToExploreFeed() = apply { + onView( + allOf( + withId(R.id.nav_tab_explore), withContentDescription("Explore"), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 0), isDisplayed() + ) + ).perform(click()) + delay(TestConfig.DELAY_LARGE) + } + + fun navigateToSavedPage() = apply { + // 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()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun navigateToSearchPage() = apply { + onView( + allOf( + withId(R.id.nav_tab_search), withContentDescription("Search"), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 2), isDisplayed() + ) + ).perform(click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun navigateToSuggestedEdits() = apply { + onView( + allOf( + withId(R.id.nav_tab_edits), withContentDescription(if (ContributionsDashboardHelper.contributionsDashboardEnabled) R.string.nav_item_contribute + else R.string.nav_item_suggested_edits), + withId(R.id.nav_tab_edits), withContentDescription(getNavTabEditsIdRes()), + childAtPosition(childAtPosition(withId(R.id.main_nav_tab_layout), 0), 3), isDisplayed() + ) + ).perform(click()) + delay(TestConfig.DELAY_LARGE) + } + + fun navigateToMoreMenu() = apply { + onView(allOf(withId(R.id.nav_tab_more), withContentDescription("More"), isDisplayed())).perform(click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun goToSettings() = apply { + // Click on `Settings` option + onView(allOf(withId(R.id.main_drawer_settings_container), isDisplayed())).perform(click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickLoginMenuItem() = apply { + clickOnViewWithId(R.id.main_drawer_login_button) + delay(TestConfig.DELAY_MEDIUM) + } + + fun gotoWatchList() = apply { + clickOnViewWithId(R.id.main_drawer_watchlist_container) + delay(TestConfig.DELAY_SHORT) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_SHORT) + } + + private fun getNavTabEditsIdRes(): Int { + return if (ContributionsDashboardHelper.contributionsDashboardEnabled) R.string.nav_item_contribute + else R.string.nav_item_suggested_edits + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/screen/HistoryScreenRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/screen/HistoryScreenRobot.kt new file mode 100644 index 00000000000..d87d2eda1b0 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/screen/HistoryScreenRobot.kt @@ -0,0 +1,21 @@ +package org.wikipedia.robots.screen + +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class HistoryScreenRobot : BaseRobot() { + fun clearHistory() = apply { + clickOnDisplayedViewWithIdAnContentDescription(viewId = R.id.history_delete, description = "Clear history") + delay(TestConfig.DELAY_MEDIUM) + } + + fun assertDeletionMessage() = apply { + checkIfViewIsDisplayingText(viewId = androidx.appcompat.R.id.alertTitle, text = "Clear browsing history") + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickNoOnAlertDialog() = apply { + clicksOnDisplayedViewWithText(viewId = android.R.id.button2, text = "No") + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/screen/HomeScreenRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/screen/HomeScreenRobot.kt new file mode 100644 index 00000000000..fb8eaad6064 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/screen/HomeScreenRobot.kt @@ -0,0 +1,67 @@ +package org.wikipedia.robots.screen + +import android.app.Activity +import android.util.Log +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withParent +import org.hamcrest.Matchers.allOf +import org.wikipedia.R +import org.wikipedia.TestUtil +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class HomeScreenRobot : BaseRobot() { + + fun clickSearchContainer() = apply { + // Click the Search box + clickOnDisplayedView(R.id.search_container) + delay(TestConfig.DELAY_SHORT) + } + + fun navigateToNotifications() = apply { + clickOnDisplayedViewWithIdAnContentDescription(viewId = R.id.menu_notifications, "Notifications") + delay(TestConfig.DELAY_LARGE) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_MEDIUM) + } + + fun assertAllFeedCardsAreHidden() = apply { + onView(allOf(withId(R.id.empty_container), withParent(withParent(withId(R.id.swipe_refresh_layout))), isDisplayed())) + .check(matches(isDisplayed())) + delay(TestConfig.DELAY_MEDIUM) + } + + fun assertEmptyMessageIsNotVisible() = apply { + // 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())) + } + + fun imagesDoesNotShow() = apply { + // 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()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun dismissTooltip(activity: Activity) = apply { + dismissTooltipIfAny(activity, viewId = R.id.buttonView) + delay(TestConfig.DELAY_SHORT) + } + + fun dismissFeedCustomization() = apply { + try { + clicksOnDisplayedViewWithText(R.id.view_announcement_action_negative, "Got it") + delay(TestConfig.DELAY_SHORT) + } catch (e: Exception) { + Log.d("HomeScreenRobot", "no view because the device has no internet") + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/screen/LanguageListRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/screen/LanguageListRobot.kt new file mode 100644 index 00000000000..df5cca1e93d --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/screen/LanguageListRobot.kt @@ -0,0 +1,68 @@ +package org.wikipedia.robots.screen + +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import org.hamcrest.Matchers.allOf +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.ColorAssertions +import org.wikipedia.base.TestConfig +import org.wikipedia.base.TestThemeColorType +import org.wikipedia.base.TestWikipediaColors +import org.wikipedia.theme.Theme + +class LanguageListRobot : BaseRobot() { + + fun addNewLanguage() = apply { + scrollToRecyclerView( + recyclerViewId = R.id.wikipedia_languages_recycler, + title = "Add language", + textViewId = R.id.wiki_language_title + ) + clickOnViewWithText("Add language") + delay(TestConfig.DELAY_MEDIUM) + } + + fun assertAddLanguageTextColor(theme: Theme) = apply { + val color = TestWikipediaColors.getGetColor(theme, TestThemeColorType.PROGRESSIVE) + scrollToRecyclerView( + recyclerViewId = R.id.wikipedia_languages_recycler, + title = "Add language", + textViewId = R.id.wiki_language_title + ) + onView(allOf( + withId(R.id.wiki_language_title), + withText("Add language") + )).check(ColorAssertions.hasColor(color)) + } + + fun openSearchLanguage() = apply { + clickOnViewWithId(R.id.menu_search_language) + delay(TestConfig.DELAY_SHORT) + } + + fun scrollToLanguageAndClick(title: String) = apply { + scrollToRecyclerView( + recyclerViewId = R.id.languages_list_recycler, + title = title, + textViewId = R.id.language_subtitle, + ) + clicksOnDisplayedViewWithText(viewId = R.id.language_subtitle, title) + delay(TestConfig.DELAY_MEDIUM) + } + + fun assertJapaneseLanguageTextColor(theme: Theme) = apply { + val color = TestWikipediaColors.getGetColor(theme, TestThemeColorType.SECONDARY) + onView( + allOf( + withId(R.id.language_subtitle), + withText("Japanese") + ) + ).check(ColorAssertions.hasColor(color)) + } + + fun pressBack() = apply { + goBack() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/screen/NotificationScreenRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/screen/NotificationScreenRobot.kt new file mode 100644 index 00000000000..dee67f4889f --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/screen/NotificationScreenRobot.kt @@ -0,0 +1,18 @@ +package org.wikipedia.robots.screen + +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class NotificationScreenRobot : BaseRobot() { + + fun clickSearchBar() = apply { + clickRecyclerViewItemAtPosition(viewId = R.id.notifications_recycler_view, position = 0) + delay(TestConfig.DELAY_MEDIUM) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_SHORT) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/screen/SavedScreenRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/screen/SavedScreenRobot.kt new file mode 100644 index 00000000000..551c33cad90 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/screen/SavedScreenRobot.kt @@ -0,0 +1,43 @@ +package org.wikipedia.robots.screen + +import android.app.Activity +import android.util.Log +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class SavedScreenRobot : BaseRobot() { + + fun clickOnFirstItemInTheList() = apply { + clickRecyclerViewItemAtPosition(R.id.recycler_view, 0) + delay(TestConfig.DELAY_LARGE) + } + + fun dismissTooltip(activity: Activity) = apply { + dismissTooltipIfAny(activity, viewId = R.id.buttonView) + } + + fun assertIfListMatchesTheArticleTitle(text: String) = apply { + checkWithTextIsDisplayed(viewId = R.id.page_list_item_title, text) + delay(TestConfig.DELAY_SHORT) + } + + fun openArticleWithTitle(text: String) = apply { + clicksOnDisplayedViewWithText(viewId = R.id.page_list_item_title, text) + delay(TestConfig.DELAY_LARGE) + } + + fun dismissSyncReadingList() = apply { + try { + clickOnViewWithId(R.id.negativeButton) + delay(TestConfig.DELAY_SHORT) + } catch (e: Exception) { + Log.e("SavedScreenRobot: ", "${e.message}") + } + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_SHORT) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/robots/screen/SuggestedEditsScreenRobot.kt b/app/src/androidTest/java/org/wikipedia/robots/screen/SuggestedEditsScreenRobot.kt new file mode 100644 index 00000000000..9aaef79220c --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/robots/screen/SuggestedEditsScreenRobot.kt @@ -0,0 +1,64 @@ +package org.wikipedia.robots.screen + +import androidx.test.espresso.action.ViewActions.click +import org.wikipedia.R +import org.wikipedia.base.BaseRobot +import org.wikipedia.base.TestConfig + +class SuggestedEditsScreenRobot : BaseRobot() { + + fun verifyEditsIsVisible() = apply { + checkViewWithTextDisplayed(text = "Edits") + delay(TestConfig.DELAY_SHORT) + } + + fun verifyViewsIsVisible() = apply { + checkViewWithTextDisplayed(text = "Views") + delay(TestConfig.DELAY_SHORT) + } + + fun verifyLastEditedIsVisible() = apply { + checkViewWithTextDisplayed(text = "Last edited") + delay(TestConfig.DELAY_SHORT) + } + + fun verifyEditQualityIsVisible() = apply { + checkViewWithTextDisplayed(text = "Edit quality") + delay(TestConfig.DELAY_SHORT) + } + + fun verifyLastDonatedIsVisible() = apply { + checkViewWithTextDisplayed(text = "Last donated") + delay(TestConfig.DELAY_SHORT) + } + + fun enterContributionScreen() = apply { + clickOnDisplayedView(R.id.contributionsContainer) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickArticleDescriptions() = apply { + scrollToRecyclerViewInsideNestedScrollView(recyclerViewId = R.id.tasksRecyclerView, position = 0, viewAction = click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickImageCaptions() = apply { + scrollToRecyclerViewInsideNestedScrollView(recyclerViewId = R.id.tasksRecyclerView, position = 1, viewAction = click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickImageTags() = apply { + scrollToRecyclerViewInsideNestedScrollView(recyclerViewId = R.id.tasksRecyclerView, position = 2, viewAction = click()) + delay(TestConfig.DELAY_MEDIUM) + } + + fun clickSuggestedEdits() = apply { + scrollToViewInsideNestedScrollView(viewId = R.id.learnMoreCard) + delay(TestConfig.DELAY_SHORT) + } + + fun pressBack() = apply { + goBack() + delay(TestConfig.DELAY_MEDIUM) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/OnboardingTest.kt b/app/src/androidTest/java/org/wikipedia/tests/OnboardingTest.kt new file mode 100644 index 00000000000..33ffc54cf18 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/OnboardingTest.kt @@ -0,0 +1,46 @@ +package org.wikipedia.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.base.DataInjector +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.OnboardingRobot +import org.wikipedia.theme.Theme + +@LargeTest +@RunWith(AndroidJUnit4::class) +class OnboardingTest : BaseTest( + activityClass = MainActivity::class.java, + dataInjector = DataInjector(isInitialOnboardingEnabled = true) +) { + private val onboardingRobot = OnboardingRobot() + private val systemRobot = SystemRobot() + + @Test + fun startOnboardingTest() { + systemRobot + .disableDarkMode(context) + onboardingRobot + .checkWelcomeScreenViewsForVisibility() + .checkPrimaryTextViewColor(Theme.LIGHT) + .checkSecondaryTextViewColor(Theme.LIGHT) + .verifyAppLanguageMatchesDeviceLanguage() + .swipeAllTheWayToEnd() + .swipeBackToWelcomeScreen() + .moveAllTheWayToEndUsingTapButton() + .swipeBackToWelcomeScreen() + systemRobot + .enableDarkMode(context) + onboardingRobot + .checkWelcomeScreenViewsForVisibility() + .checkPrimaryTextViewColor(Theme.DARK) + .checkSecondaryTextViewColor(Theme.DARK) + .skipWelcomeScreen() + systemRobot + .clickOnSystemDialogWithText("Allow") + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/SuggestedEditScreenTest.kt b/app/src/androidTest/java/org/wikipedia/tests/SuggestedEditScreenTest.kt new file mode 100644 index 00000000000..8fc769b825b --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/SuggestedEditScreenTest.kt @@ -0,0 +1,58 @@ +package org.wikipedia.tests + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.base.DataInjector +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.LoginRobot +import org.wikipedia.robots.navigation.BottomNavRobot +import org.wikipedia.robots.screen.SuggestedEditsScreenRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class SuggestedEditScreenTest : BaseTest( + activityClass = MainActivity::class.java, + dataInjector = DataInjector( + overrideEditsContribution = 10 + ) +) { + + private val navRobot = BottomNavRobot() + private val loginRobot = LoginRobot() + private val systemRobot = SystemRobot() + private val suggestedEditsScreenRobot = SuggestedEditsScreenRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + navRobot + .navigateToMoreMenu() + .clickLoginMenuItem() + loginRobot + .logInUser() + systemRobot + .clickOnSystemDialogWithText("Allow") + navRobot + .navigateToSuggestedEdits() + suggestedEditsScreenRobot + .verifyEditsIsVisible() + .verifyViewsIsVisible() + .verifyLastEditedIsVisible() + .verifyEditQualityIsVisible() + .verifyLastDonatedIsVisible() + .enterContributionScreen() + .pressBack() + .clickArticleDescriptions() + .pressBack() + .clickImageCaptions() + .pressBack() + .clickImageTags() + .pressBack() + .clickSuggestedEdits() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/explorefeed/BecauseYouReadTest.kt b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/BecauseYouReadTest.kt new file mode 100644 index 00000000000..ca5246f4a60 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/BecauseYouReadTest.kt @@ -0,0 +1,43 @@ +package org.wikipedia.tests.explorefeed + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.TestConstants.BECAUSE_YOU_READ +import org.wikipedia.TestConstants.FEATURED_ARTICLE +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.ExploreFeedRobot +import org.wikipedia.robots.screen.HomeScreenRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class BecauseYouReadTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val exploreFeedRobot = ExploreFeedRobot() + private val systemRobot = SystemRobot() + private val homeScreenRobot = HomeScreenRobot() + + @Test + fun runTest() { + // sometimes notification dialog may appear + systemRobot + .clickOnSystemDialogWithText("Allow") + // dismisses the onboarding card + homeScreenRobot + .dismissFeedCustomization() + + // Because you read, requires users to read some article for 30 seconds + exploreFeedRobot + .scrollToItem(title = FEATURED_ARTICLE) + .stayOnFeaturedArticleFor(milliseconds = 30000) + .pressBack() + .swipeToRefresh() + .scrollToItem(title = BECAUSE_YOU_READ) + .clickBecauseYouReadArticle() + .pressBack() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenSearchTest.kt b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenSearchTest.kt new file mode 100644 index 00000000000..33dca381bf0 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenSearchTest.kt @@ -0,0 +1,56 @@ +package org.wikipedia.tests.explorefeed + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.base.DataInjector +import org.wikipedia.base.TestConfig.ARTICLE_TITLE +import org.wikipedia.base.TestConfig.SEARCH_TERM +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.SearchRobot +import org.wikipedia.robots.screen.HomeScreenRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class FeedScreenSearchTest : BaseTest( + activityClass = MainActivity::class.java, + dataInjector = DataInjector() +) { + private val homeScreenRobot = HomeScreenRobot() + private val searchRobot = SearchRobot() + private val systemRobot = SystemRobot() + + @Test + fun startExploreFeedTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + homeScreenRobot + .clickSearchContainer() + + // Search Test + searchRobot + .typeTextInView(SEARCH_TERM) + .verifySearchResult(ARTICLE_TITLE) + .removeTextByTappingTrashIcon() + .verifySearchTermIsCleared() + + setDeviceOrientation(isLandscape = true) + + searchRobot + .typeTextInView(SEARCH_TERM) + .verifySearchResult(ARTICLE_TITLE) + + setDeviceOrientation(isLandscape = false) + + searchRobot + .clickOnItemFromSearchList(0) + .goBackToSearchScreen() + + searchRobot + .removeTextByTappingTrashIcon() + .verifyRecentSearchesAppears() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenSuggestedEditTest.kt b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenSuggestedEditTest.kt new file mode 100644 index 00000000000..0158ea366f6 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenSuggestedEditTest.kt @@ -0,0 +1,62 @@ +package org.wikipedia.tests.explorefeed + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.ExploreFeedRobot +import org.wikipedia.robots.feature.LoginRobot +import org.wikipedia.robots.navigation.BottomNavRobot +import org.wikipedia.robots.screen.HomeScreenRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class FeedScreenSuggestedEditTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val bottomNavRobot = BottomNavRobot() + private val loginRobot = LoginRobot() + private val systemRobot = SystemRobot() + private val homeScreenRobot = HomeScreenRobot() + private val exploreFeedRobot = ExploreFeedRobot() + + @Test + fun runTest() { + // Following test requires login + // 1. Notification click + // 2. Suggested Edit Visibility + systemRobot + .clickOnSystemDialogWithText("Allow") + loginRobot + .loginState( + loggedIn = { + homeScreenRobot + .navigateToNotifications() + .pressBack() + // Final Feed View Test which appears after user logs in and user has to be online + exploreFeedRobot + .scrollToSuggestedEditsIfVisible() + }, + loggedOut = { + // Navigating to Login Menu + bottomNavRobot + .navigateToMoreMenu() + .clickLoginMenuItem() + loginRobot + .logInUser() + // After log in, notification dialog appears + systemRobot + .clickOnSystemDialogWithText(text = "Allow") + homeScreenRobot + .navigateToNotifications() + .pressBack() + // Final Feed View Test which appears after user logs in and user has to be online + exploreFeedRobot + .scrollToSuggestedEditsIfVisible() + } + ) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenTest.kt b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenTest.kt new file mode 100644 index 00000000000..41192c7356f --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/FeedScreenTest.kt @@ -0,0 +1,80 @@ +package org.wikipedia.tests.explorefeed + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.TestConstants.FEATURED_ARTICLE +import org.wikipedia.TestConstants.NEWS_CARD +import org.wikipedia.TestConstants.ON_THIS_DAY_CARD +import org.wikipedia.TestConstants.PICTURE_OF_DAY +import org.wikipedia.TestConstants.RANDOM_CARD +import org.wikipedia.TestConstants.TODAY_ON_WIKIPEDIA_MAIN_PAGE +import org.wikipedia.TestConstants.TOP_READ_ARTICLES +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.DialogRobot +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.ExploreFeedRobot +import org.wikipedia.robots.screen.HomeScreenRobot +import org.wikipedia.theme.Theme + +@LargeTest +@RunWith(AndroidJUnit4::class) +class FeedScreenTest : BaseTest( + activityClass = MainActivity::class.java +) { + + private val exploreFeedRobot = ExploreFeedRobot() + private val systemRobot = SystemRobot() + private val homeScreenRobot = HomeScreenRobot() + private val dialogRobot = DialogRobot() + + @Test + fun runTest() { + // sometimes notification dialog may appear + systemRobot + .clickOnSystemDialogWithText("Allow") + .disableDarkMode(context) + + // dismisses the onboarding card + homeScreenRobot + .dismissFeedCustomization() + + // Feed Test flow + exploreFeedRobot + .scrollToItem(title = FEATURED_ARTICLE) + .assertFeaturedArticleTitleColor(theme = Theme.LIGHT) + .clickOnFeaturedArticle() + .pressBack() + .scrollToItem(title = TODAY_ON_WIKIPEDIA_MAIN_PAGE, verticalOffset = -100) + .clickTodayOnWikipedia() + dialogRobot + .dismissBigEnglishDialog() + .dismissContributionDialog() + exploreFeedRobot + .pressBack() + systemRobot + .enableDarkMode(context) + exploreFeedRobot + .scrollToItem(title = TODAY_ON_WIKIPEDIA_MAIN_PAGE, verticalOffset = 400) + .scrollToItem(title = TOP_READ_ARTICLES, verticalOffset = 400) + .assertTopReadTitleColor(theme = Theme.DARK) + .clickTopReadArticle() + .scrollToItem(title = PICTURE_OF_DAY) + .clickPictureOfTheDay() + .pressBack() + systemRobot + .enableDarkMode(context) + exploreFeedRobot + .scrollToItem(title = NEWS_CARD) + .clickNewsArticle() + .pressBack() + .scrollToItem(title = ON_THIS_DAY_CARD) + .clickOnThisDayCard() + .pressBack() + .scrollToItem(title = RANDOM_CARD) + .clickRandomArticle() + .pressBack() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/explorefeed/NavigationItemTest.kt b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/NavigationItemTest.kt new file mode 100644 index 00000000000..8ff40a5248f --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/explorefeed/NavigationItemTest.kt @@ -0,0 +1,33 @@ +package org.wikipedia.tests.explorefeed + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.navigation.BottomNavRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class NavigationItemTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val bottomNavRobot = BottomNavRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + // Checking the navigation menu items + bottomNavRobot + .navigateToSavedPage() + .navigateToSearchPage() + .navigateToSuggestedEdits() + .navigateToMoreMenu() + .pressBack() + .navigateToExploreFeed() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/search/SearchExternalIntentTest.kt b/app/src/androidTest/java/org/wikipedia/tests/search/SearchExternalIntentTest.kt similarity index 72% rename from app/src/androidTest/java/org/wikipedia/search/SearchExternalIntentTest.kt rename to app/src/androidTest/java/org/wikipedia/tests/search/SearchExternalIntentTest.kt index f81902fef26..a37435f83f2 100644 --- a/app/src/androidTest/java/org/wikipedia/search/SearchExternalIntentTest.kt +++ b/app/src/androidTest/java/org/wikipedia/tests/search/SearchExternalIntentTest.kt @@ -1,4 +1,4 @@ -package org.wikipedia.search +package org.wikipedia.tests.search import android.content.Intent import androidx.recyclerview.widget.RecyclerView @@ -21,6 +21,7 @@ import org.junit.runner.RunWith import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.TestUtil +import org.wikipedia.search.SearchActivity @LargeTest @RunWith(AndroidJUnit4::class) @@ -29,10 +30,10 @@ class SearchExternalIntentTest { @Rule @JvmField var mActivityTestRule = ActivityScenarioRule( - Intent(ApplicationProvider.getApplicationContext(), SearchActivity::class.java) - .setAction(Intent.ACTION_SEND) - .setType(Constants.PLAIN_TEXT_MIME_TYPE) - .putExtra(Intent.EXTRA_TEXT, "boletus edulis") + Intent(ApplicationProvider.getApplicationContext(), SearchActivity::class.java) + .setAction(Intent.ACTION_SEND) + .setType(Constants.PLAIN_TEXT_MIME_TYPE) + .putExtra(Intent.EXTRA_TEXT, "boletus edulis") ) @Test @@ -42,7 +43,7 @@ class SearchExternalIntentTest { TestUtil.delay(5) onView(allOf(withId(R.id.page_list_item_title), withText("Boletus edulis"), isDisplayed())) - .check(matches(withText("Boletus edulis"))) + .check(matches(withText("Boletus edulis"))) TestUtil.delay(2) @@ -53,7 +54,7 @@ class SearchExternalIntentTest { TestUtil.delay(1) onView(allOf(withId(R.id.page_list_item_title), withText("Boletus edulis"), isDisplayed())) - .check(matches(withText("Boletus edulis"))) + .check(matches(withText("Boletus edulis"))) device.setOrientationNatural() device.unfreezeRotation() @@ -61,32 +62,32 @@ class SearchExternalIntentTest { TestUtil.delay(2) onView(allOf(withId(R.id.search_lang_button), isDisplayed())) - .check(matches(withText("EN"))) + .check(matches(withText("EN"))) TestUtil.delay(1) onView(allOf(withId(R.id.search_lang_button_container), isDisplayed())) - .perform(ViewActions.click()) + .perform(ViewActions.click()) TestUtil.delay(1) onView(withId(R.id.wikipedia_languages_recycler)) - .perform(RecyclerViewActions.actionOnItemAtPosition(2, ViewActions.click())) + .perform(RecyclerViewActions.actionOnItemAtPosition(2, ViewActions.click())) TestUtil.delay(1) onView(allOf(withId(R.id.menu_search_language), isDisplayed())) - .perform(ViewActions.click()) + .perform(ViewActions.click()) TestUtil.delay(1) onView(allOf(withId(androidx.appcompat.R.id.search_src_text), isDisplayed())) - .perform(ViewActions.replaceText("rus"), ViewActions.closeSoftKeyboard()) + .perform(ViewActions.replaceText("rus"), ViewActions.closeSoftKeyboard()) TestUtil.delay(1) onView(withId(R.id.languages_list_recycler)) - .perform(RecyclerViewActions.actionOnItemAtPosition(0, ViewActions.click())) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, ViewActions.click())) TestUtil.delay(1) @@ -95,12 +96,12 @@ class SearchExternalIntentTest { TestUtil.delay(1) onView(allOf(TestUtil.childAtPosition(TestUtil.childAtPosition(withId(R.id.horizontal_scroll_languages), 0), 1), isDisplayed())) - .perform(ViewActions.click()) + .perform(ViewActions.click()) TestUtil.delay(5) onView(allOf(withId(R.id.page_list_item_title), withText("Белый гриб"), isDisplayed())) - .check(matches(withText("Белый гриб"))) + .check(matches(withText("Белый гриб"))) TestUtil.delay(2) } diff --git a/app/src/androidTest/java/org/wikipedia/search/SearchIntentTest.kt b/app/src/androidTest/java/org/wikipedia/tests/search/SearchIntentTest.kt similarity index 74% rename from app/src/androidTest/java/org/wikipedia/search/SearchIntentTest.kt rename to app/src/androidTest/java/org/wikipedia/tests/search/SearchIntentTest.kt index 21fdae93b8d..bb09791ba8b 100644 --- a/app/src/androidTest/java/org/wikipedia/search/SearchIntentTest.kt +++ b/app/src/androidTest/java/org/wikipedia/tests/search/SearchIntentTest.kt @@ -1,4 +1,4 @@ -package org.wikipedia.search +package org.wikipedia.tests.search import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ApplicationProvider @@ -20,6 +20,7 @@ import org.junit.runner.RunWith import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.TestUtil +import org.wikipedia.search.SearchActivity @LargeTest @RunWith(AndroidJUnit4::class) @@ -27,8 +28,12 @@ class SearchIntentTest { @Rule @JvmField - var mActivityTestRule = ActivityScenarioRule(SearchActivity.newIntent(ApplicationProvider.getApplicationContext(), - Constants.InvokeSource.INTENT_SHARE, "barack obama")) + var mActivityTestRule = ActivityScenarioRule( + SearchActivity.newIntent( + ApplicationProvider.getApplicationContext(), + Constants.InvokeSource.INTENT_SHARE, "barack obama" + ) + ) @Test fun testSearchActivityWithQuery() { @@ -37,7 +42,7 @@ class SearchIntentTest { TestUtil.delay(5) onView(allOf(withId(R.id.page_list_item_title), withText("Barack Obama"), isDisplayed())) - .check(matches(withText("Barack Obama"))) + .check(matches(withText("Barack Obama"))) TestUtil.delay(2) @@ -48,7 +53,7 @@ class SearchIntentTest { TestUtil.delay(1) onView(allOf(withId(R.id.page_list_item_title), withText("Barack Obama"), isDisplayed())) - .check(matches(withText("Barack Obama"))) + .check(matches(withText("Barack Obama"))) device.setOrientationNatural() TestUtil.delay(2) @@ -62,32 +67,32 @@ class SearchIntentTest { TestUtil.delay(2) onView(allOf(withId(R.id.search_lang_button), isDisplayed())) - .check(matches(withText("EN"))) + .check(matches(withText("EN"))) TestUtil.delay(1) onView(allOf(withId(R.id.search_lang_button_container), isDisplayed())) - .perform(ViewActions.click()) + .perform(ViewActions.click()) TestUtil.delay(1) onView(withId(R.id.wikipedia_languages_recycler)) - .perform(RecyclerViewActions.actionOnItemAtPosition(2, ViewActions.click())) + .perform(RecyclerViewActions.actionOnItemAtPosition(2, ViewActions.click())) TestUtil.delay(1) onView(allOf(withId(R.id.menu_search_language), isDisplayed())) - .perform(ViewActions.click()) + .perform(ViewActions.click()) TestUtil.delay(1) onView(allOf(withId(androidx.appcompat.R.id.search_src_text), isDisplayed())) - .perform(ViewActions.replaceText("rus"), ViewActions.closeSoftKeyboard()) + .perform(ViewActions.replaceText("rus"), ViewActions.closeSoftKeyboard()) TestUtil.delay(1) onView(withId(R.id.languages_list_recycler)) - .perform(RecyclerViewActions.actionOnItemAtPosition(0, ViewActions.click())) + .perform(RecyclerViewActions.actionOnItemAtPosition(0, ViewActions.click())) TestUtil.delay(1) @@ -96,19 +101,19 @@ class SearchIntentTest { TestUtil.delay(1) onView(allOf(TestUtil.childAtPosition(TestUtil.childAtPosition(withId(R.id.horizontal_scroll_languages), 0), 1), isDisplayed())) - .perform(ViewActions.click()) + .perform(ViewActions.click()) TestUtil.delay(1) onView(allOf(withText("Retry"), isDisplayed())) - .check(matches(isDisplayed())) + .check(matches(isDisplayed())) TestUtil.setAirplaneMode(false, 5) TestUtil.delay(5) onView(allOf(withId(R.id.page_list_item_title), withText("Обама, Барак"), isDisplayed())) - .check(matches(withText("Обама, Барак"))) + .check(matches(withText("Обама, Барак"))) TestUtil.delay(2) } diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/AboutSettingsTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/AboutSettingsTest.kt new file mode 100644 index 00000000000..2270ad50e10 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/AboutSettingsTest.kt @@ -0,0 +1,31 @@ +package org.wikipedia.tests.settings + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.SettingsRobot +import org.wikipedia.settings.SettingsActivity + +@LargeTest +@RunWith(AndroidJUnit4::class) +class AboutSettingsTest : BaseTest( + activityClass = SettingsActivity::class.java +) { + private val settingsRobot = SettingsRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + settingsRobot + .clickAboutWikipediaAppOptionItem() + .activateDeveloperMode() + .pressBack() + .clickDeveloperMode() + .assertWeAreInDeveloperSettings() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/AppThemeTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/AppThemeTest.kt new file mode 100644 index 00000000000..c8f84c1c7fb --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/AppThemeTest.kt @@ -0,0 +1,80 @@ +package org.wikipedia.tests.settings + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import junit.framework.TestCase.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.WikipediaApp +import org.wikipedia.base.BaseTest +import org.wikipedia.robots.AppThemeRobot +import org.wikipedia.robots.SystemRobot +import org.wikipedia.theme.Theme +import org.wikipedia.theme.ThemeFittingRoomActivity + +@LargeTest +@RunWith(AndroidJUnit4::class) +class AppThemeTest : BaseTest( + activityClass = ThemeFittingRoomActivity::class.java +) { + + private val appThemeRobot = AppThemeRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .disableDarkMode(context) + .clickOnSystemDialogWithText("Allow") + testLightMode() + systemRobot + .enableDarkMode(context) + testDarkMode() + appThemeRobot + .toggleMatchSystemTheme() + testLightMode() + testDarkMode() + } + + private fun testDarkMode() { + var currentTheme = getThemeAttribute() + + appThemeRobot + .applyDarkTheme() + var newTheme = getThemeAttribute() + assertNotEquals("Theme should change to Black", currentTheme, newTheme) + assertEquals("Theme should be Black", newTheme.resourceId, Theme.DARK.resourceId) + currentTheme = newTheme + + appThemeRobot + .applyBlackTheme() + newTheme = getThemeAttribute() + assertNotEquals("Theme should change to Dark", currentTheme, newTheme) + assertEquals("Theme should be Dark", newTheme.resourceId, Theme.BLACK.resourceId) + } + + private fun testLightMode() { + var currentTheme = getThemeAttribute() + appThemeRobot + .applySepiaTheme() + var newTheme = getThemeAttribute() + assertNotEquals("Theme should change to Sepia", currentTheme, newTheme) + assertEquals("Theme should be Sepia", newTheme.resourceId, Theme.SEPIA.resourceId) + currentTheme = newTheme + + appThemeRobot + .applyLightTheme() + newTheme = getThemeAttribute() + assertNotEquals("Theme should change to Light", currentTheme, newTheme) + assertEquals("Theme should be Light", newTheme.resourceId, Theme.LIGHT.resourceId) + } + + private fun getThemeAttribute(): Theme { + var currentTheme = Theme.LIGHT + activityScenarioRule.scenario.onActivity { activity -> + currentTheme = WikipediaApp.instance.currentTheme + } + return currentTheme + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/ChangingLanguageTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/ChangingLanguageTest.kt new file mode 100644 index 00000000000..8cca1ad7b08 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/ChangingLanguageTest.kt @@ -0,0 +1,69 @@ +package org.wikipedia.tests.settings + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.SearchRobot +import org.wikipedia.robots.feature.SettingsRobot +import org.wikipedia.robots.navigation.BottomNavRobot +import org.wikipedia.robots.screen.LanguageListRobot +import org.wikipedia.theme.Theme + +@LargeTest +@RunWith(AndroidJUnit4::class) +class ChangingLanguageTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val HEBREW = "Hebrew" + private val JAPANESE = "Japanese" + private val searchTerm = "apple" + private val bottomNavRobot = BottomNavRobot() + private val settingsRobot = SettingsRobot() + private val languageListRobot = LanguageListRobot() + private val searchRobot = SearchRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + bottomNavRobot + .navigateToMoreMenu() + .goToSettings() + systemRobot + .enableDarkMode(context = context) + settingsRobot + .clickLanguages() + languageListRobot + .assertAddLanguageTextColor(theme = Theme.DARK) + .addNewLanguage() + .scrollToLanguageAndClick(HEBREW) + systemRobot + .disableDarkMode(context) + languageListRobot + .addNewLanguage() + .openSearchLanguage() + searchRobot + .typeTextInView(JAPANESE) + languageListRobot + .assertJapaneseLanguageTextColor(theme = Theme.LIGHT) + .scrollToLanguageAndClick(JAPANESE) + .pressBack() + .pressBack() + bottomNavRobot + .navigateToSearchPage() + setDeviceOrientation(isLandscape = true) + searchRobot + .tapSearchView() + .checkLanguageAvailability(JAPANESE) + .checkLanguageAvailability(HEBREW) + .clickLanguage(HEBREW) + .typeTextInView(searchTerm) + .checkSearchListItemHasRTLDirection() + setDeviceOrientation(isLandscape = false) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/CollapseTablesTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/CollapseTablesTest.kt new file mode 100644 index 00000000000..a8d79b61dd5 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/CollapseTablesTest.kt @@ -0,0 +1,63 @@ +package org.wikipedia.tests.settings + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.DialogRobot +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.PageRobot +import org.wikipedia.robots.feature.SearchRobot +import org.wikipedia.robots.feature.SettingsRobot +import org.wikipedia.robots.navigation.BottomNavRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class CollapseTablesTest : BaseTest( + activityClass = MainActivity::class.java +) { + + private val bottomNavRobot = BottomNavRobot() + private val settingsRobot = SettingsRobot() + private val searchRobot = SearchRobot() + private val pageRobot = PageRobot() + private val systemRobot = SystemRobot() + private val dialogRobot = DialogRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + bottomNavRobot + .navigateToSearchPage() + searchRobot + .tapSearchView() + .typeTextInView("apple") + .clickOnItemFromSearchList(0) + pageRobot + .dismissTooltip(activity) + .scrollToCollapsingTables() + .assertCollapsingTableIsVisible(isVisible = false) + .pressBack() + .pressBack() + .pressBack() + bottomNavRobot + .navigateToMoreMenu() + .goToSettings() + settingsRobot + .toggleCollapseTables() + .pressBack() + searchRobot + .tapSearchView() + .typeTextInView("apple") + .clickOnItemFromSearchList(0) + dialogRobot + .dismissBigEnglishDialog() + .dismissContributionDialog() + pageRobot + .scrollToCollapsingTables() + .assertCollapsingTableIsVisible(isVisible = true) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/CustomizeExploreFeedTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/CustomizeExploreFeedTest.kt new file mode 100644 index 00000000000..73c9336b9f5 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/CustomizeExploreFeedTest.kt @@ -0,0 +1,53 @@ +package org.wikipedia.tests.settings + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.SettingsRobot +import org.wikipedia.robots.navigation.BottomNavRobot +import org.wikipedia.robots.screen.HomeScreenRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class CustomizeExploreFeedTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val homeScreenRobot = HomeScreenRobot() + private val bottomNavRobot = BottomNavRobot() + private val settingsRobot = SettingsRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + systemRobot + .disableDarkMode(context) + homeScreenRobot + .dismissFeedCustomization() + bottomNavRobot + .navigateToMoreMenu() + .goToSettings() + settingsRobot + .clickExploreFeed() + .openMoreOptionsToolbar() + .hideAllExploreFeeds() + .pressBack() + .pressBack() + .verifyExploreFeedIsEmpty(context) + bottomNavRobot + .navigateToMoreMenu() + .goToSettings() + settingsRobot + .clickExploreFeed() + .openMoreOptionsToolbar() + .showAllExploreFeeds() + .pressBack() + .pressBack() + .verifyExploreFeedIsNotEmpty(context) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/DownloadReadingListTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/DownloadReadingListTest.kt new file mode 100644 index 00000000000..cccc09f6b74 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/DownloadReadingListTest.kt @@ -0,0 +1,81 @@ +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.DialogRobot +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.PageRobot +import org.wikipedia.robots.feature.ReadingListRobot +import org.wikipedia.robots.feature.SearchRobot +import org.wikipedia.robots.feature.SettingsRobot +import org.wikipedia.robots.navigation.BottomNavRobot +import org.wikipedia.robots.screen.SavedScreenRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class DownloadReadingListTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val bottomNavRobot = BottomNavRobot() + private val settingsRobot = SettingsRobot() + private val systemRobot = SystemRobot() + private val savedScreenRobot = SavedScreenRobot() + private val searchRobot = SearchRobot() + private val pageRobot = PageRobot() + private val readingListRobot = ReadingListRobot() + private val dialogRobot = DialogRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + bottomNavRobot + .navigateToSavedPage() + savedScreenRobot + .dismissSyncReadingList() + bottomNavRobot + .navigateToSearchPage() + searchRobot + .tapSearchView() + .typeTextInView("apple") + .clickOnItemFromSearchList(0) + pageRobot + .dismissTooltip(activity) + + readingListRobot + .saveArticleToReadingList() + .addToReadingList(context) + .typeNameOfTheList("😎") + .saveTheList(context) + .viewTheList(context) + .clickOnGotIt() + .verifyArticleHasDownloaded() + .pressBack() + .pressBack() + .navigateUp() + .clickNoThanks(context) + bottomNavRobot + .navigateToMoreMenu() + .goToSettings() + settingsRobot + .toggleDownloadReadingList() + .pressBack() + searchRobot + .tapSearchView() + .typeTextInView("orange") + .clickOnItemFromSearchList(0) + dialogRobot + .dismissBigEnglishDialog() + .dismissContributionDialog() + readingListRobot + .saveArticleToReadingList() + .addToReadingList(context) + .clickCreateList() + .typeNameOfTheList("😎😍") + .saveTheList(context) + .viewTheList(context) + .verifyArticleHasNotDownloaded() + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/FontChangeTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/FontChangeTest.kt new file mode 100644 index 00000000000..c76ee7443c4 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/FontChangeTest.kt @@ -0,0 +1,50 @@ +package org.wikipedia.tests.settings + +import android.graphics.Typeface +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.R +import org.wikipedia.base.BaseTest +import org.wikipedia.robots.AppThemeRobot +import org.wikipedia.robots.SystemRobot +import org.wikipedia.theme.ThemeFittingRoomActivity +import org.wikipedia.theme.ThemeFittingRoomFragment + +@LargeTest +@RunWith(AndroidJUnit4::class) +class FontChangeTest : BaseTest( + activityClass = ThemeFittingRoomActivity::class.java +) { + private val appThemeRobot = AppThemeRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + appThemeRobot + .applySerif() + + activityScenarioRule.scenario.onActivity { newActivity -> + val fragment = newActivity.supportFragmentManager + .findFragmentById(R.id.fragment_container) as ThemeFittingRoomFragment + val textView = fragment.requireView().findViewById(R.id.theme_test_text) + assertTrue("Font should be Serif", textView.typeface == Typeface.SERIF) + } + + appThemeRobot + .applySansSerif() + + activityScenarioRule.scenario.onActivity { newActivity -> + activity = newActivity + val fragment = newActivity.supportFragmentManager + .findFragmentById(R.id.fragment_container) as ThemeFittingRoomFragment + val textView = fragment.requireView().findViewById(R.id.theme_test_text) + assertTrue("Font should be Serif", textView.typeface == Typeface.SANS_SERIF) + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/FontSizeTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/FontSizeTest.kt new file mode 100644 index 00000000000..ac4f14fef25 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/FontSizeTest.kt @@ -0,0 +1,49 @@ +package org.wikipedia.tests.settings + +import android.widget.TextView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import junit.framework.TestCase.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.R +import org.wikipedia.base.BaseTest +import org.wikipedia.robots.AppThemeRobot +import org.wikipedia.robots.SystemRobot +import org.wikipedia.theme.ThemeFittingRoomActivity +import org.wikipedia.theme.ThemeFittingRoomFragment + +@LargeTest +@RunWith(AndroidJUnit4::class) +class FontSizeTest : BaseTest( + activityClass = ThemeFittingRoomActivity::class.java +) { + private val appThemeRobot = AppThemeRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + val fragment = activity.supportFragmentManager + .findFragmentById(R.id.fragment_container) as ThemeFittingRoomFragment + val textView = fragment.requireView().findViewById(R.id.theme_test_text) + var currentSize = 0f + var newSize = 0f + repeat(2) { + appThemeRobot + .increaseTextSize() + newSize = textView.textSize + assertTrue("new size $newSize should be greater than current size $currentSize", newSize > currentSize) + currentSize = textView.textSize + } + + repeat(2) { + appThemeRobot + .decreaseTextSize() + newSize = textView.textSize + assertTrue("new size $newSize should be less than current size $currentSize", newSize < currentSize) + currentSize = textView.textSize + } + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/LinkPreviewTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/LinkPreviewTest.kt new file mode 100644 index 00000000000..8f90e28a9cf --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/LinkPreviewTest.kt @@ -0,0 +1,63 @@ +package org.wikipedia.tests.settings + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.DialogRobot +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.PageRobot +import org.wikipedia.robots.feature.SearchRobot +import org.wikipedia.robots.feature.SettingsRobot +import org.wikipedia.robots.navigation.BottomNavRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class LinkPreviewTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val bottomNavRobot = BottomNavRobot() + private val settingsRobot = SettingsRobot() + private val searchRobot = SearchRobot() + private val pageRobot = PageRobot() + private val systemRobot = SystemRobot() + private val dialogRobot = DialogRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + bottomNavRobot + .navigateToSearchPage() + searchRobot + .tapSearchView() + .typeTextInView("apple") + .clickOnItemFromSearchList(0) + pageRobot + .dismissTooltip(activity) + .clickLink(linkTitle = "Fruit") + .verifyPreviewDialogAppears() + .pressBack() + .pressBack() + .pressBack() + .pressBack() + bottomNavRobot + .navigateToMoreMenu() + .goToSettings() + settingsRobot + .toggleShowLinkPreviews() + .pressBack() + searchRobot + .tapSearchView() + .typeTextInView("apple") + .clickOnItemFromSearchList(0) + dialogRobot + .dismissBigEnglishDialog() + .dismissContributionDialog() + pageRobot + .clickLink(linkTitle = "Fruit") + .verifyArticleTitle("Fruit") + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/ReadingFocusModeTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/ReadingFocusModeTest.kt new file mode 100644 index 00000000000..d62c7f074c8 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/ReadingFocusModeTest.kt @@ -0,0 +1,54 @@ +package org.wikipedia.tests.settings + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.AppThemeRobot +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.PageRobot +import org.wikipedia.robots.feature.SearchRobot +import org.wikipedia.robots.feature.SettingsRobot +import org.wikipedia.robots.navigation.BottomNavRobot + +@LargeTest +@RunWith(AndroidJUnit4::class) +class ReadingFocusModeTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val bottomNavRobot = BottomNavRobot() + private val settingsRobot = SettingsRobot() + private val appThemeRobot = AppThemeRobot() + private val searchRobot = SearchRobot() + private val pageRobot = PageRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .clickOnSystemDialogWithText("Allow") + bottomNavRobot + .navigateToMoreMenu() + .goToSettings() + settingsRobot + .clickAppTheme() + appThemeRobot + .toggleReadingFocusMode() + .backToHomeScreen() + searchRobot + .tapSearchView() + .typeTextInView("apple") + .clickOnItemFromSearchList(0) + pageRobot + .dismissTooltip(activity) + .assertEditPencilVisibility(isVisible = false) + appThemeRobot + .toggleTheme() + .toggleReadingFocusMode() + .pressBack() + pageRobot + .assertEditPencilVisibility(isVisible = true) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/tests/settings/ShowImageTest.kt b/app/src/androidTest/java/org/wikipedia/tests/settings/ShowImageTest.kt new file mode 100644 index 00000000000..dfe2df01b8f --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/tests/settings/ShowImageTest.kt @@ -0,0 +1,46 @@ +package org.wikipedia.tests.settings + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.LargeTest +import org.junit.Test +import org.junit.runner.RunWith +import org.wikipedia.base.BaseTest +import org.wikipedia.main.MainActivity +import org.wikipedia.robots.SystemRobot +import org.wikipedia.robots.feature.ExploreFeedRobot +import org.wikipedia.robots.feature.SettingsRobot +import org.wikipedia.robots.navigation.BottomNavRobot +import org.wikipedia.robots.screen.HomeScreenRobot +import org.wikipedia.theme.Theme + +@LargeTest +@RunWith(AndroidJUnit4::class) +class ShowImageTest : BaseTest( + activityClass = MainActivity::class.java +) { + private val bottomNavRobot = BottomNavRobot() + private val settingsRobot = SettingsRobot() + private val exploreFeedRobot = ExploreFeedRobot() + private val homeScreenRobot = HomeScreenRobot() + private val systemRobot = SystemRobot() + + @Test + fun runTest() { + systemRobot + .disableDarkMode(context) + .clickOnSystemDialogWithText("Allow") + homeScreenRobot + .dismissFeedCustomization() + bottomNavRobot + .navigateToMoreMenu() + .goToSettings() + settingsRobot + .toggleShowImages() + exploreFeedRobot + .pressBack() + .scrollToItem(title = "Featured article") + .verifyFeaturedArticleImageIsNotVisible() + .scrollToItem(title = "Top read", verticalOffset = 350) + .verifyTopReadArticleIsGreyedOut(theme = Theme.LIGHT) + } +} diff --git a/app/src/androidTest/java/org/wikipedia/testsuites/ExploreFeedTestSuite.kt b/app/src/androidTest/java/org/wikipedia/testsuites/ExploreFeedTestSuite.kt new file mode 100644 index 00000000000..43d9482b4c9 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/testsuites/ExploreFeedTestSuite.kt @@ -0,0 +1,20 @@ +package org.wikipedia.testsuites + +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.junit.runners.Suite.SuiteClasses +import org.wikipedia.tests.explorefeed.BecauseYouReadTest +import org.wikipedia.tests.explorefeed.FeedScreenSearchTest +import org.wikipedia.tests.explorefeed.FeedScreenSuggestedEditTest +import org.wikipedia.tests.explorefeed.FeedScreenTest +import org.wikipedia.tests.explorefeed.NavigationItemTest + +@RunWith(Suite::class) +@SuiteClasses( + NavigationItemTest::class, + FeedScreenTest::class, + BecauseYouReadTest::class, + FeedScreenSuggestedEditTest::class, + FeedScreenSearchTest::class +) +class ExploreFeedTestSuite diff --git a/app/src/androidTest/java/org/wikipedia/testsuites/SettingsTestSuite.kt b/app/src/androidTest/java/org/wikipedia/testsuites/SettingsTestSuite.kt new file mode 100644 index 00000000000..8f74555b991 --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/testsuites/SettingsTestSuite.kt @@ -0,0 +1,32 @@ +package org.wikipedia.testsuites + +import DownloadReadingListTest +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.junit.runners.Suite.SuiteClasses +import org.wikipedia.tests.settings.AboutSettingsTest +import org.wikipedia.tests.settings.AppThemeTest +import org.wikipedia.tests.settings.ChangingLanguageTest +import org.wikipedia.tests.settings.CollapseTablesTest +import org.wikipedia.tests.settings.CustomizeExploreFeedTest +import org.wikipedia.tests.settings.FontChangeTest +import org.wikipedia.tests.settings.FontSizeTest +import org.wikipedia.tests.settings.LinkPreviewTest +import org.wikipedia.tests.settings.ReadingFocusModeTest +import org.wikipedia.tests.settings.ShowImageTest + +@RunWith(Suite::class) +@SuiteClasses( + ChangingLanguageTest::class, + CustomizeExploreFeedTest::class, + LinkPreviewTest::class, + CollapseTablesTest::class, + AppThemeTest::class, + FontSizeTest::class, + FontChangeTest::class, + ReadingFocusModeTest::class, + DownloadReadingListTest::class, + ShowImageTest::class, + AboutSettingsTest::class +) +class SettingsTestSuite diff --git a/app/src/androidTest/java/org/wikipedia/testsuites/TestSuite.kt b/app/src/androidTest/java/org/wikipedia/testsuites/TestSuite.kt new file mode 100644 index 00000000000..2b148fbdddf --- /dev/null +++ b/app/src/androidTest/java/org/wikipedia/testsuites/TestSuite.kt @@ -0,0 +1,16 @@ +package org.wikipedia.testsuites + +import org.junit.runner.RunWith +import org.junit.runners.Suite +import org.junit.runners.Suite.SuiteClasses +import org.wikipedia.tests.OnboardingTest +import org.wikipedia.tests.SuggestedEditScreenTest + +@RunWith(Suite::class) +@SuiteClasses( + OnboardingTest::class, + SuggestedEditScreenTest::class, + ExploreFeedTestSuite::class, + SettingsTestSuite::class +) +class TestSuite 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..293d37c5e3c --- /dev/null +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt @@ -0,0 +1,310 @@ +package org.wikipedia.donate + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.net.Uri +import android.os.Bundle +import android.text.method.LinkMovementMethod +import android.view.View +import androidx.activity.viewModels +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.gms.wallet.AutoResolveHelper +import com.google.android.gms.wallet.PaymentData +import com.google.android.gms.wallet.PaymentsClient +import com.google.android.gms.wallet.button.ButtonConstants +import com.google.android.gms.wallet.button.ButtonOptions +import com.google.android.material.button.MaterialButton +import kotlinx.coroutines.launch +import org.json.JSONArray +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.databinding.ActivityDonateBinding +import org.wikipedia.dataclient.donate.CampaignCollection +import org.wikipedia.dataclient.donate.DonationConfig +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.Resource +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.StringUtil +import org.wikipedia.util.UriUtil +import kotlin.math.max + +class GooglePayActivity : BaseActivity() { + private lateinit var binding: ActivityDonateBinding + private lateinit var paymentsClient: PaymentsClient + + private val viewModel: GooglePayViewModel by viewModels() + + private var shouldWatchText = true + private var typedManually = false + + private val transactionFee get() = max(getAmountFloat(binding.donateAmountText.text.toString()) * GooglePayComponent.TRANSACTION_FEE_PERCENTAGE, viewModel.transactionFee) + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityDonateBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + title = "" + + binding.donateAmountInput.prefixText = viewModel.currencySymbol + + paymentsClient = GooglePayComponent.createPaymentsClient(this) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.uiState.collect { resource -> + when (resource) { + is Resource.Loading -> { + setLoadingState() + } + is Resource.Error -> { + DonorExperienceEvent.logAction("error_other", "gpay") + setErrorState(resource.throwable) + } + is GooglePayViewModel.NoPaymentMethod -> { + DonorExperienceEvent.logAction("no_payment_method", "gpay") + DonateDialog.launchDonateLink(this@GooglePayActivity, intent.getStringExtra(DonateDialog.ARG_DONATE_URL)) + finish() + } + is Resource.Success -> { + DonorExperienceEvent.logAction("impression", "googlepay_initiated") + onContentsReceived(resource.data) + } + is GooglePayViewModel.DonateSuccess -> { + DonorExperienceEvent.logAction("impression", "gpay_processed", campaignId = intent.getStringExtra(DonateDialog.ARG_CAMPAIGN_ID).orEmpty()) + CampaignCollection.addDonationResult() + setResult(RESULT_OK) + finish() + } + } + } + } + } + } + + binding.errorView.backClickListener = View.OnClickListener { + onBackPressed() + } + + binding.payButton.setOnClickListener { + val amountText = binding.donateAmountText.text.toString() + if (!validateInput(amountText)) { + return@setOnClickListener + } + + var totalAmount = getAmountFloat(amountText) + if (binding.checkBoxTransactionFee.isChecked) { + totalAmount += transactionFee + } + + viewModel.finalAmount = totalAmount + + if (typedManually) { + DonorExperienceEvent.logAction("amount_entered", "gpay") + } + DonorExperienceEvent.submit("donate_confirm_click", "gpay", + "add_transaction: ${binding.checkBoxTransactionFee.isChecked}, recurring: ${binding.checkBoxRecurring.isChecked}, email_subscribe: ${binding.checkBoxAllowEmail.isChecked}") + + AutoResolveHelper.resolveTask( + paymentsClient.loadPaymentData(viewModel.getPaymentDataRequest()), + this, LOAD_PAYMENT_DATA_REQUEST_CODE + ) + } + + binding.donateAmountText.addTextChangedListener { text -> + validateInput(text.toString()) + if (!shouldWatchText) { + return@addTextChangedListener + } + val buttonToHighlight = binding.amountPresetsContainer.children.firstOrNull { child -> + if (child is MaterialButton) { + val amount = getAmountFloat(text.toString()) + child.tag == amount + } else { + false + } + } + typedManually = true + setButtonHighlighted(buttonToHighlight) + } + + binding.linkProblemsDonating.setOnClickListener { + DonorExperienceEvent.logAction("report_problem_click", "gpay") + UriUtil.visitInExternalBrowser(this, Uri.parse(getString(R.string.donate_problems_url))) + } + binding.linkOtherWays.setOnClickListener { + DonorExperienceEvent.logAction("other_give_click", "gpay") + UriUtil.visitInExternalBrowser(this, Uri.parse(getString(R.string.donate_other_ways_url))) + } + binding.linkFAQ.setOnClickListener { + DonorExperienceEvent.logAction("faq_click", "gpay") + UriUtil.visitInExternalBrowser(this, Uri.parse(getString(R.string.donate_faq_url))) + } + binding.linkTaxDeduct.setOnClickListener { + DonorExperienceEvent.logAction("taxinfo_click", "gpay") + UriUtil.visitInExternalBrowser(this, Uri.parse(getString(R.string.donate_tax_url))) + } + binding.disclaimerText1.movementMethod = LinkMovementMethod.getInstance() + binding.disclaimerText2.movementMethod = LinkMovementMethod.getInstance() + } + + private fun validateInput(text: String): Boolean { + val amount = getAmountFloat(text) + val min = viewModel.minimumAmount + val max = viewModel.maximumAmount + + updateTransactionFee() + + if (amount <= 0f || amount < min) { + binding.donateAmountInput.error = getString(R.string.donate_gpay_minimum_amount, viewModel.currencyFormat.format(min)) + DonorExperienceEvent.submit("submission_error", "gpay", "error_reason: min_amount") + return false + } else if (max > 0f && amount > max) { + binding.donateAmountInput.error = getString(R.string.donate_gpay_maximum_amount, viewModel.currencyFormat.format(max)) + DonorExperienceEvent.submit("submission_error", "gpay", "error_reason: max_amount") + return false + } else { + binding.donateAmountInput.isErrorEnabled = false + } + return true + } + + private fun setLoadingState() { + binding.contentsContainer.isVisible = false + binding.errorView.isVisible = false + binding.progressBar.isVisible = true + } + + private fun setErrorState(throwable: Throwable) { + binding.contentsContainer.isVisible = false + binding.progressBar.isVisible = false + binding.errorView.isVisible = true + binding.errorView.setError(throwable) + } + + private fun onContentsReceived(donationConfig: DonationConfig) { + binding.contentsContainer.isVisible = true + binding.progressBar.isVisible = false + binding.errorView.isVisible = false + + binding.checkBoxAllowEmail.isVisible = viewModel.emailOptInRequired + + updateTransactionFee() + + binding.disclaimerText1.text = StringUtil.fromHtml(viewModel.disclaimerInformationSharing) + binding.disclaimerText2.text = StringUtil.fromHtml(viewModel.disclaimerMonthlyCancel) + + val methods = JSONArray().put(GooglePayComponent.baseCardPaymentMethod) + binding.payButton.initialize(ButtonOptions.newBuilder() + .setButtonTheme(if (WikipediaApp.instance.currentTheme.isDark) ButtonConstants.ButtonTheme.DARK else ButtonConstants.ButtonTheme.LIGHT) + .setButtonType(ButtonConstants.ButtonType.DONATE) + .setAllowedPaymentMethods(methods.toString()) + .build()) + + val viewIds = mutableListOf() + val presets = donationConfig.currencyAmountPresets[viewModel.currencyCode] + presets?.forEach { amount -> + val viewId = View.generateViewId() + viewIds.add(viewId) + val button = MaterialButton(this) + button.text = viewModel.currencyFormat.format(amount) + button.id = viewId + button.tag = amount + binding.amountPresetsContainer.addView(button) + button.setOnClickListener { + setButtonHighlighted(it) + setAmountText(it.tag as Float) + DonorExperienceEvent.logAction("amount_selected", "gpay") + } + } + binding.amountPresetsFlow.referencedIds = viewIds.toIntArray() + setButtonHighlighted() + } + + private fun setButtonHighlighted(button: View? = null) { + binding.amountPresetsContainer.children.forEach { child -> + if (child is MaterialButton) { + if (child == button) { + child.backgroundTintList = ResourceUtil.getThemedColorStateList(this, R.attr.progressive_color) + child.setTextColor(Color.WHITE) + } else { + child.backgroundTintList = ResourceUtil.getThemedColorStateList(this, R.attr.background_color) + child.setTextColor(ResourceUtil.getThemedColor(this, R.attr.primary_color)) + } + } + } + } + + private fun updateTransactionFee() { + binding.checkBoxTransactionFee.text = getString(R.string.donate_gpay_check_transaction_fee, + viewModel.currencyFormat.format(transactionFee)) + } + + private fun getAmountFloat(text: String): Float { + var result: Float? + result = text.toFloatOrNull() + if (result == null) { + val text2 = if (text.contains(".")) text.replace(".", ",") else text.replace(",", ".") + result = text2.toFloatOrNull() + } + return result ?: 0f + } + + private fun setAmountText(amount: Float) { + shouldWatchText = false + binding.donateAmountText.setText(viewModel.decimalFormat.format(amount)) + shouldWatchText = true + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == LOAD_PAYMENT_DATA_REQUEST_CODE) { + when (resultCode) { + Activity.RESULT_OK -> { + data?.let { dataIntent -> + PaymentData.getFromIntent(dataIntent)?.let { paymentData -> + viewModel.submit(paymentData, + binding.checkBoxTransactionFee.isChecked, + binding.checkBoxRecurring.isChecked, + if (viewModel.emailOptInRequired) binding.checkBoxAllowEmail.isChecked else true, + intent.getStringExtra(DonateDialog.ARG_CAMPAIGN_ID).orEmpty().ifEmpty { CAMPAIGN_ID_APP_MENU }) + } + } + } + Activity.RESULT_CANCELED -> { + // The user cancelled the payment attempt + } + AutoResolveHelper.RESULT_ERROR -> { + AutoResolveHelper.getStatusFromIntent(data)?.let { + it.statusMessage?.let { message -> + FeedbackUtil.showMessage(this, message) + } + } + } + } + } + } + + companion object { + private const val LOAD_PAYMENT_DATA_REQUEST_CODE = 42 + private const val CAMPAIGN_ID_APP_MENU = "appmenu" + + fun newIntent(context: Context, campaignId: String? = null, donateUrl: String? = null): Intent { + return Intent(context, GooglePayActivity::class.java) + .putExtra(DonateDialog.ARG_CAMPAIGN_ID, campaignId) + .putExtra(DonateDialog.ARG_DONATE_URL, donateUrl) + } + } +} 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..afdf48cd179 --- /dev/null +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt @@ -0,0 +1,136 @@ +package org.wikipedia.donate + +import android.app.Activity +import android.content.Intent +import com.google.android.gms.wallet.IsReadyToPayRequest +import com.google.android.gms.wallet.PaymentsClient +import com.google.android.gms.wallet.Wallet +import com.google.android.gms.wallet.WalletConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.tasks.await +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import org.wikipedia.dataclient.donate.DonationConfigHelper +import org.wikipedia.settings.Prefs +import org.wikipedia.util.GeoUtil +import java.text.DecimalFormat +import java.text.DecimalFormatSymbols +import java.util.Locale + +internal object GooglePayComponent { + + const val PAYMENTS_API_URL = "https://payments.wikimedia.org/" + const val PAYMENT_METHOD_NAME = "paywithgoogle" + const val CURRENCY_FALLBACK = "USD" + const val TRANSACTION_FEE_PERCENTAGE = 0.04f + + private val CURRENCIES_THREE_DECIMAL = arrayOf("BHD", "CLF", "IQD", "KWD", "LYD", "MGA", "MRO", "OMR", "TND") + private val CURRENCIES_NO_DECIMAL = arrayOf("CLP", "DJF", "IDR", "JPY", "KMF", "KRW", "MGA", "PYG", "VND", "XAF", "XOF", "XPF") + + private const val MERCHANT_NAME = "Wikimedia Foundation" + private const val GATEWAY_NAME = "adyen" + + private const val GPAY_API_VERSION = 2 + private const val GPAY_API_VERSION_MINOR = 0 + + private val allAllowedCardNetworks: List = listOf("VISA", "MASTERCARD", "AMEX", "DISCOVER", "JCB", "INTERAC") + private val allAllowedAuthMethods: List = listOf("PAN_ONLY", "CRYPTOGRAM_3DS") + + val baseCardPaymentMethod = JSONObject().apply { + put("type", "CARD") + put("parameters", JSONObject().apply { + put("allowedCardNetworks", JSONArray(allAllowedCardNetworks)) + put("allowedAuthMethods", JSONArray(allAllowedAuthMethods)) + }) + } + + private val googlePayBaseConfiguration = JSONObject().apply { + put("apiVersion", GPAY_API_VERSION) + put("apiVersionMinor", GPAY_API_VERSION_MINOR) + put("allowedPaymentMethods", JSONArray().put(baseCardPaymentMethod)) + } + + fun getDecimalFormat(currencyCode: String, canonical: Boolean = false): DecimalFormat { + val formatSpec = if (CURRENCIES_THREE_DECIMAL.contains(currencyCode)) "0.000" else if (CURRENCIES_NO_DECIMAL.contains(currencyCode)) "0" else "0.00" + return if (canonical) DecimalFormat(formatSpec, DecimalFormatSymbols.getInstance(Locale.ROOT)) else DecimalFormat(formatSpec) + } + + fun createPaymentsClient(activity: Activity): PaymentsClient { + val walletOptions = Wallet.WalletOptions.Builder() + .setEnvironment(if (Prefs.isDonationTestEnvironment) WalletConstants.ENVIRONMENT_TEST else WalletConstants.ENVIRONMENT_PRODUCTION).build() + return Wallet.getPaymentsClient(activity, walletOptions) + } + + suspend fun isGooglePayAvailable(activity: Activity): Boolean { + var available: Boolean + withContext(Dispatchers.IO) { + val readyToPayRequest = IsReadyToPayRequest.fromJson(googlePayBaseConfiguration.toString()) + val paymentsClient = createPaymentsClient(activity) + val readyToPayTask = paymentsClient.isReadyToPay(readyToPayRequest) + available = readyToPayTask.await() + if (available) { + DonationConfigHelper.getConfig()?.let { config -> + available = config.countryCodeGooglePayEnabled.contains(GeoUtil.geoIPCountry.orEmpty()) + } + } + } + return available + } + + fun getDonateActivityIntent(activity: Activity, campaignId: String? = null, donateUrl: String? = null): Intent { + return GooglePayActivity.newIntent(activity, campaignId, donateUrl) + } + + fun getPaymentDataRequestJson( + amount: Float, + currencyCode: String, + merchantId: String?, + gatewayMerchantId: String? + ): JSONObject { + val merchantInfo = JSONObject().apply { + put("merchantName", MERCHANT_NAME) + put("merchantId", merchantId) + } + + val transactionInfo = JSONObject().apply { + put("totalPrice", amount.toString()) + put("totalPriceStatus", "FINAL") + put("currencyCode", currencyCode) + } + + val tokenizationSpecification = JSONObject().apply { + put("type", "PAYMENT_GATEWAY") + put( + "parameters", JSONObject( + mapOf( + "gateway" to GATEWAY_NAME, + "gatewayMerchantId" to gatewayMerchantId + ) + ) + ) + } + + val cardPaymentMethod = JSONObject().apply { + put("type", "CARD") + put("tokenizationSpecification", tokenizationSpecification) + put("parameters", JSONObject().apply { + put("allowedCardNetworks", JSONArray(allAllowedCardNetworks)) + put("allowedAuthMethods", JSONArray(allAllowedAuthMethods)) + put("billingAddressRequired", true) + put("billingAddressParameters", JSONObject(mapOf("format" to "FULL"))) + }) + } + + val paymentDataRequestJson = JSONObject(googlePayBaseConfiguration.toString()).apply { + put("apiVersion", GPAY_API_VERSION) + put("apiVersionMinor", GPAY_API_VERSION_MINOR) + put("allowedPaymentMethods", JSONArray().put(cardPaymentMethod)) + put("transactionInfo", transactionInfo) + put("merchantInfo", merchantInfo) + put("emailRequired", true) + } + + return paymentDataRequestJson + } +} diff --git a/app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt b/app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt new file mode 100644 index 00000000000..3b3fc1475f4 --- /dev/null +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt @@ -0,0 +1,190 @@ +package org.wikipedia.donate + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.google.android.gms.wallet.PaymentData +import com.google.android.gms.wallet.PaymentDataRequest +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.json.JSONObject +import org.wikipedia.BuildConfig +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.dataclient.Service +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.donate.CampaignCollection +import org.wikipedia.dataclient.donate.DonationConfig +import org.wikipedia.dataclient.donate.DonationConfigHelper +import org.wikipedia.settings.Prefs +import org.wikipedia.util.GeoUtil +import org.wikipedia.util.Resource +import org.wikipedia.util.log.L +import java.text.NumberFormat +import java.time.Instant +import java.util.Locale +import java.util.concurrent.TimeUnit +import kotlin.math.abs + +class GooglePayViewModel : ViewModel() { + val uiState = MutableStateFlow(Resource()) + private var donationConfig: DonationConfig? = null + private val currentCountryCode get() = GeoUtil.geoIPCountry.orEmpty() + + val currencyFormat: NumberFormat = NumberFormat.getCurrencyInstance(Locale.Builder() + .setLocale(Locale.getDefault()).setRegion(currentCountryCode).build()) + val currencyCode get() = currencyFormat.currency?.currencyCode ?: GooglePayComponent.CURRENCY_FALLBACK + val currencySymbol get() = currencyFormat.currency?.symbol ?: "$" + val decimalFormat = GooglePayComponent.getDecimalFormat(currencyCode) + + val transactionFee get() = donationConfig?.currencyTransactionFees?.get(currencyCode) + ?: donationConfig?.currencyTransactionFees?.get("default") ?: 0f + + val minimumAmount get() = donationConfig?.currencyMinimumDonation?.get(currencyCode) ?: 0f + + val maximumAmount: Float get() { + var max = donationConfig?.currencyMaximumDonation?.get(currencyCode) ?: 0f + if (max == 0f) { + val defaultMin = donationConfig?.currencyMinimumDonation?.get(GooglePayComponent.CURRENCY_FALLBACK) ?: 0f + if (defaultMin > 0f) { + max = (donationConfig?.currencyMinimumDonation?.get(currencyCode) ?: 0f) / defaultMin * + (donationConfig?.currencyMaximumDonation?.get(GooglePayComponent.CURRENCY_FALLBACK) ?: 0f) + } + } + return max + } + + val emailOptInRequired get() = donationConfig?.countryCodeEmailOptInRequired.orEmpty().contains(currentCountryCode) + + var disclaimerInformationSharing: String? = null + var disclaimerMonthlyCancel: String? = null + + var finalAmount = 0f + + init { + currencyFormat.minimumFractionDigits = 0 + load() + } + + fun load() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + uiState.value = Resource.Error(throwable) + }) { + uiState.value = Resource.Loading() + + val donationConfigCall = async { DonationConfigHelper.getConfig() } + val donationMessagesCall = async { ServiceFactory.get(WikipediaApp.instance.wikiSite, + DonationConfigHelper.DONATE_WIKI_URL, Service::class.java).getMessages( + listOf(MSG_DISCLAIMER_INFORMATION_SHARING, MSG_DISCLAIMER_MONTHLY_CANCEL).joinToString("|"), + null, WikipediaApp.instance.appOrSystemLanguageCode) } + + donationConfig = donationConfigCall.await() + donationMessagesCall.await().let { response -> + disclaimerInformationSharing = response.query?.allmessages?.find { it.name == MSG_DISCLAIMER_INFORMATION_SHARING }?.content?.replace("$1", WikipediaApp.instance.getString(R.string.donor_privacy_policy_url)) + disclaimerMonthlyCancel = response.query?.allmessages?.find { it.name == MSG_DISCLAIMER_MONTHLY_CANCEL }?.content?.replace("$1", WikipediaApp.instance.getString(R.string.donate_email)) + } + + // The paymentMethods API is rate limited, so we cache it manually. + val now = Instant.now().epochSecond + if (abs(now - Prefs.paymentMethodsLastQueryTime) > TimeUnit.DAYS.toSeconds(7)) { + Prefs.paymentMethodsMerchantId = "" + Prefs.paymentMethodsGatewayId = "" + + val paymentMethodsCall = async { + ServiceFactory.get(WikiSite(GooglePayComponent.PAYMENTS_API_URL)) + .getPaymentMethods(currentCountryCode) + } + paymentMethodsCall.await().response?.let { response -> + Prefs.paymentMethodsLastQueryTime = now + response.paymentMethods.find { it.type == GooglePayComponent.PAYMENT_METHOD_NAME }?.let { + Prefs.paymentMethodsMerchantId = it.configuration?.merchantId.orEmpty() + Prefs.paymentMethodsGatewayId = it.configuration?.gatewayMerchantId.orEmpty() + } + } + } + + if (Prefs.paymentMethodsMerchantId.isEmpty() || + Prefs.paymentMethodsGatewayId.isEmpty() || + !donationConfig!!.countryCodeGooglePayEnabled.contains(currentCountryCode) || + !donationConfig!!.currencyAmountPresets.containsKey(currencyCode)) { + uiState.value = NoPaymentMethod() + } else { + uiState.value = Resource.Success(donationConfig!!) + } + } + } + + fun getPaymentDataRequest(): PaymentDataRequest { + return PaymentDataRequest.fromJson(GooglePayComponent.getPaymentDataRequestJson(finalAmount, + currencyCode, + Prefs.paymentMethodsMerchantId, + Prefs.paymentMethodsGatewayId + ).toString()) + } + + fun submit( + paymentData: PaymentData, + payTheFee: Boolean, + recurring: Boolean, + optInEmail: Boolean, + campaignId: String + ) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + uiState.value = Resource.Error(throwable) + }) { + uiState.value = Resource.Loading() + + if (Prefs.isDonationTestEnvironment) { + uiState.value = DonateSuccess() + return@launch + } + + val paymentDataObj = JSONObject(paymentData.toJson()) + val paymentMethodObj = paymentDataObj.getJSONObject("paymentMethodData") + val infoObj = paymentMethodObj.getJSONObject("info") + val billingObj = infoObj.getJSONObject("billingAddress") + val token = paymentMethodObj.getJSONObject("tokenizationData").getString("token") + + // The backend expects the final amount in the canonical decimal format, instead of + // any localized format, e.g. comma as decimal separator. + val decimalFormatCanonical = GooglePayComponent.getDecimalFormat(currencyCode, true) + + val response = ServiceFactory.get(WikiSite(GooglePayComponent.PAYMENTS_API_URL)) + .submitPayment( + decimalFormatCanonical.format(finalAmount), + BuildConfig.VERSION_NAME, + CampaignCollection.getFormattedCampaignId(campaignId), + billingObj.optString("locality", ""), + currentCountryCode, + currencyCode, + billingObj.optString("countryCode", currentCountryCode), + paymentDataObj.optString("email", ""), + billingObj.optString("name", ""), + WikipediaApp.instance.appOrSystemLanguageCode, + if (recurring) "1" else "0", + token, + if (optInEmail) "1" else "0", + if (payTheFee) "1" else "0", + GooglePayComponent.PAYMENT_METHOD_NAME, + infoObj.optString("cardNetwork", ""), + billingObj.optString("postalCode", ""), + billingObj.optString("administrativeArea", ""), + billingObj.optString("address1", ""), + ) + + L.d("Payment response: $response") + + uiState.value = DonateSuccess() + } + } + + class NoPaymentMethod : Resource() + class DonateSuccess : Resource() + + companion object { + private const val MSG_DISCLAIMER_INFORMATION_SHARING = "donate_interface-informationsharing" + private const val MSG_DISCLAIMER_MONTHLY_CANCEL = "donate_interface-monthly-cancel" + } +} diff --git a/app/src/main/java/org/wikipedia/analytics/InstallReferrerListener.kt b/app/src/extra/java/org/wikipedia/installreferrer/InstallReferrerListener.kt similarity index 97% rename from app/src/main/java/org/wikipedia/analytics/InstallReferrerListener.kt rename to app/src/extra/java/org/wikipedia/installreferrer/InstallReferrerListener.kt index a093df8006c..e76102699cc 100644 --- a/app/src/main/java/org/wikipedia/analytics/InstallReferrerListener.kt +++ b/app/src/extra/java/org/wikipedia/installreferrer/InstallReferrerListener.kt @@ -1,4 +1,4 @@ -package org.wikipedia.analytics +package org.wikipedia.installreferrer import android.content.Context import android.content.Intent @@ -7,6 +7,7 @@ import com.android.installreferrer.api.InstallReferrerClient import com.android.installreferrer.api.InstallReferrerStateListener import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.InstallReferrerEvent +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.events.ImportReadingListsEvent import org.wikipedia.page.PageActivity import org.wikipedia.settings.Prefs @@ -130,7 +131,7 @@ class InstallReferrerListener : InstallReferrerStateListener { if (refUtmSource.orEmpty() == "readingLists") { Prefs.importReadingListsNewInstallDialogShown = false - WikipediaApp.instance.bus.post(ImportReadingListsEvent()) + FlowEventBus.post(ImportReadingListsEvent()) } } diff --git a/app/src/extra/java/org/wikipedia/push/WikipediaFirebaseMessagingService.kt b/app/src/extra/java/org/wikipedia/push/WikipediaFirebaseMessagingService.kt index 798cd755b85..938edfa5abc 100644 --- a/app/src/extra/java/org/wikipedia/push/WikipediaFirebaseMessagingService.kt +++ b/app/src/extra/java/org/wikipedia/push/WikipediaFirebaseMessagingService.kt @@ -4,10 +4,13 @@ import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage -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.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil import org.wikipedia.csrf.CsrfTokenClient @@ -20,7 +23,6 @@ import org.wikipedia.settings.Prefs import org.wikipedia.util.log.L class WikipediaFirebaseMessagingService : FirebaseMessagingService() { - override fun onMessageReceived(remoteMessage: RemoteMessage) { L.d("Message from: ${remoteMessage.from}") @@ -53,7 +55,8 @@ class WikipediaFirebaseMessagingService : FirebaseMessagingService() { const val MESSAGE_TYPE_CHECK_ECHO = "checkEchoV1" private const val SUBSCRIBE_RETRY_COUNT = 5 private const val UNSUBSCRIBE_RETRY_COUNT = 3 - private var csrfDisposables = CompositeDisposable() + + private var subscriptionJob: Job? = null fun isUsingPush(): Boolean { return GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(WikipediaApp.instance) == ConnectionResult.SUCCESS && @@ -67,24 +70,22 @@ class WikipediaFirebaseMessagingService : FirebaseMessagingService() { return } - csrfDisposables.clear() - - for (lang in WikipediaApp.instance.languageState.appLanguageCodes) { - csrfDisposables.add(CsrfTokenClient.getToken(WikiSite.forLanguageCode(lang)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - if (lang == WikipediaApp.instance.appOrSystemLanguageCode) { - subscribeWithCsrf(it) - } - setNotificationOptions(lang, it) - }, { - L.e(it) - })) + subscriptionJob?.cancel() + + subscriptionJob = MainScope().launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + for (lang in WikipediaApp.instance.languageState.appLanguageCodes) { + val csrfToken = CsrfTokenClient.getToken(WikiSite.forLanguageCode(lang)) + if (lang == WikipediaApp.instance.appOrSystemLanguageCode) { + subscribeWithCsrf(csrfToken) + } + setNotificationOptions(lang, csrfToken) + } } } - private fun subscribeWithCsrf(csrfToken: String) { + private suspend fun subscribeWithCsrf(csrfToken: String) { if (Prefs.isPushNotificationTokenSubscribed || Prefs.pushNotificationToken.isEmpty()) { // Don't do anything if the token is already subscribed, or if the token is empty. return @@ -96,39 +97,48 @@ class WikipediaFirebaseMessagingService : FirebaseMessagingService() { // Make sure to unsubscribe the previous token, if any if (oldToken.isNotEmpty()) { if (oldToken != token) { - unsubscribePushToken(csrfToken, oldToken) - .subscribe({ - L.d("Previous token unsubscribed successfully.") - Prefs.pushNotificationTokenOld = "" - }, { - L.e(it) - if (it is MwException && it.error.title == "echo-push-token-not-found") { - // token was not found in the database, so consider it gone. - Prefs.pushNotificationTokenOld = "" - } - }) + try { + unsubscribePushToken(csrfToken, oldToken) + L.d("Previous token unsubscribed successfully.") + Prefs.pushNotificationTokenOld = "" + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } else if (e is MwException && e.error.key == "echo-push-token-not-found") { + // token was not found in the database, so consider it gone. + Prefs.pushNotificationTokenOld = "" + } else { + L.e(e) + } + } } else { Prefs.pushNotificationTokenOld = "" } } - ServiceFactory.get(WikipediaApp.instance.wikiSite).subscribePush(csrfToken, token) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .retry(SUBSCRIBE_RETRY_COUNT.toLong()) - .subscribe({ + withContext(Dispatchers.IO) { + for (i in 0 until SUBSCRIBE_RETRY_COUNT) { + try { + ServiceFactory.get(WikipediaApp.instance.wikiSite).subscribePush(csrfToken, token) L.d("Token subscribed successfully.") Prefs.isPushNotificationTokenSubscribed = true - }, { - L.e(it) - if (it is MwException && it.error.title == "echo-push-token-exists") { + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } else if (e is MwException && e.error.key == "echo-push-token-exists") { // token already exists in the database, so consider it subscribed. Prefs.isPushNotificationTokenSubscribed = true + } else { + L.e(e) + continue } - }) + } + break + } + } } - private fun setNotificationOptions(lang: String, csrfToken: String) { + private suspend fun setNotificationOptions(lang: String, csrfToken: String) { if (Prefs.isPushNotificationOptionsSet) { return } @@ -144,24 +154,27 @@ class WikipediaFirebaseMessagingService : FirebaseMessagingService() { ) ServiceFactory.get(WikiSite.forLanguageCode(lang)).postSetOptions(optionList.joinToString(separator = "|"), csrfToken) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - L.d("Notification options updated successfully.") - Prefs.isPushNotificationOptionsSet = true - }, { - L.e(it) - }) + L.d("Notification options updated successfully.") + Prefs.isPushNotificationOptionsSet = true } - fun unsubscribePushToken(csrfToken: String, pushToken: String): Observable { + suspend fun unsubscribePushToken(csrfToken: String, pushToken: String): MwQueryResponse { if (pushToken.isEmpty()) { - return Observable.just(MwQueryResponse()) + return MwQueryResponse() + } + return withContext(Dispatchers.IO) { + for (i in 0 until UNSUBSCRIBE_RETRY_COUNT) { + try { + return@withContext ServiceFactory.get(WikipediaApp.instance.wikiSite).unsubscribePush(csrfToken, pushToken) + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + L.e(e) + } + } + MwQueryResponse() } - return ServiceFactory.get(WikipediaApp.instance.wikiSite).unsubscribePush(csrfToken, pushToken) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .retry(UNSUBSCRIBE_RETRY_COUNT.toLong()) } } } diff --git a/app/src/extra/res/layout/activity_donate.xml b/app/src/extra/res/layout/activity_donate.xml new file mode 100644 index 00000000000..0229a658eeb --- /dev/null +++ b/app/src/extra/res/layout/activity_donate.xml @@ -0,0 +1,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..d670b51bf2b --- /dev/null +++ b/app/src/fdroid/java/org/wikipedia/donate/GooglePayComponent.kt @@ -0,0 +1,14 @@ +package org.wikipedia.donate + +import android.app.Activity +import android.content.Intent + +object GooglePayComponent { + suspend fun isGooglePayAvailable(activity: Activity): Boolean { + return false + } + + fun getDonateActivityIntent(activity: Activity, campaignId: String? = null, donateUrl: String? = null): Intent { + return Intent() + } +} diff --git a/app/src/fdroid/java/org/wikipedia/installreferrer/InstallReferrerListener.kt b/app/src/fdroid/java/org/wikipedia/installreferrer/InstallReferrerListener.kt new file mode 100644 index 00000000000..df3c728a42e --- /dev/null +++ b/app/src/fdroid/java/org/wikipedia/installreferrer/InstallReferrerListener.kt @@ -0,0 +1,9 @@ +package org.wikipedia.installreferrer + +import android.content.Context + +class InstallReferrerListener { + companion object { + fun newInstance(context: Context) { } + } +} diff --git a/app/src/fdroid/java/org/wikipedia/push/WikipediaFirebaseMessagingService.kt b/app/src/fdroid/java/org/wikipedia/push/WikipediaFirebaseMessagingService.kt index 3dd28c8f01f..808cb298855 100644 --- a/app/src/fdroid/java/org/wikipedia/push/WikipediaFirebaseMessagingService.kt +++ b/app/src/fdroid/java/org/wikipedia/push/WikipediaFirebaseMessagingService.kt @@ -1,6 +1,5 @@ package org.wikipedia.push -import io.reactivex.rxjava3.core.Observable import org.wikipedia.dataclient.mwapi.MwQueryResponse class WikipediaFirebaseMessagingService { @@ -13,9 +12,9 @@ class WikipediaFirebaseMessagingService { // stub } - fun unsubscribePushToken(csrfToken: String, pushToken: String): Observable { + suspend fun unsubscribePushToken(csrfToken: String, pushToken: String): MwQueryResponse { // stub - return Observable.just(MwQueryResponse()) + return MwQueryResponse() } } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 70830ab2a2d..bcde6172620 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -32,19 +32,23 @@ + + + + + + + - - - - - + + @@ -79,10 +83,18 @@ + + + + @@ -95,7 +107,25 @@ - + + + + + + + + + + + + + + + android:name=".donate.GooglePayActivity"/> + + - - @@ -430,10 +457,6 @@ - - + packageManager.setComponentEnabledSetting( + launcherIcon.getComponentName(context), + if (launcherIcon == icon) PackageManager.COMPONENT_ENABLED_STATE_ENABLED else + PackageManager.COMPONENT_ENABLED_STATE_DISABLED, + PackageManager.DONT_KILL_APP + ) + } + } +} + +enum class LauncherIcon( + val key: String, + val background: Int, + val foreground: Int, + val label: Int, + var isSelected: Boolean = false +) { + DEFAULT( + key = "DefaultIcon", + background = R.drawable.launcher_background, + foreground = R.drawable.launcher_foreground, + label = R.string.app_name + ), + DONOR( + key = "DonorIcon", + background = R.drawable.launcher_background, + foreground = R.drawable.ic_launcher_donor_benefit_foreground, + label = R.string.app_name + ); + + fun getComponentName(context: Context): ComponentName { + return ComponentName(context.packageName, "org.wikipedia.$key") + } + + companion object { + fun initialValues(): List { + val savedAppIcon = Prefs.currentSelectedAppIcon ?: DEFAULT.key + entries.forEach { icon -> + icon.isSelected = icon.key == savedAppIcon + } + return entries + } + } +} diff --git a/app/src/main/java/org/wikipedia/WikipediaApp.kt b/app/src/main/java/org/wikipedia/WikipediaApp.kt index e93648ef268..6c969fdd9c6 100644 --- a/app/src/main/java/org/wikipedia/WikipediaApp.kt +++ b/app/src/main/java/org/wikipedia/WikipediaApp.kt @@ -1,31 +1,30 @@ package org.wikipedia -import android.annotation.SuppressLint import android.app.Application import android.content.Intent import android.content.pm.PackageManager import android.os.Build import android.os.Handler 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 -import org.wikipedia.analytics.InstallReferrerListener +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import org.wikipedia.analytics.eventplatform.AppSessionEvent import org.wikipedia.analytics.eventplatform.EventPlatformClient import org.wikipedia.appshortcuts.AppShortcuts import org.wikipedia.auth.AccountUtil -import org.wikipedia.concurrency.RxBus +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.connectivity.ConnectionStateMonitor +import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.SharedPreferenceCookieManager import org.wikipedia.dataclient.WikiSite import org.wikipedia.events.ChangeTextSizeEvent +import org.wikipedia.events.LoggedOutEvent import org.wikipedia.events.ThemeFontChangeEvent +import org.wikipedia.installreferrer.InstallReferrerListener import org.wikipedia.language.AcceptLanguageUtil import org.wikipedia.language.AppLanguageState import org.wikipedia.notifications.NotificationCategory @@ -33,12 +32,11 @@ 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 import org.wikipedia.util.log.L -import java.util.* +import java.util.UUID class WikipediaApp : Application() { init { @@ -66,7 +64,6 @@ class WikipediaApp : Application() { private var defaultWikiSite: WikiSite? = null val connectionStateMonitor = ConnectionStateMonitor() - val bus = RxBus() val tabList = mutableListOf() var currentTheme = Theme.fallback @@ -74,18 +71,12 @@ class WikipediaApp : Application() { if (value !== field) { field = value Prefs.currentThemeId = currentTheme.marshallingId - bus.post(ThemeFontChangeEvent()) + FlowEventBus.post(ThemeFontChangeEvent()) } } 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 +105,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!! } @@ -157,11 +145,6 @@ class WikipediaApp : Application() { // See Javadocs and http://developer.android.com/tools/support-library/index.html#rev23-4-0 AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) - // This handler will catch exceptions thrown from Observables after they are disposed, - // or from Observables that are (deliberately or not) missing an onError handler. - // TODO: consider more comprehensive handling of these errors. - RxJavaPlugins.setErrorHandler(Functions.emptyConsumer()) - currentTheme = unmarshalTheme(Prefs.currentThemeId) initTabs() @@ -202,7 +185,7 @@ class WikipediaApp : Application() { val multiplier = constrainFontSizeMultiplier(mult) if (multiplier != Prefs.textSizeMultiplier) { Prefs.textSizeMultiplier = multiplier - bus.post(ChangeTextSizeEvent()) + FlowEventBus.post(ChangeTextSizeEvent()) return true } return false @@ -211,7 +194,7 @@ class WikipediaApp : Application() { fun setFontFamily(fontFamily: String) { if (fontFamily != Prefs.fontFamily) { Prefs.fontFamily = fontFamily - bus.post(ThemeFontChangeEvent()) + FlowEventBus.post(ThemeFontChangeEvent()) } } @@ -236,12 +219,11 @@ class WikipediaApp : Application() { /** * Gets the current size of the app's font. This is given as a device-specific size (not "sp"), * and can be passed directly to setTextSize() functions. - * @param window The window on which the font will be displayed. * @return Actual current size of the font. */ - fun getFontSize(window: Window, editing: Boolean = false): Float { - return DimenUtil.getFontSizeFromSp(window, - resources.getDimension(R.dimen.textSize)) * (1.0f + (if (editing) Prefs.editingTextSizeMultiplier else Prefs.textSizeMultiplier) * + fun getFontSize(editing: Boolean = false): Float { + return DimenUtil.getFontSizeFromSp(resources.getDimension(R.dimen.textSize)) * + (1.0f + (if (editing) Prefs.editingTextSizeMultiplier else Prefs.textSizeMultiplier) * DimenUtil.getFloat(R.dimen.textSizeMultiplierFactor)) } @@ -250,21 +232,26 @@ class WikipediaApp : Application() { defaultWikiSite = null } - @SuppressLint("CheckResult") fun logOut() { - L.d("Logging out") - AccountUtil.removeAccount() - Prefs.isPushNotificationTokenSubscribed = false - Prefs.pushNotificationTokenOld = "" - ServiceFactory.get(wikiSite).getTokenObservable() - .subscribeOn(Schedulers.io()) - .flatMap { - val csrfToken = it.query!!.csrfToken() - WikipediaFirebaseMessagingService.unsubscribePushToken(csrfToken!!, Prefs.pushNotificationToken) - .flatMap { ServiceFactory.get(wikiSite).postLogout(csrfToken).subscribeOn(Schedulers.io()) } - } - .doFinally { SharedPreferenceCookieManager.instance.clearAllCookies() } - .subscribe({ L.d("Logout complete.") }) { L.e(it) } + MainScope().launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + L.d("Logging out") + AccountUtil.removeAccount() + Prefs.isPushNotificationTokenSubscribed = false + Prefs.pushNotificationTokenOld = "" + Prefs.tempAccountWelcomeShown = false + Prefs.tempAccountDialogShown = false + + val token = ServiceFactory.get(wikiSite).getToken().query!!.csrfToken() + WikipediaFirebaseMessagingService.unsubscribePushToken(token!!, Prefs.pushNotificationToken) + ServiceFactory.get(wikiSite).postLogout(token) + }.invokeOnCompletion { + SharedPreferenceCookieManager.instance.clearAllCookies() + AppDatabase.instance.notificationDao().deleteAll() + FlowEventBus.post(LoggedOutEvent()) + L.d("Logout complete.") + } } private fun enableWebViewDebugging() { @@ -282,25 +269,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..f85a6db3fb6 100644 --- a/app/src/main/java/org/wikipedia/activity/BaseActivity.kt +++ b/app/src/main/java/org/wikipedia/activity/BaseActivity.kt @@ -1,5 +1,6 @@ package org.wikipedia.activity +import android.content.Intent import android.graphics.drawable.ColorDrawable import android.os.Build import android.os.Bundle @@ -10,33 +11,44 @@ import androidx.annotation.ColorInt import androidx.appcompat.app.AppCompatActivity import androidx.core.app.ActivityCompat import androidx.core.content.pm.ShortcutManagerCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.skydoves.balloon.Balloon -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.functions.Consumer +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.BreadcrumbsContextHelper import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent +import org.wikipedia.analytics.eventplatform.EventPlatformClient import org.wikipedia.analytics.eventplatform.NotificationInteractionEvent +import org.wikipedia.analytics.metricsplatform.MetricsPlatform import org.wikipedia.appshortcuts.AppShortcuts import org.wikipedia.auth.AccountUtil +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.connectivity.ConnectionStateMonitor -import org.wikipedia.events.* +import org.wikipedia.donate.DonateDialog +import org.wikipedia.events.LoggedOutInBackgroundEvent +import org.wikipedia.events.ReadingListsEnableDialogEvent +import org.wikipedia.events.ReadingListsNoLongerSyncedEvent +import org.wikipedia.events.SplitLargeListsEvent +import org.wikipedia.events.ThemeFontChangeEvent +import org.wikipedia.events.UnreadNotificationsEvent import org.wikipedia.login.LoginActivity import org.wikipedia.main.MainActivity +import org.wikipedia.notifications.NotificationPresenter +import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.readinglist.ReadingListSyncBehaviorDialogs -import org.wikipedia.readinglist.ReadingListsReceiveSurveyHelper -import org.wikipedia.readinglist.ReadingListsShareSurveyHelper import org.wikipedia.readinglist.sync.ReadingListSyncAdapter 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.usercontrib.ContributionsDashboardHelper import org.wikipedia.util.DeviceUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.ResourceUtil @@ -46,8 +58,6 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba interface Callback { fun onPermissionResult(activity: BaseActivity, isGranted: Boolean) } - private lateinit var exclusiveBusMethods: ExclusiveBusConsumer - private val disposables = CompositeDisposable() private var currentTooltip: Balloon? = null private var imageZoomHelper: ImageZoomHelper? = null var callback: Callback? = null @@ -56,10 +66,24 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba callback?.onPermissionResult(this, isGranted) } + private val requestDonateActivity = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + ExclusiveBottomSheetPresenter.dismiss(supportFragmentManager) + if (!Prefs.contributionsDashboardEntryDialogShown && ContributionsDashboardHelper.contributionsDashboardEnabled) { + ContributionsDashboardHelper.showDonationCompletedDialog(this) + Prefs.contributionsDashboardEntryDialogShown = true + } else { + FeedbackUtil.showMessage(this, R.string.donate_gpay_success_message) + } + } + } + + private val notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + // TODO: Show message(s) to the user if they deny the permission + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - exclusiveBusMethods = ExclusiveBusConsumer() - disposables.add(WikipediaApp.instance.bus.subscribe(NonExclusiveBusConsumer())) setTheme() removeSplashBackground() @@ -77,7 +101,7 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba } // Conditionally execute all recurring tasks - RecurringTasksExecutor(WikipediaApp.instance).run() + RecurringTasksExecutor().run() if (Prefs.isReadingListsFirstTimeSync && AccountUtil.isLoggedIn) { Prefs.isReadingListsFirstTimeSync = false Prefs.isReadingListSyncEnabled = true @@ -91,39 +115,78 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba setNavigationBarColor(ResourceUtil.getThemedColor(this, R.attr.paper_color)) maybeShowLoggedOutInBackgroundDialog() - if (ReadingListsShareSurveyHelper.shouldShowSurvey(this)) { - ReadingListsShareSurveyHelper.maybeShowSurvey(this) - } else { - ReadingListsReceiveSurveyHelper.maybeShowSurvey(this) + Prefs.localClassName = localClassName + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + FlowEventBus.events.collectLatest { event -> + if (event is ThemeFontChangeEvent) { + ActivityCompat.recreate(this@BaseActivity) + } + } + } } - Prefs.localClassName = localClassName + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + FlowEventBus.events.collectLatest { event -> + when (event) { + is SplitLargeListsEvent -> { + MaterialAlertDialogBuilder(this@BaseActivity) + .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() + } + is ReadingListsNoLongerSyncedEvent -> { + ReadingListSyncBehaviorDialogs.detectedRemoteTornDownDialog(this@BaseActivity) + } + is ReadingListsEnableDialogEvent, (this@BaseActivity is MainActivity) -> { + ReadingListSyncBehaviorDialogs.promptEnableSyncDialog(this@BaseActivity) + } + is LoggedOutInBackgroundEvent -> { + maybeShowLoggedOutInBackgroundDialog() + } + is ReadingListSyncEvent -> { + if (event.showMessage && !Prefs.isSuggestedEditsHighestPriorityEnabled) { + FeedbackUtil.makeSnackbar(this@BaseActivity, getString(R.string.reading_list_toast_last_sync)).show() + } + } + is UnreadNotificationsEvent -> { + runOnUiThread { + if (!isDestroyed) { + onUnreadNotification() + } + } + } + } + } + } + } } override fun onDestroy() { WikipediaApp.instance.connectionStateMonitor.unregisterCallback(this) - disposables.dispose() - if (EXCLUSIVE_BUS_METHODS === exclusiveBusMethods) { - unregisterExclusiveBusMethods() - } CustomHtmlParser.pruneBitmaps(this) super.onDestroy() } - override fun onStop() { + override fun onPause() { + super.onPause() WikipediaApp.instance.appSessionEvent.persistSession() - super.onStop() + MetricsPlatform.client.onAppPause() + EventPlatformClient.flushCachedEvents() } override fun onResume() { super.onResume() WikipediaApp.instance.appSessionEvent.touchSession() + MetricsPlatform.client.onAppResume() BreadCrumbLogEvent.logScreenShown(this) + } - // allow this activity's exclusive bus methods to override any existing ones. - unregisterExclusiveBusMethods() - EXCLUSIVE_BUS_METHODS = exclusiveBusMethods - EXCLUSIVE_DISPOSABLE = WikipediaApp.instance.bus.subscribe(EXCLUSIVE_BUS_METHODS!!) + override fun onStart() { + super.onStart() + NotificationPresenter.maybeRequestPermission(this, notificationPermissionLauncher) } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -175,14 +238,16 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba override fun onGoOnline() {} - private fun removeSplashBackground() { - window.setBackgroundDrawable(ColorDrawable(ResourceUtil.getThemedColor(this, R.attr.paper_color))) + fun launchDonateDialog(campaignId: String? = null, donateUrl: String? = null) { + ExclusiveBottomSheetPresenter.show(supportFragmentManager, DonateDialog.newInstance(campaignId, donateUrl)) + } + + fun launchDonateActivity(intent: Intent) { + requestDonateActivity.launch(intent) } - private fun unregisterExclusiveBusMethods() { - EXCLUSIVE_DISPOSABLE?.dispose() - EXCLUSIVE_DISPOSABLE = null - EXCLUSIVE_BUS_METHODS = null + private fun removeSplashBackground() { + window.setBackgroundDrawable(ColorDrawable(ResourceUtil.getThemedColor(this, R.attr.paper_color))) } private fun maybeShowLoggedOutInBackgroundDialog() { @@ -213,50 +278,4 @@ abstract class BaseActivity : AppCompatActivity(), ConnectionStateMonitor.Callba } open fun onUnreadNotification() { } - - /** - * Bus consumer that should be registered by all created activities. - */ - private inner class NonExclusiveBusConsumer : Consumer { - override fun accept(event: Any) { - if (event is ThemeFontChangeEvent) { - ActivityCompat.recreate(this@BaseActivity) - } - } - } - - /** - * Bus methods that should be caught only by the topmost activity. - */ - private inner class ExclusiveBusConsumer : Consumer { - override fun accept(event: Any) { - if (event is SplitLargeListsEvent) { - MaterialAlertDialogBuilder(this@BaseActivity) - .setMessage(getString(R.string.split_reading_list_message, SiteInfoClient.maxPagesPerReadingList)) - .setPositiveButton(R.string.reading_list_split_dialog_ok_button_text, null) - .show() - } else if (event is ReadingListsNoLongerSyncedEvent) { - ReadingListSyncBehaviorDialogs.detectedRemoteTornDownDialog(this@BaseActivity) - } else if (event is ReadingListsEnableDialogEvent && this@BaseActivity is MainActivity) { - ReadingListSyncBehaviorDialogs.promptEnableSyncDialog(this@BaseActivity) - } else if (event is LoggedOutInBackgroundEvent) { - maybeShowLoggedOutInBackgroundDialog() - } else if (event is ReadingListSyncEvent) { - if (event.showMessage && !Prefs.isSuggestedEditsHighestPriorityEnabled) { - FeedbackUtil.makeSnackbar(this@BaseActivity, getString(R.string.reading_list_toast_last_sync)).show() - } - } else if (event is UnreadNotificationsEvent) { - runOnUiThread { - if (!isDestroyed) { - onUnreadNotification() - } - } - } - } - } - - companion object { - private var EXCLUSIVE_BUS_METHODS: ExclusiveBusConsumer? = null - private var EXCLUSIVE_DISPOSABLE: Disposable? = null - } } diff --git a/app/src/main/java/org/wikipedia/activity/SingleFragmentActivity.kt b/app/src/main/java/org/wikipedia/activity/SingleFragmentActivity.kt index 2b1af5da362..407f6acacf5 100644 --- a/app/src/main/java/org/wikipedia/activity/SingleFragmentActivity.kt +++ b/app/src/main/java/org/wikipedia/activity/SingleFragmentActivity.kt @@ -1,6 +1,7 @@ package org.wikipedia.activity import android.os.Bundle +import android.widget.FrameLayout import androidx.fragment.app.Fragment import androidx.fragment.app.commit import org.wikipedia.R @@ -29,4 +30,8 @@ abstract class SingleFragmentActivity : BaseActivity() { protected open fun inflateAndSetContentView() { setContentView(R.layout.activity_single_fragment) } + + fun disableFitsSystemWindows() { + findViewById(R.id.fragment_container).fitsSystemWindows = false + } } diff --git a/app/src/main/java/org/wikipedia/activity/SingleWebViewActivity.kt b/app/src/main/java/org/wikipedia/activity/SingleWebViewActivity.kt index ddf295e4f9a..dcdb33c4b1c 100644 --- a/app/src/main/java/org/wikipedia/activity/SingleWebViewActivity.kt +++ b/app/src/main/java/org/wikipedia/activity/SingleWebViewActivity.kt @@ -10,10 +10,13 @@ import android.view.Menu import android.view.MenuItem import android.view.ViewGroup import android.webkit.CookieManager +import android.webkit.JsResult +import android.webkit.WebChromeClient import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import androidx.core.view.isVisible +import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -70,7 +73,7 @@ class SingleWebViewActivity : BaseActivity() { override val linkHandler get() = blankLinkHandler override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean { - if (isWebForm) { + if (isWebForm && !request?.url.toString().startsWith(targetUrl)) { finish() request?.let { // Special case: If the URL is the main page, then just allow the activity to close, @@ -113,6 +116,18 @@ class SingleWebViewActivity : BaseActivity() { } } + binding.webView.webChromeClient = object : WebChromeClient() { + override fun onJsConfirm(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { + MaterialAlertDialogBuilder(this@SingleWebViewActivity) + .setMessage(message) + .setPositiveButton(android.R.string.ok) { _, _ -> result?.confirm() } + .setNegativeButton(android.R.string.cancel) { _, _ -> result?.cancel() } + .setOnDismissListener { result?.cancel() } + .show() + return true + } + } + // 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. diff --git a/app/src/main/java/org/wikipedia/adapter/PagingDataAdapterPatched.kt b/app/src/main/java/org/wikipedia/adapter/PagingDataAdapterPatched.kt new file mode 100644 index 00000000000..fe7ed8d399d --- /dev/null +++ b/app/src/main/java/org/wikipedia/adapter/PagingDataAdapterPatched.kt @@ -0,0 +1,40 @@ +package org.wikipedia.adapter + +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.paging.PagingData +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext + +abstract class PagingDataAdapterPatched +( + diffCallback: DiffUtil.ItemCallback, + mainDispatcher: CoroutineContext = Dispatchers.Main, + workerDispatcher: CoroutineContext = Dispatchers.Default, +) : PagingDataAdapter(diffCallback, mainDispatcher, workerDispatcher) { + private var submitCompleted = true + + // HACK: This is a workaround for a race condition that seems to be present in PagingDataAdapter, + // where submitting data too quickly in succession can cause the adapter to throw an exception. + // This method ensures that the adapter is not in the process of submitting data before attempting + // to submit more. This method should be used in place of the normal submitData method. + // NOTE: This may mean that when submitData is called multiple times quickly, certain calls + // may be "dropped", i.e. ignored. This is a tradeoff to prevent crashes. + // TODO: File issue with AOSP and link here. + fun submitData(scope: LifecycleCoroutineScope, pagingData: PagingData) { + scope.launch { + if (!submitCompleted) { + return@launch + } + async { + submitCompleted = false + submitData(pagingData) + submitCompleted = true + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/alphaupdater/AlphaUpdateChecker.kt b/app/src/main/java/org/wikipedia/alphaupdater/AlphaUpdateChecker.kt index e7cf59c5567..1c8252d32d6 100644 --- a/app/src/main/java/org/wikipedia/alphaupdater/AlphaUpdateChecker.kt +++ b/app/src/main/java/org/wikipedia/alphaupdater/AlphaUpdateChecker.kt @@ -1,19 +1,23 @@ package org.wikipedia.alphaupdater +import android.Manifest import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.net.Uri import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.Request -import okhttp3.Response -import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory.client +import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory import org.wikipedia.notifications.NotificationCategory import org.wikipedia.recurring.RecurringTask import org.wikipedia.settings.PrefsIoUtil import java.io.IOException -import java.util.* +import java.util.Date import java.util.concurrent.TimeUnit class AlphaUpdateChecker(private val context: Context) : RecurringTask() { @@ -23,27 +27,31 @@ class AlphaUpdateChecker(private val context: Context) : RecurringTask() { return System.currentTimeMillis() - lastRun.time >= RUN_INTERVAL_MILLI } - override fun run(lastRun: Date) { + override suspend fun run(lastRun: Date) { // Check for updates! - val hashString: String - var response: Response? = null - try { - val request: Request = Request.Builder().url(ALPHA_BUILD_DATA_URL).build() - response = client.newCall(request).execute() - hashString = response.body!!.string() - } catch (e: IOException) { - // It's ok, we can do nothing. - return - } finally { - response?.close() + var hashString: String? = null + withContext(Dispatchers.IO) { + try { + val request: Request = Request.Builder().url(ALPHA_BUILD_DATA_URL).build() + OkHttpConnectionFactory.client.newCall(request).execute().use { + hashString = it.body?.string() + } + } catch (e: IOException) { + // It's ok, we can do nothing. + } } - if (PrefsIoUtil.getString(PREFERENCE_KEY_ALPHA_COMMIT, "") != hashString) { - showNotification() + hashString?.let { + if (PrefsIoUtil.getString(PREFERENCE_KEY_ALPHA_COMMIT, "") != it) { + showNotification() + } + PrefsIoUtil.setString(PREFERENCE_KEY_ALPHA_COMMIT, it) } - PrefsIoUtil.setString(PREFERENCE_KEY_ALPHA_COMMIT, hashString) } private fun showNotification() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } val intent = Intent(Intent.ACTION_VIEW, Uri.parse(ALPHA_BUILD_APK_URL)) val pendingIntent = PendingIntentCompat.getActivity(context, 0, intent, 0, false) diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/ABTest.kt b/app/src/main/java/org/wikipedia/analytics/ABTest.kt similarity index 92% rename from app/src/main/java/org/wikipedia/analytics/eventplatform/ABTest.kt rename to app/src/main/java/org/wikipedia/analytics/ABTest.kt index d080dde0a84..3d803819ac7 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/ABTest.kt +++ b/app/src/main/java/org/wikipedia/analytics/ABTest.kt @@ -1,4 +1,4 @@ -package org.wikipedia.analytics.eventplatform +package org.wikipedia.analytics import org.wikipedia.settings.PrefsIoUtil import kotlin.random.Random @@ -24,6 +24,7 @@ open class ABTest(private val abTestName: String, private val abTestGroupCount: companion object { private const val AB_TEST_KEY_PREFIX = "ab_test_" const val GROUP_SIZE_2 = 2 + const val GROUP_SIZE_3 = 3 const val GROUP_1 = 0 const val GROUP_2 = 1 const val GROUP_3 = 2 diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/AppInteractionEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/AppInteractionEvent.kt index 0e65ca1f43b..53db0828c7f 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/AppInteractionEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/AppInteractionEvent.kt @@ -9,7 +9,7 @@ import kotlinx.serialization.Transient @Suppress("unused") @Serializable @OptIn(ExperimentalSerializationApi::class) -@SerialName("/analytics/mobile_apps/app_interaction/1.0.0") +@SerialName("/analytics/mobile_apps/app_interaction/1.1.0") class AppInteractionEvent( private val action: String, private val active_interface: String, @@ -18,4 +18,4 @@ class AppInteractionEvent( private val wiki_id: String, @Transient private val streamName: String = "", @EncodeDefault(EncodeDefault.Mode.ALWAYS) private val platform: String = "android", -) : MobileAppsEvent(streamName) +) : MobileAppsEventWithTemp(streamName) diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/AppSessionEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/AppSessionEvent.kt index 27ecac00274..59cfdde9ccb 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/AppSessionEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/AppSessionEvent.kt @@ -90,9 +90,9 @@ class AppSessionEvent { @Suppress("unused") @Serializable - @SerialName("/analytics/mobile_apps/app_session/1.0.0") + @SerialName("/analytics/mobile_apps/app_session/1.1.0") class AppSessionEventImpl(@SerialName("length_ms") private val length: Int, @SerialName("session_data") private val sessionData: SessionData, private val languages: List) : - MobileAppsEvent("app_session") + MobileAppsEventWithTemp("app_session") } diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/BreadCrumbLogEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/BreadCrumbLogEvent.kt index 19223e4b763..7dc921e6800 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/BreadCrumbLogEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/BreadCrumbLogEvent.kt @@ -16,11 +16,11 @@ import org.wikipedia.util.log.L @Suppress("unused", "CanBeParameter") @Serializable -@SerialName("/analytics/mobile_apps/android_breadcrumbs_event/1.0.0") +@SerialName("/analytics/mobile_apps/android_breadcrumbs_event/1.1.0") class BreadCrumbLogEvent( private val screen_name: String, private val action: String -) : MobileAppsEvent(STREAM_NAME) { +) : MobileAppsEventWithTemp(STREAM_NAME) { // Do NOT join the declaration and assignment to these fields, or they won't be serialized correctly. private val app_primary_language_code: String diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/BreadCrumbViewUtil.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/BreadCrumbViewUtil.kt index c3e55b80594..302584cb642 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/BreadCrumbViewUtil.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/BreadCrumbViewUtil.kt @@ -10,7 +10,6 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 -import com.bumptech.glide.manager.SupportRequestManagerFragment import com.google.android.material.button.MaterialButton import org.wikipedia.R import org.wikipedia.activity.SingleFragmentActivity @@ -126,7 +125,6 @@ object BreadCrumbViewUtil { return targetFrag } val frags = (context.baseContext as FragmentActivity).supportFragmentManager.fragments - .filter { it !is SupportRequestManagerFragment } frags.forEach { targetFrag = it.childFragmentManager.findFragmentByTag(ExclusiveBottomSheetPresenter.BOTTOM_SHEET_FRAGMENT_TAG) if (targetFrag != null) { diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/ContributionsDashboardEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/ContributionsDashboardEvent.kt new file mode 100644 index 00000000000..4199963de83 --- /dev/null +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/ContributionsDashboardEvent.kt @@ -0,0 +1,29 @@ +package org.wikipedia.analytics.eventplatform + +import org.wikipedia.WikipediaApp +import org.wikipedia.dataclient.donate.CampaignCollection +import org.wikipedia.settings.Prefs +import org.wikipedia.usercontrib.ContributionsDashboardHelper + +class ContributionsDashboardEvent : DonorExperienceEvent() { + + companion object { + + fun logAction( + action: String, + activeInterface: String, + wikiId: String = WikipediaApp.instance.appOrSystemLanguageCode, + campaignId: String? = null + ) { + if (ContributionsDashboardHelper.contributionsDashboardEnabled) { + submit( + action, + activeInterface, + campaignId?.let { "campaign_id: ${CampaignCollection.getFormattedCampaignId(it)}, " } + .orEmpty() + "donor_detected: ${Prefs.isDonor}", + wikiId + ) + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/DailyStatsEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/DailyStatsEvent.kt index d10105ee208..de8cf434741 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/DailyStatsEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/DailyStatsEvent.kt @@ -9,9 +9,9 @@ import java.util.concurrent.TimeUnit @Suppress("unused") @Serializable -@SerialName("/analytics/mobile_apps/android_daily_stats/2.0.0") +@SerialName("/analytics/mobile_apps/android_daily_stats/2.1.0") class DailyStatsEvent(private val app_install_age_in_days: Long, - private val languages: List) : MobileAppsEvent(STREAM_NAME) { + private val languages: List) : MobileAppsEventWithTemp(STREAM_NAME) { companion object { private const val STREAM_NAME = "android.daily_stats" diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/DonorExperienceEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/DonorExperienceEvent.kt index a46d8658cf3..3237fd57669 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/DonorExperienceEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/DonorExperienceEvent.kt @@ -1,37 +1,32 @@ package org.wikipedia.analytics.eventplatform import org.wikipedia.WikipediaApp +import org.wikipedia.dataclient.donate.CampaignCollection +import org.wikipedia.settings.Prefs -class DonorExperienceEvent { +open class DonorExperienceEvent { companion object { - fun logImpression(activeInterface: String, campaignId: String? = null, wikiId: String = "") { - submitDonorExperienceEvent("impression", activeInterface, getActionDataString(campaignId), wikiId) - } fun logAction( action: String, activeInterface: String, - wikiId: String = "", + wikiId: String = WikipediaApp.instance.appOrSystemLanguageCode, campaignId: String? = null ) { - submitDonorExperienceEvent( + submit( action, activeInterface, - getActionDataString(campaignId), + campaignId?.let { "campaign_id: ${CampaignCollection.getFormattedCampaignId(it)}, " }.orEmpty() + "banner_opt_in: ${Prefs.donationBannerOptIn}", wikiId ) } - fun getActionDataString(campaignId: String? = null): String { - return campaignId?.let { "campaign_id: $it, " }.orEmpty() - } - - private fun submitDonorExperienceEvent( + fun submit( action: String, activeInterface: String, actionData: String, - wikiId: String + wikiId: String = WikipediaApp.instance.appOrSystemLanguageCode ) { EventPlatformClient.submit( AppInteractionEvent( 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 5c9310dde05..af4044114c9 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/EditAttemptStepEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/EditAttemptStepEvent.kt @@ -5,6 +5,8 @@ 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") @@ -42,9 +44,13 @@ class EditAttemptStepEvent(private val event: EditAttemptStepInteractionEvent) : private fun submitEditAttemptEvent(action: String, editorInterface: String, pageTitle: PageTitle) { EventPlatformClient.submit(EditAttemptStepEvent(EditAttemptStepInteractionEvent(action, WikipediaApp.instance.appInstallID, "", editorInterface, - INTEGRATION_ID, "", WikipediaApp.instance.getString(R.string.device_type).lowercase(), 0, - if (AccountUtil.isLoggedIn) AccountUtil.getUserIdForLanguage(pageTitle.wikiSite.languageCode) else 0, - 1, pageTitle.prefixedText, pageTitle.namespace().code()))) + INTEGRATION_ID, "", WikipediaApp.instance.getString(R.string.device_type).lowercase(), 0, getUserIdForWikiSite(pageTitle.wikiSite), + !AccountUtil.isLoggedIn, AccountUtil.isTemporaryAccount, 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 } } } @@ -60,6 +66,8 @@ class EditAttemptStepInteractionEvent(private val action: String, private val platform: String, private val user_editcount: Int, private val user_id: Int, + private val is_anon: Boolean, + private val user_is_temp: Boolean, private val version: Int, private val page_title: String, private val page_ns: Int) 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 bc04c9e92b8..e50db3842b0 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/EventPlatformClient.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/EventPlatformClient.kt @@ -1,9 +1,12 @@ package org.wikipedia.analytics.eventplatform -import android.annotation.SuppressLint import android.widget.Toast import androidx.core.os.postDelayed -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import org.wikipedia.BuildConfig import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.ServiceFactory @@ -13,7 +16,8 @@ import org.wikipedia.settings.Prefs import org.wikipedia.util.ReleaseUtil import org.wikipedia.util.log.L import java.net.HttpURLConnection -import java.util.* +import java.util.Random +import java.util.UUID import java.util.concurrent.ConcurrentHashMap object EventPlatformClient { @@ -32,6 +36,8 @@ object EventPlatformClient { */ private var ENABLED = WikipediaApp.instance.isOnline + private val coroutineScope = CoroutineScope(Dispatchers.IO) + fun setStreamConfig(streamConfig: StreamConfig) { STREAM_CONFIGS[streamConfig.streamName] = streamConfig } @@ -71,11 +77,9 @@ object EventPlatformClient { OutputBuffer.sendAllScheduled() } - @SuppressLint("CheckResult") - fun refreshStreamConfigs() { - ServiceFactory.get(WikiSite(BuildConfig.META_WIKI_BASE_URI)).streamConfigs - .subscribeOn(Schedulers.io()) - .subscribe({ updateStreamConfigs(it.streamConfigs) }) { L.e(it) } + suspend fun refreshStreamConfigs() { + val response = ServiceFactory.get(WikiSite(BuildConfig.META_WIKI_BASE_URI)).getStreamConfigs() + updateStreamConfigs(response.streamConfigs) } private fun updateStreamConfigs(streamConfigs: Map) { @@ -87,7 +91,11 @@ object EventPlatformClient { fun setUpStreamConfigs() { STREAM_CONFIGS.clear() STREAM_CONFIGS.putAll(Prefs.streamConfigs) - refreshStreamConfigs() + MainScope().launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + refreshStreamConfigs() + } } /** @@ -159,39 +167,35 @@ object EventPlatformClient { } } - @SuppressLint("CheckResult") private fun sendEventsForStream(streamConfig: StreamConfig, events: List) { - (if (ReleaseUtil.isDevRelease) - ServiceFactory.getAnalyticsRest(streamConfig).postEvents(events) - else - ServiceFactory.getAnalyticsRest(streamConfig).postEventsHasty(events)) - .subscribeOn(Schedulers.io()) - .subscribe({ - when (it.code()) { - HttpURLConnection.HTTP_CREATED, - HttpURLConnection.HTTP_ACCEPTED -> {} - else -> { - // Received successful response, but unexpected HTTP code. - // TODO: queue up to retry? - } - } - }) { - L.e(it) - if (it is HttpStatusException) { - if (it.code >= HttpURLConnection.HTTP_INTERNAL_ERROR) { - // TODO: For errors >= 500, queue up to retry? - } else { - // Something unexpected happened. - if (ReleaseUtil.isDevRelease) { - // If it's a pre-beta release, show a loud toast to signal that - // a potential issue should be investigated. - WikipediaApp.instance.mainThreadHandler.post { - Toast.makeText(WikipediaApp.instance, it.message, Toast.LENGTH_LONG).show() - } - } + coroutineScope.launch(CoroutineExceptionHandler { _, caught -> + L.e(caught) + if (caught is HttpStatusException) { + if (caught.code >= HttpURLConnection.HTTP_INTERNAL_ERROR) { + // TODO: For errors >= 500, queue up to retry? + } else { + // Something unexpected happened. + if (ReleaseUtil.isDevRelease) { + // If it's a pre-beta release, show a loud toast to signal that + // a potential issue should be investigated. + WikipediaApp.instance.mainThreadHandler.post { + Toast.makeText(WikipediaApp.instance, caught.message, Toast.LENGTH_LONG).show() } } } + } + }) { + val eventService = if (ReleaseUtil.isDevRelease) ServiceFactory.getAnalyticsRest(streamConfig).postEvents(events) else + ServiceFactory.getAnalyticsRest(streamConfig).postEventsHasty(events) + when (eventService.code()) { + HttpURLConnection.HTTP_CREATED, + HttpURLConnection.HTTP_ACCEPTED -> {} + else -> { + // Received successful response, but unexpected HTTP code. + // TODO: queue up to retry? + } + } + } } } diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/EventService.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/EventService.kt index be86ab5955e..8f8ded81afd 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/EventService.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/EventService.kt @@ -1,6 +1,5 @@ package org.wikipedia.analytics.eventplatform -import io.reactivex.rxjava3.core.Observable import retrofit2.Response import retrofit2.http.Body import retrofit2.http.POST @@ -17,8 +16,8 @@ import retrofit2.http.POST */ interface EventService { @POST("/v1/events?hasty=true") - fun postEventsHasty(@Body events: @JvmSuppressWildcards List): Observable> + suspend fun postEventsHasty(@Body events: @JvmSuppressWildcards List): Response @POST("/v1/events") - fun postEvents(@Body events: @JvmSuppressWildcards List): Observable> + suspend fun postEvents(@Body events: @JvmSuppressWildcards List): Response } diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/ImageRecommendationsEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/ImageRecommendationsEvent.kt index 103532f32ce..d7cf80c76a6 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/ImageRecommendationsEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/ImageRecommendationsEvent.kt @@ -9,14 +9,14 @@ import org.wikipedia.util.UriUtil @Suppress("unused") @Serializable -@SerialName("/analytics/mobile_apps/android_image_recommendation_event/1.0.0") +@SerialName("/analytics/mobile_apps/android_image_recommendation_event/1.1.0") class ImageRecommendationsEvent( private val action: String, private val active_interface: String, private val action_data: String, private val primary_language: String, private val wiki_id: String -) : MobileAppsEvent(STREAM_NAME) { +) : MobileAppsEventWithTemp(STREAM_NAME) { companion object { private const val STREAM_NAME = "android.image_recommendation_event" diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/InstallReferrerEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/InstallReferrerEvent.kt index cb5e5900030..da3f52eaf3f 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/InstallReferrerEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/InstallReferrerEvent.kt @@ -5,13 +5,13 @@ import kotlinx.serialization.Serializable @Suppress("unused") @Serializable -@SerialName("/analytics/mobile_apps/android_install_referrer_event/1.0.0") +@SerialName("/analytics/mobile_apps/android_install_referrer_event/1.1.0") class InstallReferrerEvent(@SerialName("referrer_url") private val referrerUrl: String, @SerialName("campaign_id") private val campaignId: String, @SerialName("utm_medium") private val utfMedium: String, @SerialName("utm_campaign") private val utfCampaign: String, @SerialName("utm_source") private val utfSource: String) : - MobileAppsEvent(STREAM_NAME) { + MobileAppsEventWithTemp(STREAM_NAME) { companion object { private const val STREAM_NAME = "android.install_referrer_event" diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/MachineGeneratedArticleDescriptionABCTest.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/MachineGeneratedArticleDescriptionABCTest.kt deleted file mode 100644 index d26464bff6a..00000000000 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/MachineGeneratedArticleDescriptionABCTest.kt +++ /dev/null @@ -1,27 +0,0 @@ -package org.wikipedia.analytics.eventplatform - -import kotlinx.coroutines.runBlocking -import org.wikipedia.WikipediaApp -import org.wikipedia.auth.AccountUtil -import org.wikipedia.settings.Prefs -import org.wikipedia.util.log.L - -class MachineGeneratedArticleDescriptionABCTest : ABTest("mBART25", GROUP_SIZE_2) { - - override fun assignGroup() { - super.assignGroup() - if (AccountUtil.isLoggedIn) { - runBlocking { - try { - MachineGeneratedArticleDescriptionsAnalyticsHelper.setUserExperienced() - if (testGroup == GROUP_2 && Prefs.suggestedEditsMachineGeneratedDescriptionsIsExperienced) { - testGroup = GROUP_3 - } - } catch (e: Exception) { - L.e(e) - } - } - } - MachineGeneratedArticleDescriptionsAnalyticsHelper().logGroupAssigned(WikipediaApp.instance, testGroup) - } -} diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/MachineGeneratedArticleDescriptionsAnalyticsHelper.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/MachineGeneratedArticleDescriptionsAnalyticsHelper.kt index 8e0cf3f3ad0..885da38d8c6 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/MachineGeneratedArticleDescriptionsAnalyticsHelper.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/MachineGeneratedArticleDescriptionsAnalyticsHelper.kt @@ -1,64 +1,35 @@ package org.wikipedia.analytics.eventplatform import android.content.Context -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import org.wikipedia.WikipediaApp -import org.wikipedia.auth.AccountUtil -import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.page.PageTitle -import org.wikipedia.settings.Prefs -import org.wikipedia.util.ActiveTimer class MachineGeneratedArticleDescriptionsAnalyticsHelper { private var apiFailed = false - var apiOrderList = emptyList() - var displayOrderList = emptyList() - private var chosenSuggestion = "" - val timer = ActiveTimer() fun articleDescriptionEditingStart(context: Context) { log(context, composeGroupString() + ".start") } fun articleDescriptionEditingEnd(context: Context) { - log(context, composeGroupString() + ".end.timeSpentMs:${timer.elapsedMillis}") + log(context, composeGroupString() + ".end") } - fun logAttempt(context: Context, finalDescription: String, wasChosen: Boolean, wasModified: Boolean, title: PageTitle) { - log(context, composeLogString(title) + ".attempt:$finalDescription${getSuggestionOrderString(wasChosen, wasModified)}" + - ".timeSpentMs:${timer.elapsedMillis}") + fun logAttempt(context: Context, title: PageTitle) { + log(context, composeLogString(title) + ".attempt") } - fun logSuccess(context: Context, finalDescription: String, wasChosen: Boolean, wasModified: Boolean, title: PageTitle, revId: Long) { - log(context, composeLogString(title) + ".success:$finalDescription${getSuggestionOrderString(wasChosen, wasModified)}" + - ".timeSpentMs:${timer.elapsedMillis}.revId:$revId") - } - - private fun getSuggestionOrderString(wasChosen: Boolean, wasModified: Boolean): String { - return if (apiOrderList.isEmpty() || displayOrderList.isEmpty()) { - "" - } else { - ".suggestion1:${encode(apiOrderList.first())}" + (if (apiOrderList.size > 1) ".suggestion2:${encode(apiOrderList.last())}" else "") + - getOrderString(wasChosen, chosenSuggestion) + ".modified:$wasModified" - } + fun logSuccess(context: Context, title: PageTitle, revId: Long) { + log(context, composeLogString(title) + ".success.revId:$revId") } fun logSuggestionsReceived(context: Context, isBlp: Boolean, title: PageTitle) { apiFailed = false - log(context, composeLogString(title) + ".blp:$isBlp.count:${apiOrderList.size}.suggestion1:${encode(apiOrderList.first())}" + - if (apiOrderList.size > 1) ".suggestion2:${encode(apiOrderList.last())}" else "") - } - - fun logSuggestionsShown(context: Context, title: PageTitle) { - log(context, composeLogString(title) + ".count:${displayOrderList.size}.display1:${encode(displayOrderList.first())}" + - if (displayOrderList.size > 1) ".display2:${encode(displayOrderList.last())}" else "") + log(context, composeLogString(title) + ".blp:$isBlp") } - fun logSuggestionChosen(context: Context, suggestion: String, title: PageTitle) { - chosenSuggestion = suggestion - log(context, composeLogString(title) + ".selected:${encode(suggestion)}${getOrderString(true, suggestion)}") + fun logSuggestionChosen(context: Context, title: PageTitle) { + log(context, composeLogString(title) + ".selected") } fun logSuggestionsDismissed(context: Context, title: PageTitle) { @@ -67,7 +38,7 @@ class MachineGeneratedArticleDescriptionsAnalyticsHelper { fun logSuggestionReported(context: Context, suggestion: String, reportReasonsList: List, title: PageTitle) { val reportReasons = reportReasonsList.joinToString("|") - log(context, composeLogString(title) + ".reportDialog.suggestion:${encode(suggestion)}${getOrderString(true, suggestion)}.reasons:$reportReasons.reported") + log(context, composeLogString(title) + ".reportDialog.suggestion:${encode(suggestion)}.reasons:$reportReasons.reported") } fun logReportDialogDismissed(context: Context) { @@ -78,54 +49,31 @@ class MachineGeneratedArticleDescriptionsAnalyticsHelper { log(context, composeGroupString() + ".onboardingShown") } - fun logGroupAssigned(context: Context, testGroup: Int) { - log(context, "$MACHINE_GEN_DESC_SUGGESTIONS.groupAssigned:$testGroup") - } - fun logApiFailed(context: Context, throwable: Throwable, title: PageTitle) { log(context, composeLogString(title) + ".apiError:${throwable.message}") apiFailed = true } private fun log(context: Context, logString: String) { - if (!isUserInExperiment || apiFailed) { + if (apiFailed) { return } EventPlatformClient.submit(BreadCrumbLogEvent(BreadCrumbViewUtil.getReadableScreenName(context), logString)) } - private fun getOrderString(wasChosen: Boolean, suggestion: String): String { - return ".chosenApiIndex:${if (!wasChosen) -1 else apiOrderList.indexOf(suggestion) + 1}" + - ".chosenDisplayIndex:${if (!wasChosen) -1 else displayOrderList.indexOf(suggestion) + 1}" - } - private fun composeLogString(title: PageTitle): String { return "${composeGroupString()}.lang:${title.wikiSite.languageCode}.title:${encode(title.prefixedText)}" } private fun composeGroupString(): String { - if (!isUserInExperiment) { - return "" - } - return "$MACHINE_GEN_DESC_SUGGESTIONS.group:${abcTest.group}.experienced:${Prefs.suggestedEditsMachineGeneratedDescriptionsIsExperienced}" + return "machineSuggestions" } companion object { - private const val MACHINE_GEN_DESC_SUGGESTIONS = "machineSuggestions" - val abcTest = MachineGeneratedArticleDescriptionABCTest() - var isUserInExperiment = false - // HACK: We're using periods and colons as delimiting characters in these events, so let's // urlencode just those characters, if they appear in our strings. private fun encode(str: String): String { return str.replace(".", "%2E").replace(":", "%3A") } - - suspend fun setUserExperienced() = - withContext(Dispatchers.Default) { - val totalContributions = ServiceFactory.get(WikipediaApp.instance.wikiSite) - .globalUserInfo(AccountUtil.userName!!).query?.globalUserInfo?.editCount ?: 0 - Prefs.suggestedEditsMachineGeneratedDescriptionsIsExperienced = totalContributions > 50 - } } } diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/MobileAppsEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/MobileAppsEvent.kt index 1f164708846..a541db47666 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/MobileAppsEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/MobileAppsEvent.kt @@ -15,3 +15,9 @@ sealed class MobileAppsEvent(@Transient private val _streamName: String = "") : @SerialName("app_session_id") @Required private val sessionId = EventPlatformClient.AssociationController.sessionId @SerialName("app_install_id") @Required private val appInstallId = WikipediaApp.instance.appInstallID } + +@Suppress("unused") +@Serializable +sealed class MobileAppsEventWithTemp(@Transient private val _streamName: String = "") : MobileAppsEvent(_streamName) { + @SerialName("is_temp") @Required private val temp = AccountUtil.isTemporaryAccount +} diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/NotificationInteractionEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/NotificationInteractionEvent.kt index ca2810ebba2..dcabcf1acf4 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/NotificationInteractionEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/NotificationInteractionEvent.kt @@ -11,7 +11,7 @@ import org.wikipedia.notifications.db.Notification @Suppress("unused") @Serializable -@SerialName("/analytics/mobile_apps/android_notification_interaction/2.0.0") +@SerialName("/analytics/mobile_apps/android_notification_interaction/2.1.0") class NotificationInteractionEvent( private val notification_id: Int, private val notification_wiki: String, @@ -21,7 +21,7 @@ class NotificationInteractionEvent( private val selection_token: String, private val incoming_only: Boolean, private val device_level_enabled: Boolean -) : MobileAppsEvent(STREAM_NAME) { +) : MobileAppsEventWithTemp(STREAM_NAME) { companion object { private const val STREAM_NAME = "android.notification_interaction" 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/RabbitHolesEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/RabbitHolesEvent.kt new file mode 100644 index 00000000000..add17ff5b7c --- /dev/null +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/RabbitHolesEvent.kt @@ -0,0 +1,46 @@ +package org.wikipedia.analytics.eventplatform + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.metricsplatform.RabbitHolesAnalyticsHelper +import org.wikipedia.json.JsonUtil + +object RabbitHolesEvent { + fun submit( + action: String, + activeInterface: String, + source: String? = null, + feedbackSelect: String? = null, + feedbackText: String? = null, + wikiId: String = WikipediaApp.instance.appOrSystemLanguageCode + ) { + if (!RabbitHolesAnalyticsHelper.rabbitHolesEnabled) { + return + } + + EventPlatformClient.submit( + AppInteractionEvent( + action, + activeInterface, + JsonUtil.encodeToString(ActionData( + groupAssigned = RabbitHolesAnalyticsHelper.abcTest.getGroupName(), + source = source, + feedbackText = feedbackText, + feedbackSelect = feedbackSelect + )).orEmpty(), + WikipediaApp.instance.languageState.appLanguageCode, + wikiId, + "app_rabbit_holes" + ) + ) + } + + @Serializable + class ActionData( + @SerialName("group_assigned") val groupAssigned: String? = null, + @SerialName("source") val source: String? = null, + @SerialName("feedback_select") val feedbackSelect: String? = null, + @SerialName("feedback_text") val feedbackText: String? = null, + ) +} diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/UserContributionEvent.kt b/app/src/main/java/org/wikipedia/analytics/eventplatform/UserContributionEvent.kt index 210bfe558f2..37a377f5b6f 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/UserContributionEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/eventplatform/UserContributionEvent.kt @@ -4,8 +4,8 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -@SerialName("/analytics/mobile_apps/android_user_contribution_screen/3.0.0") -class UserContributionEvent(val action: String) : MobileAppsEvent(STREAM_NAME) { +@SerialName("/analytics/mobile_apps/android_user_contribution_screen/3.1.0") +class UserContributionEvent(val action: String) : MobileAppsEventWithTemp(STREAM_NAME) { companion object { private const val STREAM_NAME = "android.user_contribution_screen" 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 c00de61c80c..ea81343cc32 100644 --- a/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt @@ -1,7 +1,10 @@ package org.wikipedia.analytics.metricsplatform +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import org.wikimedia.metrics_platform.context.PageData import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageFragment import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs @@ -24,7 +27,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.1.0", + "/analytics/mobile_apps/product_metrics/android_find_in_page_interaction/1.1.1", "find_in_page_interaction", mapOf( "find_text" to findText, @@ -187,7 +190,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.1.0", + "/analytics/mobile_apps/product_metrics/android_article_toc_interaction/1.1.1", "article_toc_interaction", mapOf( "num_opens" to numOpens, @@ -201,9 +204,14 @@ class ArticleTocInteraction(private val fragment: PageFragment, private val numS } } -class ArticleLinkPreviewInteraction : TimedMetricsEvent { +open class ArticleLinkPreviewInteraction : TimedMetricsEvent { private val pageData: PageData? - private val source: Int + var source: Int + + constructor(source: Int) { + this.source = source + this.pageData = null + } constructor(fragment: PageFragment, source: Int) { this.source = source @@ -221,18 +229,18 @@ class ArticleLinkPreviewInteraction : TimedMetricsEvent { } fun logLinkClick() { - submitEvent("linkclick") + submitEvent("linkclick", ContextData(timeSpentMillis = timer.elapsedMillis)) } - fun logNavigate() { - submitEvent(if (Prefs.isLinkPreviewEnabled) "navigate" else "disabled") + open fun logNavigate() { + submitEvent(if (Prefs.isLinkPreviewEnabled) "navigate" else "disabled", ContextData(timeSpentMillis = timer.elapsedMillis)) } fun logCancel() { - submitEvent("cancel") + submitEvent("cancel", ContextData(timeSpentMillis = timer.elapsedMillis)) } - private fun submitEvent(action: String) { + protected fun submitEvent(action: String, contextData: ContextData) { submitEvent( "android.product_metrics.article_link_preview_interaction", "article_link_preview_interaction", @@ -240,9 +248,14 @@ class ArticleLinkPreviewInteraction : TimedMetricsEvent { action, null, source.toString(), - "time_spent_ms.${timer.elapsedMillis}", + JsonUtil.encodeToString(contextData) ), pageData ) } + + @Serializable + class ContextData( + @SerialName("time_spent_ms") val timeSpentMillis: Long? = null + ) } 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 2910ce37556..bf9b62f4abd 100644 --- a/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsEvent.kt @@ -117,7 +117,7 @@ open class MetricsEvent { AccountUtil.hashCode(), AccountUtil.userName, AccountUtil.isLoggedIn, - null, + AccountUtil.isTemporaryAccount, EventPlatformClient.AssociationController.sessionId, EventPlatformClient.AssociationController.pageViewId, AccountUtil.groups, 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 fab839c52f3..81e9d712d72 100644 --- a/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsPlatform.kt +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/MetricsPlatform.kt @@ -1,11 +1,13 @@ package org.wikipedia.analytics.metricsplatform +import android.os.Build import org.wikimedia.metrics_platform.MetricsClient import org.wikimedia.metrics_platform.context.AgentData import org.wikimedia.metrics_platform.context.ClientData import org.wikimedia.metrics_platform.context.MediawikiData import org.wikipedia.BuildConfig import org.wikipedia.WikipediaApp +import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory import org.wikipedia.settings.Prefs import org.wikipedia.util.ReleaseUtil import java.time.Duration @@ -19,6 +21,7 @@ object MetricsPlatform { "WikipediaApp/" + BuildConfig.VERSION_NAME, "android", "app", + Build.BRAND + " " + Build.MODEL, WikipediaApp.instance.languageState.systemLanguageCode, if (ReleaseUtil.isProdRelease) "prod" else "dev" ) @@ -38,6 +41,7 @@ object MetricsPlatform { ) val client: MetricsClient = MetricsClient.builder(clientData) + .httpClient(OkHttpConnectionFactory.client) .eventQueueCapacity(Prefs.analyticsQueueSize) .streamConfigFetchInterval(Duration.ofHours(12)) .sendEventsInterval(Duration.ofSeconds(30)) diff --git a/app/src/main/java/org/wikipedia/analytics/metricsplatform/RabbitHolesABCTest.kt b/app/src/main/java/org/wikipedia/analytics/metricsplatform/RabbitHolesABCTest.kt new file mode 100644 index 00000000000..1ee4fca7ae6 --- /dev/null +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/RabbitHolesABCTest.kt @@ -0,0 +1,13 @@ +package org.wikipedia.analytics.metricsplatform + +import org.wikipedia.analytics.ABTest + +class RabbitHolesABCTest : ABTest("rabbitHoles", GROUP_SIZE_3) { + fun getGroupName(): String { + return when (group) { + GROUP_2 -> "search" + GROUP_3 -> "reading_list" + else -> "control" + } + } +} diff --git a/app/src/main/java/org/wikipedia/analytics/metricsplatform/RabbitHolesAnalyticsHelper.kt b/app/src/main/java/org/wikipedia/analytics/metricsplatform/RabbitHolesAnalyticsHelper.kt new file mode 100644 index 00000000000..811f3635989 --- /dev/null +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/RabbitHolesAnalyticsHelper.kt @@ -0,0 +1,23 @@ +package org.wikipedia.analytics.metricsplatform + +import org.wikipedia.util.GeoUtil +import org.wikipedia.util.ReleaseUtil +import java.time.LocalDate + +object RabbitHolesAnalyticsHelper { + val abcTest = RabbitHolesABCTest() + + private val enabledCountries = listOf( + // sub-saharan africa + "AO", "BJ", "BW", "IO", "BF", "BI", "CV", "CM", "CF", "TD", "KM", "CG", "IC", "CD", "DJ", "GQ", "ER", + "SZ", "ET", "GA", "GM", "GH", "GN", "GW", "KE", "LS", "LR", "MG", "MW", "ML", "MR", "MU", "YT", "MZ", + "NA", "NE", "NG", "RE", "RW", "SH", "ST", "SN", "SC", "SL", "SO", "ZA", "SS", "TG", "UG", "TZ", "ZM", + "ZW", + // south asia + "IN", "PK", "BD", "LK", "MV", "NP", "BT", "AF" + ) + + val rabbitHolesEnabled get() = ReleaseUtil.isPreBetaRelease || + (enabledCountries.contains(GeoUtil.geoIPCountry.orEmpty()) && + LocalDate.now() <= LocalDate.of(2024, 12, 31)) +} diff --git a/app/src/main/java/org/wikipedia/appshortcuts/AppShortcuts.kt b/app/src/main/java/org/wikipedia/appshortcuts/AppShortcuts.kt index ab3b27060b6..4802e6693b6 100644 --- a/app/src/main/java/org/wikipedia/appshortcuts/AppShortcuts.kt +++ b/app/src/main/java/org/wikipedia/appshortcuts/AppShortcuts.kt @@ -22,11 +22,12 @@ class AppShortcuts { private const val APP_SHORTCUT_ID_CONTINUE_READING = "shortcutContinueReading" private const val APP_SHORTCUT_ID_RANDOM = "shortcutRandom" private const val APP_SHORTCUT_ID_SEARCH = "shortcutSearch" + private const val APP_SHORTCUT_ID_PLACES = "shortcutPlaces" fun setShortcuts(app: Context) { CoroutineScope(Dispatchers.Default).launch(CoroutineExceptionHandler { _, msg -> run { L.e(msg) } }) { - val list = listOf(searchShortcut(app), continueReadingShortcut(app), randomShortcut(app)) - if (ShortcutManagerCompat.getDynamicShortcuts(app).size < list.size) { + val list = listOf(searchShortcut(app), continueReadingShortcut(app), randomShortcut(app), placesShortcut(app)) + if (ShortcutManagerCompat.getDynamicShortcuts(app).map { it.id }.containsAll(list.map { it.id }).not()) { ShortcutManagerCompat.setDynamicShortcuts(app, list) } else { L.d("Create dynamic shortcuts skipped.") @@ -72,5 +73,18 @@ class AppShortcuts { .putExtra(Constants.INTENT_APP_SHORTCUT_CONTINUE_READING, true)) .build() } + + private fun placesShortcut(app: Context): ShortcutInfoCompat { + return ShortcutInfoCompat.Builder(app, APP_SHORTCUT_ID_PLACES) + .setShortLabel(app.getString(R.string.app_shortcuts_places)) + .setLongLabel(app.getString(R.string.app_shortcuts_places)) + .setIcon(IconCompat.createWithResource(app, R.drawable.appshortcut_ic_places)) + .setIntent( + Intent(ACTION_APP_SHORTCUT, Uri.EMPTY, app, MainActivity::class.java) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + .putExtra(APP_SHORTCUT_ID, APP_SHORTCUT_ID_PLACES) + .putExtra(Constants.INTENT_APP_SHORTCUT_PLACES, true)) + .build() + } } } diff --git a/app/src/main/java/org/wikipedia/auth/AccountUtil.kt b/app/src/main/java/org/wikipedia/auth/AccountUtil.kt index c6d23c7cd8d..b28b7ca7c0a 100644 --- a/app/src/main/java/org/wikipedia/auth/AccountUtil.kt +++ b/app/src/main/java/org/wikipedia/auth/AccountUtil.kt @@ -3,17 +3,24 @@ package org.wikipedia.auth import android.accounts.Account import android.accounts.AccountAuthenticatorResponse import android.accounts.AccountManager +import android.app.Activity import android.os.Build import androidx.core.os.bundleOf import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.dataclient.SharedPreferenceCookieManager import org.wikipedia.json.JsonUtil import org.wikipedia.login.LoginResult +import org.wikipedia.settings.Prefs +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L.d import org.wikipedia.util.log.L.logRemoteErrorIfProd import java.util.* +import java.util.concurrent.TimeUnit object AccountUtil { + private const val CENTRALAUTH_USER_COOKIE_NAME = "centralauth_User" fun updateAccount(response: AccountAuthenticatorResponse?, result: LoginResult) { if (createAccount(result.userName!!, result.password!!)) { @@ -25,18 +32,17 @@ object AccountUtil { return } setPassword(result.password) - putUserIdForLanguage(result.site.languageCode, result.userId) groups = result.groups } val isLoggedIn: Boolean - get() = account() != null + get() = account() != null || isTemporaryAccount - val userName: String? - get() { - val account = account() - return account?.name - } + val isTemporaryAccount: Boolean + get() = account() == null && getUserNameFromCookie().isNotEmpty() + + val userName: String + get() = account()?.name ?: getUserNameFromCookie() val password: String? get() { @@ -44,13 +50,8 @@ 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 - } + val assertUser: String? + get() = if (isLoggedIn && !isTemporaryAccount) "user" else null var groups: Set get() { @@ -97,6 +98,29 @@ object AccountUtil { return WikipediaApp.instance.getString(R.string.account_type) } + fun getUserNameFromCookie(): String { + return UriUtil.decodeURL(SharedPreferenceCookieManager.instance.getCookieValueByName(CENTRALAUTH_USER_COOKIE_NAME).orEmpty().trim()) + } + + fun getUserNameExpiryFromCookie(): Long { + return SharedPreferenceCookieManager.instance.getCookieExpiryByName(CENTRALAUTH_USER_COOKIE_NAME) + } + + fun maybeShowTempAccountWelcome(activity: Activity) { + if (!Prefs.tempAccountWelcomeShown && isTemporaryAccount) { + Prefs.tempAccountWelcomeShown = true + Prefs.tempAccountDialogShown = false + + val expiryDays = TimeUnit.MILLISECONDS.toDays(getUserNameExpiryFromCookie() - System.currentTimeMillis()).toInt() + FeedbackUtil.showMessage(activity, activity.resources.getQuantityString(R.plurals.temp_account_created, + expiryDays, userName, expiryDays)) + } + } + + fun isUserNameTemporary(userName: String): Boolean { + return userName.startsWith("~") + } + private fun createAccount(userName: String, password: String): Boolean { var account = account() if (account == null || account.name.isNullOrEmpty() || account.name != userName) { @@ -114,19 +138,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 4d25a643e64..35b71468778 100644 --- a/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt +++ b/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt @@ -6,8 +6,8 @@ import org.wikipedia.BuildConfig import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil -import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite +import org.wikipedia.json.JsonUtil import org.wikipedia.page.Namespace import org.wikipedia.page.PageTitle import org.wikipedia.page.PageViewModel @@ -118,7 +118,7 @@ object JavaScriptActionHandler { " \"areTablesInitiallyExpanded\": ${isPreview || !Prefs.isCollapseTablesEnabled}," + " \"textSizeAdjustmentPercentage\": \"100%%\"," + " \"loadImages\": ${Prefs.isImageDownloadEnabled}," + - " \"userGroups\": \"${AccountUtil.groups}\"," + + " \"userGroups\": ${JsonUtil.encodeToString(AccountUtil.groups)}," + " \"isEditable\": ${!Prefs.readingFocusModeEnabled}" + "}", topMargin, 16, 48, 16, leadImageHeight) } @@ -134,10 +134,10 @@ object JavaScriptActionHandler { val showTalkLink = model.page!!.title.namespace() !== Namespace.TALK val showMapLink = model.page!!.pageProperties.geo != null val editedDaysAgo = TimeUnit.MILLISECONDS.toDays(Date().time - model.page!!.pageProperties.lastModified.time) + val langCode = model.title?.wikiSite?.languageCode ?: WikipediaApp.instance.appOrSystemLanguageCode // TODO: page-library also supports showing disambiguation ("similar pages") links and // "page issues". We should be mindful that they exist, even if we don't want them for now. - val baseURL = ServiceFactory.getRestBasePath(model.title?.wikiSite!!).trimEnd('/') return "pcs.c1.Footer.add({" + " platform: \"android\"," + " clientVersion: \"${BuildConfig.VERSION_NAME}\"," + @@ -154,7 +154,26 @@ object JavaScriptActionHandler { " }," + " readMore: { " + " itemCount: 3," + - " baseURL: \"$baseURL\"," + + " readMoreLazy: true," + + " langCode: \"$langCode\"," + + " fragment: \"pcs-read-more\"" + + " }" + + "})" + } + + fun appendReadMode(model: PageViewModel): String { + if (model.page == null) { + return "" + } + val apiBaseURL = model.title?.wikiSite!!.scheme() + "://" + model.title?.wikiSite!!.uri.authority!!.trimEnd('/') + val langCode = model.title?.wikiSite?.languageCode ?: WikipediaApp.instance.appOrSystemLanguageCode + return "pcs.c1.Footer.appendReadMore({" + + " platform: \"android\"," + + " clientVersion: \"${BuildConfig.VERSION_NAME}\"," + + " readMore: { " + + " itemCount: 3," + + " apiBaseURL: \"$apiBaseURL\"," + + " langCode: \"$langCode\"," + " fragment: \"pcs-read-more\"" + " }" + "})" @@ -163,11 +182,17 @@ object JavaScriptActionHandler { fun mobileWebChromeShim(marginTop: Int, marginBottom: Int): String { return "(function() {" + "let style = document.createElement('style');" + - "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; }';" + + "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; } .banner-container { display: none; }';" + "document.head.appendChild(style);" + "})();" } + fun mobileWebSetDarkMode(): String { + return "(function() {" + + "document.documentElement.classList.add('skin-theme-clientpref-night');" + + "})();" + } + fun getElementAtPosition(x: Int, y: Int): String { return "(function() {" + " let element = document.elementFromPoint($x, $y);" + diff --git a/app/src/main/java/org/wikipedia/captcha/Captcha.kt b/app/src/main/java/org/wikipedia/captcha/Captcha.kt index 639088fe961..2524d32cabc 100644 --- a/app/src/main/java/org/wikipedia/captcha/Captcha.kt +++ b/app/src/main/java/org/wikipedia/captcha/Captcha.kt @@ -1,12 +1,13 @@ package org.wikipedia.captcha +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.wikipedia.dataclient.mwapi.MwResponse @Serializable -data class Captcha(private val fancycaptchareload: FancyCaptchaReload) : MwResponse() { +data class Captcha(@SerialName("fancycaptchareload") private val fancyCaptchaReload: FancyCaptchaReload) : MwResponse() { fun captchaId(): String { - return fancycaptchareload.index.orEmpty() + return fancyCaptchaReload.index.orEmpty() } @Serializable diff --git a/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt b/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt index eaa43aa88b4..7291a2e0b06 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 @@ -40,12 +41,12 @@ class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, return captchaResult?.captchaId } - fun captchaWord(): String { - return binding.captchaText.editText?.text.toString() + fun captchaWord(): String? { + return if (isActive) binding.captchaText.editText?.text.toString() else null } 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) - }) + clientJob = 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) { @@ -79,11 +78,11 @@ class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, } // In case there was a captcha attempt before binding.captchaText.editText?.setText("") - ViewUtil.loadImage(binding.captchaImage, captchaResult!!.getCaptchaUrl(wiki), roundedCorners = false, largeRoundedSize = false, force = true, listener = null) + ViewUtil.loadImage(binding.captchaImage, captchaResult!!.getCaptchaUrl(wiki), roundedCorners = false, force = true, listener = null) } 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/categories/CategoryActivity.kt b/app/src/main/java/org/wikipedia/categories/CategoryActivity.kt index e4043e88a1a..c95f133b78e 100644 --- a/app/src/main/java/org/wikipedia/categories/CategoryActivity.kt +++ b/app/src/main/java/org/wikipedia/categories/CategoryActivity.kt @@ -16,7 +16,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState import androidx.paging.LoadStateAdapter -import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -26,6 +25,7 @@ import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.activity.BaseActivity +import org.wikipedia.adapter.PagingDataAdapterPatched import org.wikipedia.databinding.ActivityCategoryBinding import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter @@ -52,7 +52,7 @@ class CategoryActivity : BaseActivity() { private val subcategoriesConcatAdapter = subcategoriesAdapter.withLoadStateHeaderAndFooter(subcategoriesLoadHeader, subcategoriesLoadFooter) private val itemCallback = ItemCallback() - private val viewModel: CategoryActivityViewModel by viewModels { CategoryActivityViewModel.Factory(intent.extras!!) } + private val viewModel: CategoryActivityViewModel by viewModels() public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -72,12 +72,12 @@ class CategoryActivity : BaseActivity() { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.categoryMembersFlow.collectLatest { - categoryMembersAdapter.submitData(it) + categoryMembersAdapter.submitData(lifecycleScope, it) } } launch { viewModel.subcategoriesFlow.collectLatest { - subcategoriesAdapter.submitData(it) + subcategoriesAdapter.submitData(lifecycleScope, it) } } launch { @@ -175,7 +175,7 @@ class CategoryActivity : BaseActivity() { } } - private inner class CategoryMembersAdapter : PagingDataAdapter(CategoryMemberDiffCallback()) { + private inner class CategoryMembersAdapter : PagingDataAdapterPatched(CategoryMemberDiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, pos: Int): CategoryItemHolder { val view = PageItemView(this@CategoryActivity) view.callback = itemCallback diff --git a/app/src/main/java/org/wikipedia/categories/CategoryActivityViewModel.kt b/app/src/main/java/org/wikipedia/categories/CategoryActivityViewModel.kt index e0a536d7f51..8420615dadb 100644 --- a/app/src/main/java/org/wikipedia/categories/CategoryActivityViewModel.kt +++ b/app/src/main/java/org/wikipedia/categories/CategoryActivityViewModel.kt @@ -1,31 +1,28 @@ package org.wikipedia.categories -import android.os.Bundle +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.paging.* import org.wikipedia.Constants import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite -import org.wikipedia.extensions.parcelable import org.wikipedia.page.PageTitle -class CategoryActivityViewModel(bundle: Bundle) : ViewModel() { - val pageTitle = bundle.parcelable(Constants.ARG_TITLE)!! +class CategoryActivityViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val pageTitle = savedStateHandle.get(Constants.ARG_TITLE)!! var showSubcategories = false val categoryMembersFlow = Pager(PagingConfig(pageSize = 10)) { - CategoryMembersPagingSource(pageTitle, "page") + CategoryMembersPagingSource("page") }.flow.cachedIn(viewModelScope) val subcategoriesFlow = Pager(PagingConfig(pageSize = 10)) { - CategoryMembersPagingSource(pageTitle, "subcat") + CategoryMembersPagingSource("subcat") }.flow.cachedIn(viewModelScope) - class CategoryMembersPagingSource( - val pageTitle: PageTitle, - private val resultType: String + inner class CategoryMembersPagingSource( + private val resultType: String ) : PagingSource() { override suspend fun load(params: LoadParams): LoadResult { return try { @@ -51,11 +48,4 @@ class CategoryActivityViewModel(bundle: Bundle) : ViewModel() { return null } } - - class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { - @Suppress("unchecked_cast") - override fun create(modelClass: Class): T { - return CategoryActivityViewModel(bundle) as T - } - } } diff --git a/app/src/main/java/org/wikipedia/categories/CategoryDialog.kt b/app/src/main/java/org/wikipedia/categories/CategoryDialog.kt index 490c139c4c6..ab897a715d2 100644 --- a/app/src/main/java/org/wikipedia/categories/CategoryDialog.kt +++ b/app/src/main/java/org/wikipedia/categories/CategoryDialog.kt @@ -28,7 +28,7 @@ class CategoryDialog : ExtendedBottomSheetDialogFragment() { private val binding get() = _binding!! private val itemCallback = ItemCallback() - private val viewModel: CategoryDialogViewModel by viewModels { CategoryDialogViewModel.Factory(requireArguments()) } + private val viewModel: CategoryDialogViewModel by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = DialogCategoriesBinding.inflate(inflater, container, false) diff --git a/app/src/main/java/org/wikipedia/categories/CategoryDialogViewModel.kt b/app/src/main/java/org/wikipedia/categories/CategoryDialogViewModel.kt index 25303d2c261..63df70681ec 100644 --- a/app/src/main/java/org/wikipedia/categories/CategoryDialogViewModel.kt +++ b/app/src/main/java/org/wikipedia/categories/CategoryDialogViewModel.kt @@ -1,20 +1,18 @@ package org.wikipedia.categories -import android.os.Bundle import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.dataclient.ServiceFactory -import org.wikipedia.extensions.parcelable import org.wikipedia.page.PageTitle import org.wikipedia.util.Resource -class CategoryDialogViewModel(bundle: Bundle) : ViewModel() { - val pageTitle = bundle.parcelable(Constants.ARG_TITLE)!! +class CategoryDialogViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val pageTitle = savedStateHandle.get(Constants.ARG_TITLE)!! val categoriesData = MutableLiveData>>() init { @@ -34,11 +32,4 @@ class CategoryDialogViewModel(bundle: Bundle) : ViewModel() { categoriesData.postValue(Resource.Success(titles)) } } - - class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { - @Suppress("unchecked_cast") - override fun create(modelClass: Class): T { - return CategoryDialogViewModel(bundle) as T - } - } } 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..b77ce295cd3 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() 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..367a25f82e1 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 { @@ -127,7 +132,6 @@ class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : ImageZoomHelper.setViewZoomable(binding.imageView) ViewUtil.loadImage(binding.imageView, ImageUrlUtil.getUrlForPreferredSize(summaryForEdit.thumbnailUrl!!, PREFERRED_GALLERY_IMAGE_SIZE), roundedCorners = false, - largeRoundedSize = false, force = true, listener = null ) @@ -146,19 +150,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..bd35a026b9a --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/FilePageViewModel.kt @@ -0,0 +1,102 @@ +package org.wikipedia.commons + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.language.LanguageUtil +import org.wikipedia.page.PageTitle +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil + +class FilePageViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + private val allowEdit = savedStateHandle[FilePageActivity.INTENT_EXTRA_ALLOW_EDIT] ?: true + val pageTitle = savedStateHandle.get(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) + .getImageInfo( + 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) + .getProtectionWithUserInfo(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.")) + } + } + } +} 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..f87b38001b3 --- /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() + + 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..d7bfbd6f89f --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/ImagePreviewViewModel.kt @@ -0,0 +1,68 @@ +package org.wikipedia.commons + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.suggestededits.PageSummaryForEdit +import org.wikipedia.util.Resource + +class ImagePreviewViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + var pageSummaryForEdit = savedStateHandle.get(ImagePreviewDialog.ARG_SUMMARY)!! + var action = savedStateHandle.get(ImagePreviewDialog.ARG_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) + .getImageInfo(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) + .getImageInfo(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.")) + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt b/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt index 2dc101a3b84..5f21dccd048 100644 --- a/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt +++ b/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt @@ -1,33 +1,28 @@ package org.wikipedia.commons -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.Schedulers import org.wikipedia.Constants import org.wikipedia.dataclient.ServiceFactory -import org.wikipedia.dataclient.mwapi.MwQueryResponse import org.wikipedia.dataclient.wikidata.Claims import org.wikipedia.language.LanguageUtil object ImageTagsProvider { - fun getImageTagsObservable(pageId: Int, langCode: String): Observable>> { - return ServiceFactory.get(Constants.commonsWikiSite).getClaims("M$pageId", "P180") - .subscribeOn(Schedulers.io()) - .onErrorReturnItem(Claims()) - .flatMap { claims -> - val ids = getDepictsClaims(claims.claims) - if (ids.isNullOrEmpty()) { - Observable.just(MwQueryResponse()) - } else { - ServiceFactory.get(Constants.wikidataWikiSite).getWikidataEntityTerms(ids.joinToString(separator = "|"), LanguageUtil.convertToUselangIfNeeded(langCode)) - } - } - .subscribeOn(Schedulers.io()) - .map { response -> - val labelList = response.query?.pages?.mapNotNull { - it.entityTerms?.label?.firstOrNull() - } - if (labelList.isNullOrEmpty()) emptyMap() else mapOf(langCode to labelList) + suspend fun getImageTags(pageId: Int, langCode: String): Map> { + try { + val claims = ServiceFactory.get(Constants.commonsWikiSite).getClaims("M$pageId", "P180") + val ids = getDepictsClaims(claims.claims) + return if (ids.isEmpty()) { + emptyMap() + } else { + val response = ServiceFactory.get(Constants.wikidataWikiSite).getWikidataEntityTerms(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 { diff --git a/app/src/main/java/org/wikipedia/concurrency/FlowEventBus.kt b/app/src/main/java/org/wikipedia/concurrency/FlowEventBus.kt new file mode 100644 index 00000000000..1695f432e95 --- /dev/null +++ b/app/src/main/java/org/wikipedia/concurrency/FlowEventBus.kt @@ -0,0 +1,18 @@ +package org.wikipedia.concurrency + +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import org.wikipedia.util.log.L + +object FlowEventBus { + + private val _events = MutableSharedFlow(extraBufferCapacity = 8, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val events = _events.asSharedFlow() + + fun post(event: Any) { + if (!_events.tryEmit(event)) { + L.e("Unable to emit event") + } + } +} diff --git a/app/src/main/java/org/wikipedia/concurrency/RxBus.kt b/app/src/main/java/org/wikipedia/concurrency/RxBus.kt deleted file mode 100644 index 97a573f92ad..00000000000 --- a/app/src/main/java/org/wikipedia/concurrency/RxBus.kt +++ /dev/null @@ -1,21 +0,0 @@ -package org.wikipedia.concurrency - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.annotations.CheckReturnValue -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.functions.Consumer -import io.reactivex.rxjava3.subjects.PublishSubject - -class RxBus { - private val bus = PublishSubject.create().toSerialized() - private val observable = bus.observeOn(AndroidSchedulers.mainThread()) - - fun post(o: Any) { - bus.onNext(o) - } - - @CheckReturnValue - fun subscribe(consumer: Consumer): Disposable { - return observable.subscribe(consumer) - } -} diff --git a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt index c78019c05f6..a0eb70b79a7 100644 --- a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt +++ b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivity.kt @@ -9,21 +9,23 @@ import android.text.TextWatcher import android.util.Patterns import android.view.KeyEvent import android.view.View +import androidx.activity.viewModels +import androidx.core.view.isVisible import androidx.core.widget.doOnTextChanged +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputLayout -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity import org.wikipedia.analytics.eventplatform.CreateAccountEvent +import org.wikipedia.auth.AccountUtil import org.wikipedia.captcha.CaptchaHandler import org.wikipedia.captcha.CaptchaResult import org.wikipedia.databinding.ActivityCreateAccountBinding -import org.wikipedia.dataclient.Service -import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.page.PageTitle import org.wikipedia.util.DeviceUtil import org.wikipedia.util.FeedbackUtil @@ -31,7 +33,6 @@ import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil.visitInExternalBrowser import org.wikipedia.util.log.L import org.wikipedia.views.NonEmptyValidator -import java.util.concurrent.TimeUnit import java.util.regex.Pattern class CreateAccountActivity : BaseActivity() { @@ -42,10 +43,9 @@ class CreateAccountActivity : BaseActivity() { private lateinit var binding: ActivityCreateAccountBinding private lateinit var captchaHandler: CaptchaHandler private lateinit var createAccountEvent: CreateAccountEvent - private val disposables = CompositeDisposable() private var wiki = WikipediaApp.instance.wikiSite private var userNameTextWatcher: TextWatcher? = null - private val userNameVerifyRunnable = UserNameVerifyRunnable() + private val viewModel: CreateAccountActivityViewModel by viewModels() public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -62,8 +62,73 @@ class CreateAccountActivity : BaseActivity() { if (savedInstanceState == null) { createAccountEvent.logStart() } + + if (AccountUtil.isTemporaryAccount) { + binding.footerContainer.tempAccountInfoContainer.isVisible = true + binding.footerContainer.tempAccountInfoText.text = StringUtil.fromHtml(getString(R.string.temp_account_login_status, AccountUtil.userName)) + } else { + binding.footerContainer.tempAccountInfoContainer.isVisible = false + } + // Set default result to failed, so we can override if it did not setResult(RESULT_ACCOUNT_NOT_CREATED) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.createAccountInfoState.collect { + when (it) { + is CreateAccountActivityViewModel.AccountInfoState.DoCreateAccount -> { + doCreateAccount(it.token) + } + is CreateAccountActivityViewModel.AccountInfoState.HandleCaptcha -> { + captchaHandler.handleCaptcha(it.token, CaptchaResult(it.captchaId)) + } + is CreateAccountActivityViewModel.AccountInfoState.InvalidToken -> { + handleAccountCreationError(getString(R.string.create_account_generic_error)) + } + is CreateAccountActivityViewModel.AccountInfoState.Error -> { + showError(it.throwable) + L.e(it.throwable) + } + } + } + } + launch { + viewModel.doCreateAccountState.collect { + when (it) { + is CreateAccountActivityViewModel.CreateAccountState.Pass -> { + finishWithUserResult(it.userName) + } + is CreateAccountActivityViewModel.CreateAccountState.Error -> { + if (it.throwable is CreateAccountException) { + createAccountEvent.logError(it.throwable.message) + } + L.e(it.throwable.toString()) + createAccountEvent.logError(it.throwable.toString()) + showProgressBar(false) + showError(it.throwable) + } + } + } + } + launch { + viewModel.verifyUserNameState.collect { + when (it) { + CreateAccountActivityViewModel.UserNameState.Initial, + CreateAccountActivityViewModel.UserNameState.Success -> { + binding.createAccountUsername.isErrorEnabled = false + } + is CreateAccountActivityViewModel.UserNameState.Blocked -> { + handleAccountCreationError(it.error) + } + is CreateAccountActivityViewModel.UserNameState.CannotCreate -> { + binding.createAccountUsername.error = getString(R.string.create_account_name_unavailable, it.userName) + } + } + } + } + } + } } private fun setClickListeners() { @@ -100,17 +165,11 @@ class CreateAccountActivity : BaseActivity() { false } userNameTextWatcher = binding.createAccountUsername.editText?.doOnTextChanged { text, _, _, _ -> - binding.createAccountUsername.removeCallbacks(userNameVerifyRunnable) - binding.createAccountUsername.isErrorEnabled = false - if (text.isNullOrEmpty()) { - return@doOnTextChanged - } - userNameVerifyRunnable.setUserName(text.toString()) - binding.createAccountUsername.postDelayed(userNameVerifyRunnable, TimeUnit.SECONDS.toMillis(1)) + viewModel.verifyUserName(text) } } - fun handleAccountCreationError(message: String) { + private fun handleAccountCreationError(message: String) { if (message.contains("blocked")) { FeedbackUtil.makeSnackbar(this, getString(R.string.create_account_ip_block_message)) .setAction(R.string.create_account_ip_block_details) { @@ -124,51 +183,13 @@ class CreateAccountActivity : BaseActivity() { L.w("Account creation failed with result $message") } - private val createAccountInfo: Unit - get() { - disposables.add(ServiceFactory.get(wiki).authManagerInfo - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ response -> - val token = response.query?.createAccountToken() - val captchaId = response.query?.captchaId() - if (token.isNullOrEmpty()) { - handleAccountCreationError(getString(R.string.create_account_generic_error)) - } else if (!captchaId.isNullOrEmpty()) { - captchaHandler.handleCaptcha(token, CaptchaResult(captchaId)) - } else { - doCreateAccount(token) - } - }) { caught -> - showError(caught) - L.e(caught) - }) - } - private fun doCreateAccount(token: String) { showProgressBar(true) val email = getText(binding.createAccountEmail).ifEmpty { null } val password = getText(binding.createAccountPasswordInput) val repeat = getText(binding.createAccountPasswordRepeat) - disposables.add(ServiceFactory.get(wiki).postCreateAccount(getText(binding.createAccountUsername), password, repeat, token, Service.WIKIPEDIA_URL, - email, - if (captchaHandler.isActive) captchaHandler.captchaId() else "null", - if (captchaHandler.isActive) captchaHandler.captchaWord() else "null") - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ response -> - if ("PASS" == response.status) { - finishWithUserResult(response.user) - } else { - createAccountEvent.logError(StringUtil.removeStyleTags(response.message)) - throw CreateAccountException(StringUtil.removeStyleTags(response.message)) - } - }) { caught -> - L.e(caught.toString()) - createAccountEvent.logError(caught.toString()) - showProgressBar(false) - showError(caught) - }) + val userName = getText(binding.createAccountUsername) + viewModel.doCreateAccount(token, captchaHandler.captchaId().toString(), captchaHandler.captchaWord().toString(), userName, password, repeat, email) } override fun onBackPressed() { @@ -187,7 +208,6 @@ class CreateAccountActivity : BaseActivity() { } public override fun onDestroy() { - disposables.clear() captchaHandler.dispose() userNameTextWatcher?.let { binding.createAccountUsername.editText?.removeTextChangedListener(it) } super.onDestroy() @@ -247,7 +267,7 @@ class CreateAccountActivity : BaseActivity() { if (captchaHandler.isActive && captchaHandler.token != null) { doCreateAccount(captchaHandler.token!!) } else { - createAccountInfo + viewModel.createAccountInfo() } } @@ -281,30 +301,6 @@ class CreateAccountActivity : BaseActivity() { binding.viewCreateAccountError.visibility = View.VISIBLE } - private inner class UserNameVerifyRunnable : Runnable { - private lateinit var userName: String - - fun setUserName(userName: String) { - this.userName = userName - } - - override fun run() { - disposables.add(ServiceFactory.get(wiki).getUserList(userName) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ response -> - response.query?.getUserResponse(userName)?.let { - binding.createAccountUsername.isErrorEnabled = false - if (it.hasBlockError) { - handleAccountCreationError(it.error) - } else if (!it.canCreate) { - binding.createAccountUsername.error = getString(R.string.create_account_name_unavailable, userName) - } - } - }) { L.e(it) }) - } - } - companion object { private const val PASSWORD_MIN_LENGTH = 8 const val RESULT_ACCOUNT_CREATED = 1 diff --git a/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivityViewModel.kt b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivityViewModel.kt new file mode 100644 index 00000000000..9a98ed5b718 --- /dev/null +++ b/app/src/main/java/org/wikipedia/createaccount/CreateAccountActivityViewModel.kt @@ -0,0 +1,103 @@ +package org.wikipedia.createaccount + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.WikipediaApp +import org.wikipedia.dataclient.Service +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.util.StringUtil +import org.wikipedia.util.log.L + +class CreateAccountActivityViewModel : ViewModel() { + private val _createAccountInfoState = MutableStateFlow(AccountInfoState()) + val createAccountInfoState = _createAccountInfoState.asStateFlow() + + private val _doCreateAccountState = MutableStateFlow(CreateAccountState()) + val doCreateAccountState = _doCreateAccountState.asStateFlow() + + private val _verifyUserNameState = MutableSharedFlow() + val verifyUserNameState = _verifyUserNameState.asSharedFlow() + + private var verifyUserNameJob: Job? = null + + fun createAccountInfo() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _createAccountInfoState.value = AccountInfoState.Error(throwable) + }) { + val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getAuthManagerInfo() + val token = response.query?.createAccountToken() + val captchaId = response.query?.captchaId() + if (token.isNullOrEmpty()) { + _createAccountInfoState.value = AccountInfoState.InvalidToken + } else if (!captchaId.isNullOrEmpty()) { + _createAccountInfoState.value = AccountInfoState.HandleCaptcha(token, captchaId) + } else { + _createAccountInfoState.value = AccountInfoState.DoCreateAccount(token) + } + } + } + + fun doCreateAccount(token: String, captchaId: String, captchaWord: String, userName: String, password: String, repeat: String, email: String?) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _doCreateAccountState.value = CreateAccountState.Error(throwable) + }) { + val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).postCreateAccount(userName, password, repeat, token, Service.WIKIPEDIA_URL, + email, captchaId, captchaWord) + if ("PASS" == response.status) { + _doCreateAccountState.value = CreateAccountState.Pass(response.user) + } else { + throw CreateAccountException(StringUtil.removeStyleTags(response.message)) + } + } + } + + fun verifyUserName(text: CharSequence?) { + verifyUserNameJob?.cancel() + verifyUserNameJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + }) { + _verifyUserNameState.emit(UserNameState.Initial) + if (text.isNullOrEmpty()) { + return@launch + } + delay(1000) + val userName = text.toString() + val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).getUserList(userName) + response.query?.getUserResponse(userName)?.let { + _verifyUserNameState.emit(UserNameState.Success) + if (it.hasBlockError) { + _verifyUserNameState.emit(UserNameState.Blocked(it.error)) + } else if (!it.canCreate) { + _verifyUserNameState.emit(UserNameState.CannotCreate(userName)) + } + } + } + } + + open class AccountInfoState { + data class DoCreateAccount(val token: String) : AccountInfoState() + data class HandleCaptcha(val token: String?, val captchaId: String) : AccountInfoState() + data object InvalidToken : AccountInfoState() + data class Error(val throwable: Throwable) : AccountInfoState() + } + + open class CreateAccountState { + data class Pass(val userName: String) : CreateAccountState() + data class Error(val throwable: Throwable) : CreateAccountState() + } + + open class UserNameState { + data object Initial : UserNameState() + data object Success : UserNameState() + data class Blocked(val error: String) : UserNameState() + data class CannotCreate(val userName: String) : UserNameState() + } +} diff --git a/app/src/main/java/org/wikipedia/csrf/CsrfTokenClient.kt b/app/src/main/java/org/wikipedia/csrf/CsrfTokenClient.kt index 47ebec8f774..0ce5811d710 100644 --- a/app/src/main/java/org/wikipedia/csrf/CsrfTokenClient.kt +++ b/app/src/main/java/org/wikipedia/csrf/CsrfTokenClient.kt @@ -1,9 +1,11 @@ package org.wikipedia.csrf -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.dataclient.Service import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite @@ -19,79 +21,63 @@ object CsrfTokenClient { private const val ANON_TOKEN = "+\\" private const val MAX_RETRIES = 3 - fun getToken(site: WikiSite, type: String = "csrf"): Observable { - return getToken(site, type, null) - } - - fun getToken(site: WikiSite, type: String = "csrf", svc: Service?): Observable { - return Observable.create { emitter -> - var token = "" + suspend fun getToken(site: WikiSite, type: String = "csrf", svc: Service? = null): String { + var token = "" + withContext(Dispatchers.IO) { try { MUTEX.acquire() val service = svc ?: ServiceFactory.get(site) - - if (emitter.isDisposed) { - return@create - } var lastError: Throwable? = null for (retry in 0 until MAX_RETRIES) { - if (retry > 0) { + if (retry > 0 && AccountUtil.isLoggedIn && !AccountUtil.isTemporaryAccount) { // Log in explicitly - LoginClient().loginBlocking(site, AccountUtil.userName!!, AccountUtil.password!!, "") - .subscribeOn(Schedulers.io()) - .blockingSubscribe({ }) { - L.e(it) - lastError = it - } + try { + LoginClient().loginBlocking(site, AccountUtil.userName, AccountUtil.password!!, "") + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + L.e(e) + lastError = e + } } - if (emitter.isDisposed) { - return@create + try { + val tokenResponse = service.getToken(type) + token = if (type == "rollback") { + tokenResponse.query?.rollbackToken().orEmpty() + } else { + tokenResponse.query?.csrfToken().orEmpty() + } + if (AccountUtil.isLoggedIn && token == ANON_TOKEN) { + throw RuntimeException("App believes we're logged in, but got anonymous token.") + } + } catch (e: CancellationException) { + throw e + } catch (e: Throwable) { + L.e(e) + lastError = e } - - service.getTokenObservable(type) - .subscribeOn(Schedulers.io()) - .blockingSubscribe({ - if (type == "rollback") { - token = it.query?.rollbackToken().orEmpty() - } else { - token = it.query?.csrfToken().orEmpty() - } - if (AccountUtil.isLoggedIn && token == ANON_TOKEN) { - throw RuntimeException("App believes we're logged in, but got anonymous token.") - } - }, { - L.e(it) - lastError = it - }) - if (emitter.isDisposed) { - return@create - } - - if (token.isEmpty() || (AccountUtil.isLoggedIn && token == ANON_TOKEN)) { + if (token.isEmpty() || (AccountUtil.isLoggedIn && !AccountUtil.isTemporaryAccount && token == ANON_TOKEN)) { continue } break } - if (token.isEmpty() || (AccountUtil.isLoggedIn && token == ANON_TOKEN)) { + if (token.isEmpty() || (AccountUtil.isLoggedIn && !AccountUtil.isTemporaryAccount && token == ANON_TOKEN)) { if (token == ANON_TOKEN) { bailWithLogout() } throw lastError ?: IOException("Invalid token, or login failure.") } - } catch (t: Throwable) { - emitter.onError(t) } finally { MUTEX.release() } - emitter.onNext(token) - emitter.onComplete() } + return token } private fun bailWithLogout() { // Signal to the rest of the app that we're explicitly logging out in the background. WikipediaApp.instance.logOut() Prefs.loggedOutInBackground = true - WikipediaApp.instance.bus.post(LoggedOutInBackgroundEvent()) + FlowEventBus.post(LoggedOutInBackgroundEvent()) } } diff --git a/app/src/main/java/org/wikipedia/database/AppDatabase.kt b/app/src/main/java/org/wikipedia/database/AppDatabase.kt index 6d1ae5bf183..12ef4ecc6e2 100644 --- a/app/src/main/java/org/wikipedia/database/AppDatabase.kt +++ b/app/src/main/java/org/wikipedia/database/AppDatabase.kt @@ -169,17 +169,17 @@ abstract class AppDatabase : RoomDatabase() { database.execSQL("DROP TABLE pageimages") } } - private val MIGRATION_23_24 = object : Migration(23, 24) { + val MIGRATION_23_24 = object : Migration(23, 24) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("CREATE TABLE IF NOT EXISTS `Notification` (`id` INTEGER NOT NULL, `wiki` TEXT NOT NULL, `read` TEXT, `category` TEXT NOT NULL, `type` TEXT NOT NULL, `revid` INTEGER NOT NULL, `title` TEXT, `agent` TEXT, `timestamp` TEXT, `contents` TEXT, PRIMARY KEY(`id`, `wiki`))") } } - private val MIGRATION_24_25 = object : Migration(24, 25) { + val MIGRATION_24_25 = object : Migration(24, 25) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("CREATE TABLE IF NOT EXISTS `TalkTemplate` (`id` INTEGER NOT NULL, `type` INTEGER NOT NULL, `order` INTEGER NOT NULL, `title` TEXT NOT NULL, `subject` TEXT NOT NULL, `message` TEXT NOT NULL, PRIMARY KEY(`id`))") } } - private val MIGRATION_25_26 = object : Migration(25, 26) { + val MIGRATION_25_26 = object : Migration(25, 26) { override fun migrate(database: SupportSQLiteDatabase) { database.execSQL("ALTER TABLE HistoryEntry ADD COLUMN description TEXT NOT NULL DEFAULT ''") } diff --git a/app/src/main/java/org/wikipedia/dataclient/CoreRestService.kt b/app/src/main/java/org/wikipedia/dataclient/CoreRestService.kt index 5e4f1f37a7d..5ea4c391362 100644 --- a/app/src/main/java/org/wikipedia/dataclient/CoreRestService.kt +++ b/app/src/main/java/org/wikipedia/dataclient/CoreRestService.kt @@ -1,6 +1,7 @@ package org.wikipedia.dataclient import org.wikipedia.dataclient.growthtasks.GrowthImageSuggestion +import org.wikipedia.dataclient.growthtasks.GrowthUserImpact import org.wikipedia.dataclient.restbase.DiffResponse import org.wikipedia.dataclient.restbase.EditCount import org.wikipedia.dataclient.restbase.Revision @@ -34,6 +35,11 @@ interface CoreRestService { @Body body: GrowthImageSuggestion.AddImageFeedbackBody ) + @GET("growthexperiments/v0/user-impact/%23{userId}") + suspend fun getUserImpact( + @Path("userId") userId: Int + ): GrowthUserImpact + companion object { const val CORE_REST_API_PREFIX = "w/rest.php/" } diff --git a/app/src/main/java/org/wikipedia/dataclient/RestService.kt b/app/src/main/java/org/wikipedia/dataclient/RestService.kt index b2127a541a8..c51e4bb6186 100644 --- a/app/src/main/java/org/wikipedia/dataclient/RestService.kt +++ b/app/src/main/java/org/wikipedia/dataclient/RestService.kt @@ -1,6 +1,5 @@ package org.wikipedia.dataclient -import io.reactivex.rxjava3.core.Observable import org.wikipedia.dataclient.okhttp.OfflineCacheInterceptor import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.dataclient.page.TalkPage @@ -12,48 +11,36 @@ 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.suggestededits.provider.SuggestedEditItem +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 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 { - /** - * Gets a page summary for a given title -- for link previews - * - * @param title the page title to be used including prefix - */ @Headers("x-analytics: preview=1", "Accept: $ACCEPT_HEADER_SUMMARY") @GET("page/summary/{title}") - fun getSummaryResponse( + suspend fun getSummaryResponse( @Path("title") title: String, - @Header("Referer") referrerUrl: String?, - @Header("Cache-Control") cacheControl: String?, - @Header(OfflineCacheInterceptor.SAVE_HEADER) saveHeader: String?, - @Header(OfflineCacheInterceptor.LANG_HEADER) langHeader: String?, - @Header(OfflineCacheInterceptor.TITLE_HEADER) titleHeader: String? - ): Observable> - - @Headers("x-analytics: preview=1", "Accept: $ACCEPT_HEADER_SUMMARY") - @GET("page/summary/{title}") - suspend fun getSummaryResponseSuspend( - @Path("title") title: String, - @Header("Referer") referrerUrl: String?, - @Header("Cache-Control") cacheControl: String?, - @Header(OfflineCacheInterceptor.SAVE_HEADER) saveHeader: String?, - @Header(OfflineCacheInterceptor.LANG_HEADER) langHeader: String?, - @Header(OfflineCacheInterceptor.TITLE_HEADER) titleHeader: String? + @Header("Referer") referrerUrl: String? = null, + @Header("Cache-Control") cacheControl: String? = null, + @Header(OfflineCacheInterceptor.SAVE_HEADER) saveHeader: String? = null, + @Header(OfflineCacheInterceptor.LANG_HEADER) langHeader: String? = null, + @Header(OfflineCacheInterceptor.TITLE_HEADER) titleHeader: String? = null ): Response - @Headers("x-analytics: preview=1", "Accept: $ACCEPT_HEADER_SUMMARY") - @GET("page/summary/{title}") - fun getSummary( - @Header("Referer") referrerUrl: String?, - @Path("title") title: String - ): Observable - @Headers("x-analytics: preview=1", "Accept: $ACCEPT_HEADER_SUMMARY") @GET("page/summary/{title}") suspend fun getPageSummary( @@ -61,58 +48,38 @@ 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( - @Path("title") title: String, - @Path("revision") revision: Long - ): Observable - - @GET("page/media-list/{title}/{revision}") - suspend fun getMediaListSuspend( + suspend fun getMediaList( @Path("title") title: String, @Path("revision") revision: Long ): MediaList @GET("page/media-list/{title}/{revision}") - fun getMediaListResponse( + suspend fun getMediaListResponse( @Path("title") title: String?, @Path("revision") revision: Long, @Header("Cache-Control") cacheControl: String?, @Header(OfflineCacheInterceptor.SAVE_HEADER) saveHeader: String?, @Header(OfflineCacheInterceptor.LANG_HEADER) langHeader: String?, @Header(OfflineCacheInterceptor.TITLE_HEADER) titleHeader: String? - ): Observable> + ): Response @GET("feed/onthisday/events/{mm}/{dd}") - fun getOnThisDay(@Path("mm") month: Int, @Path("dd") day: Int): Observable + suspend fun getOnThisDay(@Path("mm") month: Int, + @Path("dd") day: Int): OnThisDay // 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 - - @Headers("Accept: " + ACCEPT_HEADER_PREFIX + "aggregated-feed/0.5.0\"") - @GET("feed/featured/{year}/{month}/{day}") - fun getAggregatedFeed( - @Path("year") year: String?, - @Path("month") month: String?, - @Path("day") day: String? - ): 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}") @@ -122,8 +89,8 @@ interface RestService { @Path("day") day: String? ): AggregatedFeedContent - @get:GET("feed/availability") - val feedAvailability: Observable + @GET("feed/availability") + suspend fun feedAvailability(): FeedAvailability // ------- Reading lists ------- @POST("data/lists/setup") @@ -199,33 +166,10 @@ interface RestService { @Query("csrf_token") token: String? ): Call - // ------- Recommendations ------- - @Headers("Cache-Control: no-cache") - @GET("data/recommendation/caption/addition/{lang}") - fun getImagesWithoutCaptions(@Path("lang") lang: String): Observable> - - @Headers("Cache-Control: no-cache") - @GET("data/recommendation/caption/translation/from/{fromLang}/to/{toLang}") - fun getImagesWithTranslatableCaptions( - @Path("fromLang") fromLang: String, - @Path("toLang") toLang: String - ): Observable> - - @Headers("Cache-Control: no-cache") - @GET("data/recommendation/description/addition/{lang}") - fun getArticlesWithoutDescriptions(@Path("lang") lang: String): Observable> - - @Headers("Cache-Control: no-cache") - @GET("data/recommendation/description/translation/from/{fromLang}/to/{toLang}") - fun getArticlesWithTranslatableDescriptions( - @Path("fromLang") fromLang: String, - @Path("toLang") toLang: String - ): Observable> - // ------- Talk pages ------- @Headers("Cache-Control: no-cache") @GET("page/talk/{title}") - fun getTalkPage(@Path("title") title: String?): Observable + suspend fun getTalkPage(@Path("title") title: String?): TalkPage @Headers("Cache-Control: no-cache") @GET("metrics/edits/per-page/{wikiAuthority}/{title}/all-editor-types/monthly/{fromDate}/{toDate}") diff --git a/app/src/main/java/org/wikipedia/dataclient/Service.kt b/app/src/main/java/org/wikipedia/dataclient/Service.kt index dc4fe26ffea..80c8ba4a148 100644 --- a/app/src/main/java/org/wikipedia/dataclient/Service.kt +++ b/app/src/main/java/org/wikipedia/dataclient/Service.kt @@ -1,11 +1,11 @@ package org.wikipedia.dataclient -import io.reactivex.rxjava3.core.Observable import org.wikipedia.captcha.Captcha 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.donate.PaymentResponseContainer import org.wikipedia.dataclient.mwapi.CreateAccountResponse import org.wikipedia.dataclient.mwapi.MwParseResponse import org.wikipedia.dataclient.mwapi.MwPostResponse @@ -23,7 +23,7 @@ import org.wikipedia.dataclient.wikidata.Entities import org.wikipedia.dataclient.wikidata.EntityPostResponse import org.wikipedia.dataclient.wikidata.Search import org.wikipedia.edit.Edit -import org.wikipedia.login.LoginClient.LoginResponse +import org.wikipedia.login.LoginResponse import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.GET @@ -39,12 +39,6 @@ interface Service { // ------- Search ------- - @GET( - MW_API_PREFIX + "action=query&prop=pageimages&piprop=thumbnail" + - "&converttitles=&pilicense=any&pithumbsize=" + PREFERRED_THUMB_SIZE - ) - fun getPageImages(@Query("titles") titles: String): Observable - @GET( 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 @@ -68,10 +62,10 @@ interface Service { ): MwQueryResponse @GET(MW_API_PREFIX + "action=query&list=allusers&auwitheditsonly=1") - fun prefixSearchUsers( + suspend fun prefixSearchUsers( @Query("auprefix") prefix: String, @Query("aulimit") maxResults: Int - ): Observable + ): MwQueryResponse @GET( MW_API_PREFIX + "action=query&generator=search&prop=imageinfo&iiprop=extmetadata|url" + @@ -101,59 +95,53 @@ interface Service { "&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 @GET(MW_API_PREFIX + "action=query&prop=description&redirects=1") - fun getDescription(@Query("titles") titles: String): Observable + suspend fun getDescription(@Query("titles") titles: String): MwQueryResponse @GET(MW_API_PREFIX + "action=query&prop=info|description|pageimages&inprop=varianttitles|displaytitle&redirects=1&pithumbsize=" + PREFERRED_THUMB_SIZE) suspend fun getInfoByPageIdsOrTitles(@Query("pageids") pageIds: String? = null, @Query("titles") titles: String? = null): MwQueryResponse - @GET(MW_API_PREFIX + "action=query") + @GET(MW_API_PREFIX + "action=query&meta=siteinfo&siprop=general|autocreatetempuser") suspend fun getPageIds(@Query("titles") titles: String): MwQueryResponse - @GET(MW_API_PREFIX + "action=query&prop=imageinfo&iiprop=timestamp|user|url|mime|extmetadata&iiurlwidth=" + PREFERRED_THUMB_SIZE) - fun getImageInfo( - @Query("titles") titles: String, - @Query("iiextmetadatalanguage") lang: String - ): Observable - - @GET(MW_API_PREFIX + "action=query&prop=imageinfo&iiprop=timestamp|user|url|mime|extmetadata&iiurlwidth=" + PREFERRED_THUMB_SIZE) - suspend fun getImageInfoSuspend( + @GET(MW_API_PREFIX + "action=query&prop=imageinfo&iiprop=timestamp|user|url|mime|metadata|extmetadata&iiurlwidth=" + PREFERRED_THUMB_SIZE) + suspend fun getImageInfo( @Query("titles") titles: String, @Query("iiextmetadatalanguage") lang: String ): MwQueryResponse - @GET(MW_API_PREFIX + "action=query&prop=videoinfo&viprop=timestamp|user|url|mime|extmetadata|derivatives&viurlwidth=" + PREFERRED_THUMB_SIZE) - fun getVideoInfo( + @GET(MW_API_PREFIX + "action=query&prop=videoinfo&viprop=timestamp|user|url|mime|metadata|extmetadata|derivatives&viurlwidth=" + PREFERRED_THUMB_SIZE) + suspend fun getVideoInfo( @Query("titles") titles: String, @Query("viextmetadatalanguage") lang: String - ): Observable + ): MwQueryResponse @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&prop=info&inprop=protection") + suspend fun getProtection(@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 + @GET(MW_API_PREFIX + "action=query&meta=userinfo&prop=info&inprop=protection&uiprop=groups") + suspend fun getProtectionWithUserInfo(@Query("titles") titles: String): MwQueryResponse @GET(MW_API_PREFIX + "action=sitematrix&smtype=language&smlangprop=code|name|localname&maxage=" + SITE_INFO_MAXAGE + "&smaxage=" + SITE_INFO_MAXAGE) suspend fun getSiteMatrix(): SiteMatrix @@ -166,17 +154,17 @@ interface Service { @Header(OfflineCacheInterceptor.TITLE_HEADER) titleHeader: String? = null ): MwQueryResponse - @get:GET(MW_API_PREFIX + "action=query&meta=siteinfo&maxage=" + SITE_INFO_MAXAGE + "&smaxage=" + SITE_INFO_MAXAGE) - val siteInfo: Observable + @GET(MW_API_PREFIX + "action=query&meta=siteinfo&maxage=" + SITE_INFO_MAXAGE + "&smaxage=" + SITE_INFO_MAXAGE) + suspend fun getSiteInfo(): MwQueryResponse @GET(MW_API_PREFIX + "action=query&meta=siteinfo&siprop=general|magicwords") suspend fun getSiteInfoWithMagicWords(): MwQueryResponse @GET(MW_API_PREFIX + "action=parse&prop=text&mobileformat=1") - fun parsePage(@Query("page") pageTitle: String): Observable + suspend fun parsePage(@Query("page") pageTitle: String): MwParseResponse @GET(MW_API_PREFIX + "action=parse&prop=text&mobileformat=1") - fun parseText(@Query("text") text: String): Observable + suspend fun parseText(@Query("text") text: String): MwParseResponse @GET(MW_API_PREFIX + "action=parse&prop=text&mobileformat=1&mainpage=1") suspend fun parseTextForMainPage(@Query("page") mainPageTitle: String): MwParseResponse @@ -192,9 +180,15 @@ interface Service { @Query("gcmcontinue") continueStr: String? ): MwQueryResponse - @get:GET(MW_API_PREFIX + "action=query&generator=random&redirects=1&grnnamespace=6&grnlimit=10&prop=description|imageinfo|revisions&rvprop=ids|timestamp|flags|comment|user|content&rvslots=mediainfo&iiprop=timestamp|user|url|mime|extmetadata&iiurlwidth=" + PREFERRED_THUMB_SIZE) - @get:Headers("Cache-Control: no-cache") - val randomWithImageInfo: Observable + @GET(MW_API_PREFIX + "action=query&generator=random&redirects=1&grnnamespace=0&prop=pageprops|description|info&inprop=protection") + suspend fun getRandomPages( + @Query("grnlimit") count: Int = 50, + ): MwQueryResponse + + @GET(MW_API_PREFIX + "action=query&generator=random&redirects=1&grnnamespace=6&prop=info|description|imageinfo|revisions|globalusage&inprop=protection&gunamespace=0&rvprop=ids|timestamp|flags|comment|user|content&rvslots=mediainfo&iiprop=timestamp|user|url|mime|extmetadata&iilocalonly=1&iiurlwidth=" + PREFERRED_THUMB_SIZE) + suspend fun getRandomImages( + @Query("grnlimit") count: Int = 10, + ): MwQueryResponse @Headers("Cache-Control: no-cache") @GET(MW_API_PREFIX + "action=query&list=recentchanges&rcprop=title|timestamp|ids|oresscores|sizes|tags|user|parsedcomment|comment|flags&rcnamespace=0&rctype=edit|new") @@ -209,18 +203,19 @@ interface Service { @FormUrlEncoded @POST(MW_API_PREFIX + "action=options") - fun postSetOptions( + suspend fun postSetOptions( @Field("change") change: String, @Field("token") token: String - ): Observable + ): MwPostResponse - @get:GET(MW_API_PREFIX + "action=streamconfigs&format=json&constraints=destination_event_service=eventgate-analytics-external") - val streamConfigs: Observable + @GET(MW_API_PREFIX + "action=streamconfigs&format=json&constraints=destination_event_service=eventgate-analytics-external") + suspend fun getStreamConfigs(): MwStreamConfigsResponse @GET(MW_API_PREFIX + "action=query&meta=allmessages&amenableparser=1") suspend fun getMessages( @Query("ammessages") messages: String, - @Query("amargs") args: String? + @Query("amargs") args: String?, + @Query("amlang") lang: String? = null ): MwQueryResponse @FormUrlEncoded @@ -229,7 +224,7 @@ interface Service { @Field("url") url: String, ): ShortenUrlResponse - @GET(MW_API_PREFIX + "action=query&generator=geosearch&prop=coordinates|description|pageimages|info&inprop=varianttitles|displaytitle") + @GET(MW_API_PREFIX + "action=query&generator=geosearch&prop=coordinates|description|pageimages|info&inprop=varianttitles|displaytitle&pilicense=any") suspend fun getGeoSearch( @Query("ggscoord", encoded = true) coordinates: String, @Query("ggsradius") radius: Int, @@ -237,11 +232,34 @@ interface Service { @Query("colimit") coLimit: Int, ): MwQueryResponse - // ------- CSRF, Login, and Create Account ------- + @GET("api.php?format=json&action=getPaymentMethods") + suspend fun getPaymentMethods(@Query("country") country: String): PaymentResponseContainer - @Headers("Cache-Control: no-cache") - @GET(MW_API_PREFIX + "action=query&meta=tokens") - fun getTokenObservable(@Query("type") type: String = "csrf"): Observable + @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("full_name") fullName: String, + @Field("language") language: 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 ------- @GET(MW_API_PREFIX + "action=query&meta=tokens") @Headers("Cache-Control: no-cache") @@ -249,7 +267,7 @@ interface Service { @FormUrlEncoded @POST(MW_API_PREFIX + "action=createaccount&createmessageformat=html") - fun postCreateAccount( + suspend fun postCreateAccount( @Field("username") user: String, @Field("password") pass: String, @Field("retype") retype: String, @@ -258,41 +276,41 @@ interface Service { @Field("email") email: String?, @Field("captchaId") captchaId: String?, @Field("captchaWord") captchaWord: String? - ): Observable + ): CreateAccountResponse - @get:GET(MW_API_PREFIX + "action=query&meta=tokens&type=login") - @get:Headers("Cache-Control: no-cache") - val loginToken: Observable + @GET(MW_API_PREFIX + "action=query&meta=tokens&type=login") + @Headers("Cache-Control: no-cache") + suspend fun getLoginToken(): MwQueryResponse @FormUrlEncoded @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=") - fun postLogIn( + suspend fun postLogIn( @Field("username") user: String?, @Field("password") pass: String?, @Field("logintoken") token: String?, @Field("loginreturnurl") url: String? - ): Observable + ): LoginResponse @FormUrlEncoded @POST(MW_API_PREFIX + "action=clientlogin&rememberMe=") - fun postLogIn( + suspend fun postLogIn( @Field("username") user: String?, @Field("password") pass: String?, @Field("retype") retypedPass: String?, @Field("OATHToken") twoFactorCode: String?, @Field("logintoken") token: String?, @Field("logincontinue") loginContinue: Boolean - ): Observable + ): LoginResponse @FormUrlEncoded @POST(MW_API_PREFIX + "action=logout") - fun postLogout(@Field("token") token: String): Observable + suspend fun postLogout(@Field("token") token: String): MwPostResponse - @get:GET(MW_API_PREFIX + "action=query&meta=authmanagerinfo|tokens&amirequestsfor=create&type=createaccount") - val authManagerInfo: Observable + @GET(MW_API_PREFIX + "action=query&meta=authmanagerinfo|tokens&amirequestsfor=create&type=createaccount") + suspend fun getAuthManagerInfo(): MwQueryResponse - @get:GET(MW_API_PREFIX + "action=query&meta=userinfo&uiprop=groups|blockinfo|editcount|latestcontrib|hasmsg") - val userInfo: Observable + @GET(MW_API_PREFIX + "action=query&meta=userinfo&uiprop=groups|blockinfo|editcount|latestcontrib|hasmsg|options") + suspend fun getUserInfo(): MwQueryResponse @GET(MW_API_PREFIX + "action=query&list=users&usprop=editcount|groups|registration|rights") suspend fun userInfo(@Query("ususers") userName: String): MwQueryResponse @@ -304,7 +322,7 @@ interface Service { suspend fun globalUserInfo(@Query("guiuser") userName: String): MwQueryResponse @GET(MW_API_PREFIX + "action=query&list=users&usprop=groups|cancreate") - fun getUserList(@Query("ususers") userNames: String): Observable + suspend fun getUserList(@Query("ususers") userNames: String): MwQueryResponse // ------- Notifications ------- @@ -332,32 +350,27 @@ interface Service { @GET(MW_API_PREFIX + "action=query&meta=unreadnotificationpages&unplimit=max&unpwikis=*") suspend fun unreadNotificationWikis(): MwQueryResponse - // TODO: remove "KT" if we remove the Observable one. - @Headers("Cache-Control: no-cache") - @GET(MW_API_PREFIX + "action=query&meta=unreadnotificationpages&unplimit=max&unpwikis=*") - suspend fun unreadNotificationWikisKT(): MwQueryResponse - @FormUrlEncoded @POST(MW_API_PREFIX + "action=echopushsubscriptions&command=create&provider=fcm") - fun subscribePush( + suspend fun subscribePush( @Field("token") csrfToken: String, @Field("providertoken") providerToken: String - ): Observable + ): MwQueryResponse @FormUrlEncoded @POST(MW_API_PREFIX + "action=echopushsubscriptions&command=delete&provider=fcm") - fun unsubscribePush( + suspend fun unsubscribePush( @Field("token") csrfToken: String, @Field("providertoken") providerToken: String - ): Observable + ): MwQueryResponse // ------- Editing ------- - @GET(MW_API_PREFIX + "action=query&prop=revisions|info&rvslots=main&rvprop=content|timestamp|ids&rvlimit=1&converttitles=&intestactions=edit&intestactionsdetail=full&inprop=editintro") - fun getWikiTextForSectionWithInfo( + @GET(MW_API_PREFIX + "action=query&prop=revisions|info&meta=siteinfo&siprop=general|autocreatetempuser&rvslots=main&rvprop=content|timestamp|ids&rvlimit=1&converttitles=&intestactions=edit&intestactionsdetail=full&inprop=editintro") + suspend fun getWikiTextForSectionWithInfo( @Query("titles") title: String, @Query("rvsection") section: Int? - ): Observable + ): MwQueryResponse @FormUrlEncoded @POST(MW_API_PREFIX + "action=edit") @@ -368,11 +381,12 @@ interface Service { @Field("token") token: String, @Field("undo") undoRevId: Long, @Field("undoafter") undoRevAfter: Long? = null, + @Field("matags") tags: String? = null ): Edit @FormUrlEncoded @POST(MW_API_PREFIX + "action=edit") - fun postEditSubmit( + suspend fun postEditSubmit( @Field("title") title: String, @Field("section") section: String?, @Field("sectiontitle") newSectionTitle: String?, @@ -386,7 +400,8 @@ interface Service { @Field("captchaword") captchaWord: String?, @Field("minor") minor: Boolean? = null, @Field("watchlist") watchlist: String? = null, - ): Observable + @Field("matags") tags: String? = null + ): Edit @FormUrlEncoded @POST(MW_API_PREFIX + "action=visualeditoredit") @@ -406,7 +421,7 @@ interface Service { @Field("data-ge-task-image-recommendation") imageRecommendationJson: String? = null, ): Edit - @GET(MW_API_PREFIX + "action=query&list=usercontribs&ucprop=ids|title|timestamp|comment|size|flags|sizediff|tags&meta=userinfo&uiprop=groups|blockinfo|editcount|latestcontrib|rights") + @GET(MW_API_PREFIX + "action=query&list=usercontribs&ucprop=ids|title|timestamp|comment|size|flags|sizediff|tags&meta=userinfo&uiprop=groups|blockinfo|editcount|latestcontrib|rights|registrationdate") suspend fun getUserContributions( @Query("ucuser") username: String, @Query("uclimit") maxCount: Int, @@ -426,60 +441,58 @@ interface Service { @GET(MW_API_PREFIX + "action=query&prop=pageviews") suspend fun getPageViewsForTitles(@Query("titles") titles: String): MwQueryResponse - @GET(MW_API_PREFIX + "action=query&meta=wikimediaeditortaskscounts|userinfo&uiprop=groups|blockinfo|editcount|latestcontrib") - suspend fun getEditorTaskCounts(): MwQueryResponse - @FormUrlEncoded @POST(MW_API_PREFIX + "action=rollback") suspend fun postRollback( @Field("title") title: String, @Field("summary") summary: String?, @Field("user") user: String, - @Field("token") token: String + @Field("token") token: String, + @Field("matags") tags: String? = null ): RollbackPostResponse // ------- Wikidata ------- @GET(MW_API_PREFIX + "action=wbgetentities") - fun getEntitiesByTitle( + suspend fun getEntitiesByTitleSuspend( @Query("titles") titles: String, @Query("sites") sites: String - ): Observable + ): Entities @GET(MW_API_PREFIX + "action=wbsearchentities&type=item&limit=20") - fun searchEntities( + suspend fun searchEntities( @Query("search") searchTerm: String, @Query("language") searchLang: String, @Query("uselang") resultLang: String - ): Observable + ): Search @GET(MW_API_PREFIX + "action=query&prop=entityterms") - fun getWikidataEntityTerms( - @Query("titles") titles: String, - @Query("wbetlanguage") lang: String - ): Observable + suspend fun getWikidataEntityTerms( + @Query("titles") titles: String, + @Query("wbetlanguage") lang: String + ): MwQueryResponse @GET(MW_API_PREFIX + "action=wbgetclaims") - fun getClaims( + suspend fun getClaims( @Query("entity") entity: String, @Query("property") property: String? - ): Observable + ): Claims @GET(MW_API_PREFIX + "action=wbgetentities&props=descriptions|labels|sitelinks") - suspend fun getWikidataLabelsAndDescriptions(@Query("ids") idList: String): Entities + suspend fun getWikidataLabelsAndDescriptions( + @Query("ids") idList: String, + @Query("languages") languages: String? = null, + @Query("sitefilter") siteFilter: String? = null + ): Entities - @POST(MW_API_PREFIX + "action=wbsetclaim&errorlang=uselang") - @FormUrlEncoded - fun postSetClaim( - @Field("claim") claim: String, - @Field("token") token: String, - @Field("summary") summary: String?, - @Field("tags") tags: String? - ): Observable + @GET(MW_API_PREFIX + "action=wbgetentities&props=descriptions|labels") + suspend fun getWikidataDescription(@Query("titles") titles: String, + @Query("sites") sites: String, + @Query("languages") langCode: String): Entities @POST(MW_API_PREFIX + "action=wbsetdescription&errorlang=uselang") @FormUrlEncoded - fun postDescriptionEdit( + suspend fun postDescriptionEdit( @Field("language") language: String, @Field("uselang") useLang: String, @Field("site") site: String, @@ -487,12 +500,13 @@ interface Service { @Field("value") newDescription: String, @Field("summary") summary: String?, @Field("token") token: String, - @Field("assert") user: String? - ): Observable + @Field("assert") user: String?, + @Field("matags") tags: String? = null + ): EntityPostResponse @POST(MW_API_PREFIX + "action=wbsetlabel&errorlang=uselang") @FormUrlEncoded - fun postLabelEdit( + suspend fun postLabelEdit( @Field("language") language: String, @Field("uselang") useLang: String, @Field("site") site: String, @@ -500,25 +514,22 @@ interface Service { @Field("value") newDescription: String, @Field("summary") summary: String?, @Field("token") token: String, - @Field("assert") user: String? - ): Observable + @Field("assert") user: String?, + @Field("matags") tags: String? = null + ): EntityPostResponse @POST(MW_API_PREFIX + "action=wbeditentity&errorlang=uselang") @FormUrlEncoded - fun postEditEntity( + suspend fun postEditEntity( @Field("id") id: String, @Field("token") token: String, @Field("data") data: String?, @Field("summary") summary: String?, - @Field("tags") tags: String? - ): Observable + @Field("matags") tags: String? + ): EntityPostResponse // ------- Watchlist ------- - @Headers("Cache-Control: no-cache") - @GET(MW_API_PREFIX + "action=query&prop=info&converttitles=&redirects=&inprop=watched") - fun getWatchedInfo(@Query("titles") titles: String): Observable - @Headers("Cache-Control: no-cache") @GET(MW_API_PREFIX + "action=query&prop=info&converttitles=&redirects=&inprop=watched") suspend fun getWatchedStatus(@Query("titles") titles: String): MwQueryResponse @@ -531,10 +542,6 @@ interface Service { @GET(MW_API_PREFIX + "action=query&prop=info&converttitles=&redirects=&inprop=watched&meta=userinfo&uiprop=rights") suspend fun getWatchedStatusWithRights(@Query("titles") titles: String): MwQueryResponse - @get:GET(MW_API_PREFIX + "action=query&list=watchlist&wllimit=500&wlallrev=1&wlprop=ids|title|flags|comment|parsedcomment|timestamp|sizes|user|loginfo") - @get:Headers("Cache-Control: no-cache") - val watchlist: Observable - @GET(MW_API_PREFIX + "action=query&list=watchlist&wllimit=500&wlprop=ids|title|flags|comment|parsedcomment|timestamp|sizes|user|loginfo") @Headers("Cache-Control: no-cache") suspend fun getWatchlist( @@ -583,16 +590,6 @@ interface Service { @Field("token") token: String ): EntityPostResponse - @POST(MW_API_PREFIX + "action=watch&converttitles=&redirects=") - @FormUrlEncoded - fun postWatch( - @Field("unwatch") unwatch: Int?, - @Field("pageids") pageIds: String?, - @Field("titles") titles: String?, - @Field("expiry") expiry: String?, - @Field("token") token: String - ): Observable - @POST(MW_API_PREFIX + "action=watch&converttitles=&redirects=") @FormUrlEncoded suspend fun watch( @@ -603,10 +600,6 @@ interface Service { @Field("token") token: String ): WatchPostResponse - @get:GET(MW_API_PREFIX + "action=query&meta=tokens&type=watch") - @get:Headers("Cache-Control: no-cache") - val watchToken: Observable - @GET(MW_API_PREFIX + "action=query&meta=tokens&type=watch") @Headers("Cache-Control: no-cache") suspend fun getWatchToken(): MwQueryResponse @@ -642,7 +635,8 @@ interface Service { @Field("token") token: String, @Field("summary") summary: String? = null, @Field("captchaid") captchaId: Long? = null, - @Field("captchaword") captchaWord: String? = null + @Field("captchaword") captchaWord: String? = null, + @Field("matags") tags: String? = null ): DiscussionToolsEditResponse @POST(MW_API_PREFIX + "action=discussiontoolsedit&paction=addcomment") @@ -654,7 +648,8 @@ interface Service { @Field("token") token: String, @Field("summary") summary: String? = null, @Field("captchaid") captchaId: Long? = null, - @Field("captchaword") captchaWord: String? = null + @Field("captchaword") captchaWord: String? = null, + @Field("matags") tags: String? = null ): DiscussionToolsEditResponse @GET(MW_API_PREFIX + "action=query&generator=growthtasks") @@ -664,7 +659,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&pilicense=any&rvprop=ids|timestamp|flags|comment|user|content&rvslots=main&rvsection=0") suspend fun getPagesWithImageRecommendations( @Query("gsrlimit") count: Int ): MwQueryResponse @@ -687,6 +682,9 @@ interface Service { suspend fun getTemplateData(@Query("lang") langCode: String, @Query("titles") titles: String): TemplateDataResponse + @GET(MW_API_PREFIX + "action=query&prop=info&converttitles=&inprop=varianttitles") + suspend fun getVariantTitlesByTitles(@Query("titles") titles: String): MwQueryResponse + 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/ServiceError.kt b/app/src/main/java/org/wikipedia/dataclient/ServiceError.kt index 6127584836c..0243978659a 100644 --- a/app/src/main/java/org/wikipedia/dataclient/ServiceError.kt +++ b/app/src/main/java/org/wikipedia/dataclient/ServiceError.kt @@ -1,6 +1,6 @@ package org.wikipedia.dataclient interface ServiceError { - val title: String - val details: String + val key: String + val message: String } diff --git a/app/src/main/java/org/wikipedia/dataclient/ServiceFactory.kt b/app/src/main/java/org/wikipedia/dataclient/ServiceFactory.kt index ca55a6030cf..07127d9b416 100644 --- a/app/src/main/java/org/wikipedia/dataclient/ServiceFactory.kt +++ b/app/src/main/java/org/wikipedia/dataclient/ServiceFactory.kt @@ -1,7 +1,6 @@ package org.wikipedia.dataclient import androidx.collection.lruCache -import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import okhttp3.Interceptor import okhttp3.MediaType.Companion.toMediaType import okhttp3.Response @@ -14,7 +13,7 @@ import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory import org.wikipedia.json.JsonUtil import org.wikipedia.settings.Prefs import retrofit2.Retrofit -import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory +import retrofit2.converter.kotlinx.serialization.asConverterFactory import retrofit2.create import java.io.IOException @@ -85,7 +84,6 @@ object ServiceFactory { return Retrofit.Builder() .baseUrl(baseUrl) .client(builder.build()) - .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) .addConverterFactory(JsonUtil.json.asConverterFactory("application/json".toMediaType())) .build() } diff --git a/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt b/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt index 3a4829da7cf..dcfbc00a850 100644 --- a/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt +++ b/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt @@ -23,10 +23,32 @@ class SharedPreferenceCookieManager( } @Synchronized - fun getCookieByName(name: String): String? { + fun getCookieValueByName(name: String): String? { + for (domainSpec in cookieJar.keys) { + getCookieByName(name, domainSpec)?.let { + return it + } + } + return null + } + + @Synchronized + fun getCookieExpiryByName(name: String): Long { for (domainSpec in cookieJar.keys) { for (cookie in cookieJar[domainSpec]!!) { if (cookie.name == name) { + return cookie.expiresAt + } + } + } + return 0 + } + + @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 } } diff --git a/app/src/main/java/org/wikipedia/dataclient/WikiSite.kt b/app/src/main/java/org/wikipedia/dataclient/WikiSite.kt index 7c8bc198f95..07e34d79f0f 100644 --- a/app/src/main/java/org/wikipedia/dataclient/WikiSite.kt +++ b/app/src/main/java/org/wikipedia/dataclient/WikiSite.kt @@ -114,6 +114,8 @@ data class WikiSite( fun dbName(): String { return (if (uri.authority.orEmpty().contains("wikidata")) { "wikidata" + } else if (uri.authority.orEmpty().contains("commons")) { + "commons" } else { subdomain().replace("-".toRegex(), "_") }) + "wiki" @@ -132,7 +134,6 @@ data class WikiSite( DEFAULT_BASE_URL = url.ifEmpty { Service.WIKIPEDIA_URL } } - @JvmStatic fun forLanguageCode(languageCode: String): WikiSite { val uri = ensureScheme(Uri.parse(DEFAULT_BASE_URL)) return WikiSite( diff --git a/app/src/main/java/org/wikipedia/dataclient/donate/Campaign.kt b/app/src/main/java/org/wikipedia/dataclient/donate/Campaign.kt index 52af4d82b54..c6821992bef 100644 --- a/app/src/main/java/org/wikipedia/dataclient/donate/Campaign.kt +++ b/app/src/main/java/org/wikipedia/dataclient/donate/Campaign.kt @@ -37,10 +37,8 @@ class Campaign( @Serializable class Action( val title: String = "", - @SerialName("url") val rawUrl: String? = null - ) { - val url get() = rawUrl?.replace("\$platform;", "Android") - } + val url: String? = null + ) @Serializable class PlatformParams diff --git a/app/src/main/java/org/wikipedia/dataclient/donate/CampaignCollection.kt b/app/src/main/java/org/wikipedia/dataclient/donate/CampaignCollection.kt index 042eeccf4f3..66420ac8924 100644 --- a/app/src/main/java/org/wikipedia/dataclient/donate/CampaignCollection.kt +++ b/app/src/main/java/org/wikipedia/dataclient/donate/CampaignCollection.kt @@ -6,7 +6,9 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.decodeFromJsonElement import okhttp3.Request +import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory +import org.wikipedia.donate.DonationResult import org.wikipedia.json.JsonUtil import org.wikipedia.settings.Prefs import org.wikipedia.util.GeoUtil @@ -42,6 +44,16 @@ object CampaignCollection { return campaignList } + fun getFormattedCampaignId(campaignId: String): String { + return "${WikipediaApp.instance.appOrSystemLanguageCode}${GeoUtil.geoIPCountry}_${campaignId}_Android" + } + + fun addDonationResult(fromWeb: Boolean = false) { + Prefs.donationResults = Prefs.donationResults.plus(DonationResult(dateTime = LocalDateTime.now().toString(), fromWeb = fromWeb)) + Prefs.isDonor = true + Prefs.hasDonorHistorySaved = true + } + @Serializable class CampaignProto( val version: Int = 0 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..af8e052d79f --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfig.kt @@ -0,0 +1,15 @@ +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(), + val countryCodeGooglePayEnabled: 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..9b2674b3336 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfigHelper.kt @@ -0,0 +1,42 @@ +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 { + + const val DONATE_WIKI_URL = "https://donate.wikimedia.org/" + + private const val CONFIG_VERSION = 1 + private const val CONFIG_URL = DONATE_WIKI_URL + "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/growthtasks/GrowthUserImpact.kt b/app/src/main/java/org/wikipedia/dataclient/growthtasks/GrowthUserImpact.kt new file mode 100644 index 00000000000..690c9107dd4 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/growthtasks/GrowthUserImpact.kt @@ -0,0 +1,64 @@ +package org.wikipedia.dataclient.growthtasks + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement +import org.wikipedia.json.JsonUtil + +@Suppress("unused") +@Serializable +class GrowthUserImpact( + @SerialName("@version") val version: Int = 0, + val userId: Int = 0, + val userName: String = "", + val receivedThanksCount: Int = 0, + @SerialName("editCountByNamespace") private val mEditCountByNamespace: JsonElement? = null, + @SerialName("editCountByDay") private val mEditCountByDay: JsonElement? = null, + @SerialName("editCountByTaskType") private val mEditCountByTaskType: JsonElement? = null, + val totalUserEditCount: Int = 0, + val totalEditsCount: Int = 0, + val newcomerTaskEditCount: Int = 0, + val revertedEditCount: Int = 0, + val lastEditTimestamp: Long = 0, + @SerialName("longestEditingStreak") private val mLongestEditingStreak: JsonElement? = null, + @SerialName("dailyTotalViews") private val mDailyTotalViews: JsonElement? = null, + val totalPageviewsCount: Long = 0, + @SerialName("topViewedArticles") private val mTopViewedArticles: JsonElement? = null, +) { + // All of these properties need to be lazily initialized from a generic JsonElement because + // of an annoying quirk in the way PHP serializes JSON objects. If the object is empty, it + // serializes as an empty array, not an empty object, which causes kotlinx.serialization to + // fail. Therefore we need to conditionally deserialize these items at runtime. + val editCountByNamespace: Map by lazy { if (mEditCountByNamespace is JsonObject) { JsonUtil.json.decodeFromJsonElement(mEditCountByNamespace) } else { emptyMap() } } + val editCountByDay: Map by lazy { if (mEditCountByDay is JsonObject) { JsonUtil.json.decodeFromJsonElement(mEditCountByDay) } else { emptyMap() } } + val editCountByTaskType: Map by lazy { if (mEditCountByTaskType is JsonObject) { JsonUtil.json.decodeFromJsonElement(mEditCountByTaskType) } else { emptyMap() } } + val dailyTotalViews: Map by lazy { if (mDailyTotalViews is JsonObject) { JsonUtil.json.decodeFromJsonElement(mDailyTotalViews) } else { emptyMap() } } + val topViewedArticles: Map by lazy { if (mTopViewedArticles is JsonObject) { JsonUtil.json.decodeFromJsonElement(mTopViewedArticles) } else { emptyMap() } } + val longestEditingStreak: EditStreak? by lazy { if (mLongestEditingStreak is JsonObject) { JsonUtil.json.decodeFromJsonElement(mLongestEditingStreak) } else { null } } + + @Serializable + class ArticleViews( + val firstEditDate: String = "", + val newestEdit: String = "", + val imageUrl: String = "", + val viewsCount: Long = 0, + private val views: JsonElement? = null + ) { + val viewsByDay: Map by lazy { if (views is JsonObject) { JsonUtil.json.decodeFromJsonElement(views) } else { emptyMap() } } + } + + @Serializable + class EditStreak( + val datePeriod: EditDateRange? = null, + val totalEditCountForPeriod: Int = 0 + ) + + @Serializable + class EditDateRange( + val start: String = "", + val end: String = "", + val days: Int = 0, + ) +} diff --git a/app/src/main/java/org/wikipedia/dataclient/liftwing/DescriptionSuggestion.kt b/app/src/main/java/org/wikipedia/dataclient/liftwing/DescriptionSuggestion.kt new file mode 100644 index 00000000000..7cd63291eb3 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/liftwing/DescriptionSuggestion.kt @@ -0,0 +1,20 @@ +package org.wikipedia.dataclient.liftwing + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +class DescriptionSuggestion { + + @Serializable + class Request( + val lang: String, + val title: String, + @SerialName("num_beams") val count: Int = 1 + ) + + @Serializable + class Response { + val prediction: List = emptyList() + val blp = false + } +} diff --git a/app/src/main/java/org/wikipedia/dataclient/liftwing/LiftWingModelService.kt b/app/src/main/java/org/wikipedia/dataclient/liftwing/LiftWingModelService.kt new file mode 100644 index 00000000000..56f34b838e7 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/liftwing/LiftWingModelService.kt @@ -0,0 +1,16 @@ +package org.wikipedia.dataclient.liftwing + +import retrofit2.http.Body +import retrofit2.http.POST + +interface LiftWingModelService { + + @POST("models/article-descriptions:predict") + suspend fun getDescriptionSuggestion( + @Body body: DescriptionSuggestion.Request, + ): DescriptionSuggestion.Response + + companion object { + const val API_URL = "https://api.wikimedia.org/service/lw/inference/v1/" + } +} diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/EditorTaskCounts.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/EditorTaskCounts.kt deleted file mode 100644 index 02c4d91ee39..00000000000 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/EditorTaskCounts.kt +++ /dev/null @@ -1,104 +0,0 @@ -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.decodeFromJsonElement -import org.wikipedia.json.JsonUtil -import org.wikipedia.settings.Prefs - -@Serializable -class EditorTaskCounts { - - @SerialName("revert_counts") private val revertCounts: JsonElement? = null - @SerialName("edit_streak") private val editStreak: JsonElement? = null - - private val counts: JsonElement? = null - - private val descriptionEditsPerLanguage: Map - get() { - var editsPerLanguage: Map? = null - if (counts != null && counts !is JsonArray) { - editsPerLanguage = JsonUtil.json.decodeFromJsonElement(counts).appDescriptionEdits - } - return editsPerLanguage ?: emptyMap() - } - - private val captionEditsPerLanguage: Map - get() { - var editsPerLanguage: Map? = null - if (counts != null && counts !is JsonArray) { - editsPerLanguage = JsonUtil.json.decodeFromJsonElement(counts).appCaptionEdits - } - return editsPerLanguage ?: emptyMap() - } - - private val descriptionRevertsPerLanguage: Map - get() { - var revertsPerLanguage: Map? = null - if (revertCounts != null && revertCounts !is JsonArray) { - revertsPerLanguage = JsonUtil.json.decodeFromJsonElement(revertCounts).appDescriptionEdits - } - return revertsPerLanguage ?: emptyMap() - } - - private val captionRevertsPerLanguage: Map - get() { - var revertsPerLanguage: Map? = null - if (revertCounts != null && revertCounts !is JsonArray) { - revertsPerLanguage = JsonUtil.json.decodeFromJsonElement(revertCounts).appCaptionEdits - } - return revertsPerLanguage ?: emptyMap() - } - - private val totalDepictsReverts: Int - get() { - var revertsPerLanguage: Map? = null - if (revertCounts != null && revertCounts !is JsonArray) { - revertsPerLanguage = JsonUtil.json.decodeFromJsonElement(revertCounts).appDepictsEdits - } - return revertsPerLanguage?.get("*") ?: 0 - } - - val totalDepictsEdits: Int - get() { - var editsPerLanguage: Map? = null - if (counts != null && counts !is JsonArray) { - editsPerLanguage = JsonUtil.json.decodeFromJsonElement(counts).appDepictsEdits - } - return editsPerLanguage?.get("*") ?: 0 - } - - val totalEdits: Int - get() { - return if (Prefs.shouldOverrideSuggestedEditCounts()) { - Prefs.overrideSuggestedEditCount - } else { - descriptionEditsPerLanguage.values.sum() + captionEditsPerLanguage.values.sum() + totalDepictsEdits - } - } - - val totalDescriptionEdits: Int - get() = descriptionEditsPerLanguage.values.sum() - - val totalImageCaptionEdits: Int - get() = captionEditsPerLanguage.values.sum() - - val totalReverts: Int - get() { - return if (Prefs.shouldOverrideSuggestedEditCounts()) { - Prefs.overrideSuggestedRevertCount - } else { - descriptionRevertsPerLanguage.values.sum() + captionRevertsPerLanguage.values.sum() + totalDepictsReverts - } - } - - @Serializable - class Counts { - - @SerialName("app_description_edits") val appDescriptionEdits: Map? = null - @SerialName("app_caption_edits") val appCaptionEdits: Map? = null - @SerialName("app_depicts_edits") val appDepictsEdits: Map? = null - } -} diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwException.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwException.kt index 25f4bb52ea2..fc5d3bcd2ab 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwException.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwException.kt @@ -6,8 +6,8 @@ import kotlinx.serialization.Serializable class MwException(val error: MwServiceError) : RuntimeException() { val title: String - get() = error.title + get() = error.key override val message: String - get() = error.details + get() = error.message } diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.kt index f6b263c7c28..aba6fbbd8ac 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryPage.kt @@ -25,7 +25,7 @@ class MwQueryPage { @SerialName("pageprops") val pageProps: PageProps? = null @SerialName("entityterms") val entityTerms: EntityTerms? = null - private val ns = 0 + val ns = 0 val coordinates: List? = null private val thumbnail: Thumbnail? = null val varianttitles: Map? = null @@ -93,6 +93,7 @@ class MwQueryPage { @SerialName("revid") val revId: Long = 0 @SerialName("parentid") val parentRevId: Long = 0 @SerialName("anon") val isAnon = false + @SerialName("temp") val isTemp = false @SerialName("timestamp") val timeStamp: String = "" val size = 0 val user: 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 46324ed268e..428134591f7 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt @@ -14,6 +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.Instant +import java.time.LocalDateTime +import java.time.ZoneId import java.util.* @Serializable @@ -23,7 +26,7 @@ class MwQueryResult { @SerialName("unreadnotificationpages") val unreadNotificationWikis: Map? = null @SerialName("authmanagerinfo") private val amInfo: MwAuthManagerInfo? = null @SerialName("general") val siteInfo: SiteInfo? = null - @SerialName("wikimediaeditortaskscounts") val editorTaskCounts: EditorTaskCounts? = null + @SerialName("autocreatetempuser") val autoCreateTempUser: SiteInfo.AutoCreateTempUser? = null @SerialName("recentchanges") val recentChanges: List? = null @SerialName("usercontribs") val userContributions: List = emptyList() @SerialName("allusers") val allUsers: List? = null @@ -206,13 +209,16 @@ 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 private val oresscores: JsonElement? = null - val parsedDateTime by lazy { DateUtil.iso8601LocalDateTimeParse(timestamp) } + val parsedInstant: Instant by lazy { Instant.parse(timestamp) } + val parsedDateTime: LocalDateTime by lazy { + LocalDateTime.ofInstant(parsedInstant, ZoneId.systemDefault()) + } val joinedTags by lazy { tags?.joinToString(separator = ", ").orEmpty() } override fun toString(): String { diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwResponse.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwResponse.kt index fa762339ce8..7028240341b 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwResponse.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwResponse.kt @@ -13,7 +13,7 @@ abstract class MwResponse { init { if (errors?.isNotEmpty() == true) { // prioritize "blocked" or "abusefilter" errors over others. - throw MwException(errors.firstOrNull { it.title.contains("blocked") || it.title.startsWith("abusefilter-") } ?: errors.first()) + throw MwException(errors.firstOrNull { it.key.contains("blocked") || it.key.startsWith("abusefilter-") } ?: errors.first()) } } } diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwServiceError.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwServiceError.kt index f6f752474e4..dec9062b3bd 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwServiceError.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwServiceError.kt @@ -1,11 +1,14 @@ package org.wikipedia.dataclient.mwapi +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.wikipedia.dataclient.ServiceError import org.wikipedia.util.DateUtil import org.wikipedia.util.StringUtil import org.wikipedia.util.ThrowableUtil +import org.wikipedia.util.log.L import java.util.* @Serializable @@ -29,14 +32,18 @@ class MwServiceError(val code: String? = null, return data?.messages?.first { it.name == messageName }?.html } - override val title: String get() = code.orEmpty() + override val key: String get() = code.orEmpty() - override val details: String get() = StringUtil.removeStyleTags(html.orEmpty()) + override val message: String get() = StringUtil.removeStyleTags(html.orEmpty()) init { // Special case: if it's a Blocked error, parse the blockinfo structure ourselves. if (("blocked" == code || "autoblocked" == code) && data?.blockinfo != null) { - html = ThrowableUtil.getBlockMessageHtml(data.blockinfo) + runBlocking(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + html = ThrowableUtil.getBlockMessageHtml(data.blockinfo) + } } } diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt index 1004b47b7d0..f196c872058 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt @@ -24,12 +24,18 @@ class TemplateDataResponse : MwResponse() { val description: String? = null private val params: JsonElement? = null val format: String? = null - @SerialName("notemplatedata") val noTemplateData: Boolean? = null + @SerialName("notemplatedata") val noTemplateData: Boolean = false val getParams: Map? get() { try { - if (noTemplateData != true && params != null && params !is JsonArray) { - return JsonUtil.json.decodeFromJsonElement>(params) + 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") @@ -54,8 +60,6 @@ class TemplateDataResponse : MwResponse() { val aliases: List = emptyList() private val deprecated: JsonElement? = null - private val deprecatedAsString get() = deprecated?.jsonPrimitive?.contentOrNull - - val isDeprecated get() = !deprecatedAsString.equals("false", true) + val isDeprecated get() = deprecated != null && !deprecated.jsonPrimitive.contentOrNull.equals("false", true) } } diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/UserInfo.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/UserInfo.kt index 0c9208f9547..1e95ccc4899 100644 --- a/app/src/main/java/org/wikipedia/dataclient/mwapi/UserInfo.kt +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/UserInfo.kt @@ -2,6 +2,7 @@ package org.wikipedia.dataclient.mwapi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement import org.wikipedia.dataclient.mwapi.MwServiceError.BlockInfo import org.wikipedia.util.DateUtil import java.util.* @@ -22,7 +23,7 @@ class UserInfo : BlockInfo() { @SerialName("cancreateerror") private val canCreateError: List? = null val options: Options? = null - val error get() = canCreateError?.get(0)?.title.orEmpty() + val error get() = canCreateError?.get(0)?.key.orEmpty() val hasBlockError get() = error.contains("block") fun groups(): Set { @@ -52,5 +53,6 @@ class UserInfo : BlockInfo() { @Serializable class Options { @SerialName("watchdefault") val watchDefault: Int = 0 + @SerialName("centralnotice-display-campaign-type-fundraising") val fundraisingOptIn: JsonElement? = null } } diff --git a/app/src/main/java/org/wikipedia/dataclient/okhttp/AvailableInputStream.kt b/app/src/main/java/org/wikipedia/dataclient/okhttp/AvailableInputStream.kt deleted file mode 100644 index 715aa2df45b..00000000000 --- a/app/src/main/java/org/wikipedia/dataclient/okhttp/AvailableInputStream.kt +++ /dev/null @@ -1,69 +0,0 @@ -package org.wikipedia.dataclient.okhttp - -import java.io.IOException -import java.io.InputStream - -/** - * This is a subclass of InputStream that implements the available() method reliably enough - * to satisfy WebResourceResponses or other consumers like BufferedInputStream that depend - * on available() to return a meaningful value. - * - * The problem is that the InputStream provided by OkHttp's body().byteStream() returns zero - * when calling available() prior to making any read() calls, which means that it will break - * any consumers that wrap a BufferedInputStream onto this stream, or any other wrapper that - * relies on a consistent implementation of available(). - * - * This is initialized with the original InputStream plus its total size, which must be known - * at the time of instantiation. You may then call the read() and skip() methods in the usual - * way, and then be able to call available() and get the number of bytes left to read. - */ -class AvailableInputStream(private val stream: InputStream, private var available: Long) : InputStream() { - - @Throws(IOException::class) - override fun read(): Int { - decreaseAvailable(1) - return stream.read() - } - - @Throws(IOException::class) - override fun read(b: ByteArray): Int { - val ret = stream.read(b) - if (ret > 0) { - decreaseAvailable(ret.toLong()) - } - return ret - } - - @Throws(IOException::class) - override fun read(b: ByteArray, off: Int, len: Int): Int { - val ret = stream.read(b, off, len) - if (ret > 0) { - decreaseAvailable(ret.toLong()) - } - return ret - } - - @Throws(IOException::class) - override fun skip(n: Long): Long { - val ret = stream.skip(n) - if (ret > 0) { - decreaseAvailable(ret) - } - return ret - } - - @Throws(IOException::class) - override fun available(): Int { - val ret = stream.available() - return if (ret == 0 && available > 0) { - available.toInt() - } else ret - } - - private fun decreaseAvailable(n: Long) { - available -= n - if (available < 0) { - available = 0 - } - } -} diff --git a/app/src/main/java/org/wikipedia/dataclient/okhttp/HttpStatusException.kt b/app/src/main/java/org/wikipedia/dataclient/okhttp/HttpStatusException.kt index fa0925251e2..7fe63ababf6 100644 --- a/app/src/main/java/org/wikipedia/dataclient/okhttp/HttpStatusException.kt +++ b/app/src/main/java/org/wikipedia/dataclient/okhttp/HttpStatusException.kt @@ -18,7 +18,7 @@ class HttpStatusException : IOException { } else { var str = "Code: $code, URL: $url" serviceError?.run { - str += ", title: $title, detail: $details" + str += ", key: $key, message: $message" } str } diff --git a/app/src/main/java/org/wikipedia/dataclient/okhttp/OfflineCacheInterceptor.kt b/app/src/main/java/org/wikipedia/dataclient/okhttp/OfflineCacheInterceptor.kt index 86f48b086d4..6a2072d5fbd 100644 --- a/app/src/main/java/org/wikipedia/dataclient/okhttp/OfflineCacheInterceptor.kt +++ b/app/src/main/java/org/wikipedia/dataclient/okhttp/OfflineCacheInterceptor.kt @@ -230,7 +230,6 @@ class OfflineCacheInterceptor : Interceptor { const val SAVE_HEADER_SAVE = "save" const val OFFLINE_PATH = "offline_files" - @JvmStatic fun shouldSave(request: Request): Boolean { return "GET" == request.method && SAVE_HEADER_SAVE == request.header(SAVE_HEADER) } 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 08d030a7f88..eccc02d8fcb 100644 --- a/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpWebViewClient.kt +++ b/app/src/main/java/org/wikipedia/dataclient/okhttp/OkHttpWebViewClient.kt @@ -15,7 +15,6 @@ import org.wikipedia.page.PageViewModel import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L import java.io.IOException -import java.io.InputStream import java.nio.charset.Charset abstract class OkHttpWebViewClient : WebViewClient() { @@ -42,7 +41,15 @@ abstract class OkHttpWebViewClient : WebViewClient() { return null } if (request.method == "POST" || - request.url.toString().contains(RestService.PAGE_HTML_PREVIEW_ENDPOINT)) { + request.url.toString().contains(RestService.PAGE_HTML_PREVIEW_ENDPOINT) || + request.requestHeaders.containsKey("Range") || request.requestHeaders.containsKey("range")) { + // We do NOT want to intercept requests coming from the WebView in the following cases: + // 1. POST requests, because the WebView doesn't provide us the Body of the request, + // for security (?) reasons. + // 2. Page previews, because we're not interested in saving or caching them. + // 3. Requests with any kind of "Range" header, since this is very likely a request for + // playback of an audio or video file, which is problematic for us to intercept in + // the way that we do, and can lead to unintended behavior. return null } var response: WebResourceResponse @@ -55,10 +62,11 @@ abstract class OkHttpWebViewClient : WebViewClient() { if (rsp.networkResponse != null && shouldLogLatency) { WikipediaApp.instance.appSessionEvent.pageFetchEnd() } - response = if (CONTENT_TYPE_OGG == rsp.header(HEADER_CONTENT_TYPE) || - CONTENT_TYPE_WEBM == rsp.header(HEADER_CONTENT_TYPE)) { + val contentType = rsp.header(HEADER_CONTENT_TYPE).orEmpty() + response = if (contentType.startsWith("audio") || contentType.startsWith("video")) { + // One last check to make sure we're not intercepting a media file (see comments above). rsp.close() - return super.shouldInterceptRequest(view, request) + return null } else { // noinspection ConstantConditions WebResourceResponse(rsp.body!!.contentType()!!.type + "/" + rsp.body!!.contentType()!!.subtype, @@ -66,7 +74,7 @@ abstract class OkHttpWebViewClient : WebViewClient() { rsp.code, rsp.message.ifBlank { "Unknown error" }, addResponseHeaders(rsp.headers).toMap(), - getInputStream(rsp)) + rsp.body?.byteStream()) } } catch (e: Exception) { val reasonCode = if (e.message.isNullOrEmpty()) "Unknown error" else UriUtil.encodeURL(e.message!!) @@ -129,20 +137,8 @@ abstract class OkHttpWebViewClient : WebViewClient() { return headers.newBuilder().set("Access-Control-Allow-Origin", "*").build() } - private fun getInputStream(rsp: Response): InputStream? { - return rsp.body?.let { - var inputStream = it.byteStream() - if (CONTENT_TYPE_OGG == rsp.header(HEADER_CONTENT_TYPE)) { - inputStream = AvailableInputStream(it.byteStream(), it.contentLength()) - } - inputStream - } - } - companion object { private const val HEADER_CONTENT_TYPE = "content-type" - private const val CONTENT_TYPE_OGG = "application/ogg" - private const val CONTENT_TYPE_WEBM = "video/webm" private val SUPPORTED_SCHEMES = listOf("http", "https") } } diff --git a/app/src/main/java/org/wikipedia/dataclient/page/NearbyPage.kt b/app/src/main/java/org/wikipedia/dataclient/page/NearbyPage.kt new file mode 100644 index 00000000000..72e212b9267 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/page/NearbyPage.kt @@ -0,0 +1,20 @@ +package org.wikipedia.dataclient.page + +import android.graphics.Bitmap +import android.location.Location +import org.maplibre.android.plugins.annotation.Symbol +import org.wikipedia.page.PageTitle + +class NearbyPage( + val pageId: Int, + val pageTitle: PageTitle, + val latitude: Double, + val longitude: Double, + var annotation: Symbol? = null, + var bitmap: Bitmap? = null +) { + val location get() = Location("").apply { + latitude = this@NearbyPage.latitude + longitude = this@NearbyPage.longitude + } +} diff --git a/app/src/main/java/org/wikipedia/dataclient/page/PageSummary.kt b/app/src/main/java/org/wikipedia/dataclient/page/PageSummary.kt index 7e8d1d1e22e..f32141a3587 100644 --- a/app/src/main/java/org/wikipedia/dataclient/page/PageSummary.kt +++ b/app/src/main/java/org/wikipedia/dataclient/page/PageSummary.kt @@ -24,7 +24,7 @@ open class PageSummary( var description: String? = null, @SerialName("originalimage") private val originalImage: Thumbnail? = null, @SerialName("wikibase_item") val wikiBaseItem: String? = null, - @SerialName("extract_html") val extractHtml: String? = null, + @SerialName("extract_html") var extractHtml: String? = null, @SerialName("description_source") val descriptionSource: String = "", @Serializable(with = LocationSerializer::class) var coordinates: Location? = null, val type: String = TYPE_STANDARD, diff --git a/app/src/main/java/org/wikipedia/dataclient/restbase/EditCount.kt b/app/src/main/java/org/wikipedia/dataclient/restbase/EditCount.kt index 025eee094c8..98030c3c100 100644 --- a/app/src/main/java/org/wikipedia/dataclient/restbase/EditCount.kt +++ b/app/src/main/java/org/wikipedia/dataclient/restbase/EditCount.kt @@ -11,6 +11,7 @@ class EditCount { companion object { const val EDIT_TYPE_ANONYMOUS = "anonymous" + const val EDIT_TYPE_TEMPORARY = "temporary" const val EDIT_TYPE_BOT = "bot" const val EDIT_TYPE_EDITORS = "editors" const val EDIT_TYPE_EDITS = "edits" 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/restbase/RbServiceError.kt b/app/src/main/java/org/wikipedia/dataclient/restbase/RbServiceError.kt index 2f896dbff85..39bb357a7d3 100644 --- a/app/src/main/java/org/wikipedia/dataclient/restbase/RbServiceError.kt +++ b/app/src/main/java/org/wikipedia/dataclient/restbase/RbServiceError.kt @@ -4,20 +4,27 @@ import kotlinx.serialization.Serializable import org.wikipedia.dataclient.ServiceError import org.wikipedia.json.JsonUtil +/** + * Model class that can represent either a RESTBase error or a MediaWiki REST API error. + * Since both types of errors have non-overlapping fields, we can use a single class to + * represent either of them, and then phase out the RESTBase-specific fields when RESTBase + * is fully decommissioned. + */ @Serializable class RbServiceError : ServiceError { - private val type: String? = null + // These fields are given by RESTBase errors, and should be removed when RESTBase + // is fully decommissioned. + private val title: String? = null private val detail: String? = null - private val method: String? = null - private val uri: String? = null + // These fields are given by MediaWiki REST API errors, and should be preferred. private val errorKey: String? = null private val messageTranslations: Map? = null - override val title: String = "" + override val key get() = errorKey ?: title.orEmpty() - override val details: String get() { + override val message: String get() { return if (messageTranslations != null) { messageTranslations.values.firstOrNull() ?: "" } else { 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..de30ea853ef 100644 --- a/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt +++ b/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt @@ -23,10 +23,10 @@ class Entities : MwResponse() { @Serializable class Entity { val id: String = "" - val labels: Map = emptyMap() - val descriptions: Map = emptyMap() - val sitelinks: Map = emptyMap() - val statements: JsonElement? = null + private val labels: JsonElement? = null + private val descriptions: JsonElement? = null + private val sitelinks: JsonElement? = null + private val statements: JsonElement? = null val missing: JsonElement? = null @SerialName("lastrevid") val lastRevId: Long = 0 @@ -37,6 +37,30 @@ class Entities : MwResponse() { emptyMap() } } + + fun getLabels(): Map { + return if (labels != null && labels !is JsonArray) { + JsonUtil.json.decodeFromJsonElement(labels) + } else { + emptyMap() + } + } + + fun getDescriptions(): Map { + return if (descriptions != null && descriptions !is JsonArray) { + JsonUtil.json.decodeFromJsonElement(descriptions) + } else { + emptyMap() + } + } + + fun getSiteLinks(): Map { + return if (sitelinks != null && sitelinks !is JsonArray) { + JsonUtil.json.decodeFromJsonElement(sitelinks) + } else { + emptyMap() + } + } } @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..9d527e6b064 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt @@ -3,14 +3,12 @@ package org.wikipedia.descriptions import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.activity.viewModels import androidx.annotation.ColorInt import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource 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.extensions.parcelableExtra +import org.wikipedia.commons.ImagePreviewDialog import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.PageTitle @@ -18,44 +16,25 @@ import org.wikipedia.page.linkpreview.LinkPreviewDialog 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 { enum class Action { ADD_DESCRIPTION, TRANSLATE_DESCRIPTION, ADD_CAPTION, TRANSLATE_CAPTION, ADD_IMAGE_TAGS, IMAGE_RECOMMENDATIONS, VANDALISM_PATROL } + private val viewModel: DescriptionEditViewModel by viewModels() + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val action = intent.getSerializableExtra(Constants.INTENT_EXTRA_ACTION) as Action - val pageTitle = intent.parcelableExtra(Constants.ARG_TITLE)!! - - MachineGeneratedArticleDescriptionsAnalyticsHelper.isUserInExperiment = (ReleaseUtil.isPreBetaRelease && AccountUtil.isLoggedIn && - action == Action.ADD_DESCRIPTION && pageTitle.description.isNullOrEmpty() && - SuggestedArticleDescriptionsDialog.availableLanguages.contains(pageTitle.wikiSite.languageCode)) - - val shouldShowAIOnBoarding = MachineGeneratedArticleDescriptionsAnalyticsHelper.isUserInExperiment && - MachineGeneratedArticleDescriptionsAnalyticsHelper.abcTest.group != GROUP_1 - if (action == Action.ADD_DESCRIPTION && Prefs.isDescriptionEditTutorialEnabled) { + if (viewModel.action == Action.ADD_DESCRIPTION && Prefs.isDescriptionEditTutorialEnabled) { Prefs.isDescriptionEditTutorialEnabled = false - startActivity(DescriptionEditTutorialActivity.newIntent(this, shouldShowAIOnBoarding)) + startActivity(DescriptionEditTutorialActivity.newIntent(this)) } } - public override fun createFragment(): DescriptionEditFragment { - val invokeSource = intent.getSerializableExtra(Constants.INTENT_EXTRA_INVOKE_SOURCE) as InvokeSource - val action = intent.getSerializableExtra(Constants.INTENT_EXTRA_ACTION) as Action - val title = intent.parcelableExtra(Constants.ARG_TITLE)!! - return DescriptionEditFragment.newInstance(title, - intent.getStringExtra(EXTRA_HIGHLIGHT_TEXT), - intent.parcelableExtra(EXTRA_SOURCE_SUMMARY), - intent.parcelableExtra(EXTRA_TARGET_SUMMARY), - action, - invokeSource) - } + // The description edit view model provides the activity's extras to the fragment. + public override fun createFragment() = DescriptionEditFragment() override fun onBackPressed() { if (fragment.binding.fragmentDescriptionEditView.showingReviewContent()) { @@ -71,17 +50,17 @@ class DescriptionEditActivity : SingleFragmentActivity( finish() } - override fun onBottomBarContainerClicked(action: Action) { - val key = if (action == Action.TRANSLATE_DESCRIPTION) EXTRA_TARGET_SUMMARY else EXTRA_SOURCE_SUMMARY - val summary = intent.parcelableExtra(key)!! - if (action == Action.ADD_CAPTION || action == Action.TRANSLATE_CAPTION) { + override fun onBottomBarContainerClicked() { + val summary = if (viewModel.action == Action.TRANSLATE_DESCRIPTION) viewModel.targetSummary!! + else viewModel.sourceSummary!! + + if (viewModel.action == Action.ADD_CAPTION || viewModel.action == Action.TRANSLATE_CAPTION) { ExclusiveBottomSheetPresenter.show(supportFragmentManager, - ImagePreviewDialog.newInstance(summary, action)) + ImagePreviewDialog.newInstance(summary, viewModel.action)) } else { ExclusiveBottomSheetPresenter.show(supportFragmentManager, LinkPreviewDialog.newInstance(HistoryEntry(summary.pageTitle, - if (intent.hasExtra(Constants.INTENT_EXTRA_INVOKE_SOURCE) && intent.getSerializableExtra - (Constants.INTENT_EXTRA_INVOKE_SOURCE) === InvokeSource.PAGE_ACTIVITY) + if (viewModel.invokeSource == InvokeSource.PAGE_ACTIVITY) HistoryEntry.SOURCE_EDIT_DESCRIPTION else HistoryEntry.SOURCE_SUGGESTED_EDITS))) } } @@ -95,9 +74,9 @@ class DescriptionEditActivity : SingleFragmentActivity( } companion object { - private const val EXTRA_HIGHLIGHT_TEXT = "highlightText" - private const val EXTRA_SOURCE_SUMMARY = "sourceSummary" - private const val EXTRA_TARGET_SUMMARY = "targetSummary" + const val EXTRA_HIGHLIGHT_TEXT = "highlightText" + const val EXTRA_SOURCE_SUMMARY = "sourceSummary" + const val EXTRA_TARGET_SUMMARY = "targetSummary" fun newIntent(context: Context, title: PageTitle, 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 933c2f574d9..203fd3fff29 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt @@ -9,71 +9,58 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts -import androidx.core.os.bundleOf +import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope -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 androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.activity.FragmentUtil -import org.wikipedia.analytics.eventplatform.ABTest.Companion.GROUP_1 -import org.wikipedia.analytics.eventplatform.ABTest.Companion.GROUP_3 import org.wikipedia.analytics.eventplatform.EditAttemptStepEvent import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent import org.wikipedia.analytics.eventplatform.MachineGeneratedArticleDescriptionsAnalyticsHelper import org.wikipedia.auth.AccountUtil import org.wikipedia.captcha.CaptchaHandler import org.wikipedia.captcha.CaptchaResult -import org.wikipedia.csrf.CsrfTokenClient import org.wikipedia.databinding.FragmentDescriptionEditBinding -import org.wikipedia.dataclient.ServiceFactory -import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwException import org.wikipedia.dataclient.mwapi.MwServiceError -import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory import org.wikipedia.dataclient.wikidata.EntityPostResponse -import org.wikipedia.extensions.parcelable -import org.wikipedia.language.AppLanguageLookUpTable +import org.wikipedia.edit.Edit +import org.wikipedia.edit.EditTags import org.wikipedia.login.LoginActivity import org.wikipedia.notifications.AnonymousNotificationHelper -import org.wikipedia.page.PageTitle 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.Resource import org.wikipedia.util.log.L +import org.wikipedia.views.SuggestedArticleDescriptionsDialog import java.io.IOException -import java.lang.Runnable -import java.util.* +import java.util.Date import java.util.concurrent.TimeUnit class DescriptionEditFragment : Fragment() { interface Callback { fun onDescriptionEditSuccess() - fun onBottomBarContainerClicked(action: DescriptionEditActivity.Action) + fun onBottomBarContainerClicked() } + private val viewModel: DescriptionEditViewModel by activityViewModels() private var _binding: FragmentDescriptionEditBinding? = null val binding get() = _binding!! - private lateinit var invokeSource: InvokeSource - private lateinit var pageTitle: PageTitle - lateinit var action: DescriptionEditActivity.Action - private var sourceSummary: PageSummaryForEdit? = null - private var targetSummary: PageSummaryForEdit? = null - private var highlightText: String? = null - private var editingAllowed = true + private lateinit var captchaHandler: CaptchaHandler private val analyticsHelper = MachineGeneratedArticleDescriptionsAnalyticsHelper() - private val disposables = CompositeDisposable() - private val loginLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == LoginActivity.RESULT_LOGIN_SUCCESS) { binding.fragmentDescriptionEditView.loadReviewContent(binding.fragmentDescriptionEditView.showingReviewContent()) @@ -100,20 +87,20 @@ class DescriptionEditFragment : Fragment() { if (!AccountUtil.isLoggedIn) { Prefs.incrementTotalAnonDescriptionsEdited() } - if (invokeSource == InvokeSource.SUGGESTED_EDITS) { + if (viewModel.invokeSource == InvokeSource.SUGGESTED_EDITS) { SuggestedEditsSurvey.onEditSuccess() } Prefs.lastDescriptionEditTime = Date().time Prefs.isSuggestedEditsReactivationPassStageOne = false binding.fragmentDescriptionEditView.setSaveState(false) - if (Prefs.showDescriptionEditSuccessPrompt && invokeSource != InvokeSource.SUGGESTED_EDITS) { - editSuccessLauncher.launch(DescriptionEditSuccessActivity.newIntent(requireContext(), invokeSource)) + if (Prefs.showDescriptionEditSuccessPrompt && viewModel.invokeSource != InvokeSource.SUGGESTED_EDITS) { + editSuccessLauncher.launch(DescriptionEditSuccessActivity.newIntent(requireContext(), viewModel.invokeSource)) Prefs.showDescriptionEditSuccessPrompt = false } else { val intent = Intent() intent.putExtra(SuggestionsActivity.EXTRA_SOURCE_ADDED_CONTRIBUTION, binding.fragmentDescriptionEditView.description) - intent.putExtra(Constants.INTENT_EXTRA_INVOKE_SOURCE, invokeSource) - intent.putExtra(Constants.INTENT_EXTRA_ACTION, action) + intent.putExtra(Constants.INTENT_EXTRA_INVOKE_SOURCE, viewModel.invokeSource) + intent.putExtra(Constants.INTENT_EXTRA_ACTION, viewModel.action) requireActivity().setResult(Activity.RESULT_OK, intent) DeviceUtil.hideSoftKeyboard(requireActivity()) requireActivity().finish() @@ -122,13 +109,7 @@ class DescriptionEditFragment : Fragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - pageTitle = requireArguments().parcelable(Constants.ARG_TITLE)!! - highlightText = requireArguments().getString(ARG_HIGHLIGHT_TEXT) - action = requireArguments().getSerializable(ARG_ACTION) as DescriptionEditActivity.Action - invokeSource = requireArguments().getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as InvokeSource - sourceSummary = requireArguments().parcelable(ARG_SOURCE_SUMMARY) - targetSummary = requireArguments().parcelable(ARG_TARGET_SUMMARY) - EditAttemptStepEvent.logInit(pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) + EditAttemptStepEvent.logInit(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -140,19 +121,153 @@ 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, viewModel.pageTitle.wikiSite, binding.fragmentDescriptionEditView.getCaptchaContainer().root, binding.fragmentDescriptionEditView.getDescriptionEditTextView(), "", null) return binding.root } - override fun onPause() { - super.onPause() - analyticsHelper.timer.pause() - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.loadPageSummaryState.collect { + when (it) { + is Resource.Loading -> { + binding.fragmentDescriptionEditView.setEditAllowed(false) + binding.fragmentDescriptionEditView.showProgressBar(true) + } + + is Resource.Success -> { + setUpEditView(savedInstanceState) + it.data?.let { error -> + FeedbackUtil.showError(requireActivity(), MwException(error), wikiSite = viewModel.pageTitle.wikiSite) + } + } - override fun onResume() { - super.onResume() - analyticsHelper.timer.resume() + is Resource.Error -> { + FeedbackUtil.showError(requireActivity(), it.throwable, wikiSite = viewModel.pageTitle.wikiSite) + } + } + } + } + launch { + viewModel.requestSuggestionState.collect { + when (it) { + is Resource.Loading -> { + binding.fragmentDescriptionEditView.showSuggestedDescriptionsLoadingProgress() + } + is Resource.Success -> { + analyticsHelper.logSuggestionsReceived(requireContext(), it.data.first.blp, viewModel.pageTitle) + if (it.data.third.isNotEmpty() && !it.data.first.blp || it.data.second > 50) { + binding.fragmentDescriptionEditView.showSuggestedDescriptionsButton(it.data.third.first(), + if (it.data.third.size > 1) it.data.third.last() else null) + } else { + binding.fragmentDescriptionEditView.isSuggestionButtonEnabled = false + binding.fragmentDescriptionEditView.updateSuggestedDescriptionsButtonVisibility() + } + } + is Resource.Error -> { + binding.fragmentDescriptionEditView.isSuggestionButtonEnabled = false + FeedbackUtil.showError(requireActivity(), it.throwable, wikiSite = viewModel.pageTitle.wikiSite) + } + } + } + } + launch { + viewModel.postDescriptionState.collect { + when (it) { + is Resource.Loading -> { + binding.fragmentDescriptionEditView.showProgressBar(true) + binding.fragmentDescriptionEditView.setError(null) + binding.fragmentDescriptionEditView.setSaveState(true) + } + is Resource.Success -> { + if (viewModel.shouldWriteToLocalWiki()) { + (it.data as Edit).edit?.run { + when { + editSucceeded -> { + AnonymousNotificationHelper.onEditSubmitted() + viewModel.waitForRevisionUpdate(newRevId) + EditAttemptStepEvent.logSaveSuccess(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) + analyticsHelper.logSuccess(requireContext(), viewModel.pageTitle, newRevId) + ImageRecommendationsEvent.logEditSuccess(viewModel.action, viewModel.pageTitle.wikiSite.languageCode, newRevId) + } + hasEditErrorCode -> { + editFailed(MwException(MwServiceError(code, spamblacklist)), false) + } + hasCaptchaResponse -> { + binding.fragmentDescriptionEditView.showProgressBar(false) + binding.fragmentDescriptionEditView.setSaveState(false) + captchaHandler.handleCaptcha(null, CaptchaResult(captchaId)) + } + hasSpamBlacklistResponse -> { + editFailed(MwException(MwServiceError(code, info)), false) + } + else -> { + editFailed(IOException("Received unrecognized edit response"), true) + } + } + } ?: run { + editFailed(IOException("An unknown error occurred."), true) + } + } else { + (it.data as EntityPostResponse).run { + AnonymousNotificationHelper.onEditSubmitted() + if (success > 0) { + val revId = entity?.lastRevId ?: 0 + requireView().postDelayed(successRunnable, TimeUnit.SECONDS.toMillis(4)) + analyticsHelper.logSuccess(requireContext(), viewModel.pageTitle, revId) + ImageRecommendationsEvent.logEditSuccess(viewModel.action, viewModel.pageTitle.wikiSite.languageCode, revId) + EditAttemptStepEvent.logSaveSuccess(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) + } else { + editFailed(RuntimeException("Received unrecognized description edit response"), true) + } + } + } + } + is Resource.Error -> { + if (viewModel.shouldWriteToLocalWiki()) { + editFailed(it.throwable, true) + } else { + if (it.throwable is MwException) { + val error = it.throwable.error + if (error.badLoginState() || error.badToken()) { + viewModel.postDescription( + currentDescription = binding.fragmentDescriptionEditView.description.orEmpty(), + editComment = getEditComment(), + editTags = getEditTags(), + captchaId = if (captchaHandler.isActive) captchaHandler.captchaId() else null, + captchaWord = if (captchaHandler.isActive) captchaHandler.captchaWord() else null + ) + } else { + editFailed(it.throwable, true) + } + } else { + editFailed(it.throwable, true) + } + } + } + } + } + } + launch { + viewModel.waitForRevisionState.collect { + when (it) { + is Resource.Loading -> { + binding.fragmentDescriptionEditView.showProgressBar(true) + } + is Resource.Success -> { + requireView().post(successRunnable) + } + is Resource.Error -> { + editFailed(it.throwable, true) + } + } + } + } + } + } } override fun onDestroyView() { @@ -162,109 +277,45 @@ class DescriptionEditFragment : Fragment() { super.onDestroyView() } - override fun onDestroy() { - cancelCalls() - super.onDestroy() - } - override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putString(ARG_DESCRIPTION, binding.fragmentDescriptionEditView.description) outState.putBoolean(ARG_REVIEWING, binding.fragmentDescriptionEditView.showingReviewContent()) } - private fun cancelCalls() { - disposables.clear() - } - private fun loadPageSummaryIfNeeded(savedInstanceState: Bundle?) { binding.fragmentDescriptionEditView.showProgressBar(true) - if ((invokeSource == InvokeSource.PAGE_ACTIVITY || invokeSource == InvokeSource.PAGE_EDIT_PENCIL || - invokeSource == InvokeSource.PAGE_EDIT_HIGHLIGHT) && sourceSummary?.extractHtml.isNullOrEmpty()) { - editingAllowed = false - binding.fragmentDescriptionEditView.setEditAllowed(false) - binding.fragmentDescriptionEditView.showProgressBar(true) - disposables.add(Observable.zip(ServiceFactory.getRest(pageTitle.wikiSite).getSummary(null, pageTitle.prefixedText), - ServiceFactory.get(pageTitle.wikiSite).getWikiTextForSectionWithInfo(pageTitle.prefixedText, 0)) { summaryResponse, infoResponse -> - Pair(summaryResponse, infoResponse) - }.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { setUpEditView(savedInstanceState) } - .subscribe({ response -> - val editError = response.second.query?.firstPage()!!.getErrorForAction("edit") - if (editError.isEmpty()) { - editingAllowed = true - } else { - val error = editError[0] - FeedbackUtil.showError(requireActivity(), MwException(error), wikiSite = pageTitle.wikiSite) - } - sourceSummary?.extractHtml = response.first.extractHtml - }, { L.e(it) }) - ) + if ((viewModel.invokeSource == InvokeSource.PAGE_ACTIVITY || viewModel.invokeSource == InvokeSource.PAGE_EDIT_PENCIL || + viewModel.invokeSource == InvokeSource.PAGE_EDIT_HIGHLIGHT) && viewModel.sourceSummary?.extractHtml.isNullOrEmpty()) { + viewModel.loadPageSummary() } else { setUpEditView(savedInstanceState) } } private fun setUpEditView(savedInstanceState: Bundle?) { - if (action == DescriptionEditActivity.Action.ADD_DESCRIPTION) { + if (viewModel.action == DescriptionEditActivity.Action.ADD_DESCRIPTION) { analyticsHelper.articleDescriptionEditingStart(requireContext()) } - binding.fragmentDescriptionEditView.setAction(action) - binding.fragmentDescriptionEditView.setPageTitle(pageTitle) - highlightText?.let { binding.fragmentDescriptionEditView.setHighlightText(it) } + binding.fragmentDescriptionEditView.setAction(viewModel.action) + binding.fragmentDescriptionEditView.setPageTitle(viewModel.pageTitle) + viewModel.highlightText?.let { binding.fragmentDescriptionEditView.setHighlightText(it) } binding.fragmentDescriptionEditView.callback = EditViewCallback() - sourceSummary?.let { binding.fragmentDescriptionEditView.setSummaries(it, targetSummary) } + viewModel.sourceSummary?.let { binding.fragmentDescriptionEditView.setSummaries(it, viewModel.targetSummary) } if (savedInstanceState != null) { binding.fragmentDescriptionEditView.description = savedInstanceState.getString(ARG_DESCRIPTION) binding.fragmentDescriptionEditView.loadReviewContent(savedInstanceState.getBoolean(ARG_REVIEWING)) } binding.fragmentDescriptionEditView.showProgressBar(false) - binding.fragmentDescriptionEditView.setEditAllowed(editingAllowed) + binding.fragmentDescriptionEditView.setEditAllowed(viewModel.editingAllowed) binding.fragmentDescriptionEditView.updateInfoText() - binding.fragmentDescriptionEditView.isSuggestionButtonEnabled = - ReleaseUtil.isPreBetaRelease && MachineGeneratedArticleDescriptionsAnalyticsHelper.isUserInExperiment && - MachineGeneratedArticleDescriptionsAnalyticsHelper.abcTest.group != GROUP_1 + binding.fragmentDescriptionEditView.isSuggestionButtonEnabled = ReleaseUtil.isPreBetaRelease && + SuggestedArticleDescriptionsDialog.availableLanguages.contains(viewModel.pageTitle.wikiSite.languageCode) && + binding.fragmentDescriptionEditView.description.isNullOrEmpty() if (binding.fragmentDescriptionEditView.isSuggestionButtonEnabled) { - binding.fragmentDescriptionEditView.showSuggestedDescriptionsLoadingProgress() - requestSuggestion() - } - } - - private fun requestSuggestion() { - lifecycleScope.launch(CoroutineExceptionHandler { _, throwable -> - binding.fragmentDescriptionEditView.isSuggestionButtonEnabled = false - L.e(throwable) - analyticsHelper.logApiFailed(requireContext(), throwable, pageTitle) - }) { - val response = ServiceFactory[pageTitle.wikiSite, DescriptionSuggestionService.API_URL, DescriptionSuggestionService::class.java] - .getSuggestion(pageTitle.wikiSite.languageCode, pageTitle.prefixedText, 2) - - // Perform some post-processing on the predictions. - // 1) Capitalize them, if we're dealing with enwiki. - // 2) Remove duplicates. - val list = (if (pageTitle.wikiSite.languageCode == "en") { - response.prediction.map { StringUtil.capitalize(it)!! } - } else response.prediction).distinct() - analyticsHelper.apiOrderList = list - analyticsHelper.logSuggestionsReceived(requireContext(), response.blp, pageTitle) - L.d("Received suggestion: " + list.first()) - L.d("And is it a BLP? " + response.blp) - - // Randomize the display order - if (!response.blp || MachineGeneratedArticleDescriptionsAnalyticsHelper.abcTest.group == GROUP_3) { - val randomizedListIndex = (0 until 2).random() - val firstSuggestion = if (list.size == 2) list[randomizedListIndex] else list.first() - val secondSuggestion = if (list.size == 2) { if (randomizedListIndex == 0) list.last() else list.first() } else null - analyticsHelper.displayOrderList = listOfNotNull(firstSuggestion, secondSuggestion) - binding.fragmentDescriptionEditView.showSuggestedDescriptionsButton(firstSuggestion, secondSuggestion) - analyticsHelper.logSuggestionsShown(requireContext(), pageTitle) - } else { - binding.fragmentDescriptionEditView.isSuggestionButtonEnabled = false - binding.fragmentDescriptionEditView.updateSuggestedDescriptionsButtonVisibility() - } + viewModel.requestSuggestion() } } @@ -272,219 +323,23 @@ class DescriptionEditFragment : Fragment() { return FragmentUtil.getCallback(this, Callback::class.java) } - private fun shouldWriteToLocalWiki(): Boolean { - return (action == DescriptionEditActivity.Action.ADD_DESCRIPTION || - action == DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION) && - DescriptionEditUtil.wikiUsesLocalDescriptions(pageTitle.wikiSite.languageCode) - } - private inner class EditViewCallback : DescriptionEditView.Callback { override fun onSaveClick() { if (!binding.fragmentDescriptionEditView.showingReviewContent()) { - if (action == DescriptionEditActivity.Action.ADD_DESCRIPTION) { + if (viewModel.action == DescriptionEditActivity.Action.ADD_DESCRIPTION) { analyticsHelper.articleDescriptionEditingEnd(requireContext()) } binding.fragmentDescriptionEditView.loadReviewContent(true) - analyticsHelper.timer.pause() } else { - binding.fragmentDescriptionEditView.setError(null) - binding.fragmentDescriptionEditView.setSaveState(true) - cancelCalls() - analyticsHelper.logAttempt(requireContext(), - binding.fragmentDescriptionEditView.description.orEmpty(), binding.fragmentDescriptionEditView.wasSuggestionChosen, - binding.fragmentDescriptionEditView.wasSuggestionModified, pageTitle + analyticsHelper.logAttempt(requireContext(), viewModel.pageTitle) + EditAttemptStepEvent.logSaveAttempt(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) + viewModel.postDescription( + currentDescription = binding.fragmentDescriptionEditView.description.orEmpty(), + editComment = getEditComment(), + editTags = getEditTags(), + captchaId = if (captchaHandler.isActive) captchaHandler.captchaId() else null, + captchaWord = if (captchaHandler.isActive) captchaHandler.captchaWord() else null ) - getEditTokenThenSave() - EditAttemptStepEvent.logSaveAttempt(pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) - } - } - - private fun getEditTokenThenSave() { - if (captchaHandler.isActive) { - captchaHandler.hideCaptcha() - } - val csrfSite = if (action == DescriptionEditActivity.Action.ADD_CAPTION || - action == DescriptionEditActivity.Action.TRANSLATE_CAPTION) { - Constants.commonsWikiSite - } else { - if (shouldWriteToLocalWiki()) pageTitle.wikiSite else Constants.wikidataWikiSite - } - - disposables.add(CsrfTokenClient.getToken(csrfSite).subscribe({ token -> - if (shouldWriteToLocalWiki()) { - // If the description is being applied to an article on English Wikipedia, it - // should be written directly to the article instead of Wikidata. - postDescriptionToArticle(token) - } else { - postDescriptionToWikidata(token) - } - }, { - editFailed(it, false) - })) - } - - private fun postDescriptionToArticle(editToken: String) { - val wikiSite = WikiSite.forLanguageCode(pageTitle.wikiSite.languageCode) - disposables.add(ServiceFactory.get(wikiSite).getWikiTextForSectionWithInfo(pageTitle.prefixedText, 0) - .subscribeOn(Schedulers.io()) - .flatMap { mwQueryResponse -> - if (mwQueryResponse.query?.firstPage()!!.getErrorForAction("edit").isNotEmpty()) { - val error = mwQueryResponse.query?.firstPage()!!.getErrorForAction("edit")[0] - throw MwException(error) - } - var text = mwQueryResponse.query?.firstPage()!!.revisions[0].contentMain - val baseRevId = mwQueryResponse.query?.firstPage()!!.revisions[0].revId - text = updateDescriptionInArticle(text, binding.fragmentDescriptionEditView.description.orEmpty()) - - ServiceFactory.get(wikiSite).postEditSubmit(pageTitle.prefixedText, "0", null, - getEditComment().orEmpty(), - if (AccountUtil.isLoggedIn) "user" - else null, text, null, baseRevId, editToken, - if (captchaHandler.isActive) captchaHandler.captchaId() else null, - if (captchaHandler.isActive) captchaHandler.captchaWord() else null - ) - .subscribeOn(Schedulers.io()) - } - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ result -> - result.edit?.run { - when { - editSucceeded -> { - AnonymousNotificationHelper.onEditSubmitted() - waitForUpdatedRevision(newRevId) - EditAttemptStepEvent.logSaveSuccess(pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) - analyticsHelper.logSuccess(requireContext(), - binding.fragmentDescriptionEditView.description.orEmpty(), - binding.fragmentDescriptionEditView.wasSuggestionChosen, - binding.fragmentDescriptionEditView.wasSuggestionModified, - pageTitle, newRevId - ) - ImageRecommendationsEvent.logEditSuccess(action, pageTitle.wikiSite.languageCode, newRevId) - } - hasEditErrorCode -> { - editFailed(MwException(MwServiceError(code, spamblacklist)), false) - } - hasCaptchaResponse -> { - binding.fragmentDescriptionEditView.showProgressBar(false) - binding.fragmentDescriptionEditView.setSaveState(false) - captchaHandler.handleCaptcha(null, CaptchaResult(result.edit.captchaId)) - } - hasSpamBlacklistResponse -> { - editFailed(MwException(MwServiceError(code, info)), false) - } - else -> { - editFailed(IOException("Received unrecognized edit response"), true) - } - } - } ?: run { - editFailed(IOException("An unknown error occurred."), true) - } - }) { caught -> editFailed(caught, true) }) - } - - private fun postDescriptionToWikidata(editToken: String) { - disposables.add(ServiceFactory.get(WikiSite.forLanguageCode(pageTitle.wikiSite.languageCode)).getWikiTextForSectionWithInfo(pageTitle.prefixedText, 0) - .subscribeOn(Schedulers.io()) - .flatMap { response -> - if (response.query?.firstPage()!!.getErrorForAction("edit").isNotEmpty()) { - val error = response.query?.firstPage()!!.getErrorForAction("edit")[0] - throw MwException(error) - } - ServiceFactory.get(WikiSite.forLanguageCode(pageTitle.wikiSite.languageCode)).siteInfo - } - .flatMap { response -> - val languageCode = if (response.query?.siteInfo?.lang != null && - response.query?.siteInfo?.lang != AppLanguageLookUpTable.CHINESE_LANGUAGE_CODE) response.query?.siteInfo?.lang - else pageTitle.wikiSite.languageCode - getPostObservable(editToken, languageCode.orEmpty()) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ response -> - AnonymousNotificationHelper.onEditSubmitted() - if (response.success > 0) { - requireView().postDelayed(successRunnable, TimeUnit.SECONDS.toMillis(4)) - analyticsHelper.logSuccess(requireContext(), - binding.fragmentDescriptionEditView.description.orEmpty(), - binding.fragmentDescriptionEditView.wasSuggestionChosen, - binding.fragmentDescriptionEditView.wasSuggestionModified, - pageTitle, response.entity?.lastRevId ?: 0 - ) - ImageRecommendationsEvent.logEditSuccess(action, pageTitle.wikiSite.languageCode, response.entity?.lastRevId ?: 0) - EditAttemptStepEvent.logSaveSuccess(pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) - } else { - editFailed(RuntimeException("Received unrecognized description edit response"), true) - } - }) { caught -> - if (caught is MwException) { - val error = caught.error - if (error.badLoginState() || error.badToken()) { - getEditTokenThenSave() - } else { - editFailed(caught, true) - } - } else { - editFailed(caught, true) - } - }) - } - - @Suppress("SameParameterValue") - private fun waitForUpdatedRevision(newRevision: Long) { - disposables.add(ServiceFactory.getRest(WikiSite.forLanguageCode(pageTitle.wikiSite.languageCode)) - .getSummaryResponse(pageTitle.prefixedText, null, OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK.toString(), null, null, null) - .delay(2, TimeUnit.SECONDS) - .subscribeOn(Schedulers.io()) - .map { response -> - if (response.body()!!.revision < newRevision) { - throw IllegalStateException() - } - response.body()!!.revision - } - .retry(10) { it is IllegalStateException } - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { - requireView().post(successRunnable) - } - .subscribe() - ) - } - - private fun getPostObservable(editToken: String, languageCode: String): Observable { - return if (action == DescriptionEditActivity.Action.ADD_CAPTION || - action == DescriptionEditActivity.Action.TRANSLATE_CAPTION) { - ServiceFactory.get(Constants.commonsWikiSite).postLabelEdit(languageCode, languageCode, Constants.COMMONS_DB_NAME, - pageTitle.prefixedText, binding.fragmentDescriptionEditView.description.orEmpty(), - getEditComment(), editToken, if (AccountUtil.isLoggedIn) "user" else null) - } else { - ServiceFactory.get(Constants.wikidataWikiSite).postDescriptionEdit(languageCode, languageCode, pageTitle.wikiSite.dbName(), - pageTitle.prefixedText, binding.fragmentDescriptionEditView.description.orEmpty(), getEditComment(), editToken, - if (AccountUtil.isLoggedIn) "user" else null) - } - } - - private fun getEditComment(): String? { - if (action == DescriptionEditActivity.Action.ADD_DESCRIPTION && binding.fragmentDescriptionEditView.wasSuggestionChosen) { - return if (binding.fragmentDescriptionEditView.wasSuggestionModified) MACHINE_SUGGESTION_MODIFIED else MACHINE_SUGGESTION - } else if (invokeSource == InvokeSource.SUGGESTED_EDITS || invokeSource == InvokeSource.FEED) { - return when (action) { - DescriptionEditActivity.Action.ADD_DESCRIPTION -> if (!pageTitle.description.isNullOrEmpty()) - SUGGESTED_EDITS_ADD_DESC_CHANGE_COMMENT else SUGGESTED_EDITS_ADD_DESC_COMMENT - DescriptionEditActivity.Action.ADD_CAPTION -> SUGGESTED_EDITS_ADD_CAPTION_COMMENT - DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION -> SUGGESTED_EDITS_TRANSLATE_DESC_COMMENT - DescriptionEditActivity.Action.TRANSLATE_CAPTION -> SUGGESTED_EDITS_TRANSLATE_CAPTION_COMMENT - else -> null - } - } - return null - } - - private fun editFailed(caught: Throwable, logError: Boolean) { - binding.fragmentDescriptionEditView.setSaveState(false) - FeedbackUtil.showError(requireActivity(), caught) - L.e(caught) - if (logError) { - EditAttemptStepEvent.logSaveFailure(pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) } } @@ -493,7 +348,6 @@ class DescriptionEditFragment : Fragment() { captchaHandler.cancelCaptcha() } else if (binding.fragmentDescriptionEditView.showingReviewContent()) { binding.fragmentDescriptionEditView.loadReviewContent(false) - analyticsHelper.timer.resume() } else { DeviceUtil.hideSoftKeyboard(requireActivity()) requireActivity().onBackPressed() @@ -501,7 +355,7 @@ class DescriptionEditFragment : Fragment() { } override fun onBottomBarClick() { - callback()?.onBottomBarContainerClicked(action) + callback()?.onBottomBarContainerClicked() } override fun onVoiceInputClick() { @@ -518,54 +372,59 @@ class DescriptionEditFragment : Fragment() { } } - private fun updateDescriptionInArticle(articleText: String, newDescription: String): String { - return if (articleText.contains(TEMPLATE_PARSE_REGEX.toRegex())) { - // update existing description template - articleText.replaceFirst(TEMPLATE_PARSE_REGEX.toRegex(), "$1$newDescription$3") - } else { - // add new description template - "{{${DESCRIPTION_TEMPLATES[0]}|$newDescription}}\n$articleText".trimIndent() + private fun getEditComment(): String? { + if (viewModel.action == DescriptionEditActivity.Action.ADD_DESCRIPTION && binding.fragmentDescriptionEditView.wasSuggestionChosen) { + return if (binding.fragmentDescriptionEditView.wasSuggestionModified) MACHINE_SUGGESTION_MODIFIED else MACHINE_SUGGESTION } + return null } - companion object { - private const val ARG_REVIEWING = "inReviewing" - private const val ARG_DESCRIPTION = "description" - private const val ARG_HIGHLIGHT_TEXT = "highlightText" - private const val ARG_ACTION = "action" - private const val ARG_SOURCE_SUMMARY = "sourceSummary" - private const val ARG_TARGET_SUMMARY = "targetSummary" - private const val SUGGESTED_EDITS_UI_VERSION = "1.0" - const val MACHINE_SUGGESTION = "#machine-suggestion" - const val MACHINE_SUGGESTION_MODIFIED = "#machine-suggestion-modified" - const val SUGGESTED_EDITS_PATROLLER_TASKS_ROLLBACK = "#suggestededit-patrol-rollback $SUGGESTED_EDITS_UI_VERSION" - const val SUGGESTED_EDITS_PATROLLER_TASKS_UNDO = "#suggestededit-patrol-undo $SUGGESTED_EDITS_UI_VERSION" - const val SUGGESTED_EDITS_ADD_DESC_COMMENT = "#suggestededit-add-desc $SUGGESTED_EDITS_UI_VERSION" - const val SUGGESTED_EDITS_ADD_DESC_CHANGE_COMMENT = "#suggestededit-change-desc $SUGGESTED_EDITS_UI_VERSION" - 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 `\\}` - @Suppress("RegExpRedundantEscape") - const val TEMPLATE_PARSE_REGEX = "(\\{\\{[Ss]hort description\\|(?:1=)?)([^}|]+)([^}]*\\}\\})" - - fun newInstance(title: PageTitle, - highlightText: String?, - sourceSummary: PageSummaryForEdit?, - targetSummary: PageSummaryForEdit?, - action: DescriptionEditActivity.Action, - source: InvokeSource): DescriptionEditFragment { - return DescriptionEditFragment().apply { - arguments = bundleOf(Constants.ARG_TITLE to title, - ARG_HIGHLIGHT_TEXT to highlightText, - ARG_SOURCE_SUMMARY to sourceSummary, - ARG_TARGET_SUMMARY to targetSummary, - ARG_ACTION to action, - Constants.INTENT_EXTRA_INVOKE_SOURCE to source) + private fun getEditTags(): String? { + val tags = mutableListOf() + + if (viewModel.invokeSource == InvokeSource.SUGGESTED_EDITS) { + tags.add(EditTags.APP_SUGGESTED_EDIT) + } + + when (viewModel.action) { + DescriptionEditActivity.Action.ADD_DESCRIPTION -> { + if (binding.fragmentDescriptionEditView.wasSuggestionChosen) { + tags.add(EditTags.APP_DESCRIPTION_ADD) + tags.add(EditTags.APP_AI_ASSIST) + } else if (viewModel.pageTitle.description.isNullOrEmpty()) { + tags.add(EditTags.APP_DESCRIPTION_ADD) + } else { + tags.add(EditTags.APP_DESCRIPTION_CHANGE) + } + } + DescriptionEditActivity.Action.ADD_CAPTION -> { + tags.add(EditTags.APP_IMAGE_CAPTION_ADD) + } + DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION -> { + tags.add(EditTags.APP_DESCRIPTION_TRANSLATE) } + DescriptionEditActivity.Action.TRANSLATE_CAPTION -> { + tags.add(EditTags.APP_IMAGE_CAPTION_TRANSLATE) + } + else -> { } } + + return if (tags.isEmpty()) null else tags.joinToString(",") + } + + private fun editFailed(caught: Throwable, logError: Boolean) { + binding.fragmentDescriptionEditView.setSaveState(false) + FeedbackUtil.showError(requireActivity(), caught) + L.e(caught) + if (logError) { + EditAttemptStepEvent.logSaveFailure(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) + } + } + + companion object { + const val ARG_REVIEWING = "inReviewing" + const val ARG_DESCRIPTION = "description" + private const val MACHINE_SUGGESTION = "#machine-suggestion" + private const val MACHINE_SUGGESTION_MODIFIED = "#machine-suggestion-modified" } } diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditReviewView.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditReviewView.kt index f7ab9eeb21f..aa2948bc668 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditReviewView.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditReviewView.kt @@ -74,7 +74,7 @@ class DescriptionEditReviewView constructor(context: Context, attrs: AttributeSe } companion object { - const val ARTICLE_EXTRACT_MAX_LINE_WITH_IMAGE = 5 - const val ARTICLE_EXTRACT_MAX_LINE_WITHOUT_IMAGE = 15 + const val ARTICLE_EXTRACT_MAX_LINE_WITH_IMAGE = 2 + const val ARTICLE_EXTRACT_MAX_LINE_WITHOUT_IMAGE = 5 } } diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditTutorialActivity.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditTutorialActivity.kt index b8e45b46b79..44a9d082503 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditTutorialActivity.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditTutorialActivity.kt @@ -19,7 +19,7 @@ class DescriptionEditTutorialActivity : SingleFragmentActivity binding.viewDescriptionEditText.setText(suggestion) binding.viewDescriptionEditText.setSelection(binding.viewDescriptionEditText.text?.length ?: 0) - callback?.getAnalyticsHelper()?.logSuggestionChosen(context, suggestion, pageTitle) + callback?.getAnalyticsHelper()?.logSuggestionChosen(context, pageTitle) wasSuggestionChosen = true wasSuggestionModified = false }.show() diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditViewModel.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditViewModel.kt new file mode 100644 index 00000000000..d361ae7d539 --- /dev/null +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditViewModel.kt @@ -0,0 +1,268 @@ +package org.wikipedia.descriptions + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.auth.AccountUtil +import org.wikipedia.csrf.CsrfTokenClient +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.liftwing.DescriptionSuggestion +import org.wikipedia.dataclient.liftwing.LiftWingModelService +import org.wikipedia.dataclient.mwapi.MwException +import org.wikipedia.dataclient.mwapi.MwServiceError +import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory +import org.wikipedia.dataclient.wikidata.EntityPostResponse +import org.wikipedia.edit.Edit +import org.wikipedia.language.AppLanguageLookUpTable +import org.wikipedia.page.PageTitle +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.util.L10nUtil +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil +import org.wikipedia.util.log.L + +class DescriptionEditViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + + val pageTitle = savedStateHandle.get(Constants.ARG_TITLE)!! + val highlightText = savedStateHandle.get(DescriptionEditActivity.EXTRA_HIGHLIGHT_TEXT) + val action = savedStateHandle.get(Constants.INTENT_EXTRA_ACTION)!! + val invokeSource = savedStateHandle.get(Constants.INTENT_EXTRA_INVOKE_SOURCE)!! + val sourceSummary = savedStateHandle.get(DescriptionEditActivity.EXTRA_SOURCE_SUMMARY) + val targetSummary = savedStateHandle.get(DescriptionEditActivity.EXTRA_TARGET_SUMMARY) + var editingAllowed = true + + private var clientJob: Job? = null + + private val _loadPageSummaryState = MutableStateFlow(Resource()) + val loadPageSummaryState = _loadPageSummaryState.asStateFlow() + + private val _requestSuggestionState = MutableStateFlow(Resource>>()) + val requestSuggestionState = _requestSuggestionState.asStateFlow() + + private val _postDescriptionState = MutableStateFlow(Resource()) + val postDescriptionState = _postDescriptionState.asStateFlow() + + private val _waitForRevisionState = MutableStateFlow(Resource()) + val waitForRevisionState = _waitForRevisionState.asStateFlow() + + fun loadPageSummary() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + }) { + _loadPageSummaryState.value = Resource.Loading() + editingAllowed = false + val summaryResponse = async { ServiceFactory.getRest(pageTitle.wikiSite).getPageSummary(null, pageTitle.prefixedText) } + val infoResponse = async { ServiceFactory.get(pageTitle.wikiSite).getWikiTextForSectionWithInfo(pageTitle.prefixedText, 0) } + + val editError = infoResponse.await().query?.firstPage()?.getErrorForAction("edit") + var error: MwServiceError? = null + if (editError.isNullOrEmpty()) { + editingAllowed = true + } else { + error = editError[0] + } + sourceSummary?.extractHtml = summaryResponse.await().extractHtml + _loadPageSummaryState.value = Resource.Success(error) + } + } + + fun requestSuggestion() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + _requestSuggestionState.value = Resource.Error(throwable) + }) { + _requestSuggestionState.value = Resource.Loading() + val responseCall = async { ServiceFactory[pageTitle.wikiSite, LiftWingModelService.API_URL, LiftWingModelService::class.java] + .getDescriptionSuggestion(DescriptionSuggestion.Request(pageTitle.wikiSite.languageCode, pageTitle.prefixedText, 2)) } + val userInfoCall = async { ServiceFactory.get(WikipediaApp.instance.wikiSite) + .globalUserInfo(AccountUtil.userName) } + + val response = responseCall.await() + val userTotalEdits = userInfoCall.await().query?.globalUserInfo?.editCount ?: 0 + + // Perform some post-processing on the predictions. + // 1) Capitalize them, if we're dealing with enwiki. + // 2) Remove duplicates. + val list = (if (pageTitle.wikiSite.languageCode == "en") { + response.prediction.map { StringUtil.capitalize(it)!! } + } else response.prediction).distinct() + + _requestSuggestionState.value = Resource.Success(Triple(response, userTotalEdits, list)) + } + } + + fun postDescription(currentDescription: String, + editComment: String?, + editTags: String?, + captchaId: String?, + captchaWord: String?) { + clientJob?.cancel() + clientJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + _postDescriptionState.value = Resource.Error(throwable) + }) { + _postDescriptionState.value = Resource.Loading() + + val csrfSite = if (action == DescriptionEditActivity.Action.ADD_CAPTION || + action == DescriptionEditActivity.Action.TRANSLATE_CAPTION) { + Constants.commonsWikiSite + } else { + if (shouldWriteToLocalWiki()) pageTitle.wikiSite else Constants.wikidataWikiSite + } + + val csrfToken = CsrfTokenClient.getToken(csrfSite) + + val response = if (shouldWriteToLocalWiki()) { + // If the description is being applied to an article on English Wikipedia, it + // should be written directly to the article instead of Wikidata. + postDescriptionToArticle(csrfToken, currentDescription, editComment, editTags, captchaId, captchaWord) + } else { + postDescriptionToWikidata(csrfToken, currentDescription, editComment, editTags) + } + + _postDescriptionState.value = Resource.Success(response) + } + } + + fun waitForRevisionUpdate(newRevision: Long) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + _waitForRevisionState.value = Resource.Error(throwable) + }) { + _waitForRevisionState.value = Resource.Loading() + // Implement a retry mechanism to wait for the revision to be available. + var retry = 0 + var revision = -1L + while (revision < newRevision && retry < 10) { + delay(2000) + val pageSummaryResponse = ServiceFactory.getRest(pageTitle.wikiSite).getSummaryResponse(pageTitle.prefixedText, cacheControl = OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK.toString()) + revision = pageSummaryResponse.body()?.revision ?: -1L + retry++ + } + _waitForRevisionState.value = Resource.Success(true) + } + } + + private suspend fun postDescriptionToArticle(csrfToken: String, + currentDescription: String, + editComment: String?, + editTags: String?, + captchaId: String?, + captchaWord: String?): Edit { + val wikiSectionInfoResponse = ServiceFactory.get(pageTitle.wikiSite) + .getWikiTextForSectionWithInfo(pageTitle.prefixedText, 0) + val errorForAction = wikiSectionInfoResponse.query?.firstPage()?.getErrorForAction("edit") + if (!errorForAction.isNullOrEmpty()) { + val error = errorForAction.first() + throw MwException(error) + } + val firstRevision = wikiSectionInfoResponse.query?.firstPage()?.revisions?.firstOrNull() + var text = firstRevision?.contentMain.orEmpty() + val baseRevId = firstRevision?.revId ?: 0 + text = updateDescriptionInArticle(text, currentDescription) + val automaticallyAddedEditSummary = L10nUtil.getStringForArticleLanguage(pageTitle, + if (pageTitle.description.isNullOrEmpty()) R.string.edit_summary_added_short_description + else R.string.edit_summary_updated_short_description) + var editSummary = automaticallyAddedEditSummary + editComment?.let { + editSummary += ", $it" + } + + return ServiceFactory.get(pageTitle.wikiSite).postEditSubmit( + title = pageTitle.prefixedText, + section = "0", + newSectionTitle = null, + summary = editSummary, + user = AccountUtil.assertUser, + text = text, + appendText = null, + baseRevId = baseRevId, + token = csrfToken, + captchaId = captchaId, + captchaWord = captchaWord, + tags = editTags + ) + } + + private suspend fun postDescriptionToWikidata(csrfToken: String, + currentDescription: String, + editComment: String?, + editTags: String?): EntityPostResponse { + val wikiSectionInfoResponse = ServiceFactory.get(pageTitle.wikiSite).getWikiTextForSectionWithInfo(pageTitle.prefixedText, 0) + val errorForAction = wikiSectionInfoResponse.query?.firstPage()?.getErrorForAction("edit") + if (!errorForAction.isNullOrEmpty()) { + val error = errorForAction.first() + throw MwException(error) + } + + var languageCode = pageTitle.wikiSite.languageCode + if (action != DescriptionEditActivity.Action.ADD_CAPTION && + action != DescriptionEditActivity.Action.TRANSLATE_CAPTION) { + ServiceFactory.get(pageTitle.wikiSite).getSiteInfo().query?.siteInfo?.lang?.let { + if (it.isNotEmpty() && it != AppLanguageLookUpTable.CHINESE_LANGUAGE_CODE) { + languageCode = it + } + } + } + + return if (action == DescriptionEditActivity.Action.ADD_CAPTION || + action == DescriptionEditActivity.Action.TRANSLATE_CAPTION) { + ServiceFactory.get(Constants.commonsWikiSite).postLabelEdit( + language = languageCode, + useLang = languageCode, + site = Constants.COMMONS_DB_NAME, + title = pageTitle.prefixedText, + newDescription = currentDescription, + summary = editComment, + token = csrfToken, + user = AccountUtil.assertUser, + tags = editTags + ) + } else { + ServiceFactory.get(Constants.wikidataWikiSite).postDescriptionEdit( + language = languageCode, + useLang = languageCode, + site = pageTitle.wikiSite.dbName(), + title = pageTitle.prefixedText, + newDescription = currentDescription, + summary = editComment, + token = csrfToken, + user = AccountUtil.assertUser, + tags = editTags + ) + } + } + + fun shouldWriteToLocalWiki(): Boolean { + return (action == DescriptionEditActivity.Action.ADD_DESCRIPTION || + action == DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION) && + DescriptionEditUtil.wikiUsesLocalDescriptions(pageTitle.wikiSite.languageCode) + } + + private fun updateDescriptionInArticle(articleText: String, newDescription: String): String { + return if (articleText.contains(TEMPLATE_PARSE_REGEX.toRegex())) { + // update existing description template + articleText.replaceFirst(TEMPLATE_PARSE_REGEX.toRegex(), "$1$newDescription$3") + } else { + // add new description template + "{{${DESCRIPTION_TEMPLATES[0]}|$newDescription}}\n$articleText".trimIndent() + } + } + + companion object { + val DESCRIPTION_TEMPLATES = arrayOf("Short description", "SHORTDESC") + // Don't remove the ending escaped `\\}` + @Suppress("RegExpRedundantEscape") + const val TEMPLATE_PARSE_REGEX = "(\\{\\{[Ss]hort description\\|(?:1=)?)([^}|]+)([^}]*\\}\\})" + } +} diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionSuggestionResponse.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionSuggestionResponse.kt deleted file mode 100644 index 7eb1fbfc64d..00000000000 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionSuggestionResponse.kt +++ /dev/null @@ -1,9 +0,0 @@ -package org.wikipedia.descriptions - -import kotlinx.serialization.Serializable - -@Serializable -class DescriptionSuggestionResponse { - val prediction: List = emptyList() - val blp = false -} diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionSuggestionService.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionSuggestionService.kt deleted file mode 100644 index a0e0699839d..00000000000 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionSuggestionService.kt +++ /dev/null @@ -1,17 +0,0 @@ -package org.wikipedia.descriptions - -import retrofit2.http.GET -import retrofit2.http.Query - -interface DescriptionSuggestionService { - @GET("article") - suspend fun getSuggestion( - @Query("lang") lang: String, - @Query("title") title: String, - @Query("num_beams") count: Int - ): DescriptionSuggestionResponse - - companion object { - const val API_URL = "https://ml-article-description-api.wmcloud.org/" - } -} diff --git a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt index c85bced1e3c..8c6d0cb2389 100644 --- a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt +++ b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt @@ -31,6 +31,7 @@ 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 @@ -38,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 @@ -51,7 +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.CustomTabsUtil import org.wikipedia.util.DateUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.L10nUtil @@ -73,7 +77,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M private var _binding: FragmentArticleEditDetailsBinding? = null private val binding get() = _binding!! - private val viewModel: ArticleEditDetailsViewModel by viewModels { ArticleEditDetailsViewModel.Factory(requireArguments()) } + private val viewModel: ArticleEditDetailsViewModel by viewModels() private var editHistoryInteractionEvent: EditHistoryInteractionEvent? = null private val actionBarOffsetChangedListener = @@ -83,30 +87,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() } } @@ -134,6 +133,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) @@ -197,6 +200,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", @@ -205,6 +209,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() @@ -224,6 +229,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", @@ -231,6 +237,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) } @@ -274,6 +281,9 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M startActivity(TalkTopicsActivity.newIntent(requireContext(), viewModel.pageTitle, InvokeSource.DIFF_ACTIVITY)) } else if (viewModel.pageTitle.namespace() == Namespace.FILE) { startActivity(FilePageActivity.newIntent(requireContext(), viewModel.pageTitle)) + } else if (viewModel.pageTitle.wikiSite.dbName() == Constants.WIKIDATA_DB_NAME || + viewModel.pageTitle.wikiSite.dbName() == Constants.COMMONS_DB_NAME) { + CustomTabsUtil.openInCustomTab(requireContext(), viewModel.pageTitle.mobileUri) } else { ExclusiveBottomSheetPresenter.show(childFragmentManager, LinkPreviewDialog.newInstance( HistoryEntry(viewModel.pageTitle, HistoryEntry.SOURCE_EDIT_DIFF_DETAILS))) @@ -309,7 +319,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) @@ -346,11 +356,11 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M } updateWatchButton(false) - binding.warnButton.setOnClickListener { + binding.talkButton.setOnClickListener { 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)) } } @@ -366,6 +376,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 { @@ -396,6 +407,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 } } @@ -448,7 +465,6 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M binding.undoButton.isVisible = false binding.olderIdButton.isVisible = false binding.newerIdButton.isVisible = false - binding.warnButton.isVisible = viewModel.fromRecentEdits } private fun updateAfterRevisionFetchSuccess() { @@ -542,52 +558,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() { @@ -612,6 +630,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) } } @@ -619,16 +638,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() { @@ -638,6 +658,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) } } @@ -648,16 +669,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) { @@ -667,14 +689,14 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M if (Prefs.showOneTimeRecentEditsFeedbackForm) { sendPatrollerExperienceEvent("toolbar_first_feedback", "pt_feedback") } - SurveyDialog.showFeedbackOptionsDialog(requireActivity(), InvokeSource.SUGGESTED_EDITS_RECENT_EDITS) + SurveyDialog.showFeedbackOptionsDialog(requireActivity(), invokeSource = InvokeSource.SUGGESTED_EDITS_RECENT_EDITS) } private fun updateActionButtons() { binding.undoButton.isVisible = viewModel.revisionFrom != null && AccountUtil.isLoggedIn binding.thankButton.isEnabled = true binding.thankButton.isVisible = AccountUtil.isLoggedIn && - !AccountUtil.userName.equals(viewModel.revisionTo?.user) && + AccountUtil.userName != viewModel.revisionTo?.user && viewModel.revisionTo?.isAnon == false } @@ -693,6 +715,12 @@ 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) } diff --git a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt index 283a0909a4c..1256677a704 100644 --- a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt +++ b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt @@ -1,10 +1,9 @@ package org.wikipedia.diff import android.net.Uri -import android.os.Bundle import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.async @@ -21,18 +20,16 @@ import org.wikipedia.dataclient.restbase.Revision import org.wikipedia.dataclient.rollback.RollbackPostResponse import org.wikipedia.dataclient.watch.WatchPostResponse import org.wikipedia.dataclient.wikidata.EntityPostResponse -import org.wikipedia.descriptions.DescriptionEditFragment import org.wikipedia.edit.Edit -import org.wikipedia.extensions.parcelable +import org.wikipedia.edit.EditTags import org.wikipedia.page.PageTitle import org.wikipedia.suggestededits.provider.EditingSuggestionsProvider import org.wikipedia.util.Resource import org.wikipedia.util.SingleLiveData import org.wikipedia.watchlist.WatchlistExpiry -class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { - - private val invokeSource = bundle.getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as InvokeSource +class ArticleEditDetailsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val invokeSource = savedStateHandle.get(Constants.INTENT_EXTRA_INVOKE_SOURCE) val watchedStatus = MutableLiveData>() val rollbackRights = MutableLiveData>() @@ -46,20 +43,18 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { val fromRecentEdits = invokeSource == InvokeSource.SUGGESTED_EDITS_RECENT_EDITS - var pageTitle = bundle.parcelable(ArticleEditDetailsActivity.EXTRA_ARTICLE_TITLE)!! + var pageTitle = savedStateHandle.get(ArticleEditDetailsActivity.EXTRA_ARTICLE_TITLE)!! private set - var pageId = bundle.getInt(ArticleEditDetailsActivity.EXTRA_PAGE_ID, -1) + var pageId = savedStateHandle[ArticleEditDetailsActivity.EXTRA_PAGE_ID] ?: -1 private set - var revisionToId = bundle.getLong(ArticleEditDetailsActivity.EXTRA_EDIT_REVISION_TO, -1) + var revisionToId = savedStateHandle[ArticleEditDetailsActivity.EXTRA_EDIT_REVISION_TO] ?: -1L var revisionTo: MwQueryPage.Revision? = null - var revisionFromId = bundle.getLong(ArticleEditDetailsActivity.EXTRA_EDIT_REVISION_FROM, -1) + var revisionFromId = savedStateHandle[ArticleEditDetailsActivity.EXTRA_EDIT_REVISION_FROM] ?: -1L var revisionFrom: MwQueryPage.Revision? = null var canGoForward = false var hasRollbackRights = false var isWatched = false - var feedbackInput = "" - val diffSize get() = if (revisionFrom != null) revisionTo!!.size - revisionFrom!!.size else revisionTo!!.size init { @@ -220,20 +215,16 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { } } - @Suppress("KotlinConstantConditions") fun undoEdit(title: PageTitle, user: String, comment: String, revisionId: Long, revisionIdAfter: Long) { viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> undoEditResponse.postValue(Resource.Error(throwable)) }) { val msgResponse = ServiceFactory.get(title.wikiSite).getMessages("undo-summary", "$revisionId|$user") 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 - } + val summary = if (undoMessage != null) "$undoMessage $comment" else comment val token = ServiceFactory.get(title.wikiSite).getToken().query!!.csrfToken()!! val undoResponse = ServiceFactory.get(title.wikiSite).postUndoEdit(title.prefixedText, summary, - null, token, revisionId, if (revisionIdAfter > 0) revisionIdAfter else null) + null, token, revisionId, if (revisionIdAfter > 0) revisionIdAfter else null, tags = getEditTags(EditTags.APP_UNDO)) undoEditResponse.postValue(Resource.Success(undoResponse)) } } @@ -247,19 +238,17 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { .query?.allmessages?.firstOrNull { it.name == "revertpage" }?.content val rollbackToken = ServiceFactory.get(title.wikiSite).getToken("rollback").query!!.rollbackToken()!! - var summary = rollbackSummaryMsg - if (fromRecentEdits) { - summary += ", " + DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_ROLLBACK - } - val rollbackPostResponse = ServiceFactory.get(title.wikiSite).postRollback(title.prefixedText, summary, user, rollbackToken) + val rollbackPostResponse = ServiceFactory.get(title.wikiSite).postRollback(title.prefixedText, rollbackSummaryMsg, user, rollbackToken, tags = getEditTags(EditTags.APP_ROLLBACK)) rollbackResponse.postValue(Resource.Success(rollbackPostResponse)) } } - class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { - @Suppress("unchecked_cast") - override fun create(modelClass: Class): T { - return ArticleEditDetailsViewModel(bundle) as T + private fun getEditTags(tag: String): String { + val tags = mutableListOf() + if (fromRecentEdits) { + tags.add(EditTags.APP_SUGGESTED_EDIT) } + tags.add(tag) + return tags.joinToString(",") } } diff --git a/app/src/main/java/org/wikipedia/diff/DiffLineView.kt b/app/src/main/java/org/wikipedia/diff/DiffLineView.kt index ce7aa9dccdf..4a5a84be833 100644 --- a/app/src/main/java/org/wikipedia/diff/DiffLineView.kt +++ b/app/src/main/java/org/wikipedia/diff/DiffLineView.kt @@ -11,7 +11,7 @@ import org.wikipedia.databinding.ItemDiffLineBinding import org.wikipedia.dataclient.restbase.DiffResponse import org.wikipedia.util.ResourceUtil -class DiffLineView constructor(context: Context, attrs: AttributeSet? = null) : ConstraintLayout(context, attrs) { +class DiffLineView(context: Context, attrs: AttributeSet? = null) : ConstraintLayout(context, attrs) { private val binding = ItemDiffLineBinding.inflate(LayoutInflater.from(context), this) private lateinit var diffLine: DiffUtil.DiffLine diff --git a/app/src/main/java/org/wikipedia/diff/DiffUtil.kt b/app/src/main/java/org/wikipedia/diff/DiffUtil.kt index c3be9d3b72d..db2ba437d9a 100644 --- a/app/src/main/java/org/wikipedia/diff/DiffUtil.kt +++ b/app/src/main/java/org/wikipedia/diff/DiffUtil.kt @@ -28,13 +28,13 @@ object DiffUtil { val item = DiffLine(context, it) // coalesce diff lines that occur on successive line numbers if (lastItem != null && - ((item.diff.lineNumber - lastItem!!.diff.lineNumber == 1 && lastItem!!.diff.type == DiffResponse.DIFF_TYPE_LINE_ADDED && item.diff.type == DiffResponse.DIFF_TYPE_LINE_ADDED) || - (item.diff.lineNumber - lastItem!!.diff.lineNumber == 1 && lastItem!!.diff.type == DiffResponse.DIFF_TYPE_LINE_WITH_SAME_CONTENT && item.diff.type == DiffResponse.DIFF_TYPE_LINE_WITH_SAME_CONTENT) || - (lastItem!!.diff.type == DiffResponse.DIFF_TYPE_LINE_REMOVED && item.diff.type == DiffResponse.DIFF_TYPE_LINE_REMOVED))) { - if (it.lineNumber > lastItem!!.lineEnd) { - lastItem!!.lineEnd = it.lineNumber + ((item.diff.lineNumber - lastItem.diff.lineNumber == 1 && lastItem.diff.type == DiffResponse.DIFF_TYPE_LINE_ADDED && item.diff.type == DiffResponse.DIFF_TYPE_LINE_ADDED) || + (item.diff.lineNumber - lastItem.diff.lineNumber == 1 && lastItem.diff.type == DiffResponse.DIFF_TYPE_LINE_WITH_SAME_CONTENT && item.diff.type == DiffResponse.DIFF_TYPE_LINE_WITH_SAME_CONTENT) || + (lastItem.diff.type == DiffResponse.DIFF_TYPE_LINE_REMOVED && item.diff.type == DiffResponse.DIFF_TYPE_LINE_REMOVED))) { + if (it.lineNumber > lastItem.lineEnd) { + lastItem.lineEnd = it.lineNumber } - lastItem!!.parsedText = buildSpannedString { + lastItem.parsedText = buildSpannedString { appendLine(lastItem!!.parsedText) append(item.parsedText) } @@ -77,7 +77,7 @@ object DiffUtil { } } - private class DiffLineHolder constructor(itemView: DiffLineView) : RecyclerView.ViewHolder(itemView) { + private class DiffLineHolder(itemView: DiffLineView) : RecyclerView.ViewHolder(itemView) { fun bindItem(item: DiffLine) { (itemView as DiffLineView).setItem(item) } diff --git a/app/src/main/java/org/wikipedia/diff/UndoEditDialog.kt b/app/src/main/java/org/wikipedia/diff/UndoEditDialog.kt index 4f11abb2dcf..eff7ed22463 100644 --- a/app/src/main/java/org/wikipedia/diff/UndoEditDialog.kt +++ b/app/src/main/java/org/wikipedia/diff/UndoEditDialog.kt @@ -12,7 +12,7 @@ import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent import org.wikipedia.databinding.DialogUndoEditBinding import org.wikipedia.util.ResourceUtil -class UndoEditDialog constructor( +class UndoEditDialog( private val editHistoryInteractionEvent: EditHistoryInteractionEvent?, context: Context, source: Constants.InvokeSource?, diff --git a/app/src/main/java/org/wikipedia/donate/DonateDialog.kt b/app/src/main/java/org/wikipedia/donate/DonateDialog.kt new file mode 100644 index 00000000000..c438dbf0807 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/DonateDialog.kt @@ -0,0 +1,115 @@ +package org.wikipedia.donate + +import android.content.Context +import android.os.Bundle +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.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch +import org.wikipedia.BuildConfig +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.eventplatform.DonorExperienceEvent +import org.wikipedia.databinding.DialogDonateBinding +import org.wikipedia.page.ExtendedBottomSheetDialogFragment +import org.wikipedia.settings.Prefs +import org.wikipedia.util.CustomTabsUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.Resource + +class DonateDialog : ExtendedBottomSheetDialogFragment() { + private var _binding: DialogDonateBinding? = null + private val binding get() = _binding!! + + private val viewModel: DonateViewModel by viewModels() + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = DialogDonateBinding.inflate(inflater, container, false) + + binding.donateOtherButton.setOnClickListener { + DonorExperienceEvent.logAction("webpay_click", if (arguments?.getString(ARG_CAMPAIGN_ID).isNullOrEmpty()) "setting" else "article_banner") + onDonateClicked() + } + + binding.donateGooglePayButton.setOnClickListener { + invalidateCampaign() + DonorExperienceEvent.logAction("gpay_click", if (arguments?.getString(ARG_CAMPAIGN_ID).isNullOrEmpty()) "setting" else "article_banner") + (requireActivity() as? BaseActivity)?.launchDonateActivity( + GooglePayComponent.getDonateActivityIntent(requireActivity(), arguments?.getString(ARG_CAMPAIGN_ID), arguments?.getString(ARG_DONATE_URL))) + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> { + binding.progressBar.isVisible = true + binding.contentsContainer.isVisible = false + } + is Resource.Error -> { + binding.progressBar.isVisible = false + FeedbackUtil.showMessage(this@DonateDialog, it.throwable.localizedMessage.orEmpty()) + } + is Resource.Success -> { + // if Google Pay is not available, then bounce right out to external workflow. + if (!it.data) { + onDonateClicked() + return@collect + } + binding.progressBar.isVisible = false + binding.contentsContainer.isVisible = true + } + } + } + } + } + + viewModel.checkGooglePayAvailable(requireActivity()) + + return binding.root + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + private fun onDonateClicked() { + launchDonateLink(requireContext(), arguments?.getString(ARG_DONATE_URL)) + invalidateCampaign() + dismiss() + } + + private fun invalidateCampaign() { + arguments?.getString(ARG_CAMPAIGN_ID)?.let { + Prefs.announcementShownDialogs = setOf(it) + } + } + + companion object { + const val ARG_CAMPAIGN_ID = "campaignId" + const val ARG_DONATE_URL = "donateUrl" + + fun newInstance(campaignId: String? = null, donateUrl: String? = null): DonateDialog { + return DonateDialog().apply { + arguments = bundleOf( + ARG_CAMPAIGN_ID to campaignId, + ARG_DONATE_URL to donateUrl + ) + } + } + + fun launchDonateLink(context: Context, url: String? = null) { + val donateUrl = url ?: context.getString(R.string.donate_url, + WikipediaApp.instance.languageState.systemLanguageCode, BuildConfig.VERSION_NAME) + CustomTabsUtil.openInCustomTab(context, donateUrl) + } + } +} diff --git a/app/src/main/java/org/wikipedia/donate/DonateViewModel.kt b/app/src/main/java/org/wikipedia/donate/DonateViewModel.kt new file mode 100644 index 00000000000..8e3efa23bc8 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/DonateViewModel.kt @@ -0,0 +1,25 @@ +package org.wikipedia.donate + +import android.app.Activity +import androidx.lifecycle.ViewModel +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.util.Resource + +class DonateViewModel : ViewModel() { + private val _uiState = MutableStateFlow>(Resource.Loading()) + val uiState = _uiState.asStateFlow() + + fun checkGooglePayAvailable(activity: Activity) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + }) { + _uiState.value = Resource.Loading() + + _uiState.value = Resource.Success(GooglePayComponent.isGooglePayAvailable(activity)) + } + } +} diff --git a/app/src/main/java/org/wikipedia/donate/DonationResult.kt b/app/src/main/java/org/wikipedia/donate/DonationResult.kt new file mode 100644 index 00000000000..e4931c625d5 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/DonationResult.kt @@ -0,0 +1,9 @@ +package org.wikipedia.donate + +import kotlinx.serialization.Serializable + +@Serializable +class DonationResult( + val dateTime: String = "", + val fromWeb: Boolean = false +) diff --git a/app/src/main/java/org/wikipedia/donate/DonorHistoryActivity.kt b/app/src/main/java/org/wikipedia/donate/DonorHistoryActivity.kt new file mode 100644 index 00000000000..e22052b5c8e --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/DonorHistoryActivity.kt @@ -0,0 +1,214 @@ +package org.wikipedia.donate + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.text.format.DateUtils +import androidx.activity.viewModels +import androidx.core.view.isVisible +import com.google.android.material.datepicker.CalendarConstraints +import com.google.android.material.datepicker.DateValidatorPointBackward +import com.google.android.material.datepicker.MaterialDatePicker +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.wikipedia.Constants +import org.wikipedia.R +import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.eventplatform.ContributionsDashboardEvent +import org.wikipedia.databinding.ActivityDonorHistoryBinding +import org.wikipedia.main.MainActivity +import org.wikipedia.settings.Prefs +import org.wikipedia.usercontrib.ContributionsDashboardHelper +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.UriUtil +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.ZonedDateTime + +class DonorHistoryActivity : BaseActivity() { + + private lateinit var binding: ActivityDonorHistoryBinding + private val viewModel: DonorHistoryViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityDonorHistoryBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + supportActionBar?.title = getString(R.string.donor_history_title) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + init() + } + + override fun onBackPressed() { + if (viewModel.donorHistoryModified) { + MaterialAlertDialogBuilder(this) + .setMessage(getString(R.string.edit_abandon_confirm)) + .setPositiveButton(getString(R.string.edit_abandon_confirm_yes)) { dialog, _ -> + ContributionsDashboardEvent.logAction("cancel_click", "contrib_update") + dialog.dismiss() + if (viewModel.shouldGoBackToContributeTab) { + startActivity(MainActivity.newIntent(this).putExtra(Constants.INTENT_EXTRA_GO_TO_SE_TAB, true)) + } else { + finish() + } + } + .setNegativeButton(getString(R.string.edit_abandon_confirm_no)) { dialog, _ -> + ContributionsDashboardEvent.logAction("restart_click", "contrib_update") + dialog.dismiss() + } + .show() + return + } + super.onBackPressed() + } + + private fun init() { + + binding.donationInfoContainer.isVisible = viewModel.isDonor + + binding.donorStatus.setOnClickListener { + ContributionsDashboardEvent.logAction("update_click", "contrib_update") + showDonorStatusDialog() + } + + binding.lastDonationContainer.setOnClickListener { + showLastDonatedDatePicker() + } + + binding.recurringDonorCheckbox.isChecked = viewModel.isRecurringDonor + binding.recurringDonorCheckbox.setOnClickListener { + viewModel.donorHistoryModified = true + viewModel.isRecurringDonor = binding.recurringDonorCheckbox.isChecked + binding.recurringDonorCheckbox.isChecked = viewModel.isRecurringDonor + } + binding.recurringDonorContainer.setOnClickListener { + binding.recurringDonorCheckbox.performClick() + } + + binding.donateButton.setOnClickListener { + ContributionsDashboardEvent.logAction("donate_start_click", "contrib_update", campaignId = ContributionsDashboardHelper.CAMPAIGN_ID) + launchDonateDialog(campaignId = ContributionsDashboardHelper.CAMPAIGN_ID) + } + + binding.experimentLink.setOnClickListener { + ContributionsDashboardEvent.logAction("about_click", "contrib_update") + UriUtil.visitInExternalBrowser(this, Uri.parse(getString(R.string.contributions_dashboard_wiki_url))) + } + + binding.saveButton.setOnClickListener { + ContributionsDashboardEvent.logAction("save_click", "contrib_update") + viewModel.saveDonorHistory() + if (viewModel.shouldGoBackToContributeTab) { + startActivity( + MainActivity.newIntent(this) + .putExtra(Constants.INTENT_EXTRA_GO_TO_SE_TAB, true) + ) + return@setOnClickListener + } + finish() + } + updateDonorStatusText() + updateLastDonatedText() + } + + private fun updateDonorStatusText() { + var donorStatusTextColor = R.attr.primary_color + val donorStatusText = if (!Prefs.hasDonorHistorySaved && viewModel.currentDonorStatus == -1) { + donorStatusTextColor = R.attr.placeholder_color + R.string.donor_history_update_donor_status_default + } else if (viewModel.isDonor) { + viewModel.currentDonorStatus = 0 + R.string.donor_history_update_donor_status_donor + } else { + viewModel.currentDonorStatus = 1 + R.string.donor_history_update_donor_status_not_a_donor + } + binding.donorStatus.text = getString(donorStatusText) + binding.donorStatus.setTextColor(ResourceUtil.getThemedColorStateList(this, donorStatusTextColor)) + binding.donateButton.isVisible = viewModel.currentDonorStatus == 1 // Not a donor + binding.donationInfoContainer.isVisible = viewModel.isDonor + } + + private fun updateLastDonatedText() { + binding.lastDonationDate.isVisible = viewModel.lastDonated != null + var lastDonatedTextColor = R.attr.primary_color + val lastDonatedText = if (viewModel.lastDonated == null) { + lastDonatedTextColor = R.attr.placeholder_color + R.string.donor_history_last_donated_hint + } else { + R.string.donor_history_last_donated + } + binding.lastDonationLabel.text = getString(lastDonatedText) + binding.lastDonationLabel.setTextColor(ResourceUtil.getThemedColorStateList(this, lastDonatedTextColor)) + viewModel.lastDonated?.let { + binding.lastDonationDate.text = DateUtils.getRelativeTimeSpanString( + viewModel.dateTimeToMilli(it), + System.currentTimeMillis(), + DateUtils.DAY_IN_MILLIS + ) + } + } + + private fun showDonorStatusDialog() { + val donorStatusList = arrayOf( + getString(R.string.donor_history_update_donor_status_donor), + getString(R.string.donor_history_update_donor_status_not_a_donor) + ) + MaterialAlertDialogBuilder(this) + .setSingleChoiceItems(donorStatusList, viewModel.currentDonorStatus) { dialog, which -> + viewModel.isDonor = which == 0 + viewModel.currentDonorStatus = which + viewModel.donorHistoryModified = true + updateDonorStatusText() + updateLastDonatedText() + dialog.dismiss() + } + .show() + } + + private fun showLastDonatedDatePicker() { + // The CalendarConstraints handles date in UTC + val utcMillis = LocalDateTime.now().toInstant(ZoneOffset.UTC).toEpochMilli() + val defaultDatePickerMilli = viewModel.lastDonated?.let { + viewModel.dateTimeToMilli(it) + } ?: run { + utcMillis + } + + val calendarConstraints = CalendarConstraints.Builder() + .setEnd(utcMillis) + .setValidator(DateValidatorPointBackward.before(utcMillis)) + .build() + + MaterialDatePicker.Builder.datePicker() + .setTheme(R.style.MaterialDatePickerStyle) + .setSelection(defaultDatePickerMilli) + .setInputMode(MaterialDatePicker.INPUT_MODE_TEXT) + .setCalendarConstraints(calendarConstraints) + .build() + .apply { + addOnPositiveButtonClickListener { + // The date picker returns milliseconds in UTC timezone. + val utcDate = LocalDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) + viewModel.lastDonated = ZonedDateTime.of(utcDate, ZoneId.systemDefault()).toLocalDateTime().toString() + viewModel.donorHistoryModified = true + updateLastDonatedText() + } + } + .show(supportFragmentManager, "datePicker") + } + + companion object { + + const val RESULT_GO_BACK_TO_CONTRIBUTE_TAB = "goBackToContributeTab" + + fun newIntent(context: Context, completedDonation: Boolean = false, goBackToContributeTab: Boolean = false): Intent { + return Intent(context, DonorHistoryActivity::class.java) + .putExtra(Constants.ARG_BOOLEAN, completedDonation) + .putExtra(RESULT_GO_BACK_TO_CONTRIBUTE_TAB, goBackToContributeTab) + } + } +} diff --git a/app/src/main/java/org/wikipedia/donate/DonorHistoryViewModel.kt b/app/src/main/java/org/wikipedia/donate/DonorHistoryViewModel.kt new file mode 100644 index 00000000000..83646e6cf5c --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/DonorHistoryViewModel.kt @@ -0,0 +1,40 @@ +package org.wikipedia.donate + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import org.wikipedia.Constants +import org.wikipedia.settings.Prefs +import org.wikipedia.usercontrib.ContributionsDashboardHelper +import java.time.LocalDateTime +import java.time.ZoneId + +class DonorHistoryViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + + val completedDonation = savedStateHandle.get(Constants.ARG_BOOLEAN) == true + val shouldGoBackToContributeTab = savedStateHandle.get(DonorHistoryActivity.RESULT_GO_BACK_TO_CONTRIBUTE_TAB) == true + var currentDonorStatus = if (completedDonation) 0 else -1 + var isDonor = completedDonation || Prefs.hasDonorHistorySaved && Prefs.isDonor + var lastDonated = Prefs.donationResults.lastOrNull()?.dateTime + var isRecurringDonor = Prefs.isRecurringDonor + var donorHistoryModified = false + + fun saveDonorHistory() { + Prefs.hasDonorHistorySaved = true + ContributionsDashboardHelper.showSurveyDialogUI = true + if (isDonor) { + Prefs.isRecurringDonor = isRecurringDonor + lastDonated?.let { + Prefs.donationResults = Prefs.donationResults.plus(DonationResult(it, false)).distinct() + } + } else { + Prefs.isRecurringDonor = false + Prefs.donationResults = emptyList() + } + Prefs.isDonor = isDonor + donorHistoryModified = false + } + + fun dateTimeToMilli(dateTime: String): Long { + return LocalDateTime.parse(dateTime).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli() + } +} diff --git a/app/src/main/java/org/wikipedia/donate/DonorStatus.kt b/app/src/main/java/org/wikipedia/donate/DonorStatus.kt new file mode 100644 index 00000000000..b230e23d744 --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/DonorStatus.kt @@ -0,0 +1,19 @@ +package org.wikipedia.donate + +import org.wikipedia.settings.Prefs + +enum class DonorStatus { + DONOR, NON_DONOR, UNKNOWN; + + companion object { + fun donorStatus(): DonorStatus { + return if (Prefs.hasDonorHistorySaved.not()) { + UNKNOWN + } else if (Prefs.isDonor) { + DONOR + } else { + NON_DONOR + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt b/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt index e5265bbb1be..3049527dbcd 100644 --- a/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt +++ b/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt @@ -8,6 +8,7 @@ import android.os.Bundle import android.os.Handler import android.text.TextUtils import android.text.TextWatcher +import android.text.method.LinkMovementMethod import android.view.ActionMode import android.view.Menu import android.view.MenuItem @@ -15,16 +16,19 @@ import android.view.View import android.view.WindowManager import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.core.app.ActivityCompat -import androidx.core.net.toUri import androidx.core.os.postDelayed import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.widget.doAfterTextChanged +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.google.android.material.dialog.MaterialAlertDialogBuilder -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.flow.collectLatest +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -32,18 +36,15 @@ import org.wikipedia.activity.BaseActivity import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent import org.wikipedia.analytics.eventplatform.EditAttemptStepEvent import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent -import org.wikipedia.auth.AccountUtil.isLoggedIn +import org.wikipedia.auth.AccountUtil import org.wikipedia.captcha.CaptchaHandler import org.wikipedia.captcha.CaptchaResult -import org.wikipedia.csrf.CsrfTokenClient import org.wikipedia.databinding.ActivityEditSectionBinding import org.wikipedia.databinding.DialogWithCheckboxBinding import org.wikipedia.databinding.ItemEditActionbarButtonBinding import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.mwapi.MwException -import org.wikipedia.dataclient.mwapi.MwParseResponse import org.wikipedia.dataclient.mwapi.MwServiceError -import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory import org.wikipedia.edit.insertmedia.InsertMediaActivity import org.wikipedia.edit.insertmedia.InsertMediaViewModel import org.wikipedia.edit.preview.EditPreviewFragment @@ -60,12 +61,12 @@ import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle import org.wikipedia.page.linkpreview.LinkPreviewDialog import org.wikipedia.settings.Prefs -import org.wikipedia.suggestededits.SuggestedEditsImageRecsFragment import org.wikipedia.theme.ThemeChooserDialog 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 @@ -75,37 +76,25 @@ import org.wikipedia.views.ViewUtil import java.io.IOException import java.util.concurrent.TimeUnit -class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPreviewFragment.Callback, LinkPreviewDialog.LoadPageCallback { +class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPreviewFragment.Callback, LinkPreviewDialog.LoadPageCallback, LinkPreviewDialog.DismissCallback { + private val viewModel: EditSectionViewModel by viewModels() + private lateinit var binding: ActivityEditSectionBinding private lateinit var textWatcher: TextWatcher private lateinit var captchaHandler: CaptchaHandler private lateinit var editPreviewFragment: EditPreviewFragment private lateinit var editSummaryFragment: EditSummaryFragment private lateinit var syntaxHighlighter: SyntaxHighlighter - lateinit var invokeSource: Constants.InvokeSource - private set - lateinit var pageTitle: PageTitle - private set - - private var sectionID = -1 - private var sectionAnchor: String? = null - private var textToHighlight: String? = null - private var sectionWikitext: String? = null - private var sectionWikitextOriginal: String? = null - private val editNotices = mutableListOf() private var sectionTextModified = false private var sectionTextFirstLoad = true - private var editingAllowed = false - // Current revision of the article, to be passed back to the server to detect possible edit conflicts. - private var currentRevision: Long = 0 private var actionMode: ActionMode? = null - private val disposables = CompositeDisposable() private val requestLogin = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == LoginActivity.RESULT_LOGIN_SUCCESS) { updateEditLicenseText() + invalidateOptionsMenu() FeedbackUtil.showMessage(this, R.string.login_success_toast) } } @@ -130,11 +119,11 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre intent.putExtra(InsertMediaActivity.RESULT_IMAGE_TYPE, imageType) intent.putExtra(InsertMediaActivity.RESULT_IMAGE_POS, imagePos) - val newWikiText = InsertMediaViewModel.insertImageIntoWikiText(pageTitle.wikiSite.languageCode, - sectionWikitext.orEmpty(), imageTitle?.text.orEmpty(), imageCaption.orEmpty(), + val newWikiText = InsertMediaViewModel.insertImageIntoWikiText(viewModel.pageTitle.wikiSite.languageCode, + viewModel.sectionWikitext.orEmpty(), imageTitle?.text.orEmpty(), imageCaption.orEmpty(), imageAlt.orEmpty(), imageSize.orEmpty(), imageType.orEmpty(), imagePos.orEmpty(), - if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) 0 else binding.editSectionText.selectionStart, - invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE, + if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) 0 else binding.editSectionText.selectionStart, + viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE, intent.getBooleanExtra(InsertMediaActivity.EXTRA_ATTEMPT_INSERT_INTO_INFOBOX, false)) binding.editSectionText.setText(newWikiText.first) @@ -143,31 +132,19 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre val insertPos = newWikiText.third binding.editSectionText.setSelection(insertPos.first, insertPos.first + insertPos.second) - if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { // If we came from the Image Recommendation workflow, go directly to Preview. clickNextButton() } } - } else if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + } else if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { // If the user cancels image insertion, back out immediately. finish() } } - private val editTokenThenSave: Unit - get() { - cancelCalls() - binding.editSectionCaptchaContainer.visibility = View.GONE - captchaHandler.hideCaptcha() - editSummaryFragment.saveSummary() - disposables.add(CsrfTokenClient.getToken(pageTitle.wikiSite) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ doSave(it) }) { showError(it) }) - } - private val movementMethod = LinkMovementMethodExt { urlStr -> - UriUtil.visitInExternalBrowser(this, Uri.parse(UriUtil.resolveProtocolRelativeUrl(pageTitle.wikiSite, urlStr))) + UriUtil.visitInExternalBrowser(this, Uri.parse(UriUtil.resolveProtocolRelativeUrl(viewModel.pageTitle.wikiSite, urlStr))) } public override fun onCreate(savedInstanceState: Bundle?) { @@ -176,35 +153,30 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre setContentView(binding.root) setNavigationBarColor(ResourceUtil.getThemedColor(this, android.R.attr.colorBackground)) - pageTitle = intent.parcelableExtra(Constants.ARG_TITLE)!! - sectionID = intent.getIntExtra(EXTRA_SECTION_ID, -1) - sectionAnchor = intent.getStringExtra(EXTRA_SECTION_ANCHOR) - textToHighlight = intent.getStringExtra(EXTRA_HIGHLIGHT_TEXT) - invokeSource = intent.getSerializableExtra(Constants.INTENT_EXTRA_INVOKE_SOURCE) as Constants.InvokeSource - setSupportActionBar(binding.toolbar) supportActionBar?.title = "" syntaxHighlighter = SyntaxHighlighter(this, binding.editSectionText, binding.editSectionScroll) binding.editSectionScroll.isSmoothScrollingEnabled = false - captchaHandler = CaptchaHandler(this, pageTitle.wikiSite, binding.captchaContainer.root, + captchaHandler = CaptchaHandler(this, viewModel.pageTitle.wikiSite, binding.captchaContainer.root, binding.editSectionText, "", null) editPreviewFragment = supportFragmentManager.findFragmentById(R.id.edit_section_preview_fragment) as EditPreviewFragment editSummaryFragment = supportFragmentManager.findFragmentById(R.id.edit_section_summary_fragment) as EditSummaryFragment - editSummaryFragment.title = pageTitle + editSummaryFragment.title = viewModel.pageTitle // Only send the editing start log event if the activity is created for the first time if (savedInstanceState == null) { - EditAttemptStepEvent.logInit(pageTitle) + EditAttemptStepEvent.logInit(viewModel.pageTitle) } if (savedInstanceState != null) { if (savedInstanceState.containsKey(EXTRA_KEY_TEMPORARY_WIKITEXT_STORED)) { - sectionWikitext = Prefs.temporaryWikitext + viewModel.sectionWikitext = Prefs.temporaryWikitext } - editingAllowed = savedInstanceState.getBoolean(EXTRA_KEY_EDITING_ALLOWED, false) + viewModel.editingAllowed = savedInstanceState.getBoolean(EXTRA_KEY_EDITING_ALLOWED, false) sectionTextModified = savedInstanceState.getBoolean(EXTRA_KEY_SECTION_TEXT_MODIFIED, false) } - L10nUtil.setConditionalTextDirection(binding.editSectionText, pageTitle.wikiSite.languageCode) + L10nUtil.setConditionalTextDirection(binding.editSectionText, viewModel.pageTitle.wikiSite.languageCode) + fetchSectionText() binding.viewEditSectionError.retryClickListener = View.OnClickListener { @@ -228,14 +200,14 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre } } - SyntaxHighlightViewAdapter(this, pageTitle, binding.root, binding.editSectionText, + SyntaxHighlightViewAdapter(this, viewModel.pageTitle, binding.root, binding.editSectionText, binding.editKeyboardOverlay, binding.editKeyboardOverlayFormatting, binding.editKeyboardOverlayHeadings, Constants.InvokeSource.EDIT_ACTIVITY, requestInsertMedia) binding.editSectionText.setOnClickListener { finishActionMode() } onEditingPrefsChanged() - if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { // If the intent is to add an image to the article, go directly to the image insertion flow. startInsertImageFlow() } @@ -243,6 +215,82 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre // set focus to the EditText, but keep the keyboard hidden until the user changes the cursor location: binding.editSectionText.requestFocus() window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.fetchSectionTextState.collectLatest { + when (it) { + is Resource.Loading -> { + showProgressBar(true) + } + is Resource.Success -> { + showProgressBar(false) + displaySectionText() + maybeShowEditSourceDialog() + invalidateOptionsMenu() + if (!maybeShowTempAccountDialog()) { + if (Prefs.autoShowEditNotices) { + showEditNotices() + } else { + maybeShowEditNoticesTooltip() + } + } + it.data?.let { error -> + FeedbackUtil.showError(this@EditSectionActivity, MwException(error), viewModel.pageTitle.wikiSite) + } + } + is Resource.Error -> { + showProgressBar(false) + showError(it.throwable) + } + } + } + } + + launch { + viewModel.postEditState.collectLatest { + when (it) { + is Resource.Loading -> { + showProgressBar(true) + binding.editSectionCaptchaContainer.visibility = View.GONE + captchaHandler.hideCaptcha() + editSummaryFragment.saveSummary() + } + is Resource.Success -> { + it.data.edit?.run { + when { + editSucceeded -> { + AnonymousNotificationHelper.onEditSubmitted() + viewModel.waitForRevisionUpdate(newRevId) + } + hasCaptchaResponse -> onEditSuccess(CaptchaResult(captchaId)) + hasSpamBlacklistResponse -> onEditFailure(MwException(MwServiceError(code, spamblacklist))) + hasEditErrorCode -> onEditFailure(MwException(MwServiceError(code, info))) + else -> onEditFailure(IOException("Received unrecognized edit response")) + } + } ?: run { + onEditFailure(IOException("An unknown error occurred.")) + } + } + is Resource.Error -> { + onEditFailure(it.throwable) + } + } + } + } + + launch { + viewModel.waitForRevisionState.collect { + when (it) { + is Resource.Success -> { + onEditSuccess(EditSuccessResult(it.data)) + } + } + } + } + } + } } public override fun onStart() { @@ -252,7 +300,6 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre public override fun onDestroy() { captchaHandler.dispose() - cancelCalls() binding.editSectionText.removeTextChangedListener(textWatcher) syntaxHighlighter.cleanup() super.onDestroy() @@ -260,96 +307,62 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre public override fun onPause() { super.onPause() - sectionWikitext = binding.editSectionText.text.toString() + viewModel.sectionWikitext = binding.editSectionText.text.toString() + } + + fun getInvokeSource(): Constants.InvokeSource { + return viewModel.invokeSource } private fun updateEditLicenseText() { val editLicenseText = ActivityCompat.requireViewById(this, R.id.licenseText) - editLicenseText.text = StringUtil.fromHtml(getString(if (isLoggedIn) R.string.edit_save_action_license_logged_in else R.string.edit_save_action_license_anon, + editLicenseText.text = StringUtil.fromHtml(getString(R.string.edit_save_action_license_logged_in, getString(R.string.terms_of_use_url), getString(R.string.cc_by_sa_4_url))) - editLicenseText.movementMethod = LinkMovementMethodExt { url: String -> - if (url == "https://#login") { - val loginIntent = LoginActivity.newIntent(this@EditSectionActivity, LoginActivity.SOURCE_EDIT) - requestLogin.launch(loginIntent) - } else { - UriUtil.handleExternalLink(this@EditSectionActivity, url.toUri()) - } - } - } - - private fun cancelCalls() { - disposables.clear() + editLicenseText.movementMethod = LinkMovementMethod.getInstance() } - private fun doSave(token: String) { - val sectionAnchor = StringUtil.addUnderscores(StringUtil.removeHTMLTags(sectionAnchor.orEmpty())) + private fun doSave() { + val sectionAnchor = StringUtil.addUnderscores(StringUtil.removeHTMLTags(viewModel.sectionAnchor.orEmpty())) val isMinorEdit = if (editSummaryFragment.isMinorEdit) true else null val watchThisPage = if (editSummaryFragment.watchThisPage) "watch" else "unwatch" - var summaryText = if (sectionAnchor.isEmpty() || sectionAnchor == pageTitle.prefixedText) { - if (pageTitle.wikiSite.languageCode == "en") "/* top */" else "" + var summaryText = if (sectionAnchor.isEmpty() || sectionAnchor == viewModel.pageTitle.prefixedText) { + if (viewModel.pageTitle.wikiSite.languageCode == "en") "/* top */" else "" } else "/* ${StringUtil.removeUnderscores(sectionAnchor)} */ " summaryText += editSummaryFragment.summary - if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { - summaryText += " ${if (intent.getBooleanExtra(InsertMediaActivity.EXTRA_INSERTED_INTO_INFOBOX, false)) - SuggestedEditsImageRecsFragment.IMAGE_REC_EDIT_COMMENT_INFOBOX else SuggestedEditsImageRecsFragment.IMAGE_REC_EDIT_COMMENT_TOP}" - } // Summaries are plaintext, so remove any HTML that's made its way into the summary summaryText = StringUtil.removeHTMLTags(summaryText) if (!isFinishing) { showProgressBar(true) } - disposables.add(ServiceFactory.get(pageTitle.wikiSite).postEditSubmit(pageTitle.prefixedText, - if (sectionID >= 0) sectionID.toString() else null, null, summaryText, if (isLoggedIn) "user" else null, - binding.editSectionText.text.toString(), null, currentRevision, token, - if (captchaHandler.isActive) captchaHandler.captchaId() else "null", - if (captchaHandler.isActive) captchaHandler.captchaWord() else "null", isMinorEdit, watchThisPage) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ result -> - result.edit?.run { - when { - editSucceeded -> waitForUpdatedRevision(newRevId) - hasCaptchaResponse -> onEditSuccess(CaptchaResult(captchaId)) - hasSpamBlacklistResponse -> onEditFailure(MwException(MwServiceError(code, spamblacklist))) - hasEditErrorCode -> onEditFailure(MwException(MwServiceError(code, info))) - else -> onEditFailure(IOException("Received unrecognized edit response")) - } - } ?: run { - onEditFailure(IOException("An unknown error occurred.")) - } - }) { onEditFailure(it) } + + viewModel.postEdit( + isMinorEdit = isMinorEdit, + watchThisPage = watchThisPage, + summaryText = summaryText, + editSectionText = binding.editSectionText.text.toString(), + captchaId = captchaHandler.captchaId().toString(), + captchaWord = captchaHandler.captchaWord().toString(), + editTags = getEditTag() ) BreadCrumbLogEvent.logInputField(this, editSummaryFragment.summaryText) } - private fun waitForUpdatedRevision(newRevision: Long) { - AnonymousNotificationHelper.onEditSubmitted() - disposables.add(ServiceFactory.getRest(pageTitle.wikiSite) - .getSummaryResponse(pageTitle.prefixedText, null, OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK.toString(), null, null, null) - .delay(2, TimeUnit.SECONDS) - .subscribeOn(Schedulers.io()) - .map { response -> - if (response.body()!!.revision < newRevision) { - throw IllegalStateException() - } - response.body()!!.revision - } - .retry(10) { it is IllegalStateException } - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - onEditSuccess(EditSuccessResult(it)) - }, { - onEditSuccess(EditSuccessResult(newRevision)) - }) - ) + private fun getEditTag(): String { + return when { + viewModel.invokeSource == Constants.InvokeSource.TALK_TOPIC_ACTIVITY -> EditTags.APP_TALK_SOURCE + viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE -> if (intent.getBooleanExtra(InsertMediaActivity.EXTRA_INSERTED_INTO_INFOBOX, false)) EditTags.APP_IMAGE_ADD_INFOBOX else EditTags.APP_IMAGE_ADD_TOP + !viewModel.textToHighlight.isNullOrEmpty() -> EditTags.APP_SELECT_SOURCE + viewModel.sectionID >= 0 -> EditTags.APP_SECTION_SOURCE + else -> EditTags.APP_FULL_SOURCE + } } private fun onEditSuccess(result: EditResult) { if (result is EditSuccessResult) { - EditAttemptStepEvent.logSaveSuccess(pageTitle) + EditAttemptStepEvent.logSaveSuccess(viewModel.pageTitle) // TODO: remove the artificial delay and use the new revision // ID returned to request the updated version of the page once // revision support for mobile-sections is added to RESTBase @@ -359,7 +372,7 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre // Build intent that includes the section we were editing, so we can scroll to it later val data = Intent() - data.putExtra(EXTRA_SECTION_ID, sectionID) + data.putExtra(EXTRA_SECTION_ID, viewModel.sectionID) data.putExtra(EXTRA_REV_ID, result.revID) setResult(EditHandler.RESULT_REFRESH_PAGE, data) DeviceUtil.hideSoftKeyboard(this@EditSectionActivity) @@ -372,7 +385,7 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre binding.editSectionCaptchaContainer.visibility = View.VISIBLE captchaHandler.handleCaptcha(null, result) } else { - EditAttemptStepEvent.logSaveFailure(pageTitle) + EditAttemptStepEvent.logSaveFailure(viewModel.pageTitle) // Expand to do everything. onEditFailure(Throwable()) } @@ -393,7 +406,7 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre .setTitle(R.string.dialog_message_edit_failed) .setMessage(t.localizedMessage) .setPositiveButton(R.string.dialog_message_edit_failed_retry) { dialog, _ -> - editTokenThenSave + doSave() dialog.dismiss() } .setNegativeButton(R.string.dialog_message_edit_failed_cancel) { dialog, _ -> dialog.dismiss() }.show() @@ -405,23 +418,24 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre */ private fun handleEditingException(caught: MwException) { val code = caught.title - - // In the case of certain AbuseFilter responses, they are sent as a code, instead of a - // fully parsed response. We need to make one more API call to get the parsed message: - if (code.startsWith("abusefilter-") && caught.message.contains("abusefilter-") && caught.message.length < 100) { - disposables.add(ServiceFactory.get(pageTitle.wikiSite).parsePage("MediaWiki:" + StringUtil.sanitizeAbuseFilterCode(caught.message)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ response: MwParseResponse -> showError(MwException(MwServiceError(code, response.text))) }) { showError(it) }) - } else if ("editconflict" == code) { - MaterialAlertDialogBuilder(this@EditSectionActivity) + lifecycleScope.launch(CoroutineExceptionHandler { _, t -> + showError(t) + }) { + // In the case of certain AbuseFilter responses, they are sent as a code, instead of a + // fully parsed response. We need to make one more API call to get the parsed message: + if (code.startsWith("abusefilter-") && caught.message.contains("abusefilter-") && caught.message.length < 100) { + val response = ServiceFactory.get(viewModel.pageTitle.wikiSite).parsePage("MediaWiki:" + StringUtil.sanitizeAbuseFilterCode(caught.message)) + showError(MwException(MwServiceError(code, response.text))) + } else if ("editconflict" == code) { + MaterialAlertDialogBuilder(this@EditSectionActivity) .setTitle(R.string.edit_conflict_title) .setMessage(R.string.edit_conflict_message) .setPositiveButton(R.string.edit_conflict_dialog_ok_button_text, null) .show() - resetToStart() - } else { - showError(caught) + resetToStart() + } else { + showError(caught) + } } } @@ -435,28 +449,28 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre val addImageSourceProjects = intent.getStringExtra(InsertMediaActivity.EXTRA_IMAGE_SOURCE_PROJECTS) when { editSummaryFragment.isActive -> { - if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { ImageRecommendationsEvent.logAction("editsummary_save", "editsummary_dialog", ImageRecommendationsEvent.getActionDataString( filename = addImageTitle?.prefixedText.orEmpty(), recommendationSource = addImageSource.orEmpty(), recommendationSourceProjects = addImageSourceProjects.orEmpty(), acceptanceState = "accepted", captionAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION).isNullOrEmpty(), altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), addImageTitle?.wikiSite?.languageCode.orEmpty()) } - editTokenThenSave - EditAttemptStepEvent.logSaveAttempt(pageTitle) + doSave() + EditAttemptStepEvent.logSaveAttempt(viewModel.pageTitle) supportActionBar?.title = getString(R.string.preview_edit_summarize_edit_title) } editPreviewFragment.isActive -> { - if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { ImageRecommendationsEvent.logAction("caption_preview_accept", "caption_preview", ImageRecommendationsEvent.getActionDataString( filename = addImageTitle?.prefixedText.orEmpty(), recommendationSource = addImageSource.orEmpty(), recommendationSourceProjects = addImageSourceProjects.orEmpty(), acceptanceState = "accepted", captionAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION).isNullOrEmpty(), - altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), pageTitle.wikiSite.languageCode) + altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), viewModel.pageTitle.wikiSite.languageCode) ImageRecommendationsEvent.logImpression("editsummary_dialog", ImageRecommendationsEvent.getActionDataString( filename = addImageTitle?.prefixedText.orEmpty(), recommendationSource = addImageSource.orEmpty(), recommendationSourceProjects = addImageSourceProjects.orEmpty(), acceptanceState = "accepted", captionAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION).isNullOrEmpty(), - altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), pageTitle.wikiSite.languageCode) + altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), viewModel.pageTitle.wikiSite.languageCode) } editSummaryFragment.show() supportActionBar?.title = getString(R.string.preview_edit_summarize_edit_title) @@ -465,16 +479,16 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre // 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) + editPreviewFragment.showPreview(viewModel.pageTitle, binding.editSectionText.text.toString()) + EditAttemptStepEvent.logSaveIntent(viewModel.pageTitle) supportActionBar?.title = getString(R.string.edit_preview) setNavigationBarColor(ResourceUtil.getThemedColor(this, R.attr.paper_color)) - if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { ImageRecommendationsEvent.logImpression("caption_preview", ImageRecommendationsEvent.getActionDataString( filename = addImageTitle?.prefixedText.orEmpty(), recommendationSource = addImageSource.orEmpty(), recommendationSourceProjects = addImageSourceProjects.orEmpty(), acceptanceState = "accepted", captionAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION).isNullOrEmpty(), - altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), pageTitle.wikiSite.languageCode) + altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), viewModel.pageTitle.wikiSite.languageCode) } } } @@ -499,20 +513,29 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre showEditNotices() true } + R.id.menu_temp_account -> { + maybeShowTempAccountDialog(true) + true + } else -> super.onOptionsItemSelected(item) } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_edit_section, menu) - val item = menu.findItem(R.id.menu_save_section) + menu.findItem(R.id.menu_temp_account).apply { + isVisible = !AccountUtil.isLoggedIn || AccountUtil.isTemporaryAccount + setIcon(if (AccountUtil.isTemporaryAccount) R.drawable.ic_temp_account else R.drawable.ic_anon_account) + } + + val item = menu.findItem(R.id.menu_save_section) supportActionBar?.elevation = if (editPreviewFragment.isActive) 0f else DimenUtil.dpToPx(4f) - menu.findItem(R.id.menu_edit_notices).isVisible = editNotices.isNotEmpty() && !editPreviewFragment.isActive + menu.findItem(R.id.menu_edit_notices).isVisible = viewModel.editNotices.isNotEmpty() && !editPreviewFragment.isActive menu.findItem(R.id.menu_edit_theme).isVisible = !editPreviewFragment.isActive menu.findItem(R.id.menu_find_in_editor).isVisible = !editPreviewFragment.isActive - item.title = getString(if (editSummaryFragment.isActive) R.string.edit_done else (if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) R.string.onboarding_continue else R.string.edit_next)) - if (editingAllowed && binding.viewProgressBar.isGone) { + item.title = getString(if (editSummaryFragment.isActive) R.string.edit_done else (if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) R.string.onboarding_continue else R.string.edit_next)) + if (viewModel.editingAllowed && binding.viewProgressBar.isGone) { item.isEnabled = sectionTextModified } else { item.isEnabled = false @@ -585,12 +608,12 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre super.onSaveInstanceState(outState) outState.putBoolean(EXTRA_KEY_TEMPORARY_WIKITEXT_STORED, true) outState.putBoolean(EXTRA_KEY_SECTION_TEXT_MODIFIED, sectionTextModified) - outState.putBoolean(EXTRA_KEY_EDITING_ALLOWED, editingAllowed) - Prefs.temporaryWikitext = sectionWikitext.orEmpty() + outState.putBoolean(EXTRA_KEY_EDITING_ALLOWED, viewModel.editingAllowed) + Prefs.temporaryWikitext = viewModel.sectionWikitext.orEmpty() } private fun updateTextSize() { - binding.editSectionText.textSize = WikipediaApp.instance.getFontSize(window, editing = true) + binding.editSectionText.textSize = WikipediaApp.instance.getFontSize(editing = true) } private fun resetToStart() { @@ -608,49 +631,8 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre } private fun fetchSectionText() { - if (sectionWikitext == null) { - showProgressBar(true) - disposables.add(ServiceFactory.get(pageTitle.wikiSite).getWikiTextForSectionWithInfo(pageTitle.prefixedText, if (sectionID >= 0) sectionID else null) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doOnTerminate { showProgressBar(false) } - .subscribe({ response -> - val firstPage = response.query?.firstPage()!! - val rev = firstPage.revisions[0] - - pageTitle = PageTitle(firstPage.title, pageTitle.wikiSite).apply { - this.displayText = pageTitle.displayText - } - sectionWikitext = rev.contentMain - sectionWikitextOriginal = sectionWikitext - currentRevision = rev.revId - - val editError = response.query?.firstPage()!!.getErrorForAction("edit") - if (editError.isEmpty()) { - editingAllowed = true - } else { - val error = editError[0] - FeedbackUtil.showError(this, MwException(error), pageTitle.wikiSite) - } - displaySectionText() - maybeShowEditSourceDialog() - - editNotices.clear() - // Populate edit notices, but filter out anonymous edit warnings, since - // we show that type of warning ourselves when previewing. - editNotices.addAll(firstPage.getEditNotices() - .filterKeys { key -> (key.startsWith("editnotice") && !key.endsWith("-notext")) } - .values.filter { str -> StringUtil.fromHtml(str).trim().isNotEmpty() }) - invalidateOptionsMenu() - if (Prefs.autoShowEditNotices) { - showEditNotices() - } else { - maybeShowEditNoticesTooltip() - } - }) { - showError(it) - L.e(it) - }) + if (viewModel.sectionWikitext != null) { + viewModel.fetchSectionText() } else { displaySectionText() } @@ -669,14 +651,14 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre } private fun showEditNotices() { - if (editNotices.isEmpty()) { + if (viewModel.editNotices.isEmpty()) { return } - EditNoticesDialog(pageTitle.wikiSite, editNotices, this).show() + EditNoticesDialog(viewModel.pageTitle.wikiSite, viewModel.editNotices, this).show() } private fun maybeShowEditSourceDialog() { - if (!Prefs.showEditTalkPageSourcePrompt || (pageTitle.namespace() !== Namespace.TALK && pageTitle.namespace() !== Namespace.USER_TALK)) { + if (!Prefs.showEditTalkPageSourcePrompt || (viewModel.pageTitle.namespace() !== Namespace.TALK && viewModel.pageTitle.namespace() !== Namespace.USER_TALK)) { return } val binding = DialogWithCheckboxBinding.inflate(layoutInflater) @@ -692,12 +674,12 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre } private fun displaySectionText() { - binding.editSectionText.setText(sectionWikitext) showProgressBar(false) + binding.editSectionText.setText(viewModel.sectionWikitext) binding.editSectionContainer.isVisible = true - scrollToHighlight(textToHighlight) - binding.editSectionText.isEnabled = editingAllowed - binding.editKeyboardOverlay.isVisible = editingAllowed + binding.editSectionText.isEnabled = viewModel.editingAllowed + binding.editKeyboardOverlay.isVisible = viewModel.editingAllowed + scrollToHighlight(viewModel.textToHighlight) } private fun scrollToHighlight(highlightText: String?) { @@ -708,7 +690,7 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre } override fun getParentPageTitle(): PageTitle { - return pageTitle + return viewModel.pageTitle } override fun showProgressBar(visible: Boolean) { @@ -716,6 +698,10 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre 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) @@ -734,7 +720,7 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre ImageRecommendationsEvent.logAction("back", "editsummary_dialog", ImageRecommendationsEvent.getActionDataString( filename = addImageTitle?.prefixedText.orEmpty(), recommendationSource = addImageSource.orEmpty(), recommendationSourceProjects = addImageSourceProjects.orEmpty(), acceptanceState = "accepted", captionAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION).isNullOrEmpty(), - altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), pageTitle.wikiSite.languageCode) + altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), viewModel.pageTitle.wikiSite.languageCode) supportActionBar?.title = getString(R.string.edit_preview) return } @@ -743,17 +729,17 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre filename = addImageTitle?.prefixedText.orEmpty(), recommendationSource = addImageSource.orEmpty(), recommendationSourceProjects = addImageSourceProjects.orEmpty(), acceptanceState = "accepted", captionAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION).isNullOrEmpty(), - altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), pageTitle.wikiSite.languageCode) + altTextAdd = !intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()), viewModel.pageTitle.wikiSite.languageCode) editPreviewFragment.hide() binding.editSectionContainer.isVisible = true supportActionBar?.title = null // If we came from the Image Recommendations workflow, bring back the Add Image activity. - if (invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (viewModel.invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { // ...and reset the wikitext to the original, since the Add Image flow will re- // modify it when the user returns to it. - sectionWikitext = sectionWikitextOriginal - binding.editSectionText.setText(sectionWikitext) + viewModel.sectionWikitext = viewModel.sectionWikitextOriginal + binding.editSectionText.setText(viewModel.sectionWikitext) startInsertImageFlow() } @@ -783,14 +769,54 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre } } + private fun maybeShowTempAccountDialog(fromToolbar: Boolean = false): Boolean { + if (fromToolbar || (!Prefs.tempAccountDialogShown && (!AccountUtil.isLoggedIn || AccountUtil.isTemporaryAccount))) { + val dialog = MaterialAlertDialogBuilder(this, R.style.AlertDialogTheme_Icon_NegativeInactive) + .setIcon(if (AccountUtil.isTemporaryAccount) R.drawable.ic_temp_account else R.drawable.ic_anon_account) + .setTitle(if (AccountUtil.isTemporaryAccount) R.string.temp_account_using_title else R.string.temp_account_not_logged_in) + .setMessage(StringUtil.fromHtml(if (AccountUtil.isTemporaryAccount) getString(R.string.temp_account_temp_dialog_body, AccountUtil.userName) + else getString(if (viewModel.tempAccountsEnabled) R.string.temp_account_anon_dialog_body else R.string.temp_account_anon_ip_dialog_body, getString(R.string.temp_accounts_help_url)))) + .setPositiveButton(getString(if (fromToolbar) R.string.temp_account_dialog_ok else R.string.create_account_button)) { dialog, _ -> + dialog.dismiss() + if (!fromToolbar) { + launchLogin() + } + } + .setNegativeButton(getString(if (fromToolbar) R.string.create_account_login else R.string.temp_account_dialog_ok)) { dialog, _ -> + dialog.dismiss() + if (fromToolbar) { + launchLogin() + } + } + .show() + dialog.window?.let { + it.decorView.findViewById(android.R.id.message)?.movementMethod = LinkMovementMethodExt { link -> + if (link.contains("#login") || link.contains("#createaccount")) { + launchLogin(link.contains("#createaccount")) + } else { + UriUtil.handleExternalLink(this, Uri.parse(link)) + } + dialog.dismiss() + } + } + Prefs.tempAccountDialogShown = true + return true + } + return false + } + + private fun launchLogin(createAccountFirst: Boolean = true) { + requestLogin.launch(LoginActivity.newIntent(this, LoginActivity.SOURCE_EDIT, createAccountFirst)) + } + private fun startInsertImageFlow() { val addImageTitle = intent.parcelableExtra(InsertMediaActivity.EXTRA_IMAGE_TITLE)!! val addImageSource = intent.getStringExtra(InsertMediaActivity.EXTRA_IMAGE_SOURCE)!! - val addImageIntent = InsertMediaActivity.newIntent(this, pageTitle.wikiSite, - pageTitle.displayText, invokeSource, addImageTitle, addImageSource) + val addImageIntent = InsertMediaActivity.newIntent(this, viewModel.pageTitle.wikiSite, + viewModel.pageTitle.displayText, viewModel.invokeSource, addImageTitle, addImageSource) // implicitly add any saved parameters from the previous insertion. - addImageIntent.putExtra(InsertMediaActivity.EXTRA_IMAGE_TITLE, intent.getParcelableExtra(InsertMediaActivity.EXTRA_IMAGE_TITLE)) + addImageIntent.putExtra(InsertMediaActivity.EXTRA_IMAGE_TITLE, intent.parcelableExtra(InsertMediaActivity.EXTRA_IMAGE_TITLE)) addImageIntent.putExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION, intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION)) addImageIntent.putExtra(InsertMediaActivity.RESULT_IMAGE_ALT, intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT)) addImageIntent.putExtra(InsertMediaActivity.RESULT_IMAGE_SIZE, intent.getStringExtra(InsertMediaActivity.RESULT_IMAGE_SIZE)) @@ -825,13 +851,21 @@ class EditSectionActivity : BaseActivity(), ThemeChooserDialog.Callback, EditPre } } + override fun onLinkPreviewDismiss() { + if (!isDestroyed) { + binding.editSectionText.postDelayed({ + DeviceUtil.showSoftKeyboard(binding.editSectionText) + }, 200) + } + } + companion object { - private const val EXTRA_KEY_SECTION_TEXT_MODIFIED = "sectionTextModified" - private const val EXTRA_KEY_TEMPORARY_WIKITEXT_STORED = "hasTemporaryWikitextStored" - private const val EXTRA_KEY_EDITING_ALLOWED = "editingAllowed" - const val EXTRA_SECTION_ID = "org.wikipedia.edit_section.sectionid" - const val EXTRA_SECTION_ANCHOR = "org.wikipedia.edit_section.anchor" - const val EXTRA_HIGHLIGHT_TEXT = "org.wikipedia.edit_section.highlight" + const val EXTRA_KEY_SECTION_TEXT_MODIFIED = "sectionTextModified" + const val EXTRA_KEY_TEMPORARY_WIKITEXT_STORED = "hasTemporaryWikitextStored" + const val EXTRA_KEY_EDITING_ALLOWED = "editingAllowed" + const val EXTRA_SECTION_ID = "sectionId" + const val EXTRA_SECTION_ANCHOR = "sectionAnchor" + const val EXTRA_HIGHLIGHT_TEXT = "sectionHighlightText" const val EXTRA_REV_ID = "revId" fun newIntent(context: Context, sectionId: Int, sectionAnchor: String?, title: PageTitle, diff --git a/app/src/main/java/org/wikipedia/edit/EditSectionViewModel.kt b/app/src/main/java/org/wikipedia/edit/EditSectionViewModel.kt new file mode 100644 index 00000000000..2bc5eee3f45 --- /dev/null +++ b/app/src/main/java/org/wikipedia/edit/EditSectionViewModel.kt @@ -0,0 +1,145 @@ +package org.wikipedia.edit + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.auth.AccountUtil +import org.wikipedia.csrf.CsrfTokenClient +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.mwapi.MwServiceError +import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory +import org.wikipedia.page.PageTitle +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil +import org.wikipedia.util.log.L + +class EditSectionViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + + var pageTitle = savedStateHandle.get(Constants.ARG_TITLE)!! + var invokeSource = savedStateHandle.get(Constants.INTENT_EXTRA_INVOKE_SOURCE)!! + var sectionID = savedStateHandle[EditSectionActivity.EXTRA_SECTION_ID] ?: -1 + var sectionAnchor = savedStateHandle.get(EditSectionActivity.EXTRA_SECTION_ANCHOR) + var textToHighlight = savedStateHandle.get(EditSectionActivity.EXTRA_HIGHLIGHT_TEXT) + var sectionWikitext: String? = null + var sectionWikitextOriginal: String? = null + var tempAccountsEnabled = true + var editingAllowed = false + val editNotices = mutableListOf() + + // Current revision of the article, to be passed back to the server to detect possible edit conflicts. + private var currentRevision: Long = 0 + + private var clientJob: Job? = null + + private val _fetchSectionTextState = MutableStateFlow(Resource()) + val fetchSectionTextState = _fetchSectionTextState.asStateFlow() + + private val _postEditState = MutableStateFlow(Resource()) + val postEditState = _postEditState.asStateFlow() + + private val _waitForRevisionState = MutableStateFlow(Resource()) + val waitForRevisionState = _waitForRevisionState.asStateFlow() + + init { + fetchSectionText() + } + + fun fetchSectionText() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _fetchSectionTextState.value = Resource.Error(throwable) + }) { + _fetchSectionTextState.value = Resource.Loading() + + val infoResponse = ServiceFactory.get(pageTitle.wikiSite).getWikiTextForSectionWithInfo(pageTitle.prefixedText, if (sectionID >= 0) sectionID else null) + + tempAccountsEnabled = infoResponse.query?.autoCreateTempUser?.enabled == true + + infoResponse.query?.firstPage()?.let { firstPage -> + val rev = firstPage.revisions.first() + + pageTitle = PageTitle(firstPage.title, pageTitle.wikiSite).apply { + this.displayText = pageTitle.displayText + } + sectionWikitext = rev.contentMain + sectionWikitextOriginal = sectionWikitext + currentRevision = rev.revId + + editNotices.clear() + // Populate edit notices, but filter out anonymous edit warnings, since + // we show that type of warning ourselves when previewing. + editNotices.addAll(firstPage.getEditNotices() + .filterKeys { key -> (key.startsWith("editnotice") && !key.endsWith("-notext")) } + .values.filter { str -> StringUtil.fromHtml(str).trim().isNotEmpty() }) + + val editError = firstPage.getErrorForAction("edit") + var error: MwServiceError? = null + if (editError.isEmpty()) { + editingAllowed = true + } else { + error = editError[0] + } + _fetchSectionTextState.value = Resource.Success(error) + } + } + } + + fun postEdit(isMinorEdit: Boolean?, + watchThisPage: String, + summaryText: String, + editSectionText: String, + editTags: String, + captchaId: String?, + captchaWord: String?) { + clientJob?.cancel() + clientJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + _postEditState.value = Resource.Error(throwable) + }) { + _postEditState.value = Resource.Loading() + val csrfToken = CsrfTokenClient.getToken(pageTitle.wikiSite) + val result = ServiceFactory.get(pageTitle.wikiSite).postEditSubmit( + title = pageTitle.prefixedText, + section = if (sectionID >= 0) sectionID.toString() else null, + newSectionTitle = null, + summary = summaryText, + user = AccountUtil.assertUser, + text = editSectionText, + appendText = null, + baseRevId = currentRevision, + token = csrfToken, + captchaId = captchaId, + captchaWord = captchaWord, + minor = isMinorEdit, + watchlist = watchThisPage, + tags = editTags + ) + _postEditState.value = Resource.Success(result) + } + } + + fun waitForRevisionUpdate(newRevision: Long) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + _waitForRevisionState.value = Resource.Success(newRevision) + }) { + // Implement a retry mechanism to wait for the revision to be available. + var retry = 0 + var revision = -1L + while (revision < newRevision && retry < 10) { + delay(2000) + val pageSummaryResponse = ServiceFactory.getRest(pageTitle.wikiSite) + .getSummaryResponse(pageTitle.prefixedText, cacheControl = OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK.toString()) + revision = pageSummaryResponse.body()?.revision ?: -1L + retry++ + } + _waitForRevisionState.value = Resource.Success(revision) + } + } +} diff --git a/app/src/main/java/org/wikipedia/edit/EditTags.kt b/app/src/main/java/org/wikipedia/edit/EditTags.kt new file mode 100644 index 00000000000..3342f87178e --- /dev/null +++ b/app/src/main/java/org/wikipedia/edit/EditTags.kt @@ -0,0 +1,22 @@ +package org.wikipedia.edit + +object EditTags { + const val APP_SUGGESTED_EDIT = "app-suggestededit" + const val APP_DESCRIPTION_ADD = "app-description-add" + const val APP_DESCRIPTION_CHANGE = "app-description-change" + const val APP_DESCRIPTION_TRANSLATE = "app-description-translate" + const val APP_ROLLBACK = "app-rollback" + const val APP_UNDO = "app-undo" + const val APP_SECTION_SOURCE = "app-section-source" + const val APP_FULL_SOURCE = "app-full-source" + const val APP_SELECT_SOURCE = "app-select-source" + const val APP_TALK_SOURCE = "app-talk-source" + const val APP_TALK_REPLY = "app-talk-reply" + const val APP_TALK_TOPIC = "app-talk-topic" + const val APP_IMAGE_CAPTION_ADD = "app-image-caption-add" + const val APP_IMAGE_CAPTION_TRANSLATE = "app-image-caption-translate" + const val APP_IMAGE_TAG_ADD = "app-image-tag-add" + const val APP_IMAGE_ADD_TOP = "app-image-add-top" + const val APP_IMAGE_ADD_INFOBOX = "app-image-add-infobox" + const val APP_AI_ASSIST = "app-ai-assist" +} diff --git a/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt b/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt index e3d81417de3..025e6e8c51b 100644 --- a/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt +++ b/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt @@ -7,7 +7,6 @@ import android.view.View import org.wikipedia.edit.richtext.SyntaxHighlighter import org.wikipedia.util.DeviceUtil import org.wikipedia.util.DimenUtil -import org.wikipedia.util.StringUtil import org.wikipedia.views.FindInPageActionProvider import org.wikipedia.views.FindInPageActionProvider.FindInPageListener @@ -62,21 +61,31 @@ class FindInEditorActionProvider(private val scrollView: View, searchQuery = text?.ifEmpty { null } currentResultIndex = 0 resultPositions.clear() - - searchQuery?.let { - resultPositions += it.toRegex(StringUtil.SEARCH_REGEX_OPTIONS).findAll(textView.text) - .map { it.range.first } + searchQuery?.let { query -> + val textToSearch = textView.text + var index = 0 + while (index >= 0 && index < textToSearch.length) { + index = textToSearch.indexOf(query, index, ignoreCase = true) + if (index >= 0) { + resultPositions.add(index) + index += query.length + } + } } scrollToCurrentResult() } 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 417d925699f..6eba8db503e 100644 --- a/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt +++ b/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt @@ -7,6 +7,7 @@ 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 @@ -28,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 { @@ -72,17 +74,8 @@ class SyntaxHighlightViewAdapter( } override fun onPreviewLink(title: String) { - val dialog = LinkPreviewDialog.newInstance(HistoryEntry(PageTitle(title, pageTitle.wikiSite), HistoryEntry.SOURCE_INTERNAL_LINK)) - ExclusiveBottomSheetPresenter.show(activity.supportFragmentManager, dialog) - editText.post { - dialog.dialog?.setOnDismissListener { - if (!activity.isDestroyed) { - editText.postDelayed({ - DeviceUtil.showSoftKeyboard(editText) - }, 200) - } - } - } + ExclusiveBottomSheetPresenter.show(activity.supportFragmentManager, + LinkPreviewDialog.newInstance(HistoryEntry(PageTitle(title, pageTitle.wikiSite), HistoryEntry.SOURCE_INTERNAL_LINK))) } override fun onRequestInsertMedia() { @@ -92,7 +85,11 @@ class SyntaxHighlightViewAdapter( } override fun onRequestInsertTemplate() { - requestInsertTemplate.launch(TemplatesSearchActivity.newIntent(activity, pageTitle.wikiSite, invokeSource)) + 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() { diff --git a/app/src/main/java/org/wikipedia/edit/SyntaxHighlightableEditText.kt b/app/src/main/java/org/wikipedia/edit/SyntaxHighlightableEditText.kt index cc1f61b9c19..5543aeb9d21 100644 --- a/app/src/main/java/org/wikipedia/edit/SyntaxHighlightableEditText.kt +++ b/app/src/main/java/org/wikipedia/edit/SyntaxHighlightableEditText.kt @@ -87,11 +87,11 @@ open class SyntaxHighlightableEditText : EditText { (if (enabled) 0 else (EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS or EditorInfo.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD)) } - override fun bringPointIntoView(offset: Int): Boolean { + override fun requestRectangleOnScreen(rectangle: Rect?): Boolean { if (!allowScrollToCursor) { return false } - return super.bringPointIntoView(offset) + return super.requestRectangleOnScreen(rectangle) } override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { diff --git a/app/src/main/java/org/wikipedia/edit/db/EditSummary.kt b/app/src/main/java/org/wikipedia/edit/db/EditSummary.kt index a67983fc765..5417c88c42e 100644 --- a/app/src/main/java/org/wikipedia/edit/db/EditSummary.kt +++ b/app/src/main/java/org/wikipedia/edit/db/EditSummary.kt @@ -2,10 +2,10 @@ package org.wikipedia.edit.db import androidx.room.Entity import androidx.room.PrimaryKey -import java.util.* +import java.util.Date @Entity -class EditSummary constructor( +class EditSummary( @PrimaryKey val summary: String, val lastUsed: Date = Date()) { diff --git a/app/src/main/java/org/wikipedia/edit/db/EditSummaryDao.kt b/app/src/main/java/org/wikipedia/edit/db/EditSummaryDao.kt index 140f5771f38..6b200b4121b 100644 --- a/app/src/main/java/org/wikipedia/edit/db/EditSummaryDao.kt +++ b/app/src/main/java/org/wikipedia/edit/db/EditSummaryDao.kt @@ -1,17 +1,18 @@ package org.wikipedia.edit.db -import androidx.room.* -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Single +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query @Dao interface EditSummaryDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertEditSummary(summary: EditSummary): Completable + suspend fun insertEditSummary(summary: EditSummary) @Query("SELECT * FROM EditSummary ORDER BY lastUsed DESC") - fun getEditSummaries(): Single> + suspend fun getEditSummaries(): List @Query("DELETE FROM EditSummary") - fun deleteAll(): Completable + suspend fun deleteAll() } diff --git a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaActivity.kt b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaActivity.kt index 9d6ef91750b..af9472a0025 100644 --- a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaActivity.kt +++ b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaActivity.kt @@ -18,7 +18,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState -import androidx.paging.PagingDataAdapter import androidx.palette.graphics.Palette import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.GridLayoutManager @@ -30,6 +29,7 @@ import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity +import org.wikipedia.adapter.PagingDataAdapterPatched import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent import org.wikipedia.commons.FilePageActivity import org.wikipedia.databinding.ActivityInsertMediaBinding @@ -56,7 +56,7 @@ class InsertMediaActivity : BaseActivity() { private var actionMode: ActionMode? = null private val searchActionModeCallback = SearchCallback() - val viewModel: InsertMediaViewModel by viewModels { InsertMediaViewModel.Factory(intent.extras!!) } + val viewModel: InsertMediaViewModel by viewModels() public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -83,7 +83,7 @@ class InsertMediaActivity : BaseActivity() { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.insertMediaFlow.collectLatest { - insertMediaAdapter?.submitData(it) + insertMediaAdapter?.submitData(lifecycleScope, it) } } launch { @@ -251,7 +251,7 @@ class InsertMediaActivity : BaseActivity() { binding.progressBar.isVisible = true binding.selectedImage.loadImage( Uri.parse(ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl!!, Constants.PREFERRED_CARD_THUMBNAIL_SIZE)), - roundedCorners = false, cropped = false, emptyPlaceholder = true, listener = object : FaceAndColorDetectImageView.OnImageLoadListener { + cropped = false, emptyPlaceholder = true, listener = object : FaceAndColorDetectImageView.OnImageLoadListener { override fun onImageLoaded(palette: Palette, bmpWidth: Int, bmpHeight: Int) { if (!isDestroyed) { val params = binding.imageInfoButton.layoutParams as FrameLayout.LayoutParams @@ -296,7 +296,7 @@ class InsertMediaActivity : BaseActivity() { } } - private inner class InsertMediaAdapter : PagingDataAdapter(InsertMediaDiffCallback()) { + private inner class InsertMediaAdapter : PagingDataAdapterPatched(InsertMediaDiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, pos: Int): InsertMediaItemHolder { return InsertMediaItemHolder(ItemInsertMediaBinding.inflate(layoutInflater)) } @@ -328,18 +328,10 @@ class InsertMediaActivity : BaseActivity() { private inner class SearchCallback : SearchActionModeCallback() { var searchActionProvider: SearchActionProvider? = null override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - searchActionProvider = SearchActionProvider(this@InsertMediaActivity, searchHintString, - object : SearchActionProvider.Callback { - override fun onQueryTextChange(s: String) { - onQueryChange(s) - } - - override fun onQueryTextFocusChange() { - } - }) + searchActionProvider = SearchActionProvider(this@InsertMediaActivity, getSearchHintString()) { onQueryChange(it) } searchActionProvider?.setQueryText(viewModel.searchQuery) searchActionProvider?.selectAllQueryTexts() - val menuItem = menu.add(searchHintString) + val menuItem = menu.add(getSearchHintString()) MenuItemCompat.setActionProvider(menuItem, searchActionProvider) actionMode = mode binding.imageInfoContainer.isVisible = false 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..f24168dd645 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() { @@ -142,7 +142,7 @@ class InsertMediaSettingsFragment : Fragment() { activity.invalidateOptionsMenu() activity.supportActionBar?.title = getString(R.string.insert_media_settings) viewModel.selectedImage?.let { - ViewUtil.loadImageWithRoundedCorners(binding.imageView, it.thumbUrl, true) + ViewUtil.loadImageWithRoundedCorners(binding.imageView, it.thumbUrl) binding.mediaTitle.text = it.text binding.mediaDescription.text = StringUtil.removeHTMLTags(it.description.orEmpty().ifEmpty { it.displayText }).trim() } diff --git a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaViewModel.kt b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaViewModel.kt index 11b534505d0..64d5de40ff8 100644 --- a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaViewModel.kt +++ b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaViewModel.kt @@ -1,38 +1,33 @@ package org.wikipedia.edit.insertmedia -import android.os.Bundle +import androidx.lifecycle.SavedStateHandle 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 androidx.paging.* import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.dataclient.Service import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite -import org.wikipedia.extensions.parcelable import org.wikipedia.page.PageTitle import org.wikipedia.staticdata.FileAliasData +import org.wikipedia.util.L10nUtil import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L -class InsertMediaViewModel(bundle: Bundle) : ViewModel() { - - val invokeSource = bundle.getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as Constants.InvokeSource - val wikiSite = bundle.parcelable(Constants.ARG_WIKISITE)!! - var searchQuery = StringUtil.removeHTMLTags(StringUtil.removeUnderscores(bundle.getString(InsertMediaActivity.EXTRA_SEARCH_QUERY)!!)) +class InsertMediaViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val invokeSource = savedStateHandle.get(Constants.INTENT_EXTRA_INVOKE_SOURCE)!! + val wikiSite = savedStateHandle.get(Constants.ARG_WIKISITE)!! + var searchQuery = StringUtil.removeHTMLTags(StringUtil.removeUnderscores(savedStateHandle[InsertMediaActivity.EXTRA_SEARCH_QUERY]!!)) val originalSearchQuery = searchQuery - var selectedImage = bundle.parcelable(InsertMediaActivity.EXTRA_IMAGE_TITLE) - var selectedImageSource = bundle.getString(InsertMediaActivity.EXTRA_IMAGE_SOURCE).orEmpty() - var selectedImageSourceProjects = bundle.getString(InsertMediaActivity.EXTRA_IMAGE_SOURCE_PROJECTS).orEmpty() - var imagePosition: String = bundle.getString(InsertMediaActivity.RESULT_IMAGE_POS, IMAGE_POSITION_RIGHT) - var imageType: String = bundle.getString(InsertMediaActivity.RESULT_IMAGE_TYPE, IMAGE_TYPE_THUMBNAIL) - var imageSize: String = bundle.getString(InsertMediaActivity.RESULT_IMAGE_SIZE, IMAGE_SIZE_DEFAULT) + var selectedImage = savedStateHandle.get(InsertMediaActivity.EXTRA_IMAGE_TITLE) + var selectedImageSource = savedStateHandle[InsertMediaActivity.EXTRA_IMAGE_SOURCE] ?: "" + var selectedImageSourceProjects = savedStateHandle[InsertMediaActivity.EXTRA_IMAGE_SOURCE_PROJECTS] ?: "" + var imagePosition = savedStateHandle[InsertMediaActivity.RESULT_IMAGE_POS] + ?: defaultImagePositionForLang(wikiSite.languageCode) + var imageType = savedStateHandle[InsertMediaActivity.RESULT_IMAGE_TYPE] ?: IMAGE_TYPE_THUMBNAIL + var imageSize = savedStateHandle[InsertMediaActivity.RESULT_IMAGE_SIZE] ?: IMAGE_SIZE_DEFAULT val insertMediaFlow = Pager(PagingConfig(pageSize = 10)) { InsertMediaPagingSource(searchQuery) @@ -101,13 +96,6 @@ class InsertMediaViewModel(bundle: Bundle) : ViewModel() { } } - class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { - @Suppress("unchecked_cast") - override fun create(modelClass: Class): T { - return InsertMediaViewModel(bundle) as T - } - } - class InfoboxVars( val templateNameContains: String, val imageParamName: String, @@ -164,6 +152,10 @@ class InsertMediaViewModel(bundle: Bundle) : ViewModel() { magicWords[IMAGE_ALT_TEXT] = "alt=$1" } + fun defaultImagePositionForLang(langCode: String): String { + return if (L10nUtil.isLangRTL(langCode)) IMAGE_POSITION_LEFT else IMAGE_POSITION_RIGHT + } + fun insertImageIntoWikiText(langCode: String, oldWikiText: String, imageTitle: String, imageCaption: String, imageAltText: String, imageSize: String, imageType: String, imagePos: String, cursorPos: Int = 0, autoInsert: Boolean = false, attemptInfobox: Boolean = false): Triple> { @@ -177,8 +169,10 @@ class InsertMediaViewModel(bundle: Bundle) : ViewModel() { magicWords[imageType]?.let { type -> template += "|$type" } - magicWords[imagePos]?.let { pos -> - template += "|$pos" + if (!(imageType == IMAGE_TYPE_THUMBNAIL && imagePos == defaultImagePositionForLang(langCode))) { + magicWords[imagePos]?.let { pos -> + template += "|$pos" + } } if (imageAltText.isNotEmpty()) { template += "|" + magicWords[IMAGE_ALT_TEXT].orEmpty().replace("$1", imageAltText) @@ -213,7 +207,10 @@ class InsertMediaViewModel(bundle: Bundle) : ViewModel() { } } - if (autoInsert && attemptInfobox && infoboxMatch != null) { + // Verify a few conditions before attempting to insert into an infobox, including + // whether the infobox actually exists, and whether the current language wiki is + // supported by our hardcoded infoboxVars. + if (autoInsert && attemptInfobox && infoboxMatch != null && infoboxVarsByLang.containsKey(langCode)) { val infoboxStartIndex = infoboxMatch.range.first val infoboxEndIndex = infoboxMatch.range.last 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 69a1709fb6e..fb857516740 100644 --- a/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt +++ b/app/src/main/java/org/wikipedia/edit/preview/EditPreviewFragment.kt @@ -20,11 +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.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 @@ -35,6 +41,7 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi interface Callback { fun getParentPageTitle(): PageTitle fun showProgressBar(visible: Boolean) + fun isNewPage(): Boolean } private var _binding: FragmentPreviewEditBinding? = null @@ -75,8 +82,12 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi fun showPreview(title: PageTitle, wikiText: String) { DeviceUtil.hideSoftKeyboard(requireActivity()) callback().showProgressBar(true) - val url = ServiceFactory.getRestBasePath(model.title!!.wikiSite) + - RestService.PAGE_HTML_PREVIEW_ENDPOINT + UriUtil.encodeURL(title.prefixedText) + + // 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()) binding.editPreviewContainer.isVisible = true @@ -150,7 +161,7 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi } override fun onInternalLinkClicked(title: PageTitle) { - showLeavingEditDialogue { + showLeavingEditDialog { startActivity( PageActivity.newIntentForCurrentTab( context, @@ -161,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) { @@ -169,7 +180,9 @@ class EditPreviewFragment : Fragment(), CommunicationBridgeListener, ReferenceDi } override fun onDiffLinkClicked(title: PageTitle, revisionId: Long) { - // ignore + showLeavingEditDialog { + startActivity(ArticleEditDetailsActivity.newIntent(requireContext(), title, revisionId)) + } } /** @@ -178,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..c5546f9cb3e 100644 --- a/app/src/main/java/org/wikipedia/edit/richtext/SyntaxHighlighter.kt +++ b/app/src/main/java/org/wikipedia/edit/richtext/SyntaxHighlighter.kt @@ -1,28 +1,25 @@ package org.wikipedia.edit.richtext -import android.content.Context import android.os.Build import android.text.Spanned +import android.widget.ScrollView +import androidx.activity.ComponentActivity import androidx.core.text.getSpans -import androidx.core.widget.NestedScrollView import androidx.core.widget.doAfterTextChanged -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.* import org.wikipedia.edit.SyntaxHighlightableEditText import org.wikipedia.util.log.L import java.util.* -import java.util.concurrent.Callable -import java.util.concurrent.TimeUnit class SyntaxHighlighter( - private var context: Context, + private val activity: ComponentActivity, private val textBox: SyntaxHighlightableEditText, - private val scrollView: NestedScrollView?, - private val highlightDelayMillis: Long = HIGHLIGHT_DELAY_MILLIS) { - + private val scrollView: ScrollView?, + 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), @@ -42,11 +39,8 @@ class SyntaxHighlighter( SyntaxRule("==", "==", SyntaxRuleStyle.HEADING_LARGE), ) - private val disposables = CompositeDisposable() - private var currentHighlightTask: SyntaxHighlightTask? = null + private var currentHighlightJob: Job? = null private var lastScrollY = -1 - private val highlightOnScrollRunnable = Runnable { postHighlightOnScroll() } - private var searchQueryPositions: List? = null private var searchQueryLength = 0 private var searchQueryPositionIndex = 0 @@ -55,212 +49,207 @@ class SyntaxHighlighter( set(value) { field = value if (!value) { - currentHighlightTask?.cancel() - disposables.clear() + currentHighlightJob?.cancel() textBox.text.getSpans().forEach { textBox.text.removeSpan(it) } } else { - runHighlightTasks(highlightDelayMillis) + activity.lifecycleScope.launch { + performHighlight(highlightDelayMillis) + } } } init { - textBox.doAfterTextChanged { runHighlightTasks(highlightDelayMillis * 2) } + textBox.doAfterTextChanged { + activity.lifecycleScope.launch { + performHighlight(highlightDelayMillis * 2) + } + } textBox.scrollView = scrollView - postHighlightOnScroll() + activity.lifecycleScope.launch { + highlightOnScroll() + } } - private fun runHighlightTasks(delayMillis: Long) { + private fun performHighlight(delayMillis: Long = 0) { + currentHighlightJob?.cancel() + currentHighlightJob = activity.lifecycleScope.launch { + runHighlightTasks(delayMillis) + } + } - currentHighlightTask?.cancel() - disposables.clear() + private suspend fun runHighlightTasks(delayMillis: Long) { if (!enabled) { return } - disposables.add(Observable.timer(delayMillis, TimeUnit.MILLISECONDS) - .flatMap { - if (textBox.layout == null) { - throw IllegalArgumentException() - } - - var firstVisibleLine = if (scrollView != null) textBox.layout.getLineForVertical(scrollView.scrollY) else 0 - if (firstVisibleLine < 0) firstVisibleLine = 0 - - var lastVisibleLine = if (scrollView != null) textBox.layout.getLineForVertical(scrollView.scrollY + scrollView.height) else textBox.layout.lineCount - 1 - if (lastVisibleLine < firstVisibleLine) lastVisibleLine = firstVisibleLine - else if (lastVisibleLine >= textBox.lineCount) lastVisibleLine = textBox.lineCount - 1 + delay(delayMillis) - val firstVisibleIndex = textBox.layout.getLineStart(firstVisibleLine) - val lastVisibleIndex = textBox.layout.getLineEnd(lastVisibleLine) + while (textBox.layout == null) { + delay(HIGHLIGHT_DELAY_MILLIS) + } - val textToHighlight = textBox.text.substring(firstVisibleIndex, lastVisibleIndex) - currentHighlightTask = SyntaxHighlightTask(textToHighlight, firstVisibleIndex) + val layout = textBox.layout!! + val maxLast = layout.lineCount - 1 - Observable.zip, List, List>(Observable.fromCallable(currentHighlightTask!!), - if (searchQueryPositions.isNullOrEmpty()) Observable.just(emptyList()) - else Observable.fromCallable(SyntaxHighlightSearchMatchesTask(firstVisibleIndex, textToHighlight.length))) { f, s -> - f.addAll(s) - f - } - } - .retry(10) - .subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ result -> - textBox.enqueueNoScrollingLayoutChange() + val firstVisibleLine = scrollView?.let { layout.getLineForVertical(it.scrollY) } ?: 0 + val lastVisibleLine = scrollView?.let { + layout.getLineForVertical(it.scrollY + it.height) + .coerceIn(firstVisibleLine, maxLast) + } ?: maxLast - var time = System.currentTimeMillis() - val oldSpans = textBox.text.getSpans().toMutableList() - val newSpans = result.toMutableList() + val firstVisibleIndex = layout.getLineStart(firstVisibleLine) + val lastVisibleIndex = layout.getLineEnd(lastVisibleLine) + val textToHighlight = textBox.text.substring(firstVisibleIndex, lastVisibleIndex) - val dupes = oldSpans.filter { item -> - val r = result.find { - it.start == textBox.text.getSpanStart(item) && it.end == textBox.text.getSpanEnd(item) && it.syntaxRule == item.syntaxRule - } - if (r != null) { - newSpans.remove(r) - } - r != null - } - oldSpans.removeAll(dupes) - - oldSpans.forEach { textBox.text.removeSpan(it) } - newSpans.forEach { textBox.text.setSpan(it, it.start, it.end, Spanned.SPAN_INCLUSIVE_INCLUSIVE) } - - time = System.currentTimeMillis() - time - L.d("Took $time ms to remove ${oldSpans.size} spans and add ${newSpans.size} new.") - }) { L.e(it) }) - } - - fun setSearchQueryInfo(searchQueryPositions: List?, searchQueryLength: Int, searchQueryPositionIndex: Int) { - this.searchQueryPositions = searchQueryPositions - this.searchQueryLength = searchQueryLength - this.searchQueryPositionIndex = searchQueryPositionIndex - runHighlightTasks(0) - } + val result = withContext(Dispatchers.Default) { + listOf( + async { getHighlightSpans(textToHighlight, firstVisibleIndex) }, + async { getSyntaxMatches(firstVisibleIndex, textToHighlight.length) }, + ).awaitAll().flatten() + } - fun clearSearchQueryInfo() { - setSearchQueryInfo(null, 0, 0) - } + textBox.enqueueNoScrollingLayoutChange() - fun cleanup() { - scrollView?.removeCallbacks(highlightOnScrollRunnable) - disposables.clear() - textBox.text.clearSpans() - } + var time = System.currentTimeMillis() + val oldSpans = textBox.text.getSpans().toMutableList() + val newSpans = result.toMutableList() - private fun postHighlightOnScroll() { - scrollView?.let { - if (lastScrollY != it.scrollY) { - lastScrollY = it.scrollY - runHighlightTasks(0) + val dupes = oldSpans.filter { item -> + val r = result.find { + it.start == textBox.text.getSpanStart(item) && + it.end == textBox.text.getSpanEnd(item) && + it.syntaxRule == item.syntaxRule } - it.postDelayed(highlightOnScrollRunnable, highlightDelayMillis) + if (r != null) { + newSpans.remove(r) + } + r != null } - } - - private inner class SyntaxHighlightTask constructor(private val text: CharSequence, private val startOffset: Int) : Callable> { - private var cancelled = false + oldSpans.removeAll(dupes) - fun cancel() { - cancelled = true + oldSpans.forEach { textBox.text.removeSpan(it) } + newSpans.forEach { + textBox.text.setSpan(it, it.start, it.end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } - override fun call(): MutableList { - val spanStack = Stack() - val spansToSet = mutableListOf() - val textChars = text.toString().toCharArray() + time = System.currentTimeMillis() - time + L.d("Took $time ms to remove ${oldSpans.size} spans and add ${newSpans.size} new.") + } - /* - The (naïve) algorithm: - Iterate through the text string, and maintain a stack of matched syntax rules. - When the "start" and "end" symbol of a rule are matched in sequence, create a new - Span to be added to the EditText at the corresponding location. - */ - var i = 0 - while (i < textChars.size) { - var newSpanInfo: SpanExtents - var completed = false + private fun getHighlightSpans(text: CharSequence, startOffset: Int): List { + val spanStack = Stack() + val spansToSet = mutableListOf() + val textChars = text.toString().toCharArray() + + /* + The (naïve) algorithm: + Iterate through the text string, and maintain a stack of matched syntax rules. + When the "start" and "end" symbol of a rule are matched in sequence, create a new + Span to be added to the EditText at the corresponding location. + */ + var i = 0 + while (i < textChars.size) { + var newSpanInfo: SpanExtents + var completed = false + + for (rule in syntaxRules) { + if (i + rule.endChars.size > textChars.size) { + continue + } + var pass = true + for (j in 0 until rule.endChars.size) { + if (textChars[i + j] != rule.endChars[j]) { + pass = false + break + } + } + if (pass) { + val sr = spanStack.find { it.syntaxRule == rule } + if (sr != null) { + newSpanInfo = sr + spanStack.remove(sr) + newSpanInfo.end = i + rule.endChars.size + spansToSet.add(newSpanInfo) + i += rule.endChars.size - 1 + completed = true + break + } + } + } + if (!completed) { for (rule in syntaxRules) { - if (i + rule.endChars.size > textChars.size) { + if (i + rule.startChars.size > textChars.size) { continue } var pass = true - for (j in 0 until rule.endChars.size) { - if (textChars[i + j] != rule.endChars[j]) { + for (j in 0 until rule.startChars.size) { + if (textChars[i + j] != rule.startChars[j]) { pass = false break } } if (pass) { - val sr = spanStack.find { it.syntaxRule == rule } - if (sr != null) { - newSpanInfo = sr - spanStack.remove(sr) - newSpanInfo.end = i + rule.endChars.size - spansToSet.add(newSpanInfo) - i += rule.endChars.size - 1 - completed = true - break - } - } - } - - if (!completed) { - for (rule in syntaxRules) { - if (i + rule.startChars.size > textChars.size) { - continue - } - var pass = true - for (j in 0 until rule.startChars.size) { - if (textChars[i + j] != rule.startChars[j]) { - pass = false - break - } - } - if (pass) { - val sp = rule.spanStyle.createSpan(context, i, rule) - spanStack.push(sp) - i += rule.startChars.size - 1 - break - } - } - if (cancelled) { + val sp = rule.spanStyle.createSpan(activity, i, rule) + spanStack.push(sp) + i += rule.startChars.size - 1 break } } - - i++ - } - spansToSet.forEach { - it.start += startOffset - it.end += startOffset } - spansToSet.sortWith { a, b -> a.syntaxRule.spanStyle.compareTo(b.syntaxRule.spanStyle) } - return spansToSet + + i++ + } + spansToSet.forEach { + it.start += startOffset + it.end += startOffset } + spansToSet.sortBy { it.syntaxRule.spanStyle } + return spansToSet } - private inner class SyntaxHighlightSearchMatchesTask constructor(private val startOffset: Int, private val textLength: Int) : Callable> { - override fun call(): List { - val spansToSet = mutableListOf() - val syntaxItem = SyntaxRule("", "", SyntaxRuleStyle.SEARCH_MATCHES) + private fun getSyntaxMatches(startOffset: Int, textLength: Int): List { + val syntaxItem = SyntaxRule("", "", SyntaxRuleStyle.SEARCH_MATCHES) - searchQueryPositions?.let { - for (i in it.indices) { - if (it[i] >= startOffset && it[i] < startOffset + textLength) { - val newSpanInfo = if (i == searchQueryPositionIndex) { - SyntaxRuleStyle.SEARCH_MATCH_SELECTED.createSpan(context, it[i], syntaxItem) - } else { - SyntaxRuleStyle.SEARCH_MATCHES.createSpan(context, it[i], syntaxItem) - } - newSpanInfo.start = it[i] - newSpanInfo.end = it[i] + searchQueryLength - spansToSet.add(newSpanInfo) - } + return searchQueryPositions.orEmpty().toMutableList() + .filter { it >= startOffset && it < startOffset + textLength } + .mapIndexed { index, i -> + val newSpanInfoCreator = if (index == searchQueryPositionIndex) { + SyntaxRuleStyle.SEARCH_MATCH_SELECTED + } else { + SyntaxRuleStyle.SEARCH_MATCHES } + newSpanInfoCreator.createSpan(activity, i, syntaxItem).apply { + start = i + end = i + searchQueryLength + } + } + } + + fun setSearchQueryInfo(searchQueryPositions: List?, searchQueryLength: Int, searchQueryPositionIndex: Int) { + this.searchQueryPositions = searchQueryPositions + this.searchQueryLength = searchQueryLength + this.searchQueryPositionIndex = searchQueryPositionIndex + activity.lifecycleScope.launch { + performHighlight() + } + } + + fun clearSearchQueryInfo() { + setSearchQueryInfo(null, 0, 0) + } + + fun cleanup() { + textBox.text.clearSpans() + } + + private suspend fun highlightOnScroll() { + scrollView?.let { + if (lastScrollY != it.scrollY) { + lastScrollY = it.scrollY + performHighlight() } - return spansToSet + delay(highlightDelayMillis) + highlightOnScroll() } } 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/summaries/EditSummaryFragment.kt b/app/src/main/java/org/wikipedia/edit/summaries/EditSummaryFragment.kt index fa0016c6643..9789344ea98 100644 --- a/app/src/main/java/org/wikipedia/edit/summaries/EditSummaryFragment.kt +++ b/app/src/main/java/org/wikipedia/edit/summaries/EditSummaryFragment.kt @@ -7,15 +7,18 @@ import android.content.res.ColorStateList import android.net.Uri import android.os.Bundle import android.speech.RecognizerIntent +import android.text.method.LinkMovementMethod import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible import androidx.core.widget.TextViewCompat import androidx.core.widget.addTextChangedListener import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.chip.Chip import kotlinx.coroutines.CoroutineExceptionHandler @@ -27,6 +30,7 @@ import org.wikipedia.auth.AccountUtil import org.wikipedia.databinding.FragmentPreviewSummaryBinding import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.edit.EditSectionActivity +import org.wikipedia.edit.EditSectionViewModel import org.wikipedia.edit.insertmedia.InsertMediaActivity import org.wikipedia.extensions.parcelableExtra import org.wikipedia.page.PageTitle @@ -35,6 +39,7 @@ import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.L10nUtil import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L import org.wikipedia.views.ViewAnimations @@ -52,6 +57,8 @@ class EditSummaryFragment : Fragment() { val watchThisPage get() = binding.watchPageCheckBox.isChecked val isActive get() = binding.root.visibility == View.VISIBLE + private val viewModel: EditSectionViewModel by activityViewModels() + private val voiceSearchLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { val voiceSearchResult = it.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS) if (it.resultCode == Activity.RESULT_OK && voiceSearchResult != null) { @@ -83,14 +90,14 @@ class EditSummaryFragment : Fragment() { } binding.editSummaryTextLayout.setEndIconOnClickListener { - if ((requireActivity() as EditSectionActivity).invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (invokeSource() == Constants.InvokeSource.EDIT_ADD_IMAGE) { ImageRecommendationsEvent.logAction("tts_open", "editsummary_dialog", getActionDataStringForData()) } launchVoiceInput() } binding.learnMoreButton.setOnClickListener { - if ((requireActivity() as EditSectionActivity).invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (invokeSource() == Constants.InvokeSource.EDIT_ADD_IMAGE) { ImageRecommendationsEvent.logAction("view_help", "editsummary_dialog", getActionDataStringForData()) } UriUtil.visitInExternalBrowser(requireContext(), Uri.parse(getString(R.string.meta_edit_summary_url))) @@ -105,7 +112,7 @@ class EditSummaryFragment : Fragment() { } binding.watchPageCheckBox.setOnCheckedChangeListener { _, isChecked -> - if ((requireActivity() as EditSectionActivity).invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) { + if (invokeSource() == Constants.InvokeSource.EDIT_ADD_IMAGE) { ImageRecommendationsEvent.logAction(if (isChecked) "add_watchlist" else "remove_watchlist", "editsummary_dialog", getActionDataStringForData()) } } @@ -126,9 +133,10 @@ class EditSummaryFragment : Fragment() { acceptanceState = "accepted", captionAdd = !activity?.intent?.getStringExtra(InsertMediaActivity.RESULT_IMAGE_CAPTION).isNullOrEmpty(), altTextAdd = !activity?.intent?.getStringExtra(InsertMediaActivity.RESULT_IMAGE_ALT).isNullOrEmpty()) } + override fun onStart() { super.onStart() - editSummaryHandler = EditSummaryHandler(binding.root, binding.editSummaryText, title) + editSummaryHandler = EditSummaryHandler(lifecycleScope, binding.root, binding.editSummaryText, title) } override fun onDestroyView() { @@ -162,7 +170,7 @@ class EditSummaryFragment : Fragment() { } private fun addEditSummaries() { - val summaryTagStrings = if ((requireActivity() as EditSectionActivity).invokeSource == Constants.InvokeSource.EDIT_ADD_IMAGE) + val summaryTagStrings = if (invokeSource() == Constants.InvokeSource.EDIT_ADD_IMAGE) intArrayOf(R.string.edit_summary_added_image_and_caption, R.string.edit_summary_added_image) else intArrayOf(R.string.edit_summary_tag_typo, R.string.edit_summary_tag_grammar, R.string.edit_summary_tag_links) @@ -198,7 +206,18 @@ class EditSummaryFragment : Fragment() { return chip } + private fun invokeSource() = (requireActivity() as EditSectionActivity).getInvokeSource() + fun show() { + if (!AccountUtil.isLoggedIn || AccountUtil.isTemporaryAccount) { + binding.footerContainer.tempAccountInfoContainer.isVisible = true + binding.footerContainer.tempAccountInfoIcon.setImageResource(if (AccountUtil.isTemporaryAccount) R.drawable.ic_temp_account else R.drawable.ic_anon_account) + binding.footerContainer.tempAccountInfoText.movementMethod = LinkMovementMethod.getInstance() + binding.footerContainer.tempAccountInfoText.text = StringUtil.fromHtml(if (AccountUtil.isTemporaryAccount) getString(R.string.temp_account_edit_status, AccountUtil.getUserNameFromCookie(), getString(R.string.temp_accounts_help_url)) + else getString(if (viewModel.tempAccountsEnabled) R.string.temp_account_anon_edit_status else R.string.temp_account_anon_ip_edit_status, getString(R.string.temp_accounts_help_url))) + } else { + binding.footerContainer.tempAccountInfoContainer.isVisible = false + } ViewAnimations.fadeIn(binding.root) { requireActivity().invalidateOptionsMenu() binding.editSummaryText.requestFocus() diff --git a/app/src/main/java/org/wikipedia/edit/summaries/EditSummaryHandler.kt b/app/src/main/java/org/wikipedia/edit/summaries/EditSummaryHandler.kt index ce01fb14ef1..3e2b246d61a 100644 --- a/app/src/main/java/org/wikipedia/edit/summaries/EditSummaryHandler.kt +++ b/app/src/main/java/org/wikipedia/edit/summaries/EditSummaryHandler.kt @@ -3,14 +3,15 @@ package org.wikipedia.edit.summaries import android.view.View import android.widget.ArrayAdapter import android.widget.AutoCompleteTextView -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.wikipedia.database.AppDatabase import org.wikipedia.edit.db.EditSummary import org.wikipedia.page.PageTitle import org.wikipedia.util.L10nUtil.setConditionalTextDirection -class EditSummaryHandler(private val container: View, +class EditSummaryHandler(private val coroutineScope: CoroutineScope, + private val container: View, private val summaryEdit: AutoCompleteTextView, title: PageTitle) { @@ -18,14 +19,10 @@ class EditSummaryHandler(private val container: View, container.setOnClickListener { summaryEdit.requestFocus() } setConditionalTextDirection(summaryEdit, title.wikiSite.languageCode) - AppDatabase.instance.editSummaryDao().getEditSummaries() - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { summaries -> - if (container.isAttachedToWindow) { - updateAutoCompleteList(summaries) - } - } + coroutineScope.launch { + val summaries = AppDatabase.instance.editSummaryDao().getEditSummaries() + updateAutoCompleteList(summaries) + } } private fun updateAutoCompleteList(editSummaries: List) { @@ -38,9 +35,9 @@ class EditSummaryHandler(private val container: View, } fun persistSummary() { - AppDatabase.instance.editSummaryDao().insertEditSummary(EditSummary(summary = summaryEdit.text.toString())) - .subscribeOn(Schedulers.io()) - .subscribe() + coroutineScope.launch { + AppDatabase.instance.editSummaryDao().insertEditSummary(EditSummary(summary = summaryEdit.text.toString())) + } } fun handleBackPressed(): Boolean { diff --git a/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt b/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt index 7f6c35e61e8..50c91ebee37 100644 --- a/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt +++ b/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt @@ -37,9 +37,11 @@ class InsertTemplateFragment : Fragment() { private fun buildParamsInputFields(templateData: TemplateDataResponse.TemplateData) { activity.updateInsertButton(true) binding.templateDataParamsContainer.removeAllViews() - templateData.getParams?.filter { !it.value.isDeprecated }?.forEach { + templateData.getParams?.filter { + !it.value.isDeprecated + }?.forEach { val itemBinding = ItemInsertTemplateBinding.inflate(layoutInflater) - val labelText = StringUtil.capitalize(it.key) + val labelText = it.value.label.orEmpty().ifEmpty { StringUtil.capitalize(it.key) } itemBinding.root.tag = false if (it.value.required) { itemBinding.textInputLayout.hint = labelText @@ -73,15 +75,17 @@ class InsertTemplateFragment : Fragment() { } 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 == true + 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) diff --git a/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt index ef4cbcb8885..918704e2454 100644 --- a/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt +++ b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt @@ -14,7 +14,6 @@ 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 @@ -23,6 +22,8 @@ import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.activity.BaseActivity +import org.wikipedia.adapter.PagingDataAdapterPatched +import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent import org.wikipedia.databinding.ActivityTemplatesSearchBinding import org.wikipedia.databinding.ItemTemplatesSearchBinding import org.wikipedia.dataclient.WikiSite @@ -41,7 +42,7 @@ class TemplatesSearchActivity : BaseActivity() { private var templatesSearchAdapter: TemplatesSearchAdapter? = null - val viewModel: TemplatesSearchViewModel by viewModels { TemplatesSearchViewModel.Factory(intent.extras!!) } + val viewModel: TemplatesSearchViewModel by viewModels() private val searchCloseListener = SearchView.OnCloseListener { closeSearch() @@ -66,7 +67,7 @@ class TemplatesSearchActivity : BaseActivity() { binding = ActivityTemplatesSearchBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) - + sendPatrollerExperienceEvent("search_init", "pt_templates") initSearchView() templatesSearchAdapter = TemplatesSearchAdapter() @@ -77,6 +78,7 @@ class TemplatesSearchActivity : BaseActivity() { 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) @@ -89,7 +91,7 @@ class TemplatesSearchActivity : BaseActivity() { repeatOnLifecycle(Lifecycle.State.CREATED) { launch { viewModel.searchTemplatesFlow.collectLatest { - templatesSearchAdapter?.submitData(it) + templatesSearchAdapter?.submitData(lifecycleScope, it) } } launch { @@ -149,6 +151,12 @@ class TemplatesSearchActivity : BaseActivity() { 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) { @@ -174,7 +182,7 @@ class TemplatesSearchActivity : BaseActivity() { } } - private inner class TemplatesSearchAdapter : PagingDataAdapter(TemplatesSearchDiffCallback()) { + private inner class TemplatesSearchAdapter : PagingDataAdapterPatched(TemplatesSearchDiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, pos: Int): TemplatesSearchItemHolder { return TemplatesSearchItemHolder(ItemTemplatesSearchBinding.inflate(layoutInflater, parent, false)) } @@ -207,11 +215,13 @@ class TemplatesSearchActivity : BaseActivity() { } 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, invokeSource: Constants.InvokeSource): Intent { + 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 index e7985d31280..4f85e746a3e 100644 --- a/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchViewModel.kt +++ b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchViewModel.kt @@ -1,14 +1,9 @@ package org.wikipedia.edit.templates -import android.os.Bundle +import androidx.lifecycle.SavedStateHandle 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 androidx.paging.* import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -16,15 +11,14 @@ 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)!! +class TemplatesSearchViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val invokeSource = savedStateHandle.get(Constants.INTENT_EXTRA_INVOKE_SOURCE)!! + val wikiSite = savedStateHandle.get(Constants.ARG_WIKISITE)!! + val isFromDiff = savedStateHandle[TemplatesSearchActivity.EXTRA_FROM_DIFF] ?: false var searchQuery: String? = null var selectedPageTitle: PageTitle? = null val searchTemplatesFlow = Pager(PagingConfig(pageSize = 10)) { @@ -49,12 +43,15 @@ class TemplatesSearchViewModel(bundle: Bundle) : ViewModel() { val recentUsedTemplates = Prefs.recentUsedTemplates.filter { it.wikiSite == wikiSite } return LoadResult.Page(recentUsedTemplates, null, null) } - val query = Namespace.TEMPLATE.name + ":" + searchQuery + "*" + val query = Namespace.TEMPLATE.name + ":" + searchQuery val response = ServiceFactory.get(wikiSite) - .fullTextSearchTemplates(query, params.loadSize, params.key) + .fullTextSearchTemplates("$query*", params.loadSize, params.key) return response.query?.pages?.let { list -> - val results = list.sortedBy { it.index }.map { + 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 @@ -73,13 +70,6 @@ class TemplatesSearchViewModel(bundle: Bundle) : ViewModel() { } } - 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/events/LoggedOutEvent.kt b/app/src/main/java/org/wikipedia/events/LoggedOutEvent.kt new file mode 100644 index 00000000000..75dcd651660 --- /dev/null +++ b/app/src/main/java/org/wikipedia/events/LoggedOutEvent.kt @@ -0,0 +1,3 @@ +package org.wikipedia.events + +class LoggedOutEvent diff --git a/app/src/main/java/org/wikipedia/extensions/View.kt b/app/src/main/java/org/wikipedia/extensions/View.kt new file mode 100644 index 00000000000..b0ce2ae5c18 --- /dev/null +++ b/app/src/main/java/org/wikipedia/extensions/View.kt @@ -0,0 +1,12 @@ +package org.wikipedia.extensions + +import android.view.View +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlin.coroutines.CoroutineContext + +fun View.coroutineScope(coroutineContext: CoroutineContext = Dispatchers.Main): CoroutineScope { + return (context as? AppCompatActivity)?.lifecycleScope ?: CoroutineScope(coroutineContext) +} diff --git a/app/src/main/java/org/wikipedia/feed/FeedContentType.kt b/app/src/main/java/org/wikipedia/feed/FeedContentType.kt index f366811868e..ba3ff85c803 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedContentType.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedContentType.kt @@ -1,6 +1,7 @@ package org.wikipedia.feed import androidx.annotation.StringRes +import kotlinx.coroutines.CoroutineScope import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil @@ -9,6 +10,7 @@ import org.wikipedia.feed.aggregated.AggregatedFeedContentClient import org.wikipedia.feed.becauseyouread.BecauseYouReadClient import org.wikipedia.feed.dataclient.FeedClient import org.wikipedia.feed.mainpage.MainPageClient +import org.wikipedia.feed.places.PlacesFeedClient import org.wikipedia.feed.random.RandomClient import org.wikipedia.feed.suggestededits.SuggestedEditsFeedClient import org.wikipedia.model.EnumCode @@ -22,52 +24,57 @@ enum class FeedContentType(private val code: Int, var showInConfig: Boolean = true) : EnumCode { FEATURED_ARTICLE(6, R.string.view_featured_article_card_title, R.string.feed_item_type_featured_article, true) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { - return if (isEnabled) AggregatedFeedContentClient.FeaturedArticle(aggregatedClient) else null + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled) AggregatedFeedContentClient.FeaturedArticle(coroutineScope, aggregatedClient) else null } }, TOP_READ_ARTICLES(3, R.string.view_top_read_card_title, R.string.feed_item_type_trending, true) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { - return if (isEnabled) AggregatedFeedContentClient.TopReadArticles(aggregatedClient) else null + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled) AggregatedFeedContentClient.TopReadArticles(coroutineScope, aggregatedClient) else null + } + }, + PLACES(11, R.string.places_title, R.string.feed_item_type_places, false) { + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled) PlacesFeedClient(coroutineScope) else null } }, FEATURED_IMAGE(7, R.string.view_featured_image_card_title, R.string.feed_item_type_featured_image, false) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { - return if (isEnabled) AggregatedFeedContentClient.FeaturedImage(aggregatedClient) else null + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled) AggregatedFeedContentClient.FeaturedImage(coroutineScope, aggregatedClient) else null } }, BECAUSE_YOU_READ(8, R.string.view_because_you_read_card_title, R.string.feed_item_type_because_you_read, false) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { - return if (isEnabled) BecauseYouReadClient() else null + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled) BecauseYouReadClient(coroutineScope) else null } }, NEWS(0, R.string.view_card_news_title, R.string.feed_item_type_news, true) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { - return if (isEnabled && age == 0) AggregatedFeedContentClient.InTheNews(aggregatedClient) else null + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled && age == 0) AggregatedFeedContentClient.InTheNews(coroutineScope, aggregatedClient) else null } }, ON_THIS_DAY(1, R.string.on_this_day_card_title, R.string.feed_item_type_on_this_day, true) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { - return if (isEnabled) AggregatedFeedContentClient.OnThisDayFeed(aggregatedClient) else null + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled) AggregatedFeedContentClient.OnThisDayFeed(coroutineScope, aggregatedClient) else null } }, RANDOM(5, R.string.view_random_card_title, R.string.feed_item_type_randomizer, true) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { - return if (isEnabled) RandomClient() else null + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled) RandomClient(coroutineScope) else null } }, MAIN_PAGE(4, R.string.view_main_page_card_title, R.string.feed_item_type_main_page, true) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { return if (isEnabled && age == 0) MainPageClient() else null } }, SUGGESTED_EDITS(9, R.string.suggested_edits_feed_card_title, R.string.feed_item_type_suggested_edits, false) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { - return if (isEnabled && AccountUtil.isLoggedIn && WikipediaApp.instance.isOnline) SuggestedEditsFeedClient() else null + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + return if (isEnabled && AccountUtil.isLoggedIn && WikipediaApp.instance.isOnline) SuggestedEditsFeedClient(coroutineScope) else null } }, ACCESSIBILITY(10, 0, 0, false, false) { - override fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { + override fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? { return if (DeviceUtil.isAccessibilityEnabled) AccessibilityCardClient() else null } }; @@ -77,25 +84,13 @@ enum class FeedContentType(private val code: Int, val langCodesSupported = mutableListOf() val langCodesDisabled = mutableListOf() - abstract fun newClient(aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? + abstract fun newClient(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient, age: Int): FeedClient? override fun code(): Int { return code } companion object { - val aggregatedLanguages: List - get() { - val appLangCodes = WikipediaApp.instance.languageState.appLanguageCodes - val list = mutableListOf() - entries.filter { it.isEnabled }.forEach { type -> - list.addAll(appLangCodes.filter { - (type.langCodesSupported.isEmpty() || type.langCodesSupported.contains(it)) && - !type.langCodesDisabled.contains(it) && !list.contains(it) - }) - } - return list - } fun saveState() { val enabledList = mutableListOf() diff --git a/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt b/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt index 07cab03f492..0284b9f8722 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt @@ -1,19 +1,15 @@ 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 org.wikipedia.WikipediaApp import org.wikipedia.feed.aggregated.AggregatedFeedContentClient import org.wikipedia.feed.announcement.AnnouncementClient -import org.wikipedia.feed.dataclient.FeedClient -import org.wikipedia.feed.model.Card import org.wikipedia.feed.offline.OfflineCardClient import org.wikipedia.feed.onboarding.OnboardingClient import org.wikipedia.feed.searchbar.SearchClient -class FeedCoordinator internal constructor(context: Context) : FeedCoordinatorBase(context) { +class FeedCoordinator internal constructor(private val coroutineScope: CoroutineScope, context: Context) : FeedCoordinatorBase(context) { private val aggregatedClient = AggregatedFeedContentClient() @@ -29,23 +25,12 @@ class FeedCoordinator internal constructor(context: Context) : FeedCoordinatorBa override fun buildScript(age: Int) { val online = WikipediaApp.instance.isOnline conditionallyAddPendingClient(SearchClient(), age == 0) - conditionallyAddPendingClient(AnnouncementClient(), age == 0 && online) + conditionallyAddPendingClient(AnnouncementClient(coroutineScope), age == 0 && online) conditionallyAddPendingClient(OnboardingClient(), age == 0) conditionallyAddPendingClient(OfflineCardClient(), age == 0 && !online) for (contentType in FeedContentType.entries.sortedBy { it.order }) { - addPendingClient(contentType.newClient(aggregatedClient, age)) - } - } - - 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) } + addPendingClient(contentType.newClient(coroutineScope, aggregatedClient, age)) } } } diff --git a/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt b/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt index 994ab113278..a5b0cc68d21 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt @@ -14,6 +14,7 @@ import org.wikipedia.feed.model.CardType import org.wikipedia.feed.news.NewsCard import org.wikipedia.feed.offline.OfflineCard import org.wikipedia.feed.onthisday.OnThisDayCard +import org.wikipedia.feed.places.PlacesFeedClient import org.wikipedia.feed.progress.ProgressCard import org.wikipedia.feed.suggestededits.SuggestedEditsFeedClient import org.wikipedia.feed.topread.TopReadListCard @@ -21,7 +22,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) { @@ -101,6 +102,10 @@ abstract class FeedCoordinatorBase(private val context: Context) { FeedContentType.MAIN_PAGE.isEnabled = false FeedContentType.saveState() } + card.type() == CardType.PLACES -> { + FeedContentType.PLACES.isEnabled = false + FeedContentType.saveState() + } else -> { addHiddenCard(card) } @@ -120,6 +125,10 @@ abstract class FeedCoordinatorBase(private val context: Context) { FeedContentType.MAIN_PAGE.isEnabled = true FeedContentType.saveState() } + card.type() == CardType.PLACES -> { + FeedContentType.PLACES.isEnabled = true + FeedContentType.saveState() + } else -> unHideCard(card) } insertCard(card, position) @@ -149,7 +158,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 +271,12 @@ 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 is PlacesFeedClient || + pendingClient == null } companion object { diff --git a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt index ef6a6ba2708..bd645854217 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt @@ -8,6 +8,7 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.wikipedia.BackPressedHandler @@ -30,6 +31,7 @@ import org.wikipedia.feed.random.RandomCardView import org.wikipedia.feed.topread.TopReadArticlesActivity import org.wikipedia.feed.topread.TopReadListCard import org.wikipedia.feed.view.FeedAdapter +import org.wikipedia.feed.view.RegionalLanguageVariantSelectionDialog import org.wikipedia.history.HistoryEntry import org.wikipedia.language.AppLanguageLookUpTable import org.wikipedia.random.RandomActivity @@ -38,7 +40,6 @@ import org.wikipedia.settings.Prefs import org.wikipedia.settings.SettingsActivity import org.wikipedia.settings.languages.WikipediaLanguagesActivity import org.wikipedia.util.FeedbackUtil -import org.wikipedia.util.ResourceUtil import org.wikipedia.util.UriUtil class FeedFragment : Fragment(), BackPressedHandler { @@ -50,7 +51,7 @@ class FeedFragment : Fragment(), BackPressedHandler { private val feedScrollListener = FeedScrollListener() private val callback get() = getCallback(this, Callback::class.java) private var app: WikipediaApp = WikipediaApp.instance - private var coordinator: FeedCoordinator = FeedCoordinator(app) + private var coordinator: FeedCoordinator = FeedCoordinator(lifecycleScope, app) private var shouldElevateToolbar = false interface Callback { @@ -93,7 +94,6 @@ class FeedFragment : Fragment(), BackPressedHandler { feedAdapter = FeedAdapter(coordinator, feedCallback) binding.feedView.adapter = feedAdapter binding.feedView.addOnScrollListener(feedScrollListener) - binding.swipeRefreshLayout.setColorSchemeResources(ResourceUtil.getThemedAttributeId(requireContext(), R.attr.progressive_color)) binding.swipeRefreshLayout.setOnRefreshListener { refresh() } binding.customizeButton.setOnClickListener { showConfigureActivity(-1) } coordinator.setFeedUpdateListener(object : FeedUpdateListener { @@ -118,6 +118,7 @@ class FeedFragment : Fragment(), BackPressedHandler { if (feedAdapter.itemCount < 2) { binding.emptyContainer.visibility = View.VISIBLE } else { + binding.emptyContainer.visibility = View.GONE if (shouldUpdatePreviousCard) { feedAdapter.notifyItemChanged(feedAdapter.itemCount - 1) } @@ -130,26 +131,13 @@ class FeedFragment : Fragment(), BackPressedHandler { return binding.root } - private fun showRemoveChineseVariantPrompt() { - if (app.languageState.appLanguageCodes.contains(AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE) && - app.languageState.appLanguageCodes.contains(AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE) && - Prefs.shouldShowRemoveChineseVariantPrompt) { - MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.dialog_of_remove_chinese_variants_from_app_lang_title) - .setMessage(R.string.dialog_of_remove_chinese_variants_from_app_lang_text) - .setPositiveButton(R.string.dialog_of_remove_chinese_variants_from_app_lang_edit) { _, _ -> showLanguagesActivity(InvokeSource.LANG_VARIANT_DIALOG) } - .setNegativeButton(R.string.dialog_of_remove_chinese_variants_from_app_lang_no, null) - .show() - } - Prefs.shouldShowRemoveChineseVariantPrompt = false - } - override fun onResume() { super.onResume() - showRemoveChineseVariantPrompt() + maybeShowRegionalLanguageVariantDialog() // Explicitly invalidate the feed adapter, since it occasionally crashes the StaggeredGridLayout - // on certain devices. (TODO: investigate further) + // on certain devices. + // https://issuetracker.google.com/issues/188096921 feedAdapter.notifyDataSetChanged() } @@ -195,7 +183,8 @@ class FeedFragment : Fragment(), BackPressedHandler { binding.emptyContainer.visibility = View.GONE coordinator.reset() feedAdapter.notifyDataSetChanged() - coordinator.more(app.wikiSite) + WikipediaApp.instance.resetWikiSite() + coordinator.more(WikipediaApp.instance.wikiSite) } fun updateHiddenCards() { @@ -349,6 +338,32 @@ class FeedFragment : Fragment(), BackPressedHandler { } } + private fun maybeShowRegionalLanguageVariantDialog() { + val deprecatedLanguageCodes = listOf(AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE, AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE) + val primaryLanguage = WikipediaApp.instance.languageState.appLanguageCode + val remainingLanguages = WikipediaApp.instance.languageState.appLanguageCodes.toMutableList().apply { + remove(primaryLanguage) + } + if (deprecatedLanguageCodes.contains(primaryLanguage)) { + val dialog = RegionalLanguageVariantSelectionDialog(requireContext()).show() + dialog.setOnDismissListener { + refresh() + } + } else if (remainingLanguages.any(deprecatedLanguageCodes::contains)) { + MaterialAlertDialogBuilder(requireContext()) + .setCancelable(false) + .setTitle(R.string.feed_language_variants_removal_secondary_dialog_title) + .setMessage(R.string.feed_language_variants_removal_secondary_dialog_message) + .setPositiveButton(R.string.feed_language_variants_removal_secondary_dialog_settings) { _, _ -> + val list = RegionalLanguageVariantSelectionDialog.removeNonRegionalLanguageVariants() + WikipediaApp.instance.languageState.setAppLanguageCodes(list) + refresh() + showLanguagesActivity(InvokeSource.FEED) + } + .show() + } + } + private fun showConfigureActivity(invokeSource: Int) { requestFeedConfigurationLauncher.launch(ConfigureActivity.newIntent(requireActivity(), invokeSource)) } 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..c8ea8dbfea7 100644 --- a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt +++ b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt @@ -2,6 +2,7 @@ package org.wikipedia.feed.aggregated import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.feed.image.FeaturedImage import org.wikipedia.feed.news.NewsItem @@ -9,10 +10,12 @@ 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 +) { + @Transient var randomOnThisDayEvent: OnThisDay.Event? = 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..8f21111d120 100644 --- a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt +++ b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt @@ -1,49 +1,52 @@ 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.awaitAll +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.feed.FeedContentType -import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient import org.wikipedia.feed.featured.FeaturedArticleCard import org.wikipedia.feed.image.FeaturedImageCard import org.wikipedia.feed.model.Card import org.wikipedia.feed.news.NewsCard +import org.wikipedia.feed.onthisday.OnThisDay import org.wikipedia.feed.onthisday.OnThisDayCard +import org.wikipedia.feed.topread.TopRead import org.wikipedia.feed.topread.TopReadListCard import org.wikipedia.util.DateUtil +import org.wikipedia.util.L10nUtil 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) { + class OnThisDayFeed(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient) : BaseClient(coroutineScope, aggregatedClient) { override fun getCardFromResponse(responses: Map, wiki: WikiSite, age: Int, outCards: MutableList) { for (appLangCode in WikipediaApp.instance.languageState.appLanguageCodes) { if (responses.containsKey(appLangCode) && !FeedContentType.ON_THIS_DAY.langCodesDisabled.contains(appLangCode)) { - responses[appLangCode]?.onthisday?.let { - if (it.isNotEmpty()) { - outCards.add(OnThisDayCard(it, WikiSite.forLanguageCode(appLangCode), age)) - } + responses[appLangCode]?.randomOnThisDayEvent?.let { + outCards.add(OnThisDayCard(it, WikiSite.forLanguageCode(appLangCode), age)) } } } } } - class InTheNews(aggregatedClient: AggregatedFeedContentClient) : BaseClient(aggregatedClient) { + class InTheNews(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient) : BaseClient(coroutineScope, aggregatedClient) { override fun getCardFromResponse(responses: Map, wiki: WikiSite, age: Int, @@ -58,7 +61,7 @@ class AggregatedFeedContentClient { } } - class FeaturedArticle(aggregatedClient: AggregatedFeedContentClient) : BaseClient(aggregatedClient) { + class FeaturedArticle(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient) : BaseClient(coroutineScope, aggregatedClient) { override fun getCardFromResponse(responses: Map, wiki: WikiSite, age: Int, @@ -73,7 +76,7 @@ class AggregatedFeedContentClient { } } - class TopReadArticles(aggregatedClient: AggregatedFeedContentClient) : BaseClient(aggregatedClient) { + class TopReadArticles(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient) : BaseClient(coroutineScope, aggregatedClient) { override fun getCardFromResponse(responses: Map, wiki: WikiSite, age: Int, @@ -93,7 +96,7 @@ class AggregatedFeedContentClient { } } - class FeaturedImage(aggregatedClient: AggregatedFeedContentClient) : BaseClient(aggregatedClient) { + class FeaturedImage(coroutineScope: CoroutineScope, aggregatedClient: AggregatedFeedContentClient) : BaseClient(coroutineScope, aggregatedClient) { override fun getCardFromResponse(responses: Map, wiki: WikiSite, age: Int, @@ -110,11 +113,10 @@ class AggregatedFeedContentClient { aggregatedResponseAge = -1 } - fun cancel() { - disposables.clear() - } - - abstract class BaseClient internal constructor(private val aggregatedClient: AggregatedFeedContentClient) : FeedClient { + abstract class BaseClient internal constructor( + private val coroutineScope: CoroutineScope, + private val aggregatedClient: AggregatedFeedContentClient + ) : FeedClient { private lateinit var cb: FeedClient.Callback private lateinit var wiki: WikiSite private var age = 0 @@ -128,7 +130,7 @@ class AggregatedFeedContentClient { if (aggregatedClient.aggregatedResponseAge == age && aggregatedClient.aggregatedResponses.containsKey(wiki.languageCode)) { val cards = mutableListOf() getCardFromResponse(aggregatedClient.aggregatedResponses, wiki, age, cards) - FeedCoordinator.postCardsToCallback(cb, cards) + cb.success(cards) } else { requestAggregated() } @@ -137,31 +139,84 @@ 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.clientJob = coroutineScope.launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.error(caught) + } + ) { + val cards = mutableListOf() + val deferredResponses = WikipediaApp.instance.languageState.appLanguageCodes.map { langCode -> + async { + 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) + + feedContentResponse.randomOnThisDayEvent = feedContentResponse.onthisday?.random() + + // TODO: This is a temporary fix for T355192 + feedContentResponse = handleLanguageVariant(feedContentResponse, wikiSite, hasParentLanguageCode) + + aggregatedClient.aggregatedResponses[langCode] = feedContentResponse aggregatedClient.aggregatedResponseAge = age } - if (aggregatedClient.aggregatedResponses.containsKey(wiki.languageCode)) { - getCardFromResponse(aggregatedClient.aggregatedResponses, wiki, age, cards) + } + + deferredResponses.awaitAll() + + if (aggregatedClient.aggregatedResponses.containsKey(wiki.languageCode)) { + getCardFromResponse(aggregatedClient.aggregatedResponses, wiki, age, cards) + } + cb.success(cards) + } + } + + private suspend fun handleLanguageVariant(feedContentResponse: AggregatedFeedContent, + wikiSite: WikiSite, + hasParentLanguageCode: Boolean): AggregatedFeedContent { + return withContext(Dispatchers.IO) { + var response = feedContentResponse + if (hasParentLanguageCode) { + val tfaDeferred = feedContentResponse.tfa?.let { + async { L10nUtil.getPagesForLanguageVariant(listOf(it), wikiSite, shouldUpdateExtracts = true).first() } } - FeedCoordinator.postCardsToCallback(cb, cards) - }) { caught -> - L.v(caught) - cb.error(caught) - }) + + val topReadDeferred = feedContentResponse.topRead?.let { + async { L10nUtil.getPagesForLanguageVariant(it.articles, wikiSite) } + } + + // Same logic in OnThisDayCardView and we only need to send one page to the event class. + val randomOnThisDayPageDeferred = feedContentResponse.randomOnThisDayEvent?.pages?.find { it.thumbnailUrl != null }?.let { + async { L10nUtil.getPagesForLanguageVariant(listOf(it), wikiSite) } + } + + val tfaResponse = tfaDeferred?.await() + val topReadResponse = topReadDeferred?.await() + val randomOnThisDayPageResponse = randomOnThisDayPageDeferred?.await() + + response = AggregatedFeedContent( + tfa = tfaResponse ?: feedContentResponse.tfa, + news = feedContentResponse.news, + topRead = topReadResponse?.let { + TopRead( + feedContentResponse.topRead.date, + it + ) + } ?: feedContentResponse.topRead, + potd = feedContentResponse.potd, + onthisday = feedContentResponse.onthisday + ).apply { + randomOnThisDayEvent = OnThisDay.Event( + pages = randomOnThisDayPageResponse ?: feedContentResponse.randomOnThisDayEvent?.pages ?: emptyList(), + text = feedContentResponse.randomOnThisDayEvent?.text ?: "", + year = feedContentResponse.randomOnThisDayEvent?.year ?: 0 + ) + } + } + response + } } } } 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..3ff26c8edbb 100644 --- a/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt +++ b/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt @@ -2,14 +2,14 @@ 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.Job +import kotlinx.coroutines.launch import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite -import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient import org.wikipedia.feed.model.Card import org.wikipedia.settings.Prefs @@ -18,25 +18,27 @@ import org.wikipedia.util.ReleaseUtil import org.wikipedia.util.log.L import java.util.Date -class AnnouncementClient : FeedClient { +class AnnouncementClient( + private val coroutineScope: CoroutineScope +) : 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.launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.error(caught) + } + ) { + val announcementsResponse = ServiceFactory.getRest(wiki).getAnnouncements() + cb.success(buildCards(announcementsResponse.items)) + } } override fun cancel() { - disposables.clear() + clientJob?.cancel() } companion object { diff --git a/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookie.java b/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookie.java deleted file mode 100644 index 48088eb54a4..00000000000 --- a/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookie.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.wikipedia.feed.announcement; - -import android.location.Location; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public class GeoIPCookie { - - @NonNull private final String country; - @NonNull private final String region; - @NonNull private final String city; - @Nullable private final Location location; - - GeoIPCookie(@NonNull String country, @NonNull String region, @NonNull String city, @Nullable Location location) { - this.country = country; - this.region = region; - this.city = city; - this.location = location; - } - - @NonNull - public String country() { - return country; - } - - @NonNull - public String region() { - return region; - } - - @NonNull - public String city() { - return city; - } - - @Nullable - public Location location() { - return location; - } -} diff --git a/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookie.kt b/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookie.kt new file mode 100644 index 00000000000..d45006ab10e --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookie.kt @@ -0,0 +1,10 @@ +package org.wikipedia.feed.announcement + +import android.location.Location + +class GeoIPCookie( + val country: String, + val region: String, + val city: String, + val location: Location? +) diff --git a/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookieUnmarshaller.kt b/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookieUnmarshaller.kt index 962e5941903..9dba289d8e1 100644 --- a/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookieUnmarshaller.kt +++ b/app/src/main/java/org/wikipedia/feed/announcement/GeoIPCookieUnmarshaller.kt @@ -15,7 +15,7 @@ object GeoIPCookieUnmarshaller { private const val COOKIE_NAME = "GeoIP" fun unmarshal(): GeoIPCookie { - return unmarshal(SharedPreferenceCookieManager.instance.getCookieByName(COOKIE_NAME)) + return unmarshal(SharedPreferenceCookieManager.instance.getCookieValueByName(COOKIE_NAME)) } @VisibleForTesting 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..981446c8c38 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,10 @@ 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.Job +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -12,70 +12,59 @@ 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.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient -import org.wikipedia.history.HistoryEntry +import org.wikipedia.util.L10nUtil import org.wikipedia.util.StringUtil +import org.wikipedia.util.log.L -class BecauseYouReadClient : FeedClient { - - private val disposables = CompositeDisposable() - +class BecauseYouReadClient( + private val coroutineScope: CoroutineScope +) : FeedClient { + 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.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) - 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 { - if (it.title != searchTerm) { - relatedPages.add(PageSummary(it.displayTitle(langCode), it.title, it.description, - it.extract, it.thumbUrl(), langCode)) - } + var relatedPages = moreLikeResponse.query?.pages?.filter { it.title != searchTerm }?.map { + 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 (hasParentLanguageCode && relatedPages != null) { + relatedPages = L10nUtil.getPagesForLanguageVariant(relatedPages, entry.title.wikiSite) + } + + cb.success( + relatedPages?.let { + listOf(toBecauseYouReadCard(it, headerPage, entry.title.wikiSite)) + } ?: emptyList() ) - }) { 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/configure/ConfigureFragment.kt b/app/src/main/java/org/wikipedia/feed/configure/ConfigureFragment.kt index 221e94127a3..624a67f85ea 100644 --- a/app/src/main/java/org/wikipedia/feed/configure/ConfigureFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/configure/ConfigureFragment.kt @@ -1,39 +1,46 @@ package org.wikipedia.feed.configure import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup import androidx.appcompat.app.AppCompatActivity import androidx.core.view.MenuProvider +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil import org.wikipedia.databinding.FragmentFeedConfigureBinding -import org.wikipedia.dataclient.ServiceFactory -import org.wikipedia.dataclient.WikiSite import org.wikipedia.feed.FeedContentType import org.wikipedia.settings.Prefs import org.wikipedia.settings.SettingsActivity +import org.wikipedia.util.Resource import org.wikipedia.util.log.L import org.wikipedia.views.DefaultViewHolder import org.wikipedia.views.DrawableItemDecoration -import java.util.* +import java.util.Collections class ConfigureFragment : Fragment(), MenuProvider, ConfigureItemView.Callback { private var _binding: FragmentFeedConfigureBinding? = null private val binding get() = _binding!! + private val viewModel: ConfigureViewModel by viewModels() private lateinit var itemTouchHelper: ItemTouchHelper private val orderedContentTypes = mutableListOf() - private val disposables = CompositeDisposable() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentFeedConfigureBinding.inflate(inflater, container, false) @@ -42,35 +49,6 @@ class ConfigureFragment : Fragment(), MenuProvider, ConfigureItemView.Callback { setupRecyclerView() - disposables.add(ServiceFactory.getRest(WikiSite("wikimedia.org")).feedAvailability - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { prepareContentTypeList() } - .subscribe({ result -> - // apply the new availability rules to our content types - FeedContentType.NEWS.langCodesSupported.clear() - if (isLimitedToDomains(result.news)) { - addDomainNamesAsLangCodes(FeedContentType.NEWS.langCodesSupported, result.news) - } - FeedContentType.ON_THIS_DAY.langCodesSupported.clear() - if (isLimitedToDomains(result.onThisDay)) { - addDomainNamesAsLangCodes(FeedContentType.ON_THIS_DAY.langCodesSupported, result.onThisDay) - } - FeedContentType.TOP_READ_ARTICLES.langCodesSupported.clear() - if (isLimitedToDomains(result.mostRead)) { - addDomainNamesAsLangCodes(FeedContentType.TOP_READ_ARTICLES.langCodesSupported, result.mostRead) - } - FeedContentType.FEATURED_ARTICLE.langCodesSupported.clear() - if (isLimitedToDomains(result.featuredArticle)) { - addDomainNamesAsLangCodes(FeedContentType.FEATURED_ARTICLE.langCodesSupported, result.featuredArticle) - } - FeedContentType.FEATURED_IMAGE.langCodesSupported.clear() - if (isLimitedToDomains(result.featuredPicture)) { - addDomainNamesAsLangCodes(FeedContentType.FEATURED_IMAGE.langCodesSupported, result.featuredPicture) - } - FeedContentType.saveState() - }) { caught -> L.e(caught) }) - return binding.root } @@ -79,9 +57,21 @@ class ConfigureFragment : Fragment(), MenuProvider, ConfigureItemView.Callback { FeedContentType.saveState() } - override fun onDestroyView() { - disposables.clear() - super.onDestroyView() + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> onLoading() + is Resource.Success -> prepareContentTypeList() + is Resource.Error -> onError(it.throwable) + } + } + } + } + } } override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -93,13 +83,13 @@ class ConfigureFragment : Fragment(), MenuProvider, ConfigureItemView.Callback { R.id.menu_feed_configure_select_all -> { FeedContentType.entries.map { it.isEnabled = true } touch() - binding.contentTypesRecycler.adapter?.notifyDataSetChanged() + binding.contentTypesRecycler.adapter?.notifyItemRangeChanged(0, orderedContentTypes.size) true } R.id.menu_feed_configure_deselect_all -> { FeedContentType.entries.map { it.isEnabled = false } touch() - binding.contentTypesRecycler.adapter?.notifyDataSetChanged() + binding.contentTypesRecycler.adapter?.notifyItemRangeChanged(0, orderedContentTypes.size) true } R.id.menu_feed_configure_reset -> { @@ -114,6 +104,8 @@ class ConfigureFragment : Fragment(), MenuProvider, ConfigureItemView.Callback { } private fun prepareContentTypeList() { + binding.progressBar.isVisible = false + binding.contentTypesRecycler.isVisible = true orderedContentTypes.clear() orderedContentTypes.addAll(FeedContentType.entries) orderedContentTypes.sortBy { it.order } @@ -138,7 +130,7 @@ class ConfigureFragment : Fragment(), MenuProvider, ConfigureItemView.Callback { i.remove() } } - binding.contentTypesRecycler.adapter?.notifyDataSetChanged() + binding.contentTypesRecycler.adapter?.notifyItemRangeChanged(0, orderedContentTypes.size) } private fun setupRecyclerView() { @@ -151,6 +143,16 @@ class ConfigureFragment : Fragment(), MenuProvider, ConfigureItemView.Callback { itemTouchHelper.attachToRecyclerView(binding.contentTypesRecycler) } + private fun onLoading() { + binding.progressBar.isVisible = true + binding.contentTypesRecycler.isVisible = false + } + + private fun onError(throwable: Throwable) { + L.e(throwable) + prepareContentTypeList() + } + override fun onCheckedChanged(contentType: FeedContentType, checked: Boolean) { touch() contentType.isEnabled = checked @@ -240,14 +242,6 @@ class ConfigureFragment : Fragment(), MenuProvider, ConfigureItemView.Callback { } companion object { - private fun isLimitedToDomains(domainNames: List): Boolean { - return domainNames.isNotEmpty() && !domainNames[0].contains("*") - } - - private fun addDomainNamesAsLangCodes(outList: MutableList, domainNames: List) { - outList.addAll(domainNames.map { WikiSite(it).languageCode }) - } - fun newInstance(): ConfigureFragment { return ConfigureFragment() } diff --git a/app/src/main/java/org/wikipedia/feed/configure/ConfigureViewModel.kt b/app/src/main/java/org/wikipedia/feed/configure/ConfigureViewModel.kt new file mode 100644 index 00000000000..a8a98e65ab2 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/configure/ConfigureViewModel.kt @@ -0,0 +1,65 @@ +package org.wikipedia.feed.configure + +import androidx.lifecycle.ViewModel +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.WikipediaApp +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.feed.FeedContentType +import org.wikipedia.util.Resource + +class ConfigureViewModel() : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + loadFeedAvailability() + } + + private fun loadFeedAvailability() { + _uiState.value = Resource.Loading() + viewModelScope.launch(handler) { + val result = ServiceFactory.getRest(WikipediaApp.instance.wikiSite).feedAvailability() + // apply the new availability rules to our content types + FeedContentType.NEWS.langCodesSupported.clear() + if (isLimitedToDomains(result.news)) { + addDomainNamesAsLangCodes(FeedContentType.NEWS.langCodesSupported, result.news) + } + FeedContentType.ON_THIS_DAY.langCodesSupported.clear() + if (isLimitedToDomains(result.onThisDay)) { + addDomainNamesAsLangCodes(FeedContentType.ON_THIS_DAY.langCodesSupported, result.onThisDay) + } + FeedContentType.TOP_READ_ARTICLES.langCodesSupported.clear() + if (isLimitedToDomains(result.mostRead)) { + addDomainNamesAsLangCodes(FeedContentType.TOP_READ_ARTICLES.langCodesSupported, result.mostRead) + } + FeedContentType.FEATURED_ARTICLE.langCodesSupported.clear() + if (isLimitedToDomains(result.featuredArticle)) { + addDomainNamesAsLangCodes(FeedContentType.FEATURED_ARTICLE.langCodesSupported, result.featuredArticle) + } + FeedContentType.FEATURED_IMAGE.langCodesSupported.clear() + if (isLimitedToDomains(result.featuredPicture)) { + addDomainNamesAsLangCodes(FeedContentType.FEATURED_IMAGE.langCodesSupported, result.featuredPicture) + } + FeedContentType.saveState() + _uiState.value = Resource.Success(true) + } + } + + private fun isLimitedToDomains(domainNames: List): Boolean { + return domainNames.isNotEmpty() && !domainNames[0].contains("*") + } + + private fun addDomainNamesAsLangCodes(outList: MutableList, domainNames: List) { + outList.addAll(domainNames.map { WikiSite(it).languageCode }) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/dataclient/DummyClient.kt b/app/src/main/java/org/wikipedia/feed/dataclient/DummyClient.kt index af38d313fba..1d69279233e 100644 --- a/app/src/main/java/org/wikipedia/feed/dataclient/DummyClient.kt +++ b/app/src/main/java/org/wikipedia/feed/dataclient/DummyClient.kt @@ -2,19 +2,13 @@ package org.wikipedia.feed.dataclient import android.content.Context import org.wikipedia.dataclient.WikiSite -import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.model.Card -import org.wikipedia.feed.searchbar.SearchCard abstract class DummyClient : FeedClient { override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { getNewCard(wiki).let { - if (it is SearchCard) { - cb.success(listOf(it)) - } else { - FeedCoordinator.postCardsToCallback(cb, listOf(it)) - } + cb.success(listOf(it)) } } 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/mainpage/MainPageClient.kt b/app/src/main/java/org/wikipedia/feed/mainpage/MainPageClient.kt index 1e124e0f270..3693b5df891 100644 --- a/app/src/main/java/org/wikipedia/feed/mainpage/MainPageClient.kt +++ b/app/src/main/java/org/wikipedia/feed/mainpage/MainPageClient.kt @@ -4,7 +4,6 @@ import android.content.Context import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.WikiSite import org.wikipedia.feed.FeedContentType -import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient class MainPageClient : FeedClient { @@ -13,7 +12,7 @@ class MainPageClient : FeedClient { val cards = WikipediaApp.instance.languageState.appLanguageCodes .filterNot { FeedContentType.MAIN_PAGE.langCodesDisabled.contains(it) } .map { MainPageCard(WikiSite.forLanguageCode(it)) } - FeedCoordinator.postCardsToCallback(cb, cards) + cb.success(cards) } override fun cancel() {} diff --git a/app/src/main/java/org/wikipedia/feed/model/CardType.kt b/app/src/main/java/org/wikipedia/feed/model/CardType.kt index d562fb1f042..03908abbc0e 100644 --- a/app/src/main/java/org/wikipedia/feed/model/CardType.kt +++ b/app/src/main/java/org/wikipedia/feed/model/CardType.kt @@ -13,6 +13,7 @@ import org.wikipedia.feed.mainpage.MainPageCardView import org.wikipedia.feed.news.NewsCardView import org.wikipedia.feed.offline.OfflineCardView import org.wikipedia.feed.onthisday.OnThisDayCardView +import org.wikipedia.feed.places.PlacesCardView import org.wikipedia.feed.progress.ProgressCardView import org.wikipedia.feed.random.RandomCardView import org.wikipedia.feed.searchbar.SearchCardView @@ -103,6 +104,11 @@ enum class CardType constructor(private val code: Int, return AccessibilityCardView(ctx) } }, + PLACES(23, FeedContentType.PLACES) { + override fun newView(ctx: Context): FeedCardView<*> { + return PlacesCardView(ctx) + } + }, // TODO: refactor this item when the new Modern Event Platform is finished. ARTICLE_ANNOUNCEMENT(96) { override fun newView(ctx: Context): FeedCardView<*> { diff --git a/app/src/main/java/org/wikipedia/feed/news/NewsFragment.kt b/app/src/main/java/org/wikipedia/feed/news/NewsFragment.kt index 2378682ca36..b825e0a7efc 100644 --- a/app/src/main/java/org/wikipedia/feed/news/NewsFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/news/NewsFragment.kt @@ -13,6 +13,7 @@ import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.appbar.AppBarLayout import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R @@ -39,7 +40,16 @@ class NewsFragment : Fragment() { private var _binding: FragmentNewsBinding? = null private val binding get() = _binding!! - private val viewModel: NewsViewModel by viewModels { NewsViewModel.Factory(requireArguments()) } + private val viewModel: NewsViewModel by viewModels() + + private val offsetChangedListener = + AppBarLayout.OnOffsetChangedListener { layout: AppBarLayout, offset: Int -> + DeviceUtil.updateStatusBarTheme( + requireActivity(), binding.toolbar, + layout.totalScrollRange + offset > layout.totalScrollRange / 2 + ) + (requireActivity() as NewsActivity).updateNavigationBarColor() + } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) @@ -59,13 +69,7 @@ class NewsFragment : Fragment() { binding.headerImageView.loadImage(imageUri) DeviceUtil.updateStatusBarTheme(requireActivity(), binding.toolbar, true) - binding.appBarLayout.addOnOffsetChangedListener { layout, offset -> - DeviceUtil.updateStatusBarTheme( - requireActivity(), binding.toolbar, - layout.totalScrollRange + offset > layout.totalScrollRange / 2 - ) - (requireActivity() as NewsActivity).updateNavigationBarColor() - } + binding.appBarLayout.addOnOffsetChangedListener(offsetChangedListener) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { binding.toolbarContainer.setStatusBarScrimColor(ResourceUtil.getThemedColor(requireContext(), R.attr.paper_color)) } @@ -80,6 +84,7 @@ class NewsFragment : Fragment() { } override fun onDestroyView() { + binding.appBarLayout.removeOnOffsetChangedListener(offsetChangedListener) _binding = null super.onDestroyView() } diff --git a/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt b/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt index 95a2d0bc45e..5880e959644 100644 --- a/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt +++ b/app/src/main/java/org/wikipedia/feed/news/NewsItem.kt @@ -13,11 +13,11 @@ import org.wikipedia.util.ImageUrlUtil @Serializable class NewsItem( val story: String = "", - val links: List = emptyList() + val links: List = emptyList() ) : Parcelable { fun linkCards(wiki: WikiSite): List { - return links.filterNotNull().map { NewsLinkCard(it, wiki) } + return links.map { NewsLinkCard(it, wiki) } } fun thumb(): Uri? { @@ -27,7 +27,7 @@ class NewsItem( } } - private fun getFirstImageUri(links: List): Uri? { - return links.firstOrNull { !it?.thumbnailUrl.isNullOrEmpty() }?.run { Uri.parse(thumbnailUrl) } + private fun getFirstImageUri(links: List): Uri? { + return links.firstOrNull { !it.thumbnailUrl.isNullOrEmpty() }?.run { Uri.parse(thumbnailUrl) } } } diff --git a/app/src/main/java/org/wikipedia/feed/news/NewsLinkCard.kt b/app/src/main/java/org/wikipedia/feed/news/NewsLinkCard.kt index 71a7c3fe7f8..37e1f8eb4b6 100644 --- a/app/src/main/java/org/wikipedia/feed/news/NewsLinkCard.kt +++ b/app/src/main/java/org/wikipedia/feed/news/NewsLinkCard.kt @@ -8,7 +8,6 @@ import org.wikipedia.feed.model.Card import org.wikipedia.feed.model.CardType import org.wikipedia.page.PageTitle import org.wikipedia.util.ImageUrlUtil -import org.wikipedia.util.ImageUrlUtil.getUrlForPreferredSize class NewsLinkCard(private val page: PageSummary, private val wiki: WikiSite) : Card() { diff --git a/app/src/main/java/org/wikipedia/feed/news/NewsViewModel.kt b/app/src/main/java/org/wikipedia/feed/news/NewsViewModel.kt index 27536bd91ce..1cbeac16780 100644 --- a/app/src/main/java/org/wikipedia/feed/news/NewsViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/news/NewsViewModel.kt @@ -1,20 +1,11 @@ package org.wikipedia.feed.news -import android.os.Bundle +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import org.wikipedia.Constants import org.wikipedia.dataclient.WikiSite -import org.wikipedia.extensions.parcelable -class NewsViewModel(bundle: Bundle) : ViewModel() { - val item = bundle.parcelable(NewsActivity.EXTRA_NEWS_ITEM)!! - val wiki = bundle.parcelable(Constants.ARG_WIKISITE)!! - - class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { - @Suppress("unchecked_cast") - override fun create(modelClass: Class): T { - return NewsViewModel(bundle) as T - } - } +class NewsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val item = savedStateHandle.get(NewsActivity.EXTRA_NEWS_ITEM)!! + val wiki = savedStateHandle.get(Constants.ARG_WIKISITE)!! } diff --git a/app/src/main/java/org/wikipedia/feed/onboarding/OnboardingClient.kt b/app/src/main/java/org/wikipedia/feed/onboarding/OnboardingClient.kt index 1bacc013bf2..86ab5193ab0 100644 --- a/app/src/main/java/org/wikipedia/feed/onboarding/OnboardingClient.kt +++ b/app/src/main/java/org/wikipedia/feed/onboarding/OnboardingClient.kt @@ -3,7 +3,6 @@ package org.wikipedia.feed.onboarding import android.content.Context import org.wikipedia.R import org.wikipedia.dataclient.WikiSite -import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.announcement.Announcement import org.wikipedia.feed.dataclient.FeedClient import org.wikipedia.feed.model.Card @@ -13,7 +12,7 @@ import org.wikipedia.util.UriUtil class OnboardingClient : FeedClient { override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { - FeedCoordinator.postCardsToCallback(cb, listOfNotNull(getCards(context).getOrNull(age))) + cb.success(listOfNotNull(getCards(context).getOrNull(age))) } private fun getCards(context: Context): List { diff --git a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDay.kt b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDay.kt index f5fe543309c..1e7a79acd55 100644 --- a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDay.kt +++ b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDay.kt @@ -10,21 +10,15 @@ class OnThisDay { private val births: List = emptyList() private val deaths: List = emptyList() private val holidays: List = emptyList() - var selected: List = emptyList() fun allEvents(): List { return (events + births + deaths + holidays).sortedByDescending { it.year } } @Serializable - class Event { - - private val pages: List? = null - val text = "" - val year = 0 - - fun pages(): List? { - return pages?.filterNotNull() - } - } + class Event( + val pages: List = emptyList(), + val text: String = "", + val year: Int = 0 + ) } diff --git a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayCard.kt b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayCard.kt index 3a8f3cacf18..dd13487d55f 100644 --- a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayCard.kt +++ b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayCard.kt @@ -8,24 +8,13 @@ import org.wikipedia.feed.model.WikiSiteCard import org.wikipedia.feed.view.FeedAdapter import org.wikipedia.util.DateUtil import org.wikipedia.util.L10nUtil -import java.util.* +import java.util.Calendar import java.util.concurrent.TimeUnit -class OnThisDayCard(events: List, wiki: WikiSite, val age: Int) : WikiSiteCard(wiki) { - private val nextYear: Int +class OnThisDayCard(val event: OnThisDay.Event, wiki: WikiSite, val age: Int) : WikiSiteCard(wiki) { private val date: Calendar = DateUtil.getDefaultDateFor(age) - private val eventShownOnCard: OnThisDay.Event var callback: FeedAdapter.Callback? = null - init { - var randomIndex = 0 - if (events.size > 1) { - randomIndex = Random().nextInt(events.size - 1) - } - eventShownOnCard = events[randomIndex] - nextYear = events.getOrElse(randomIndex + 1) { eventShownOnCard }.year - } - override fun type(): CardType { return CardType.ON_THIS_DAY } @@ -47,18 +36,18 @@ class OnThisDayCard(events: List, wiki: WikiSite, val age: Int) } fun text(): CharSequence { - return eventShownOnCard.text + return event.text } fun year(): Int { - return eventShownOnCard.year + return event.year } fun date(): Calendar { return date } - fun pages(): List? { - return eventShownOnCard.pages() + fun pages(): List { + return event.pages } } diff --git a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayCardView.kt b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayCardView.kt index 4245318922c..96acbbcb03f 100644 --- a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayCardView.kt +++ b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayCardView.kt @@ -6,7 +6,6 @@ import android.content.Context import android.net.Uri import android.view.LayoutInflater import android.view.View -import androidx.appcompat.app.AppCompatActivity import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.databinding.ViewCardOnThisDayBinding @@ -91,67 +90,67 @@ class OnThisDayCardView(context: Context) : DefaultFeedCardView(c private fun updateOtdEventUI(card: OnThisDayCard) { binding.eventLayout.pagesPager.visibility = GONE - card.pages()?.let { pages -> - binding.eventLayout.onThisDayPage.root.visibility = VISIBLE - val chosenPage = pages.find { it.thumbnailUrl != null } - chosenPage?.let { page -> - if (page.thumbnailUrl.isNullOrEmpty()) { - binding.eventLayout.onThisDayPage.imageContainer.visibility = GONE + if (card.pages().isEmpty()) { + binding.eventLayout.onThisDayPage.root.visibility = GONE + return + } + binding.eventLayout.onThisDayPage.root.visibility = VISIBLE + val chosenPage = card.pages().find { it.thumbnailUrl != null } + chosenPage?.let { page -> + if (page.thumbnailUrl.isNullOrEmpty()) { + binding.eventLayout.onThisDayPage.imageContainer.visibility = GONE + } else { + binding.eventLayout.onThisDayPage.imageContainer.visibility = VISIBLE + binding.eventLayout.onThisDayPage.image.loadImage(Uri.parse(page.thumbnailUrl)) + ImageZoomHelper.setViewZoomable(binding.eventLayout.onThisDayPage.image) + } + binding.eventLayout.onThisDayPage.description.text = page.description + binding.eventLayout.onThisDayPage.description.visibility = + if (page.description.isNullOrEmpty()) GONE else VISIBLE + binding.eventLayout.onThisDayPage.title.maxLines = + if (page.description.isNullOrEmpty()) 2 else 1 + binding.eventLayout.onThisDayPage.title.text = StringUtil.fromHtml(page.displayTitle) + binding.eventLayout.onThisDayPage.root.setOnClickListener { + callback?.onSelectPage(card, + HistoryEntry(page.getPageTitle(card.wikiSite()), HistoryEntry.SOURCE_ON_THIS_DAY_CARD), + TransitionUtil.getSharedElements(context, binding.eventLayout.onThisDayPage.image) + ) + } + binding.eventLayout.onThisDayPage.root.setOnLongClickListener { view -> + if (ImageZoomHelper.isZooming) { + ImageZoomHelper.dispatchCancelEvent(binding.eventLayout.onThisDayPage.root) } else { - binding.eventLayout.onThisDayPage.imageContainer.visibility = VISIBLE - binding.eventLayout.onThisDayPage.image.loadImage(Uri.parse(page.thumbnailUrl)) - ImageZoomHelper.setViewZoomable(binding.eventLayout.onThisDayPage.image) - } - binding.eventLayout.onThisDayPage.description.text = page.description - binding.eventLayout.onThisDayPage.description.visibility = - if (page.description.isNullOrEmpty()) GONE else VISIBLE - binding.eventLayout.onThisDayPage.title.maxLines = - if (page.description.isNullOrEmpty()) 2 else 1 - binding.eventLayout.onThisDayPage.title.text = StringUtil.fromHtml(page.displayTitle) - binding.eventLayout.onThisDayPage.root.setOnClickListener { - callback?.onSelectPage(card, - HistoryEntry(page.getPageTitle(card.wikiSite()), HistoryEntry.SOURCE_ON_THIS_DAY_CARD), - TransitionUtil.getSharedElements(context, binding.eventLayout.onThisDayPage.image) - ) - } - binding.eventLayout.onThisDayPage.root.setOnLongClickListener { view -> - if (ImageZoomHelper.isZooming) { - ImageZoomHelper.dispatchCancelEvent(binding.eventLayout.onThisDayPage.root) - } else { - val pageTitle = page.getPageTitle(card.wikiSite()) - val entry = HistoryEntry(pageTitle, HistoryEntry.SOURCE_ON_THIS_DAY_CARD) - LongPressMenu(view, callback = object : LongPressMenu.Callback { - override fun onOpenLink(entry: HistoryEntry) { - callback?.onSelectPage( - card, - entry, - TransitionUtil.getSharedElements( - context, - binding.eventLayout.onThisDayPage.image - ) + val pageTitle = page.getPageTitle(card.wikiSite()) + val entry = HistoryEntry(pageTitle, HistoryEntry.SOURCE_ON_THIS_DAY_CARD) + LongPressMenu(view, callback = object : LongPressMenu.Callback { + override fun onOpenLink(entry: HistoryEntry) { + callback?.onSelectPage( + card, + entry, + TransitionUtil.getSharedElements( + context, + binding.eventLayout.onThisDayPage.image ) - } + ) + } - override fun onOpenInNewTab(entry: HistoryEntry) { - callback?.onSelectPage(card, entry, true) - } + override fun onOpenInNewTab(entry: HistoryEntry) { + callback?.onSelectPage(card, entry, true) + } - override fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) { - ReadingListBehaviorsUtil.addToDefaultList(context as AppCompatActivity, entry.title, addToDefault, InvokeSource.ON_THIS_DAY_CARD_BODY) - } + override fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) { + ReadingListBehaviorsUtil.addToDefaultList(context as Activity, entry.title, addToDefault, InvokeSource.ON_THIS_DAY_CARD_BODY) + } - override fun onMoveRequest(page: ReadingListPage?, entry: HistoryEntry) { - page?.let { - ReadingListBehaviorsUtil.moveToList(context as AppCompatActivity, page.listId, entry.title, InvokeSource.ON_THIS_DAY_CARD_BODY) - } + override fun onMoveRequest(page: ReadingListPage?, entry: HistoryEntry) { + page?.let { + ReadingListBehaviorsUtil.moveToList(context as Activity, page.listId, entry.title, InvokeSource.ON_THIS_DAY_CARD_BODY) } - }).show(entry) - } - true + } + }).show(entry) } + true } - } ?: run { - binding.eventLayout.onThisDayPage.root.visibility = GONE } } } diff --git a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayFragment.kt b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayFragment.kt index 2c2440d1a5a..f2a33d3d4e3 100644 --- a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayFragment.kt @@ -1,191 +1,181 @@ package org.wikipedia.feed.onthisday -import android.app.Activity import android.os.Bundle +import android.util.TypedValue import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.animation.AnimationUtils import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf +import androidx.core.widget.TextViewCompat import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener +import com.google.android.material.appbar.AppBarLayout import com.google.android.material.tabs.TabLayoutMediator -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -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.databinding.FragmentOnThisDayBinding import org.wikipedia.databinding.ViewEventsLayoutBinding -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.DateUtil +import org.wikipedia.util.Resource import org.wikipedia.util.ResourceUtil import org.wikipedia.util.log.L import org.wikipedia.views.CustomDatePicker import org.wikipedia.views.HeaderMarginItemDecoration -import java.util.* +import java.util.Calendar +import java.util.Locale import kotlin.math.abs class OnThisDayFragment : Fragment(), CustomDatePicker.Callback { private var _binding: FragmentOnThisDayBinding? = null private val binding get() = _binding!! + private val viewModel: OnThisDayViewModel by viewModels() - private lateinit var date: Calendar - private lateinit var wiki: WikiSite - private lateinit var invokeSource: InvokeSource - private var yearOnCardView = 0 - private var positionToScrollTo = 0 - private val disposables = CompositeDisposable() - private val appCompatActivity get() = requireActivity() as AppCompatActivity - private var onThisDay: OnThisDay? = null + private val offsetChangedListener = + AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> + binding.headerFrameLayout.alpha = 1.0f - abs(verticalOffset / appBarLayout.totalScrollRange.toFloat()) + if (verticalOffset > -appBarLayout.totalScrollRange) { + binding.dropDownToolbar.visibility = View.GONE + } else if (verticalOffset <= -appBarLayout.totalScrollRange) { + binding.dropDownToolbar.visibility = View.VISIBLE + } + val newText = if (verticalOffset <= -appBarLayout.totalScrollRange) DateUtil.getMonthOnlyDateString(viewModel.date.time) else "" + if (newText != binding.toolbarDay.text.toString()) { + appBarLayout.post { binding.toolbarDay.text = newText } + } + } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { - _binding = FragmentOnThisDayBinding.inflate(inflater, container, false) - - val topDecorationDp = 24 - val age = requireArguments().getInt(OnThisDayActivity.EXTRA_AGE, 0) - wiki = requireArguments().parcelable(Constants.ARG_WIKISITE)!! - invokeSource = requireArguments().getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as InvokeSource - date = DateUtil.getDefaultDateFor(age) - yearOnCardView = requireArguments().getInt(OnThisDayActivity.EXTRA_YEAR, -1) - setUpToolbar() binding.eventsRecycler.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false) - binding.eventsRecycler.addItemDecoration(HeaderMarginItemDecoration(topDecorationDp, 0)) - setUpRecycler(binding.eventsRecycler) - setUpTransitionAnimation(savedInstanceState, age) - binding.progressBar.visibility = View.GONE - binding.eventsRecycler.visibility = View.GONE - binding.errorView.visibility = View.GONE + binding.eventsRecycler.addItemDecoration(HeaderMarginItemDecoration(24, 0)) + binding.eventsRecycler.isNestedScrollingEnabled = true + binding.eventsRecycler.clipToPadding = false binding.errorView.backClickListener = View.OnClickListener { requireActivity().finish() } binding.dayContainer.setOnClickListener { onCalendarClicked() } binding.toolbarDayContainer.setOnClickListener { onCalendarClicked() } binding.indicatorLayout.setOnClickListener { onIndicatorLayoutClicked() } + setUpToolbar() + setUpTransitionAnimation(savedInstanceState) return binding.root } - private fun setUpTransitionAnimation(savedInstanceState: Bundle?, age: Int) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + 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) + } + } + } + } + } + } + + private fun onLoading() { + binding.progressBar.visibility = View.VISIBLE + binding.eventsRecycler.visibility = View.GONE + binding.errorView.visibility = View.GONE + } + + private fun setUpTransitionAnimation(savedInstanceState: Bundle?) { val animDelay = if (requireActivity().window.sharedElementEnterTransition != null && savedInstanceState == null) 500L else 0L binding.onThisDayTitleView.postDelayed({ if (!isAdded) { return@postDelayed } - updateContents(age) + viewModel.loadOnThisDay() }, animDelay) } - private fun updateContents(age: Int) { - val today = DateUtil.getDefaultDateFor(age) - requestEvents(today[Calendar.MONTH], today[Calendar.DATE]) + private fun onSuccess(events: List) { + binding.progressBar.visibility = View.GONE + binding.eventsRecycler.visibility = View.VISIBLE + binding.errorView.visibility = View.GONE + binding.eventsRecycler.adapter = RecyclerAdapter(events, viewModel.wikiSite) + binding.dayInfo.text = getString(R.string.events_count_text, events.size.toString(), + DateUtil.yearToStringWithEra(events.last().year), events[0].year) + + val positionToScrollTo = events.indices.find { viewModel.year == events[it].year } ?: 0 + binding.eventsRecycler.postDelayed({ + if (isAdded && positionToScrollTo != -1 && viewModel.year != -1) { + binding.eventsRecycler.scrollToPosition(positionToScrollTo) + } + }, 500) } - private fun requestEvents(month: Int, date: Int) { - binding.progressBar.visibility = View.VISIBLE + private fun onError(throwable: Throwable) { + L.e(throwable) + binding.progressBar.visibility = View.GONE binding.eventsRecycler.visibility = View.GONE - binding.errorView.visibility = View.GONE - disposables.add(ServiceFactory.getRest(wiki).getOnThisDay(month + 1, date) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { - if (!isAdded) { - return@doAfterTerminate - } - binding.progressBar.visibility = View.GONE - binding.eventsRecycler.postDelayed({ - if (positionToScrollTo != -1 && yearOnCardView != -1) { - binding.eventsRecycler.scrollToPosition(positionToScrollTo) - } - }, 500) - } - .subscribe({ response -> - onThisDay = response - onThisDay?.let { onThisDayResponse -> - binding.eventsRecycler.visibility = View.VISIBLE - binding.eventsRecycler.adapter = RecyclerAdapter(onThisDayResponse.allEvents(), wiki) - val events = onThisDayResponse.allEvents() - positionToScrollTo = events.indices.find { yearOnCardView == events[it].year } ?: 0 - val beginningYear = events.last().year - binding.dayInfo.text = getString(R.string.events_count_text, events.size.toString(), - DateUtil.yearToStringWithEra(beginningYear), events[0].year) - } - }) { throwable -> - L.e(throwable) - binding.errorView.setError(throwable) - binding.errorView.visibility = View.VISIBLE - binding.eventsRecycler.visibility = View.GONE - }) + binding.errorView.visibility = View.VISIBLE + binding.errorView.setError(throwable) } private fun setUpToolbar() { - appCompatActivity.setSupportActionBar(binding.toolbar) - appCompatActivity.supportActionBar?.setDisplayHomeAsUpEnabled(true) - appCompatActivity.supportActionBar?.title = "" + (requireActivity() as AppCompatActivity).apply { + setSupportActionBar(binding.toolbar) + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = "" + } binding.collapsingToolbarLayout.setCollapsedTitleTextColor( ResourceUtil.getThemedColor(requireContext(), R.attr.primary_color) ) - binding.day.text = DateUtil.getMonthOnlyDateString(date.time) + binding.day.text = DateUtil.getMonthOnlyDateString(viewModel.date.time) maybeHideDateIndicator() - binding.appBar.addOnOffsetChangedListener(OnOffsetChangedListener { appBarLayout, verticalOffset -> - binding.headerFrameLayout.alpha = 1.0f - abs(verticalOffset / appBarLayout.totalScrollRange.toFloat()) - if (verticalOffset > -appBarLayout.totalScrollRange) { - binding.dropDownToolbar.visibility = View.GONE - } else if (verticalOffset <= -appBarLayout.totalScrollRange) { - binding.dropDownToolbar.visibility = View.VISIBLE - } - val newText = if (verticalOffset <= -appBarLayout.totalScrollRange) DateUtil.getMonthOnlyDateString(date.time) else "" - if (newText != binding.toolbarDay.text.toString()) { - appBarLayout.post { binding.toolbarDay.text = newText } - } - }) + binding.appBar.addOnOffsetChangedListener(offsetChangedListener) } private fun maybeHideDateIndicator() { binding.indicatorLayout.visibility = - if (date[Calendar.MONTH] == Calendar.getInstance()[Calendar.MONTH] && - date[Calendar.DATE] == Calendar.getInstance()[Calendar.DATE]) View.GONE else View.VISIBLE + if (viewModel.date[Calendar.MONTH] == Calendar.getInstance()[Calendar.MONTH] && + viewModel.date[Calendar.DATE] == Calendar.getInstance()[Calendar.DATE]) View.GONE else View.VISIBLE binding.indicatorDate.text = String.format(Locale.getDefault(), "%d", Calendar.getInstance()[Calendar.DATE]) + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration(binding.indicatorDate, 4, 10, 1, TypedValue.COMPLEX_UNIT_SP) } override fun onDestroyView() { - disposables.clear() binding.eventsRecycler.adapter = null + binding.appBar.removeOnOffsetChangedListener(offsetChangedListener) _binding = null super.onDestroyView() } - private fun setUpRecycler(recycler: RecyclerView) { - recycler.isNestedScrollingEnabled = true - recycler.clipToPadding = false - } - - override fun onDatePicked(month: Int, day: Int) { + override fun onDatePicked(calendar: Calendar) { binding.eventsRecycler.visibility = View.GONE - date[CustomDatePicker.LEAP_YEAR, month, day, 0] = 0 - binding.day.text = DateUtil.getMonthOnlyDateString(date.time) + viewModel.date[CustomDatePicker.LEAP_YEAR, calendar[Calendar.MONTH], calendar[Calendar.DATE], 0] = 0 + binding.day.text = DateUtil.getMonthOnlyDateString(viewModel.date.time) binding.appBar.setExpanded(true) - requestEvents(month, day) + viewModel.loadOnThisDay(calendar) maybeHideDateIndicator() } private fun onCalendarClicked() { val newFragment = CustomDatePicker() - newFragment.setSelectedDay(date[Calendar.MONTH], date[Calendar.DATE]) + newFragment.setSelectedDay(viewModel.date[Calendar.MONTH], viewModel.date[Calendar.DATE]) newFragment.callback = this@OnThisDayFragment - newFragment.show(parentFragmentManager, "date picker") + newFragment.show(parentFragmentManager, "datePicker") } private fun onIndicatorLayoutClicked() { - onDatePicked(Calendar.getInstance()[Calendar.MONTH], Calendar.getInstance()[Calendar.DATE]) + onDatePicked(Calendar.getInstance()) } private inner class RecyclerAdapter(private val events: List, @@ -242,22 +232,20 @@ class OnThisDayFragment : Fragment(), CustomDatePicker.Callback { } private fun setPagesViewPager(event: OnThisDay.Event) { - event.pages()?.let { - val viewPagerAdapter = ViewPagerAdapter(childFragmentManager, it, wiki) - otdEventLayout.pagesPager.adapter = viewPagerAdapter - otdEventLayout.pagesPager.offscreenPageLimit = 2 - TabLayoutMediator(otdEventLayout.pagesIndicator, otdEventLayout.pagesPager) { _, _ -> }.attach() - otdEventLayout.pagesPager.visibility = View.VISIBLE - otdEventLayout.pagesIndicator.visibility = if (it.size == 1) View.GONE else View.VISIBLE - } ?: run { + if (event.pages.isEmpty()) { otdEventLayout.pagesPager.visibility = View.GONE otdEventLayout.pagesIndicator.visibility = View.GONE + return } + otdEventLayout.pagesPager.adapter = ViewPagerAdapter(event.pages, wiki) + otdEventLayout.pagesPager.offscreenPageLimit = 2 + TabLayoutMediator(otdEventLayout.pagesIndicator, otdEventLayout.pagesPager) { _, _ -> }.attach() + otdEventLayout.pagesPager.visibility = View.VISIBLE + otdEventLayout.pagesIndicator.visibility = if (event.pages.size == 1) View.GONE else View.VISIBLE } fun animateRadioButton() { - val pulse = AnimationUtils.loadAnimation(context, R.anim.pulse) - otdEventLayout.radioImageView.startAnimation(pulse) + otdEventLayout.radioImageView.startAnimation(AnimationUtils.loadAnimation(context, R.anim.pulse)) } } @@ -271,12 +259,11 @@ class OnThisDayFragment : Fragment(), CustomDatePicker.Callback { } } - internal inner class ViewPagerAdapter(private val fragmentManager: FragmentManager, - private val pages: List, private val wiki: WikiSite) : RecyclerView.Adapter() { + internal inner class ViewPagerAdapter(private val pages: List, private val wiki: WikiSite) : RecyclerView.Adapter() { override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): OnThisDayPagesViewHolder { val itemView = LayoutInflater.from(viewGroup.context).inflate(R.layout.item_on_this_day_pages, viewGroup, false) - return OnThisDayPagesViewHolder((viewGroup.context as Activity), fragmentManager, itemView, wiki) + return OnThisDayPagesViewHolder((viewGroup.context as AppCompatActivity), itemView, wiki) } override fun onBindViewHolder(onThisDayPagesViewHolder: OnThisDayPagesViewHolder, i: Int) { @@ -294,10 +281,12 @@ class OnThisDayFragment : Fragment(), CustomDatePicker.Callback { fun newInstance(age: Int, wikiSite: WikiSite, year: Int, invokeSource: InvokeSource): OnThisDayFragment { return OnThisDayFragment().apply { - arguments = bundleOf(OnThisDayActivity.EXTRA_AGE to age, + arguments = bundleOf( + OnThisDayActivity.EXTRA_AGE to age, Constants.ARG_WIKISITE to wikiSite, OnThisDayActivity.EXTRA_YEAR to year, - Constants.INTENT_EXTRA_INVOKE_SOURCE to invokeSource) + Constants.INTENT_EXTRA_INVOKE_SOURCE to invokeSource + ) } } } diff --git a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayPagesViewHolder.kt b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayPagesViewHolder.kt index c5e00a2c35a..93477addc91 100644 --- a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayPagesViewHolder.kt +++ b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayPagesViewHolder.kt @@ -1,12 +1,11 @@ package org.wikipedia.feed.onthisday -import android.app.Activity import android.app.ActivityOptions import android.net.Uri import android.view.View import android.widget.FrameLayout import android.widget.TextView -import androidx.fragment.app.FragmentManager +import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.RecyclerView import org.wikipedia.Constants import org.wikipedia.R @@ -26,8 +25,7 @@ import org.wikipedia.util.TransitionUtil import org.wikipedia.views.FaceAndColorDetectImageView class OnThisDayPagesViewHolder( - private val activity: Activity, - private val fragmentManager: FragmentManager, + private val activity: AppCompatActivity, v: View, private val wiki: WikiSite ) : RecyclerView.ViewHolder(v) { diff --git a/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayViewModel.kt b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayViewModel.kt new file mode 100644 index 00000000000..abf07f6ad0a --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/onthisday/OnThisDayViewModel.kt @@ -0,0 +1,38 @@ +package org.wikipedia.feed.onthisday + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.util.DateUtil +import org.wikipedia.util.Resource +import java.util.Calendar + +class OnThisDayViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + + val wikiSite = savedStateHandle.get(Constants.ARG_WIKISITE)!! + val age = savedStateHandle[OnThisDayActivity.EXTRA_AGE] ?: 0 + val year = savedStateHandle[OnThisDayActivity.EXTRA_YEAR] ?: 0 + val invokeSource = savedStateHandle.get(Constants.INTENT_EXTRA_INVOKE_SOURCE)!! + val date = DateUtil.getDefaultDateFor(age) + + private val _uiState = MutableStateFlow(Resource>()) + val uiState = _uiState.asStateFlow() + + fun loadOnThisDay(calendar: Calendar = DateUtil.getDefaultDateFor(age)) { + _uiState.value = Resource.Loading() + viewModelScope.launch(handler) { + val response = ServiceFactory.getRest(wikiSite).getOnThisDay(calendar[Calendar.MONTH] + 1, calendar[Calendar.DATE]) + _uiState.value = Resource.Success(response.allEvents()) + } + } +} diff --git a/app/src/main/java/org/wikipedia/feed/places/PlacesCard.kt b/app/src/main/java/org/wikipedia/feed/places/PlacesCard.kt new file mode 100644 index 00000000000..97de094dce5 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/places/PlacesCard.kt @@ -0,0 +1,30 @@ +package org.wikipedia.feed.places + +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.NearbyPage +import org.wikipedia.feed.model.CardType +import org.wikipedia.feed.model.WikiSiteCard +import org.wikipedia.util.DateUtil + +class PlacesCard(wiki: WikiSite, + val age: Int, + val nearbyPage: NearbyPage? = null) : WikiSiteCard(wiki) { + + override fun type(): CardType { + return CardType.PLACES + } + + override fun title(): String { + return WikipediaApp.instance.getString(R.string.places_card_title) + } + + override fun subtitle(): String { + return DateUtil.getFeedCardDateString(age) + } + + fun footerActionText(): String { + return WikipediaApp.instance.getString(R.string.places_card_action) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/places/PlacesCardView.kt b/app/src/main/java/org/wikipedia/feed/places/PlacesCardView.kt new file mode 100644 index 00000000000..3a11849ab14 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/places/PlacesCardView.kt @@ -0,0 +1,117 @@ +package org.wikipedia.feed.places + +import android.content.Context +import android.location.Location +import android.view.LayoutInflater +import androidx.core.view.isVisible +import org.wikipedia.R +import org.wikipedia.analytics.eventplatform.PlacesEvent +import org.wikipedia.databinding.ViewPlacesCardBinding +import org.wikipedia.feed.view.CardFooterView +import org.wikipedia.feed.view.DefaultFeedCardView +import org.wikipedia.feed.view.FeedAdapter +import org.wikipedia.history.HistoryEntry +import org.wikipedia.page.PageTitle +import org.wikipedia.places.PlacesActivity +import org.wikipedia.readinglist.LongPressMenu +import org.wikipedia.readinglist.database.ReadingListPage +import org.wikipedia.settings.Prefs +import org.wikipedia.util.GeoUtil +import org.wikipedia.util.StringUtil +import org.wikipedia.views.ViewUtil +import java.util.Locale + +class PlacesCardView(context: Context) : DefaultFeedCardView(context) { + + private val binding = ViewPlacesCardBinding.inflate(LayoutInflater.from(context), this, true) + + override var card: PlacesCard? = null + set(value) { + field = value + value?.let { + header(it) + footer(it) + updateContents(it) + } + } + + override var callback: FeedAdapter.Callback? = null + set(value) { + field = value + binding.cardHeader.setCallback(value) + } + + private fun updateContents(card: PlacesCard) { + card.nearbyPage?.let { + binding.placesEnableLocationContainer.isVisible = false + binding.placesArticleContainer.isVisible = true + binding.placesCardTitle.text = StringUtil.fromHtml(it.pageTitle.displayText) + binding.placesCardDescription.text = StringUtil.fromHtml(it.pageTitle.description) + binding.placesCardDescription.isVisible = !it.pageTitle.description.isNullOrEmpty() + it.pageTitle.thumbUrl?.let { url -> + ViewUtil.loadImage(binding.placesCardThumbnail, url, circleShape = true) + } + Prefs.placesLastLocationAndZoomLevel?.first?.let { location -> + if (GeoUtil.isSamePlace(location.latitude, it.location.latitude, location.longitude, it.location.longitude)) { + binding.placesCardDistance.text = context.getString(R.string.places_card_distance_unknown) + } else { + val distanceText = GeoUtil.getDistanceWithUnit(location, it.location, Locale.getDefault()) + binding.placesCardDistance.text = context.getString(R.string.places_card_distance_suffix, distanceText) + } + } + binding.placesCardContainer.setOnClickListener { _ -> + goToPlaces(it.pageTitle, it.location) + } + binding.placesCardContainer.setOnLongClickListener { view -> + PlacesEvent.logAction("places_click", "explore_feed_more_menu") + LongPressMenu(view, openPageInPlaces = true, location = it.location, callback = object : LongPressMenu.Callback { + override fun onOpenInPlaces(entry: HistoryEntry, location: Location) { + goToPlaces(entry.title, location) + } + + override fun onOpenInNewTab(entry: HistoryEntry) { + callback?.onSelectPage(card, entry, true) + } + + override fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) { + callback?.onAddPageToList(entry, addToDefault) + } + + override fun onMoveRequest(page: ReadingListPage?, entry: HistoryEntry) { + callback?.onMovePageToList(page!!.listId, entry) + } + }).show(HistoryEntry(it.pageTitle, HistoryEntry.SOURCE_FEED_PLACES)) + + false + } + } ?: run { + binding.placesEnableLocationContainer.isVisible = true + binding.placesArticleContainer.isVisible = false + binding.placesCardContainer.setOnClickListener { + goToPlaces() + } + binding.placesEnableLocationButton.setOnClickListener { + goToPlaces() + } + } + } + + private fun header(card: PlacesCard) { + binding.cardHeader.setTitle(card.title()) + .setCard(card) + .setLangCode(null) + .setCallback(callback) + } + + private fun footer(card: PlacesCard) { + binding.cardFooter.callback = CardFooterView.Callback { + PlacesEvent.logAction("places_click", "explore_feed") + goToPlaces() + } + binding.cardFooter.setFooterActionText(card.footerActionText(), card.wikiSite().languageCode) + } + + private fun goToPlaces(pageTitle: PageTitle? = null, location: Location? = null) { + context.startActivity(PlacesActivity.newIntent(context, pageTitle, location)) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/places/PlacesFeedClient.kt b/app/src/main/java/org/wikipedia/feed/places/PlacesFeedClient.kt new file mode 100644 index 00000000000..d1889a8989d --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/places/PlacesFeedClient.kt @@ -0,0 +1,53 @@ +package org.wikipedia.feed.places + +import android.content.Context +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.NearbyPage +import org.wikipedia.feed.dataclient.FeedClient +import org.wikipedia.page.PageTitle +import org.wikipedia.places.PlacesFragment +import org.wikipedia.settings.Prefs +import org.wikipedia.util.GeoUtil +import org.wikipedia.util.ImageUrlUtil + +class PlacesFeedClient( + private val coroutineScope: CoroutineScope +) : FeedClient { + + private lateinit var cb: FeedClient.Callback + private var age: Int = 0 + private var clientJob: Job? = null + + override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { + this.age = age + this.cb = cb + + Prefs.placesLastLocationAndZoomLevel?.let { + coroutineScope.launch(CoroutineExceptionHandler { _, throwable -> + cb.error(throwable) + }) { + val location = it.first + val response = ServiceFactory.get(wiki).getGeoSearch("${location.latitude}|${location.longitude}", 10000, 10, 10) + val lastPage = response.query?.pages.orEmpty() + .filter { it.coordinates != null && !GeoUtil.isSamePlace(location.latitude, it.coordinates[0].lat, location.longitude, it.coordinates[0].lon) } + .map { + NearbyPage(it.pageId, PageTitle(it.title, wiki, + if (it.thumbUrl().isNullOrEmpty()) null else ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl()!!, PlacesFragment.THUMB_SIZE), + it.description, it.displayTitle(wiki.languageCode)), it.coordinates!![0].lat, it.coordinates[0].lon) + }[age % 10] + cb.success(listOf(PlacesCard(wiki, age, lastPage))) + } + } ?: run { + cb.success(listOf(PlacesCard(wiki, age))) + } + } + + override fun cancel() { + clientJob?.cancel() + } +} 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..28c499aa106 100644 --- a/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt +++ b/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt @@ -1,53 +1,58 @@ 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.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch +import org.wikipedia.WikipediaApp 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 import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.util.log.L -class RandomClient : FeedClient { +class RandomClient( + private val coroutineScope: CoroutineScope +) : 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.launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.error(caught) } + ) { + val deferredSummaries = WikipediaApp.instance.languageState.appLanguageCodes + .filter { !FeedContentType.RANDOM.langCodesDisabled.contains(it) } + .map { lang -> + async { + val wikiSite = WikiSite.forLanguageCode(lang) + val randomSummary = try { + ServiceFactory.getRest(wikiSite).getRandomSummary() + } catch (e: Exception) { + AppDatabase.instance.readingListPageDao().getRandomPage(lang)?.let { + ReadingListPage.toPageSummary(it) + } + } + randomSummary?.let { + RandomCard(it, age, wikiSite) + } + } + } + + cb.success(deferredSummaries.awaitAll().filterNotNull()) + } } override fun cancel() { - disposables.clear() + clientJob?.cancel() } } diff --git a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCard.kt b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCard.kt index daea4170a49..bef929fc7e6 100644 --- a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCard.kt +++ b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCard.kt @@ -3,13 +3,12 @@ package org.wikipedia.feed.suggestededits import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.WikiSite -import org.wikipedia.dataclient.mwapi.MwQueryPage +import org.wikipedia.descriptions.DescriptionEditActivity import org.wikipedia.feed.model.CardType import org.wikipedia.feed.model.WikiSiteCard import org.wikipedia.util.DateUtil -class SuggestedEditsCard(val summaryList: List?, - val imageTagsPage: MwQueryPage?, +class SuggestedEditsCard(val cardTypes: List, wiki: WikiSite, val age: Int) : WikiSiteCard(wiki) { 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..9031fe25e43 100644 --- a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt @@ -1,6 +1,7 @@ package org.wikipedia.feed.suggestededits import android.app.Activity.RESULT_OK +import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.LayoutInflater @@ -8,9 +9,15 @@ import android.view.View import android.view.View.GONE import android.view.View.VISIBLE import android.view.ViewGroup +import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts 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 kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource.FEED import org.wikipedia.R @@ -18,100 +25,86 @@ import org.wikipedia.WikipediaApp import org.wikipedia.commons.FilePageActivity import org.wikipedia.databinding.FragmentSuggestedEditsCardItemBinding 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 import org.wikipedia.gallery.GalleryActivity import org.wikipedia.history.HistoryEntry -import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle -import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.suggestededits.SuggestedEditsImageTagEditActivity import org.wikipedia.suggestededits.SuggestedEditsSnackbars import org.wikipedia.util.ImageUrlUtil import org.wikipedia.util.L10nUtil +import org.wikipedia.util.Resource import org.wikipedia.util.StringUtil class SuggestedEditsCardItemFragment : Fragment() { private var _binding: FragmentSuggestedEditsCardItemBinding? = null private val binding get() = _binding!! + private val viewModel: SuggestedEditsCardItemViewModel by viewModels() - private lateinit var cardActionType: Action - private var age = 0 - private var app = WikipediaApp.instance - private var appLanguages = app.languageState.appLanguageCodes - private var langFromCode: String = appLanguages[0] - private var targetLanguage: String? = null - private var sourceSummaryForEdit: PageSummaryForEdit? = null - private var targetSummaryForEdit: PageSummaryForEdit? = null - private var imageTagPage: MwQueryPage? = null private var itemClickable = false - private var previousImageTagPage: MwQueryPage? = null - private var previousSourceSummaryForEdit: PageSummaryForEdit? = null - - private val requestSuggestedEditsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - if (isAdded) { - previousImageTagPage = imageTagPage - previousSourceSummaryForEdit = sourceSummaryForEdit - - val openPageListener = SuggestedEditsSnackbars.OpenPageListener { - if (cardActionType === ADD_IMAGE_TAGS) { - startActivity(FilePageActivity.newIntent(requireActivity(), PageTitle(previousImageTagPage!!.title, WikiSite(appLanguages[0])))) - return@OpenPageListener - } - val pageTitle = previousSourceSummaryForEdit!!.pageTitle - if (cardActionType === ADD_CAPTION || cardActionType === TRANSLATE_CAPTION) { - startActivity(GalleryActivity.newIntent(requireActivity(), pageTitle, pageTitle.prefixedText, pageTitle.wikiSite, 0, GalleryActivity.SOURCE_NON_LEAD_IMAGE)) - } else { - startActivity(PageActivity.newIntentForNewTab(requireContext(), HistoryEntry(pageTitle, HistoryEntry.SOURCE_SUGGESTED_EDITS), pageTitle)) - } - } - SuggestedEditsSnackbars.show(requireActivity(), cardActionType, true, targetLanguage, true, openPageListener) - showCardContent() - } - } - } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - arguments?.let { - age = it.getInt(AGE) - val pageSummary = it.parcelable(PAGE_SUMMARY) - if (pageSummary != null) { - sourceSummaryForEdit = pageSummary.sourceSummaryForEdit - targetSummaryForEdit = pageSummary.targetSummaryForEdit - cardActionType = pageSummary.cardActionType - targetLanguage = targetSummaryForEdit?.lang - } else { - cardActionType = ADD_IMAGE_TAGS - imageTagPage = JsonUtil.decodeFromString(it.getString(IMAGE_TAG_PAGE)) - } - } - } + private var requestSuggestedEditsLauncher: ActivityResultLauncher? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentSuggestedEditsCardItemBinding.inflate(inflater, container, false) + initRequestLauncher() return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - updateContents() + + binding.seCardErrorView.backClickListener = View.OnClickListener { viewModel.fetchCardData() } + binding.seCardErrorView.retryClickListener = View.OnClickListener { viewModel.fetchCardData() } + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> onLoading() + is Resource.Success -> updateContents() + is Resource.Error -> onError(it.throwable) + } + } + } + } + } + } + + private fun initRequestLauncher() { + requestSuggestedEditsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) { + if (isAdded) { + val openPageListener = SuggestedEditsSnackbars.OpenPageListener { + if (viewModel.cardActionType === ADD_IMAGE_TAGS) { + startActivity(FilePageActivity.newIntent(requireActivity(), PageTitle(viewModel.imageTagPage?.title, WikiSite(WikipediaApp.instance.appOrSystemLanguageCode)))) + return@OpenPageListener + } + val pageTitle = viewModel.sourceSummaryForEdit!!.pageTitle + if (viewModel.cardActionType === ADD_CAPTION || viewModel.cardActionType === TRANSLATE_CAPTION) { + startActivity(GalleryActivity.newIntent(requireActivity(), pageTitle, pageTitle.prefixedText, pageTitle.wikiSite, 0)) + } else { + startActivity(PageActivity.newIntentForNewTab(requireContext(), HistoryEntry(pageTitle, HistoryEntry.SOURCE_SUGGESTED_EDITS), pageTitle)) + } + } + SuggestedEditsSnackbars.show(requireActivity(), viewModel.cardActionType, true, viewModel.targetSummaryForEdit?.lang, true, openPageListener) + showCardContent() + } + } + } } private fun updateContents() { binding.cardItemContainer.setOnClickListener(startDescriptionEditScreenListener()) binding.callToActionButton.setOnClickListener(startDescriptionEditScreenListener()) - binding.seCardErrorView.backClickListener = View.OnClickListener { - binding.seCardErrorView.visibility = GONE - showCardContent() - } showCardContent() } @@ -125,34 +118,34 @@ class SuggestedEditsCardItemFragment : Fragment() { if (!isAdded) { return } - if (cardActionType == ADD_IMAGE_TAGS) { - imageTagPage?.let { - requestSuggestedEditsLauncher.launch(SuggestedEditsImageTagEditActivity.newIntent(requireActivity(), it, FEED)) + if (viewModel.cardActionType == ADD_IMAGE_TAGS) { + viewModel.imageTagPage?.let { + requestSuggestedEditsLauncher?.launch(SuggestedEditsImageTagEditActivity.newIntent(requireActivity(), it, FEED)) } return } - sourceSummaryForEdit?.let { - val pageTitle = if (cardActionType == TRANSLATE_DESCRIPTION || cardActionType == TRANSLATE_CAPTION) targetSummaryForEdit!!.pageTitle else it.pageTitle - requestSuggestedEditsLauncher.launch(DescriptionEditActivity.newIntent( - requireContext(), pageTitle, null, sourceSummaryForEdit, targetSummaryForEdit, cardActionType, FEED + viewModel.sourceSummaryForEdit?.let { + val pageTitle = if (viewModel.cardActionType == TRANSLATE_DESCRIPTION || viewModel.cardActionType == TRANSLATE_CAPTION) viewModel.targetSummaryForEdit!!.pageTitle else it.pageTitle + requestSuggestedEditsLauncher?.launch(DescriptionEditActivity.newIntent( + requireContext(), pageTitle, null, viewModel.sourceSummaryForEdit, viewModel.targetSummaryForEdit, viewModel.cardActionType, FEED )) } } private fun showCardContent() { - if (!isAdded || (cardActionType != ADD_IMAGE_TAGS && sourceSummaryForEdit == null)) { + if (!isAdded || (viewModel.cardActionType != ADD_IMAGE_TAGS && viewModel.sourceSummaryForEdit == null)) { return } itemClickable = true binding.seFeedCardProgressBar.visibility = GONE binding.seCardErrorView.visibility = GONE binding.callToActionButton.visibility = VISIBLE - sourceSummaryForEdit?.let { - val langCode = targetSummaryForEdit?.lang ?: it.lang + viewModel.sourceSummaryForEdit?.let { + val langCode = viewModel.targetSummaryForEdit?.lang ?: it.lang L10nUtil.setConditionalLayoutDirection(binding.cardView, langCode) } - when (cardActionType) { + when (viewModel.cardActionType) { TRANSLATE_DESCRIPTION -> showTranslateDescriptionUI() ADD_CAPTION -> showAddImageCaptionUI() TRANSLATE_CAPTION -> showTranslateImageCaptionUI() @@ -169,7 +162,7 @@ class SuggestedEditsCardItemFragment : Fragment() { private fun showImageTagsUI() { showAddImageCaptionUI() binding.callToActionButton.text = context?.getString(R.string.suggested_edits_feed_card_add_image_tags) - binding.viewArticleExtract.text = StringUtil.removeNamespace(imageTagPage!!.title) + binding.viewArticleExtract.text = StringUtil.removeNamespace(viewModel.imageTagPage!!.title) } private fun showAddDescriptionUI() { @@ -178,8 +171,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(viewModel.sourceSummaryForEdit?.displayTitle) + binding.viewArticleExtract.text = StringUtil.fromHtml(viewModel.sourceSummaryForEdit?.extract) binding.viewArticleExtract.maxLines = ARTICLE_EXTRACT_MAX_LINE_WITHOUT_IMAGE showItemImage() } @@ -187,44 +180,49 @@ class SuggestedEditsCardItemFragment : Fragment() { private fun showTranslateDescriptionUI() { showAddDescriptionUI() binding.callToActionButton.text = context?.getString(R.string.suggested_edits_feed_card_add_translation_in_language_button, - app.languageState.getAppLanguageCanonicalName(targetLanguage)) + WikipediaApp.instance.languageState.getAppLanguageCanonicalName(viewModel.targetSummaryForEdit?.lang)) binding.viewArticleSubtitle.visibility = VISIBLE - binding.viewArticleSubtitle.text = sourceSummaryForEdit?.description + binding.viewArticleSubtitle.text = viewModel.sourceSummaryForEdit?.description } private fun showAddImageCaptionUI() { binding.callToActionButton.text = context?.getString(R.string.suggested_edits_feed_card_add_image_caption) binding.viewArticleTitle.visibility = GONE binding.viewArticleExtract.visibility = VISIBLE - binding.viewArticleExtract.text = StringUtil.removeNamespace(sourceSummaryForEdit?.displayTitle.orEmpty()) + binding.viewArticleExtract.text = StringUtil.removeNamespace(viewModel.sourceSummaryForEdit?.displayTitle.orEmpty()) showItemImage() } private fun showTranslateImageCaptionUI() { showAddImageCaptionUI() binding.callToActionButton.text = context?.getString(R.string.suggested_edits_feed_card_translate_image_caption, - app.languageState.getAppLanguageCanonicalName(targetLanguage)) + WikipediaApp.instance.languageState.getAppLanguageCanonicalName(viewModel.targetSummaryForEdit?.lang)) binding.viewArticleSubtitle.visibility = VISIBLE - binding.viewArticleSubtitle.text = sourceSummaryForEdit?.description + binding.viewArticleSubtitle.text = viewModel.sourceSummaryForEdit?.description } private fun showItemImage() { binding.viewArticleImage.visibility = VISIBLE - if (cardActionType == ADD_IMAGE_TAGS) { + if (viewModel.cardActionType == ADD_IMAGE_TAGS) { binding.viewArticleImage.loadImage(Uri.parse(ImageUrlUtil.getUrlForPreferredSize - (imageTagPage!!.imageInfo()!!.thumbUrl, Constants.PREFERRED_CARD_THUMBNAIL_SIZE))) + (viewModel.imageTagPage!!.imageInfo()!!.thumbUrl, Constants.PREFERRED_CARD_THUMBNAIL_SIZE))) } else { - if (sourceSummaryForEdit!!.thumbnailUrl.isNullOrBlank()) { + if (viewModel.sourceSummaryForEdit!!.thumbnailUrl.isNullOrBlank()) { binding.viewArticleImage.visibility = GONE binding.viewArticleExtract.maxLines = ARTICLE_EXTRACT_MAX_LINE_WITHOUT_IMAGE } else { - binding.viewArticleImage.loadImage(Uri.parse(sourceSummaryForEdit!!.thumbnailUrl)) + binding.viewArticleImage.loadImage(Uri.parse(viewModel.sourceSummaryForEdit!!.thumbnailUrl)) binding.viewArticleExtract.maxLines = ARTICLE_EXTRACT_MAX_LINE_WITH_IMAGE } } } - fun showError(caught: Throwable?) { + fun onLoading() { + binding.seFeedCardProgressBar.visibility = VISIBLE + binding.seCardErrorView.visibility = GONE + } + + fun onError(caught: Throwable?) { binding.seFeedCardProgressBar.visibility = GONE binding.seCardErrorView.setError(caught) binding.seCardErrorView.visibility = VISIBLE @@ -232,14 +230,16 @@ class SuggestedEditsCardItemFragment : Fragment() { } companion object { - private const val AGE = "age" - private const val PAGE_SUMMARY = "pageSummary" - private const val IMAGE_TAG_PAGE = "imageTagPage" - const val MAX_RETRY_LIMIT: Long = 5 + const val EXTRA_AGE = "age" + const val EXTRA_ACTION_TYPE = "actionType" + const val MAX_RETRY_LIMIT = 5L - fun newInstance(age: Int, pageSummary: SuggestedEditsFeedClient.SuggestedEditsSummary?, imageTagPage: MwQueryPage?) = + fun newInstance(age: Int, cardActionType: DescriptionEditActivity.Action) = SuggestedEditsCardItemFragment().apply { - arguments = bundleOf(AGE to age, PAGE_SUMMARY to pageSummary, IMAGE_TAG_PAGE to JsonUtil.encodeToString(imageTagPage)) + arguments = bundleOf( + EXTRA_AGE to age, + EXTRA_ACTION_TYPE to cardActionType + ) } } } diff --git a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemViewModel.kt b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemViewModel.kt new file mode 100644 index 00000000000..6adc4ee8dbe --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemViewModel.kt @@ -0,0 +1,199 @@ +package org.wikipedia.feed.suggestededits + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.WikipediaApp +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.mwapi.MwQueryPage +import org.wikipedia.descriptions.DescriptionEditActivity +import org.wikipedia.page.Namespace +import org.wikipedia.page.PageTitle +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.suggestededits.provider.EditingSuggestionsProvider +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil + +class SuggestedEditsCardItemViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val age = savedStateHandle[SuggestedEditsCardItemFragment.EXTRA_AGE] ?: 0 + var cardActionType = savedStateHandle.get(SuggestedEditsCardItemFragment.EXTRA_ACTION_TYPE)!! + var sourceSummaryForEdit: PageSummaryForEdit? = null + var targetSummaryForEdit: PageSummaryForEdit? = null + var imageTagPage: MwQueryPage? = null + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + fetchCardData() + } + fun fetchCardData() { + _uiState.value = Resource.Loading() + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + // Give retry option to user in case of network error + _uiState.value = Resource.Error(throwable) + }) { + val appLanguages = WikipediaApp.instance.languageState.appLanguageCodes + val langFromCode = appLanguages.first() + val targetLanguage = appLanguages.getOrElse(age % appLanguages.size) { langFromCode } + if (appLanguages.size > 1) { + if (cardActionType == DescriptionEditActivity.Action.ADD_DESCRIPTION && targetLanguage != langFromCode) + cardActionType = DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION + if (cardActionType == DescriptionEditActivity.Action.ADD_CAPTION && targetLanguage != langFromCode) + cardActionType = DescriptionEditActivity.Action.TRANSLATE_CAPTION + } + when (cardActionType) { + DescriptionEditActivity.Action.ADD_DESCRIPTION -> { + sourceSummaryForEdit = addDescription(langFromCode) + } + DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION -> { + translateDescription(langFromCode, targetLanguage).let { + sourceSummaryForEdit = it.first + targetSummaryForEdit = it.second + } + } + DescriptionEditActivity.Action.ADD_CAPTION -> { + sourceSummaryForEdit = addCaption(langFromCode) + } + DescriptionEditActivity.Action.TRANSLATE_CAPTION -> { + translateCaption(langFromCode, targetLanguage)?.let { + sourceSummaryForEdit = it.first + targetSummaryForEdit = it.second + } + } + DescriptionEditActivity.Action.ADD_IMAGE_TAGS -> { + imageTagPage = addImageTags() + } + DescriptionEditActivity.Action.IMAGE_RECOMMENDATIONS -> { + // ignore + } + else -> { + // ignore + } + } + _uiState.value = Resource.Success(true) + } + } + + private suspend fun addDescription(langFromCode: String): PageSummaryForEdit { + val pageSummary = EditingSuggestionsProvider.getNextArticleWithMissingDescription( + WikiSite.forLanguageCode(langFromCode)) + + return PageSummaryForEdit( + pageSummary.apiTitle, + langFromCode, + pageSummary.getPageTitle(WikiSite.forLanguageCode(langFromCode)), + pageSummary.displayTitle, + pageSummary.description, + pageSummary.thumbnailUrl, + pageSummary.extract, + pageSummary.extractHtml + ) + } + + private suspend fun translateDescription(langFromCode: String, targetLanguage: String): Pair { + val pair = EditingSuggestionsProvider.getNextArticleWithMissingDescription( + WikiSite.forLanguageCode(langFromCode), targetLanguage) + val source = pair.first + val target = pair.second + + return PageSummaryForEdit( + source.apiTitle, + langFromCode, + source.getPageTitle(WikiSite.forLanguageCode(langFromCode)), + source.displayTitle, + source.description, + source.thumbnailUrl, + source.extract, + source.extractHtml + ) to PageSummaryForEdit( + target.apiTitle, + targetLanguage, + target.getPageTitle(WikiSite.forLanguageCode(targetLanguage)), + target.displayTitle, + target.description, + target.thumbnailUrl, + target.extract, + target.extractHtml + ) + } + + private suspend fun addCaption(langFromCode: String): PageSummaryForEdit? { + val title = EditingSuggestionsProvider.getNextImageWithMissingCaption(langFromCode, + SuggestedEditsCardItemFragment.MAX_RETRY_LIMIT) + val imageInfoResponse = ServiceFactory.get(Constants.commonsWikiSite).getImageInfo(title, langFromCode) + val page = imageInfoResponse.query?.firstPage() + return page?.imageInfo()?.let { + return@let PageSummaryForEdit( + page.title, langFromCode, + PageTitle( + Namespace.FILE.name, + StringUtil.removeNamespace(page.title), + null, + it.thumbUrl, + WikiSite.forLanguageCode(langFromCode)), + StringUtil.removeHTMLTags(page.title), + it.metadata!!.imageDescription(), + it.thumbUrl, + null, + null, + it.timestamp, + it.user, + it.metadata + ) + } + } + + private suspend fun translateCaption(langFromCode: String, targetLanguage: String): Pair? { + val pair = EditingSuggestionsProvider.getNextImageWithMissingCaption(langFromCode, targetLanguage, + SuggestedEditsCardItemFragment.MAX_RETRY_LIMIT + ) + val fileCaption = pair.first + val imageInfoResponse = ServiceFactory.get(Constants.commonsWikiSite).getImageInfo(pair.second, langFromCode) + val page = imageInfoResponse.query?.firstPage() + return page?.imageInfo()?.let { + val sourceSummaryForEdit = PageSummaryForEdit( + page.title, + langFromCode, + PageTitle( + Namespace.FILE.name, + StringUtil.removeNamespace(page.title), + null, + it.thumbUrl, + WikiSite.forLanguageCode(langFromCode) + ), + StringUtil.removeHTMLTags(page.title), + fileCaption, + it.thumbUrl, + null, + null, + it.timestamp, + it.user, + it.metadata + ) + val targetSummaryForEdit = sourceSummaryForEdit.copy( + description = null, + lang = targetLanguage, + pageTitle = PageTitle( + Namespace.FILE.name, + StringUtil.removeNamespace(page.title), + null, + it.thumbUrl, + WikiSite.forLanguageCode(targetLanguage) + ) + ) + return@let sourceSummaryForEdit to targetSummaryForEdit + } + } + + private suspend fun addImageTags(): MwQueryPage { + return EditingSuggestionsProvider + .getNextImageWithMissingTags(SuggestedEditsCardItemFragment.MAX_RETRY_LIMIT) + } +} diff --git a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardView.kt b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardView.kt index a36d5c441b2..eb53392ad1e 100644 --- a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardView.kt +++ b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardView.kt @@ -40,17 +40,13 @@ class SuggestedEditsCardView(context: Context) : DefaultFeedCardView }.attach() + binding.cardFooter.setFooterActionText(card.footerActionText(), null) } private fun header(card: SuggestedEditsCard) { @@ -60,13 +56,13 @@ class SuggestedEditsCardView(context: Context) : DefaultFeedCardView - getCardTypeAndData(DescriptionEditActivity.Action.ADD_CAPTION) { captionSummary, _ -> - getCardTypeAndData(DescriptionEditActivity.Action.ADD_IMAGE_TAGS) { _, imageTagsPage -> - FeedCoordinator.postCardsToCallback(cb, - listOf( - SuggestedEditsCard( - listOf(descriptionSummary!!, captionSummary!!), - imageTagsPage, - wiki, - age - ) - ) - ) - cancel() - } - } - } + cb.success(listOf(SuggestedEditsCard(listOf( + DescriptionEditActivity.Action.ADD_DESCRIPTION, + DescriptionEditActivity.Action.ADD_CAPTION, + DescriptionEditActivity.Action.ADD_IMAGE_TAGS + ), wiki, age))) } override fun cancel() { - disposables.clear() - } - - private fun getCardTypeAndData(cardActionType: DescriptionEditActivity.Action, clientCallback: ClientCallback) { - val suggestedEditsCard = SuggestedEditsSummary(cardActionType) - val langFromCode = appLanguages.first() - val targetLanguage = appLanguages.getOrElse(age % appLanguages.size) { langFromCode } - if (appLanguages.size > 1) { - if (cardActionType == DescriptionEditActivity.Action.ADD_DESCRIPTION && targetLanguage != langFromCode) - suggestedEditsCard.cardActionType = DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION - if (cardActionType == DescriptionEditActivity.Action.ADD_CAPTION && targetLanguage != langFromCode) - suggestedEditsCard.cardActionType = DescriptionEditActivity.Action.TRANSLATE_CAPTION - } - - when (suggestedEditsCard.cardActionType) { - DescriptionEditActivity.Action.ADD_DESCRIPTION -> addDescription(langFromCode, actionCallback(suggestedEditsCard, clientCallback)) - DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION -> translateDescription(langFromCode, targetLanguage, actionCallback(suggestedEditsCard, clientCallback)) - DescriptionEditActivity.Action.ADD_CAPTION -> addCaption(langFromCode, actionCallback(suggestedEditsCard, clientCallback)) - DescriptionEditActivity.Action.TRANSLATE_CAPTION -> translateCaption(langFromCode, targetLanguage, actionCallback(suggestedEditsCard, clientCallback)) - DescriptionEditActivity.Action.ADD_IMAGE_TAGS -> addImageTags(actionCallback(suggestedEditsCard, clientCallback)) - DescriptionEditActivity.Action.IMAGE_RECOMMENDATIONS -> clientCallback.onComplete(null, null) - else -> { clientCallback.onComplete(null, null) } - } - } - - private fun actionCallback(suggestedEditsCard: SuggestedEditsSummary, clientCallback: ClientCallback): Callback { - return object : Callback { - override fun onReceiveSource(pageSummaryForEdit: PageSummaryForEdit) { - suggestedEditsCard.sourceSummaryForEdit = pageSummaryForEdit - clientCallback.onComplete(suggestedEditsCard, null) - } - - override fun onReceiveTarget(pageSummaryForEdit: PageSummaryForEdit) { - suggestedEditsCard.targetSummaryForEdit = pageSummaryForEdit - clientCallback.onComplete(suggestedEditsCard, null) - } - - override fun onReceiveImageTag(imageTagPage: MwQueryPage) { - clientCallback.onComplete(null, imageTagPage) - } - } - } - - private fun addDescription(langFromCode: String, callback: Callback) { - disposables.add(EditingSuggestionsProvider.getNextArticleWithMissingDescription(WikiSite.forLanguageCode(langFromCode), - SuggestedEditsCardItemFragment.MAX_RETRY_LIMIT) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ pageSummary -> - callback.onReceiveSource( - PageSummaryForEdit( - pageSummary.apiTitle, - langFromCode, - pageSummary.getPageTitle(WikiSite.forLanguageCode(langFromCode)), - pageSummary.displayTitle, - pageSummary.description, - pageSummary.thumbnailUrl, - pageSummary.extract, - pageSummary.extractHtml - ) - ) - }, { - L.e(it) - cb.error(it) - })) - } - - private fun translateDescription(langFromCode: String, targetLanguage: String, callback: Callback) { - disposables.add( - EditingSuggestionsProvider - .getNextArticleWithMissingDescription(WikiSite.forLanguageCode(langFromCode), targetLanguage, true, - SuggestedEditsCardItemFragment.MAX_RETRY_LIMIT - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ pair -> - val source = pair.first - val target = pair.second - - callback.onReceiveSource( - PageSummaryForEdit( - source.apiTitle, - langFromCode, - source.getPageTitle(WikiSite.forLanguageCode(langFromCode)), - source.displayTitle, - source.description, - source.thumbnailUrl, - source.extract, - source.extractHtml - ) - ) - - callback.onReceiveTarget( - PageSummaryForEdit( - target.apiTitle, - targetLanguage, - target.getPageTitle(WikiSite.forLanguageCode(targetLanguage)), - target.displayTitle, - target.description, - target.thumbnailUrl, - target.extract, - target.extractHtml - ) - ) - }, { - L.e(it) - cb.error(it) - })) + clientJob?.cancel() } - - private fun addCaption(langFromCode: String, callback: Callback) { - disposables.add( - EditingSuggestionsProvider.getNextImageWithMissingCaption(langFromCode, - SuggestedEditsCardItemFragment.MAX_RETRY_LIMIT - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .flatMap { title -> - ServiceFactory.get(Constants.commonsWikiSite).getImageInfo(title, langFromCode) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - .subscribe({ response -> - val page = response.query?.firstPage()!! - page.imageInfo()?.let { - callback.onReceiveSource( - PageSummaryForEdit( - page.title, langFromCode, - PageTitle( - Namespace.FILE.name, - StringUtil.removeNamespace(page.title), - null, - it.thumbUrl, - WikiSite.forLanguageCode(langFromCode)), - StringUtil.removeHTMLTags(page.title), - it.metadata!!.imageDescription(), - it.thumbUrl, - null, - null, - it.timestamp, - it.user, - it.metadata - ) - ) - } - }, { - L.e(it) - cb.error(it) - })) - } - - private fun translateCaption(langFromCode: String, targetLanguage: String, callback: Callback) { - var fileCaption: String? = null - disposables.add( - EditingSuggestionsProvider.getNextImageWithMissingCaption(langFromCode, targetLanguage, - SuggestedEditsCardItemFragment.MAX_RETRY_LIMIT - ) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .flatMap { pair -> - fileCaption = pair.first - ServiceFactory.get(Constants.commonsWikiSite).getImageInfo(pair.second, langFromCode) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - } - .subscribe({ response -> - val page = response.query?.firstPage()!! - page.imageInfo()?.let { - val sourceSummaryForEdit = PageSummaryForEdit( - page.title, - langFromCode, - PageTitle( - Namespace.FILE.name, - StringUtil.removeNamespace(page.title), - null, - it.thumbUrl, - WikiSite.forLanguageCode(langFromCode) - ), - StringUtil.removeHTMLTags(page.title), - fileCaption, - it.thumbUrl, - null, - null, - it.timestamp, - it.user, - it.metadata - ) - callback.onReceiveSource(sourceSummaryForEdit) - callback.onReceiveTarget( - sourceSummaryForEdit.copy( - description = null, - lang = targetLanguage, - pageTitle = PageTitle( - Namespace.FILE.name, - StringUtil.removeNamespace(page.title), - null, - it.thumbUrl, - WikiSite.forLanguageCode(targetLanguage) - ) - ) - ) - } - }, { - L.e(it) - cb.error(it) - })) - } - - private fun addImageTags(callback: Callback) { - disposables.add( - EditingSuggestionsProvider - .getNextImageWithMissingTags(SuggestedEditsCardItemFragment.MAX_RETRY_LIMIT) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ page -> - callback.onReceiveImageTag(page) - }, { - L.e(it) - cb.error(it) - })) - } - - @Suppress("unused") - @Parcelize - data class SuggestedEditsSummary( - var cardActionType: DescriptionEditActivity.Action, - var sourceSummaryForEdit: PageSummaryForEdit? = null, - var targetSummaryForEdit: PageSummaryForEdit? = null - ) : Parcelable } diff --git a/app/src/main/java/org/wikipedia/feed/topread/TopReadFragment.kt b/app/src/main/java/org/wikipedia/feed/topread/TopReadFragment.kt index d983cd08401..90f17ddc914 100644 --- a/app/src/main/java/org/wikipedia/feed/topread/TopReadFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/topread/TopReadFragment.kt @@ -32,7 +32,7 @@ class TopReadFragment : Fragment() { private var _binding: FragmentMostReadBinding? = null private val binding get() = _binding!! - private val viewModel: TopReadViewModel by viewModels { TopReadViewModel.Factory(requireActivity().intent.extras!!) } + private val viewModel: TopReadViewModel by viewModels() override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) @@ -40,8 +40,10 @@ class TopReadFragment : Fragment() { val card = viewModel.card - appCompatActivity.supportActionBar?.setDisplayHomeAsUpEnabled(true) - appCompatActivity.supportActionBar?.title = getString(R.string.top_read_activity_title, card.subtitle()) + (requireActivity() as AppCompatActivity).run { + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = getString(R.string.top_read_activity_title, card.subtitle()) + } L10nUtil.setConditionalLayoutDirection(binding.root, card.wikiSite().languageCode) @@ -58,8 +60,6 @@ class TopReadFragment : Fragment() { super.onDestroyView() } - private val appCompatActivity get() = requireActivity() as AppCompatActivity - private class RecyclerAdapter constructor(items: List, private val callback: Callback) : DefaultRecyclerAdapter(items) { diff --git a/app/src/main/java/org/wikipedia/feed/topread/TopReadViewModel.kt b/app/src/main/java/org/wikipedia/feed/topread/TopReadViewModel.kt index aeedb8659cb..2fcd93742af 100644 --- a/app/src/main/java/org/wikipedia/feed/topread/TopReadViewModel.kt +++ b/app/src/main/java/org/wikipedia/feed/topread/TopReadViewModel.kt @@ -1,18 +1,8 @@ package org.wikipedia.feed.topread -import android.os.Bundle +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import org.wikipedia.extensions.parcelable -class TopReadViewModel(bundle: Bundle) : ViewModel() { - val card = bundle.parcelable(TopReadArticlesActivity.TOP_READ_CARD)!! - - class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { - - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return TopReadViewModel(bundle) as T - } - } +class TopReadViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val card = savedStateHandle.get(TopReadArticlesActivity.TOP_READ_CARD)!! } diff --git a/app/src/main/java/org/wikipedia/feed/view/CardLargeHeaderView.kt b/app/src/main/java/org/wikipedia/feed/view/CardLargeHeaderView.kt index c7580c542f9..19d3cc7e2ab 100644 --- a/app/src/main/java/org/wikipedia/feed/view/CardLargeHeaderView.kt +++ b/app/src/main/java/org/wikipedia/feed/view/CardLargeHeaderView.kt @@ -41,7 +41,7 @@ class CardLargeHeaderView : ConstraintLayout { fun setImage(uri: Uri?): CardLargeHeaderView { binding.viewCardHeaderLargeImage.visibility = if (uri == null) GONE else VISIBLE - binding.viewCardHeaderLargeImage.loadImage(uri, roundedCorners = true, cropped = true, listener = ImageLoadListener()) + binding.viewCardHeaderLargeImage.loadImage(uri, cropped = true, listener = ImageLoadListener()) return this } diff --git a/app/src/main/java/org/wikipedia/feed/view/FeedView.kt b/app/src/main/java/org/wikipedia/feed/view/FeedView.kt index 8becfd239a6..81211c856e9 100644 --- a/app/src/main/java/org/wikipedia/feed/view/FeedView.kt +++ b/app/src/main/java/org/wikipedia/feed/view/FeedView.kt @@ -11,8 +11,7 @@ import org.wikipedia.views.AutoFitRecyclerView import org.wikipedia.views.HeaderMarginItemDecoration import org.wikipedia.views.MarginItemDecoration -class FeedView constructor(context: Context, attrs: AttributeSet? = null) : AutoFitRecyclerView(context, attrs) { - private var recyclerLayoutManager: StaggeredGridLayoutManager +class FeedView(context: Context, attrs: AttributeSet? = null) : AutoFitRecyclerView(context, attrs) { val firstVisibleItemPosition: Int get() { @@ -24,10 +23,7 @@ class FeedView constructor(context: Context, attrs: AttributeSet? = null) : Auto init { isVerticalScrollBarEnabled = true - recyclerLayoutManager = StaggeredGridLayoutManager(columns, - StaggeredGridLayoutManager.VERTICAL) itemAnimator = DefaultItemAnimator() - layoutManager = recyclerLayoutManager addItemDecoration(MarginItemDecoration(context, R.dimen.view_list_card_margin_horizontal, R.dimen.view_list_card_margin_vertical, R.dimen.view_list_card_margin_horizontal, R.dimen.view_list_card_margin_vertical)) @@ -40,11 +36,6 @@ class FeedView constructor(context: Context, attrs: AttributeSet? = null) : Auto private inner class RecyclerViewColumnCallback : Callback { override fun onColumns(columns: Int) { - // todo: when there is only one element, should we setSpanCount to 1? e.g.: - // adapter.getItemCount() <= 1 ? 1 : columns; - // we would need to also notify the layout manager when the data set changes - // though. - recyclerLayoutManager.spanCount = columns val padding = DimenUtil.roundedDpToPx(DimenUtil.getDimension(R.dimen.view_list_card_margin_horizontal)) setPadding(padding, 0, padding, 0) diff --git a/app/src/main/java/org/wikipedia/feed/view/ListCardItemView.kt b/app/src/main/java/org/wikipedia/feed/view/ListCardItemView.kt index 837158bafe5..62b3bfe941f 100644 --- a/app/src/main/java/org/wikipedia/feed/view/ListCardItemView.kt +++ b/app/src/main/java/org/wikipedia/feed/view/ListCardItemView.kt @@ -10,9 +10,10 @@ import androidx.annotation.VisibleForTesting import androidx.constraintlayout.widget.ConstraintLayout import org.wikipedia.databinding.ViewListCardItemBinding import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.extensions.coroutineScope import org.wikipedia.feed.model.Card import org.wikipedia.history.HistoryEntry -import org.wikipedia.page.PageAvailableOfflineHandler.check +import org.wikipedia.page.PageAvailableOfflineHandler import org.wikipedia.readinglist.LongPressMenu import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.util.DeviceUtil @@ -102,7 +103,7 @@ class ListCardItemView(context: Context, attrs: AttributeSet? = null) : Constrai setTitle(StringUtil.fromHtml(entry.title.displayText)) setSubtitle(entry.title.description) setImage(entry.title.thumbUrl) - check(entry.title) { available -> setViewsGreyedOut(!available) } + PageAvailableOfflineHandler.check(coroutineScope(), entry.title) { setViewsGreyedOut(!it) } return this } @@ -112,7 +113,7 @@ class ListCardItemView(context: Context, attrs: AttributeSet? = null) : Constrai binding.viewListCardItemImage.visibility = GONE } else { binding.viewListCardItemImage.visibility = VISIBLE - ViewUtil.loadImageWithRoundedCorners(binding.viewListCardItemImage, url, true) + ViewUtil.loadImageWithRoundedCorners(binding.viewListCardItemImage, url) } } diff --git a/app/src/main/java/org/wikipedia/feed/view/ListCardView.kt b/app/src/main/java/org/wikipedia/feed/view/ListCardView.kt index 917ed68ff4d..0513ce22d4a 100644 --- a/app/src/main/java/org/wikipedia/feed/view/ListCardView.kt +++ b/app/src/main/java/org/wikipedia/feed/view/ListCardView.kt @@ -9,7 +9,6 @@ import org.wikipedia.databinding.ViewListCardBinding import org.wikipedia.feed.model.Card import org.wikipedia.views.DrawableItemDecoration -@Suppress("LeakingThis") abstract class ListCardView(context: Context) : DefaultFeedCardView(context) { interface Callback { fun onFooterClick(card: Card) diff --git a/app/src/main/java/org/wikipedia/feed/view/RegionalLanguageVariantSelectionDialog.kt b/app/src/main/java/org/wikipedia/feed/view/RegionalLanguageVariantSelectionDialog.kt new file mode 100644 index 00000000000..88990ebe7e8 --- /dev/null +++ b/app/src/main/java/org/wikipedia/feed/view/RegionalLanguageVariantSelectionDialog.kt @@ -0,0 +1,85 @@ +package org.wikipedia.feed.view + +import android.content.Context +import android.view.LayoutInflater +import android.widget.RadioButton +import androidx.appcompat.app.AlertDialog +import androidx.core.view.children +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.databinding.DialogRegionalLanguageVariantSelectionBinding +import org.wikipedia.databinding.ItemLanguageVariantSelectionBinding +import org.wikipedia.language.AppLanguageLookUpTable + +class RegionalLanguageVariantSelectionDialog(context: Context) : MaterialAlertDialogBuilder(context) { + private var dialog: AlertDialog? = null + private var binding = DialogRegionalLanguageVariantSelectionBinding.inflate(LayoutInflater.from(context)) + private var selectedLanguageCode = AppLanguageLookUpTable.CHINESE_TW_LANGUAGE_CODE + private var regionalLanguageVariants = listOf( + AppLanguageLookUpTable.CHINESE_CN_LANGUAGE_CODE, + AppLanguageLookUpTable.CHINESE_HK_LANGUAGE_CODE, + AppLanguageLookUpTable.CHINESE_MO_LANGUAGE_CODE, + AppLanguageLookUpTable.CHINESE_MY_LANGUAGE_CODE, + AppLanguageLookUpTable.CHINESE_SG_LANGUAGE_CODE, + AppLanguageLookUpTable.CHINESE_TW_LANGUAGE_CODE + ) + + init { + setView(binding.root) + setCancelable(false) + buildRadioButtons(context) + setPositiveButton(R.string.feed_language_variants_removal_dialog_save) { _, _ -> + val list = removeNonRegionalLanguageVariants() + list.remove(selectedLanguageCode) // Remove the existing one to add it to the top + list.add(0, selectedLanguageCode) + WikipediaApp.instance.languageState.setAppLanguageCodes(list) + } + } + + override fun show(): AlertDialog { + dialog = super.show() + setPositiveButtonEnabled(false) + return dialog!! + } + + private fun buildRadioButtons(context: Context) { + regionalLanguageVariants.forEach { languageCode -> + val radioButtonBinding = ItemLanguageVariantSelectionBinding.inflate(LayoutInflater.from(context)) + radioButtonBinding.root.tag = languageCode + radioButtonBinding.radioButtonTitle.text = WikipediaApp.instance.languageState.getAppLanguageLocalizedName(languageCode) + radioButtonBinding.radioButtonDescription.text = WikipediaApp.instance.languageState.getAppLanguageCanonicalName(languageCode) + radioButtonBinding.root.setOnClickListener { radioButtonBinding.radioButton.isChecked = true } + radioButtonBinding.radioButton.setOnCheckedChangeListener { _, isChecked -> + if (isChecked) { + selectedLanguageCode = languageCode + setPositiveButtonEnabled(true) + clearCheckedButtons() + } + } + binding.radioGroup.addView(radioButtonBinding.root) + } + } + + private fun clearCheckedButtons() { + binding.radioGroup.children.iterator().forEach { + val radioButton = it.findViewById(R.id.radioButton) + radioButton.isChecked = selectedLanguageCode == it.tag + } + } + + private fun setPositiveButtonEnabled(enabled: Boolean) { + dialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = enabled + } + + companion object { + fun removeNonRegionalLanguageVariants(): MutableList { + val list = WikipediaApp.instance.languageState.appLanguageCodes.toMutableList() + list.removeAll(listOf( + AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE, + AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE + )) + return list + } + } +} diff --git a/app/src/main/java/org/wikipedia/gallery/ExtMetadata.kt b/app/src/main/java/org/wikipedia/gallery/ExtMetadata.kt index 35482fee04b..0366a9c5d5f 100644 --- a/app/src/main/java/org/wikipedia/gallery/ExtMetadata.kt +++ b/app/src/main/java/org/wikipedia/gallery/ExtMetadata.kt @@ -5,6 +5,7 @@ import kotlinx.parcelize.Parcelize import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +@Suppress("unused") @Parcelize @Serializable class ExtMetadata( @@ -35,18 +36,6 @@ class ExtMetadata( return imageDescription?.value.orEmpty() } - fun imageDescriptionSource(): String { - return imageDescription?.source.orEmpty() - } - - fun objectName(): String { - return objectName?.value.orEmpty() - } - - fun usageTerms(): String { - return usageTerms?.value.orEmpty() - } - fun dateTime(): String { return dateTimeOriginal?.value.orEmpty() } diff --git a/app/src/main/java/org/wikipedia/gallery/GalleryActivity.kt b/app/src/main/java/org/wikipedia/gallery/GalleryActivity.kt index 2aee2defa84..834ea9d6d75 100644 --- a/app/src/main/java/org/wikipedia/gallery/GalleryActivity.kt +++ b/app/src/main/java/org/wikipedia/gallery/GalleryActivity.kt @@ -1,27 +1,28 @@ package org.wikipedia.gallery -import android.app.Activity +import android.app.assist.AssistContent import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Color import android.net.Uri +import android.os.Build import android.os.Bundle -import android.util.Pair import android.view.Gravity import android.view.View import android.widget.FrameLayout import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.ImageEditType import org.wikipedia.Constants.InvokeSource @@ -31,13 +32,11 @@ import org.wikipedia.activity.BaseActivity import org.wikipedia.auth.AccountUtil import org.wikipedia.bridge.JavaScriptActionHandler import org.wikipedia.commons.FilePageActivity -import org.wikipedia.commons.ImageTagsProvider import org.wikipedia.databinding.ActivityGalleryBinding import org.wikipedia.dataclient.Service -import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.wikidata.Entities import org.wikipedia.descriptions.DescriptionEditActivity -import org.wikipedia.extensions.parcelableExtra import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.LinkMovementMethodExt @@ -54,6 +53,7 @@ import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.GradientUtil import org.wikipedia.util.ImageUrlUtil +import org.wikipedia.util.Resource import org.wikipedia.util.ResourceUtil import org.wikipedia.util.ShareUtil import org.wikipedia.util.StringUtil @@ -67,34 +67,23 @@ import java.io.File class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, GalleryItemFragment.Callback { private lateinit var binding: ActivityGalleryBinding - private lateinit var sourceWiki: WikiSite private lateinit var galleryAdapter: GalleryItemAdapter + private val viewModel: GalleryViewModel by viewModels() private var pageChangeListener = GalleryPageChangeListener() - private var pageTitle: PageTitle? = null private var imageEditType: ImageEditType? = null - private val disposables = CompositeDisposable() - private var imageCaptionDisposable: Disposable? = null - private var revision = 0L private var controlsShowing = true - /** - * If we have an intent that tells us a specific image to jump to within the gallery, - * then this will be non-null. - */ - private var initialFilename: String? = null - /** - * If we come back from savedInstanceState, then this will be the previous pager position. - */ private var initialImageIndex = -1 private var targetLanguageCode: String? = null - private val app = WikipediaApp.instance + private val downloadReceiver = MediaDownloadReceiver() private val downloadReceiverCallback = MediaDownloadReceiverCallback() + private val currentItem get() = galleryAdapter.getFragmentAt(binding.pager.currentItem) as GalleryItemFragment? private val requestAddCaptionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { val action = it.data?.getSerializableExtra(Constants.INTENT_EXTRA_ACTION) as DescriptionEditActivity.Action? SuggestedEditsSnackbars.show(this, action, true, targetLanguageCode, false) - layOutGalleryDescription(currentItem) + fetchGalleryDescription(currentItem) setResult(ACTIVITY_RESULT_IMAGE_CAPTION_ADDED) } } @@ -103,13 +92,11 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall if (it.resultCode == RESULT_OK) { val action = DescriptionEditActivity.Action.ADD_IMAGE_TAGS SuggestedEditsSnackbars.show(this, action, true, targetLanguageCode, true) { - currentItem?.let { fragment -> - fragment.imageTitle?.let { pageTitle -> - startActivity(FilePageActivity.newIntent(this@GalleryActivity, pageTitle)) - } + currentItem?.let { + startActivity(FilePageActivity.newIntent(this@GalleryActivity, it.imageTitle)) } } - layOutGalleryDescription(currentItem) + fetchGalleryDescription(currentItem) setResult(ACTIVITY_RESULT_IMAGE_TAGS_ADDED) } } @@ -119,8 +106,8 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall binding = ActivityGalleryBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) - supportActionBar!!.setDisplayHomeAsUpEnabled(true) - supportActionBar!!.title = "" + supportActionBar?.setDisplayHomeAsUpEnabled(true) + supportActionBar?.title = "" setNavigationBarColor(Color.BLACK) binding.toolbarGradient.background = GradientUtil.getPowerGradient(ResourceUtil.getThemedColor(this, R.attr.overlay_color), Gravity.TOP) binding.infoGradient.background = GradientUtil.getPowerGradient(ResourceUtil.getThemedColor(this, R.attr.overlay_color), Gravity.BOTTOM) @@ -131,14 +118,8 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall binding.errorView.backClickListener = View.OnClickListener { onBackPressed() } binding.errorView.retryClickListener = View.OnClickListener { binding.errorView.visibility = View.GONE - loadGalleryContent() - } - if (intent.hasExtra(Constants.ARG_TITLE)) { - pageTitle = intent.parcelableExtra(Constants.ARG_TITLE) + viewModel.fetchGalleryItems() } - initialFilename = intent.getStringExtra(EXTRA_FILENAME) - revision = intent.getLongExtra(EXTRA_REVISION, 0) - sourceWiki = intent.parcelableExtra(Constants.ARG_WIKISITE)!! galleryAdapter = GalleryItemAdapter(this@GalleryActivity) binding.pager.adapter = galleryAdapter binding.pager.registerOnPageChangeCallback(pageChangeListener) @@ -148,12 +129,11 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall initialImageIndex = savedInstanceState.getInt(KEY_PAGER_INDEX) // if we have a savedInstanceState, then the initial index overrides // the initial Title from our intent. - initialFilename = null - val fm = supportFragmentManager + viewModel.initialFilename = null if (supportFragmentManager.backStackEntryCount > 0) { val ft = supportFragmentManager.beginTransaction() - for (i in 0 until fm.backStackEntryCount) { - val fragment = fm.findFragmentById(fm.getBackStackEntryAt(i).id) + for (i in 0 until supportFragmentManager.backStackEntryCount) { + val fragment = supportFragmentManager.findFragmentById(supportFragmentManager.getBackStackEntryAt(i).id) if (fragment is GalleryItemFragment) { ft.remove(fragment) } @@ -175,28 +155,50 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall binding.transitionReceiver.layoutParams = params binding.transitionReceiver.visibility = View.VISIBLE ViewUtil.loadImage(binding.transitionReceiver, TRANSITION_INFO!!.src, TRANSITION_INFO!!.centerCrop, - largeRoundedSize = false, force = false, listener = null) + force = false, listener = null) val transitionMillis = 500 binding.transitionReceiver.postDelayed({ if (isDestroyed) { return@postDelayed } - loadGalleryContent() + viewModel.fetchGalleryItems() }, transitionMillis.toLong()) } else { TRANSITION_INFO = null binding.transitionReceiver.visibility = View.GONE - loadGalleryContent() + viewModel.fetchGalleryItems() } binding.captionEditButton.setOnClickListener { onEditClick(it) } binding.ctaButton.setOnClickListener { onTranslateClick() } binding.licenseContainer.setOnClickListener { onLicenseClick() } binding.licenseContainer.setOnLongClickListener { onLicenseLongClick() } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> onLoading() + is Resource.Success -> onGallerySuccess(it.data.getItems("image", "video")) + is Resource.Error -> onError(it.throwable) + } + } + } + + launch { + viewModel.descriptionState.collect { + when (it) { + is Resource.Loading -> onLoading() + is Resource.Success -> onDescriptionSuccess(it.data.first, it.data.second) + is Resource.Error -> onDescriptionError(it.throwable) + } + } + } + } + } } public override fun onDestroy() { - disposables.clear() - disposeImageCaptionDisposable() binding.pager.unregisterOnPageChangeCallback(pageChangeListener) TRANSITION_INFO = null super.onDestroy() @@ -213,26 +215,28 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall } override fun onDownload(item: GalleryItemFragment) { - if (item.imageTitle != null && item.mediaInfo != null) { - downloadReceiver.download(this, item.imageTitle!!, item.mediaInfo!!) + item.mediaInfo?.let { + downloadReceiver.download(this, item.imageTitle, it) FeedbackUtil.showMessage(this, R.string.gallery_save_progress) - } else { + } ?: run { FeedbackUtil.showMessage(this, R.string.err_cannot_save_file) } } override fun onShare(item: GalleryItemFragment, bitmap: Bitmap?, subject: String, title: PageTitle) { - if (bitmap != null && item.mediaInfo != null) { - ShareUtil.shareImage(this, bitmap, - File(ImageUrlUtil.getUrlForPreferredSize(item.mediaInfo!!.thumbUrl, Constants.PREFERRED_GALLERY_IMAGE_SIZE)).name, - subject, title.uri) + if (bitmap != null) { + item.mediaInfo?.let { + ShareUtil.shareImage(lifecycleScope, this, bitmap, + File(ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl, Constants.PREFERRED_GALLERY_IMAGE_SIZE)).name, subject, title.uri) + } } else { ShareUtil.shareText(this, title) } } override fun onError(throwable: Throwable) { - showError(throwable) + binding.errorView.setError(throwable) + binding.errorView.visibility = View.VISIBLE } override fun setTheme() { @@ -244,7 +248,8 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall if (item?.imageTitle == null || item.mediaInfo?.metadata == null) { return } - val isProtected = v.tag != null && v.tag as Boolean + + val isProtected = v.tag as Boolean if (isProtected) { MaterialAlertDialogBuilder(this) .setCancelable(false) @@ -258,19 +263,20 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall } private fun startCaptionEdit(item: GalleryItemFragment) { - val title = PageTitle(item.imageTitle!!.prefixedText, - WikiSite(Service.COMMONS_URL, sourceWiki.languageCode)) - val currentCaption = item.mediaInfo!!.captions[sourceWiki.languageCode] - title.description = currentCaption - val summary = PageSummaryForEdit(title.prefixedText, sourceWiki.languageCode, title, - title.displayText, RichTextUtil.stripHtml(item.mediaInfo!!.metadata!!.imageDescription()), item.mediaInfo!!.thumbUrl) - requestAddCaptionLauncher.launch(DescriptionEditActivity.newIntent(this, title, null, summary, null, - DescriptionEditActivity.Action.ADD_CAPTION, InvokeSource.GALLERY_ACTIVITY)) + item.mediaInfo?.let { + val title = PageTitle(item.imageTitle.prefixedText, + WikiSite(Service.COMMONS_URL, viewModel.wikiSite.languageCode)) + title.description = it.captions[viewModel.wikiSite.languageCode] + val summary = PageSummaryForEdit(title.prefixedText, viewModel.wikiSite.languageCode, title, + title.displayText, RichTextUtil.stripHtml(it.metadata?.imageDescription()), it.thumbUrl) + requestAddCaptionLauncher.launch(DescriptionEditActivity.newIntent(this, title, null, summary, null, + DescriptionEditActivity.Action.ADD_CAPTION, InvokeSource.GALLERY_ACTIVITY)) + } } private fun onTranslateClick() { val item = currentItem - if (item?.imageTitle == null || item.mediaInfo?.metadata == null || imageEditType == null) { + if (item?.mediaInfo?.metadata == null || imageEditType == null) { return } when (imageEditType) { @@ -281,32 +287,33 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall } private fun startTagsEdit(item: GalleryItemFragment) { - requestAddImageTagsLauncher.launch(SuggestedEditsImageTagEditActivity.newIntent(this, item.mediaPage!!, - InvokeSource.GALLERY_ACTIVITY)) + item.mediaPage?.let { + requestAddImageTagsLauncher.launch(SuggestedEditsImageTagEditActivity.newIntent(this, it, InvokeSource.GALLERY_ACTIVITY)) + } } private fun startCaptionTranslation(item: GalleryItemFragment) { - val sourceTitle = PageTitle(item.imageTitle!!.prefixedText, WikiSite(Service.COMMONS_URL, sourceWiki.languageCode)) - val targetTitle = PageTitle(item.imageTitle!!.prefixedText, WikiSite(Service.COMMONS_URL, - targetLanguageCode ?: app.languageState.appLanguageCodes[1])) - val currentCaption = item.mediaInfo!!.captions[sourceWiki.languageCode].orEmpty().ifEmpty { - RichTextUtil.stripHtml(item.mediaInfo!!.metadata!!.imageDescription()) + item.mediaInfo?.let { + val sourceTitle = PageTitle(item.imageTitle.prefixedText, WikiSite(Service.COMMONS_URL, viewModel.wikiSite.languageCode)) + val targetTitle = PageTitle(item.imageTitle.prefixedText, WikiSite(Service.COMMONS_URL, + targetLanguageCode ?: WikipediaApp.instance.languageState.appLanguageCodes[1])) + val currentCaption = it.captions[viewModel.wikiSite.languageCode].orEmpty().ifEmpty { + RichTextUtil.stripHtml(it.metadata?.imageDescription()) + } + val sourceSummary = PageSummaryForEdit(sourceTitle.prefixedText, sourceTitle.wikiSite.languageCode, + sourceTitle, sourceTitle.displayText, currentCaption, it.thumbUrl) + val targetSummary = PageSummaryForEdit(targetTitle.prefixedText, targetTitle.wikiSite.languageCode, + targetTitle, targetTitle.displayText, null, it.thumbUrl) + requestAddCaptionLauncher.launch(DescriptionEditActivity.newIntent(this, targetTitle, null, sourceSummary, + targetSummary, if (sourceSummary.lang == targetSummary.lang) DescriptionEditActivity.Action.ADD_CAPTION + else DescriptionEditActivity.Action.TRANSLATE_CAPTION, InvokeSource.GALLERY_ACTIVITY)) } - val sourceSummary = PageSummaryForEdit(sourceTitle.prefixedText, sourceTitle.wikiSite.languageCode, - sourceTitle, sourceTitle.displayText, currentCaption, item.mediaInfo!!.thumbUrl) - val targetSummary = PageSummaryForEdit(targetTitle.prefixedText, targetTitle.wikiSite.languageCode, - targetTitle, targetTitle.displayText, null, item.mediaInfo!!.thumbUrl) - requestAddCaptionLauncher.launch(DescriptionEditActivity.newIntent(this, targetTitle, null, sourceSummary, - targetSummary, if (sourceSummary.lang == targetSummary.lang) DescriptionEditActivity.Action.ADD_CAPTION - else DescriptionEditActivity.Action.TRANSLATE_CAPTION, InvokeSource.GALLERY_ACTIVITY)) } private fun onLicenseClick() { - if (binding.licenseIcon.contentDescription == null) { - return + binding.licenseIcon.contentDescription?.let { + FeedbackUtil.showMessageAsPlainText(this, it) } - FeedbackUtil.showMessageAsPlainText((binding.licenseIcon.context as Activity), - binding.licenseIcon.contentDescription) } private fun onLicenseLongClick(): Boolean { @@ -318,18 +325,12 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall return true } - private fun disposeImageCaptionDisposable() { - if (imageCaptionDisposable != null && !imageCaptionDisposable!!.isDisposed) { - imageCaptionDisposable!!.dispose() - } - } - private inner class GalleryPageChangeListener : OnPageChangeCallback() { private var currentPosition = -1 override fun onPageSelected(position: Int) { // the pager has settled on a new position currentItem?.let { item -> - layOutGalleryDescription(item) + fetchGalleryDescription(item) } currentPosition = position } @@ -345,11 +346,6 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall outState.putInt(KEY_PAGER_INDEX, binding.pager.currentItem) } - private fun updateProgressBar(visible: Boolean) { - binding.progressBar.isIndeterminate = true - binding.progressBar.visibility = if (visible) View.VISIBLE else View.GONE - } - override fun onBackPressed() { if (TRANSITION_INFO != null) { showTransitionReceiver() @@ -403,28 +399,23 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall LinkPreviewDialog.newInstance(HistoryEntry(title, HistoryEntry.SOURCE_GALLERY))) } - fun setViewPagerEnabled(enabled: Boolean) { - binding.pager.isUserInputEnabled = enabled - } - private val linkMovementMethod = LinkMovementMethodExt { urlStr -> L.v("Link clicked was $urlStr") var url = UriUtil.resolveProtocolRelativeUrl(urlStr) if (url.startsWith("/wiki/")) { - val title = PageTitle.titleForInternalLink(url, app.wikiSite) + val title = PageTitle.titleForInternalLink(url, WikipediaApp.instance.wikiSite) showLinkPreview(title) } else { val uri = Uri.parse(url) val authority = uri.authority - if (authority != null && WikiSite.supportedAuthority(authority) && - uri.path != null && uri.path!!.startsWith("/wiki/")) { + if (authority != null && WikiSite.supportedAuthority(authority) && uri.path?.startsWith("/wiki/") == true) { val title = PageTitle.titleForUri(uri, WikiSite(uri)) showLinkPreview(title) } else { // if it's a /w/ URI, turn it into a full URI and go external if (url.startsWith("/w/")) { - url = String.format("%1\$s://%2\$s", app.wikiSite.scheme(), - app.wikiSite.authority()) + url + url = String.format("%1\$s://%2\$s", WikipediaApp.instance.wikiSite.scheme(), + WikipediaApp.instance.wikiSite.authority()) + url } UriUtil.handleExternalLink(this@GalleryActivity, Uri.parse(url)) } @@ -441,37 +432,16 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall finishWithPageResult(title, entry) } - fun showError(caught: Throwable?) { - binding.errorView.setError(caught) - binding.errorView.visibility = View.VISIBLE - } - - private fun fetchGalleryItems() { - pageTitle?.let { - updateProgressBar(true) - disposables.add(ServiceFactory.getRest(it.wikiSite) - .getMediaList(it.prefixedText, revision) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ mediaList -> - applyGalleryList(mediaList.getItems("image", "video")) - }) { caught -> - updateProgressBar(false) - showError(caught) - }) - } - } - - private fun loadGalleryContent() { - updateProgressBar(false) - fetchGalleryItems() + private fun onLoading() { + binding.progressBar.isVisible = true } - private fun applyGalleryList(mediaListItems: MutableList) { + private fun onGallerySuccess(mediaListItems: MutableList) { + binding.progressBar.isVisible = false // first, verify that the collection contains the item that the user // initially requested, if we have one... var initialImagePos = -1 - initialFilename?.let { + viewModel.initialFilename?.let { for (item in mediaListItems) { // the namespace of a file could be in a different language than English. if (StringUtil.removeNamespace(item.title) == StringUtil.removeNamespace(it)) { @@ -499,42 +469,33 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall } } - private val currentItem get() = galleryAdapter.getFragmentAt(binding.pager.currentItem) as GalleryItemFragment? + private fun onDescriptionSuccess(isProtected: Boolean, entity: Entities.Entity?) { + binding.progressBar.isVisible = false + currentItem?.mediaInfo?.captions = viewModel.getCaptions(entity) + updateGalleryDescription(isProtected, viewModel.getDepicts(entity).size) + } + + private fun onDescriptionError(throwable: Throwable) { + L.e(throwable) + updateGalleryDescription(false, 0) + } - fun layOutGalleryDescription(callingFragment: GalleryItemFragment?) { + fun fetchGalleryDescription(callingFragment: GalleryItemFragment?) { val item = currentItem - if (item != callingFragment) { + if (item != callingFragment || item == null) { return } - if (item?.imageTitle == null || item.mediaInfo?.metadata == null) { + if (item.mediaInfo?.metadata == null) { binding.infoContainer.visibility = View.GONE return } - updateProgressBar(true) - disposeImageCaptionDisposable() - imageCaptionDisposable = - Observable.zip( - ServiceFactory.get(Constants.commonsWikiSite).getEntitiesByTitle(item.imageTitle!!.prefixedText, Constants.COMMONS_DB_NAME), - ServiceFactory.get(Constants.commonsWikiSite).getProtectionInfo(item.imageTitle!!.prefixedText) - ) { entities, protectionInfoRsp -> - val captions = entities.first?.labels?.values?.associate { it.language to it.value }.orEmpty() - item.mediaInfo!!.captions = captions - val depicts = ImageTagsProvider.getDepictsClaims(entities.first?.getStatements().orEmpty()) - Pair(protectionInfoRsp.query?.isEditProtected == true, depicts.size) - }.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ - updateGalleryDescription(it.first, it.second) - }, { - L.e(it) - updateGalleryDescription(false, 0) - }) - } - - fun updateGalleryDescription(isProtected: Boolean, tagsCount: Int) { - updateProgressBar(false) + + viewModel.fetchGalleryDescription(item.imageTitle) + } + + private fun updateGalleryDescription(isProtected: Boolean, tagsCount: Int) { val item = currentItem - if (item?.imageTitle == null || item.mediaInfo?.metadata == null) { + if (item?.mediaInfo?.metadata == null) { binding.infoContainer.visibility = View.GONE return } @@ -560,43 +521,43 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall } private fun decideImageEditType(item: GalleryItemFragment, tagsCount: Int) { - imageEditType = null - if (!item.mediaInfo!!.captions.containsKey(sourceWiki.languageCode)) { - imageEditType = ImageEditType.ADD_CAPTION - targetLanguageCode = sourceWiki.languageCode - binding.ctaButtonText.text = getString(R.string.gallery_add_image_caption_button) - return - } - if (tagsCount == 0) { - imageEditType = ImageEditType.ADD_TAGS - binding.ctaButtonText.text = getString(R.string.suggested_edits_feed_card_add_image_tags) - return - } + item.mediaInfo?.let { mediaInfo -> + imageEditType = null + if (!mediaInfo.captions.containsKey(viewModel.wikiSite.languageCode)) { + imageEditType = ImageEditType.ADD_CAPTION + targetLanguageCode = viewModel.wikiSite.languageCode + binding.ctaButtonText.text = getString(R.string.gallery_add_image_caption_button) + return + } + if (tagsCount == 0) { + imageEditType = ImageEditType.ADD_TAGS + binding.ctaButtonText.text = getString(R.string.suggested_edits_feed_card_add_image_tags) + return + } - // and if we have another language in which the caption doesn't exist, then offer - // it to be translatable. - if (app.languageState.appLanguageCodes.size > 1) { - for (lang in app.languageState.appLanguageCodes) { - if (!item.mediaInfo!!.captions.containsKey(lang)) { - targetLanguageCode = lang + // and if we have another language in which the caption doesn't exist, then offer + // it to be translatable. + val languageState = WikipediaApp.instance.languageState + if (languageState.appLanguageCodes.size > 1) { + languageState.appLanguageCodes.firstOrNull { !mediaInfo.captions.containsKey(it) }?.let { + targetLanguageCode = it imageEditType = ImageEditType.ADD_CAPTION_TRANSLATION binding.ctaButtonText.text = getString(R.string.gallery_add_image_caption_in_language_button, - app.languageState.getAppLanguageLocalizedName(targetLanguageCode)) - break + WikipediaApp.instance.languageState.getAppLanguageLocalizedName(targetLanguageCode)) } } + binding.ctaContainer.isVisible = imageEditType != null } - binding.ctaContainer.visibility = if (imageEditType == null) View.GONE else View.VISIBLE } private fun displayApplicableDescription(item: GalleryItemFragment) { // If we have a structured caption in our current language, then display that instead // of the unstructured description, and make it editable. - val descriptionStr = item.mediaInfo?.captions!!.getOrElse(sourceWiki.languageCode) { - StringUtil.fromHtml(item.mediaInfo!!.metadata!!.imageDescription()) + val descriptionStr = item.mediaInfo?.captions?.getOrElse(viewModel.wikiSite.languageCode) { + StringUtil.fromHtml(item.mediaInfo?.metadata?.imageDescription()) } - if (descriptionStr.isNotEmpty()) { + if (!descriptionStr.isNullOrEmpty()) { binding.descriptionContainer.visibility = View.VISIBLE binding.descriptionText.text = StringUtil.strip(descriptionStr) } else { @@ -609,14 +570,14 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall val license = ImageLicense(metadata.license(), metadata.licenseShortName(), metadata.licenseUrl()) // determine which icon to display... - if (license.licenseIcon == R.drawable.ic_license_by) { + if (license.licenseIcon() == R.drawable.ic_license_by) { binding.licenseIcon.setImageResource(R.drawable.ic_license_cc) binding.licenseIconBy.setImageResource(R.drawable.ic_license_by) binding.licenseIconBy.visibility = View.VISIBLE binding.licenseIconSa.setImageResource(R.drawable.ic_license_sharealike) binding.licenseIconSa.visibility = View.VISIBLE } else { - binding.licenseIcon.setImageResource(license.licenseIcon) + binding.licenseIcon.setImageResource(license.licenseIcon()) binding.licenseIconBy.visibility = View.GONE binding.licenseIconSa.visibility = View.GONE } @@ -636,6 +597,15 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall binding.infoContainer.visibility = View.VISIBLE } + override fun onProvideAssistContent(outContent: AssistContent) { + super.onProvideAssistContent(outContent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + currentItem?.mediaInfo?.commonsUrl?.let { + outContent.setWebUri(Uri.parse(it)) + } + } + } + private inner class GalleryItemAdapter(activity: AppCompatActivity) : PositionAwareFragmentStateAdapter(activity) { private val list = mutableListOf() fun setList(list: List) { @@ -649,7 +619,7 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall } override fun createFragment(position: Int): Fragment { - return GalleryItemFragment.newInstance(pageTitle, list[position]) + return GalleryItemFragment.newInstance(viewModel.pageTitle, list[position]) } } @@ -662,28 +632,19 @@ class GalleryActivity : BaseActivity(), LinkPreviewDialog.LoadPageCallback, Gall companion object { private const val KEY_CONTROLS_SHOWING = "controlsShowing" private const val KEY_PAGER_INDEX = "pagerIndex" + private var TRANSITION_INFO: JavaScriptActionHandler.ImageHitInfo? = null const val ACTIVITY_RESULT_PAGE_SELECTED = 1 const val ACTIVITY_RESULT_IMAGE_CAPTION_ADDED = 2 const val ACTIVITY_RESULT_IMAGE_TAGS_ADDED = 3 const val EXTRA_FILENAME = "filename" const val EXTRA_REVISION = "revision" - const val EXTRA_SOURCE = "source" - const val SOURCE_LEAD_IMAGE = 0 - const val SOURCE_NON_LEAD_IMAGE = 1 - const val SOURCE_LINK_PREVIEW = 2 - private var TRANSITION_INFO: JavaScriptActionHandler.ImageHitInfo? = null - fun newIntent(context: Context, pageTitle: PageTitle?, filename: String, wiki: WikiSite, revision: Long, source: Int): Intent { - val intent = Intent() - .setClass(context, GalleryActivity::class.java) - .putExtra(EXTRA_FILENAME, filename) + fun newIntent(context: Context, pageTitle: PageTitle?, filename: String, wiki: WikiSite, revision: Long): Intent { + return Intent(context, GalleryActivity::class.java) .putExtra(Constants.ARG_WIKISITE, wiki) + .putExtra(Constants.ARG_TITLE, pageTitle) + .putExtra(EXTRA_FILENAME, filename) .putExtra(EXTRA_REVISION, revision) - .putExtra(EXTRA_SOURCE, source) - if (pageTitle != null) { - intent.putExtra(Constants.ARG_TITLE, pageTitle) - } - return intent } fun setTransitionInfo(hitInfo: JavaScriptActionHandler.ImageHitInfo) { diff --git a/app/src/main/java/org/wikipedia/gallery/GalleryItem.kt b/app/src/main/java/org/wikipedia/gallery/GalleryItem.kt index c0285aa5c94..698aaf8d998 100644 --- a/app/src/main/java/org/wikipedia/gallery/GalleryItem.kt +++ b/app/src/main/java/org/wikipedia/gallery/GalleryItem.kt @@ -2,10 +2,9 @@ package org.wikipedia.gallery import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.wikipedia.Constants.PREFERRED_GALLERY_IMAGE_SIZE import org.wikipedia.dataclient.Service -import org.wikipedia.util.ImageUrlUtil +@Suppress("unused") @Serializable open class GalleryItem { @@ -21,12 +20,10 @@ open class GalleryItem { @SerialName("structured") var structuredData: StructuredData? = null - // return the base url of Wiki Commons for WikiSite() if the file_page is null. @SerialName("file_page") var filePage: String = Service.COMMONS_URL val duration = 0.0 - val isShowInGallery = false var type: String = "" var thumbnail = ImageInfo() var original = ImageInfo() @@ -39,23 +36,9 @@ open class GalleryItem { var license: ImageLicense? = null val thumbnailUrl get() = thumbnail.source - val preferredSizedImageUrl get() = ImageUrlUtil.getUrlForPreferredSize(thumbnailUrl, PREFERRED_GALLERY_IMAGE_SIZE) - - // The getSources has different levels of source, - // should have an option that allows user to chose which quality to play - val originalVideoSource get() = sources?.lastOrNull() - - var structuredCaptions - get() = structuredData?.captions ?: emptyMap() - set(captions) { - if (structuredData == null) { - structuredData = StructuredData() - } - structuredData?.captions = HashMap(captions) - } @Serializable - class Titles constructor(val display: String = "", val canonical: String = "", val normalized: String = "") + class Titles(val display: String = "", val canonical: String = "", val normalized: String = "") @Serializable class StructuredData(var captions: HashMap? = null) diff --git a/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt b/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt index e9c51dfaba0..c5244fe97b9 100644 --- a/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt +++ b/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt @@ -18,32 +18,29 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.MenuProvider +import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels +import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.bumptech.glide.load.DataSource import com.bumptech.glide.load.engine.GlideException import com.bumptech.glide.request.RequestListener import com.bumptech.glide.request.target.Target -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.launch import org.wikipedia.Constants import org.wikipedia.R -import org.wikipedia.WikipediaApp import org.wikipedia.activity.FragmentUtil import org.wikipedia.commons.FilePageActivity import org.wikipedia.databinding.FragmentGalleryItemBinding -import org.wikipedia.dataclient.ServiceFactory -import org.wikipedia.dataclient.mwapi.MwQueryPage -import org.wikipedia.dataclient.mwapi.MwQueryResponse -import org.wikipedia.extensions.parcelable import org.wikipedia.page.PageTitle import org.wikipedia.util.DeviceUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil -import org.wikipedia.util.FileUtil import org.wikipedia.util.ImageUrlUtil +import org.wikipedia.util.Resource import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L import org.wikipedia.views.ViewUtil @@ -59,13 +56,14 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener private var _binding: FragmentGalleryItemBinding? = null private val binding get() = _binding!! - private lateinit var mediaListItem: MediaListItem - private val disposables = CompositeDisposable() + private val viewModel: GalleryItemViewModel by viewModels() + private val activityViewModel: GalleryViewModel by activityViewModels() + private var mediaController: MediaController? = null - private var pageTitle: PageTitle? = null - var imageTitle: PageTitle? = null - var mediaPage: MwQueryPage? = null - val mediaInfo get() = mediaPage?.imageInfo() + + val imageTitle get() = viewModel.imageTitle + val mediaPage get() = viewModel.mediaPage + val mediaInfo get() = viewModel.mediaPage?.imageInfo() private val requestPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted: Boolean -> if (isGranted) { @@ -75,14 +73,6 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - mediaListItem = requireArguments().parcelable(ARG_GALLERY_ITEM)!! - pageTitle = requireArguments().parcelable(Constants.ARG_TITLE) - ?: PageTitle(mediaListItem.title, Constants.commonsWikiSite) - imageTitle = PageTitle("File:${StringUtil.removeNamespace(mediaListItem.title)}", pageTitle!!.wikiSite) - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentGalleryItemBinding.inflate(inflater, container, false) requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) @@ -98,12 +88,27 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener binding.imageView.setAllowParentInterceptOnEdge(abs(binding.imageView.scale - 1f) < 0.01f) } } - loadMedia() return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> onLoading(true) + is Resource.Success -> onSuccess(it.data) + is Resource.Error -> onError(it.throwable) + } + } + } + } + } + } + override fun onDestroyView() { - disposables.clear() binding.imageView.setOnMatrixChangeListener(null) binding.imageView.setOnClickListener(null) binding.videoThumbnail.setOnClickListener(null) @@ -121,10 +126,6 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener } } - private fun updateProgressBar(visible: Boolean) { - binding.progressBar.visibility = if (visible) View.VISIBLE else View.GONE - } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.menu_gallery, menu) } @@ -133,19 +134,16 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener if (!isAdded) { return } + val enableShareAndSave = mediaInfo?.thumbUrl?.isNotEmpty() == true && binding.imageView.drawable != null menu.findItem(R.id.menu_gallery_visit_image_page).isEnabled = mediaInfo != null - menu.findItem(R.id.menu_gallery_share).isEnabled = mediaInfo != null && - mediaInfo!!.thumbUrl.isNotEmpty() && binding.imageView.drawable != null - menu.findItem(R.id.menu_gallery_save).isEnabled = mediaInfo != null && - mediaInfo!!.thumbUrl.isNotEmpty() && binding.imageView.drawable != null + menu.findItem(R.id.menu_gallery_share).isEnabled = enableShareAndSave + menu.findItem(R.id.menu_gallery_save).isEnabled = enableShareAndSave } override fun onMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.menu_gallery_visit_image_page -> { - if (mediaInfo != null && imageTitle != null) { - startActivity(FilePageActivity.newIntent(requireContext(), imageTitle!!)) - } + startActivity(FilePageActivity.newIntent(requireContext(), imageTitle)) true } R.id.menu_gallery_save -> { @@ -169,65 +167,53 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener } } - private fun loadMedia() { - if (pageTitle == null || imageTitle == null) { - return - } - updateProgressBar(true) - disposables.add(getMediaInfoDisposable(imageTitle!!.prefixedText, WikipediaApp.instance.appOrSystemLanguageCode) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { - updateProgressBar(false) - requireActivity().invalidateOptionsMenu() - (requireActivity() as GalleryActivity).layOutGalleryDescription(this) - } - .subscribe({ response -> - mediaPage = response.query?.firstPage() - if (FileUtil.isVideo(mediaListItem.type)) { - loadVideo() - } else { - loadImage(ImageUrlUtil.getUrlForPreferredSize(mediaInfo!!.thumbUrl, - Constants.PREFERRED_GALLERY_IMAGE_SIZE)) - } - }) { throwable -> - FeedbackUtil.showMessage(requireActivity(), R.string.gallery_error_draw_failed) - L.d(throwable) - }) + private fun onLoading(visible: Boolean) { + binding.progressBar.isVisible = visible } - private fun getMediaInfoDisposable(title: String, lang: String): Observable { - return if (FileUtil.isVideo(mediaListItem.type)) { - ServiceFactory.get(if (mediaListItem.isInCommons) Constants.commonsWikiSite - else pageTitle!!.wikiSite).getVideoInfo(title, lang) + private fun onSuccess(isVideo: Boolean) { + if (isVideo) { + loadVideo() } else { - ServiceFactory.get(if (mediaListItem.isInCommons) Constants.commonsWikiSite - else pageTitle!!.wikiSite).getImageInfo(title, lang) + var url = ImageUrlUtil.getUrlForPreferredSize(mediaInfo?.thumbUrl.orEmpty(), Constants.PREFERRED_GALLERY_IMAGE_SIZE) + if (mediaInfo?.mime?.contains("svg") == true && mediaInfo?.getMetadataTranslations()?.contains(activityViewModel.wikiSite.languageCode) == true) { + // SVG thumbnails can be rendered with language-specific translations, so let's + // get the correct URL that points to the appropriate language. + url = ImageUrlUtil.insertLangIntoThumbUrl(url, activityViewModel.wikiSite.languageCode) + } + loadImage(url) } + onLoading(false) + requireActivity().invalidateOptionsMenu() + (requireActivity() as GalleryActivity).fetchGalleryDescription(this) + } + + private fun onError(throwable: Throwable) { + FeedbackUtil.showMessage(requireActivity(), R.string.gallery_error_draw_failed) + L.d(throwable) } 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) { mediaController?.setPadding(0, 0, 0, DimenUtil.dpToPx(DimenUtil.getNavigationBarHeight(requireContext())).toInt()) } - updateProgressBar(true) + onLoading(true) binding.videoView.setMediaController(mediaController) binding.videoView.setOnPreparedListener { - updateProgressBar(false) + onLoading(false) // ...update the parent activity, which will trigger us to start playing! - (requireActivity() as GalleryActivity).layOutGalleryDescription(this@GalleryItemFragment) + (requireActivity() as GalleryActivity).fetchGalleryDescription(this@GalleryItemFragment) // hide the video thumbnail, since we're about to start playback binding.videoThumbnail.visibility = View.GONE binding.videoPlayButton.visibility = View.GONE @@ -236,7 +222,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener loading = false } binding.videoView.setOnErrorListener { _, _, _ -> - updateProgressBar(false) + onLoading(false) FeedbackUtil.showMessage(activity!!, R.string.gallery_error_video_failed) binding.videoView.visibility = View.GONE binding.videoThumbnail.visibility = View.VISIBLE @@ -244,7 +230,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener loading = false true } - binding.videoView.setVideoURI(Uri.parse(bestDerivative)) + binding.videoView.setVideoURI(Uri.parse(bestUrl)) } } @@ -252,20 +238,20 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener binding.videoContainer.visibility = View.VISIBLE binding.videoPlayButton.visibility = View.VISIBLE binding.videoView.visibility = View.GONE - if (mediaInfo == null || mediaInfo!!.thumbUrl.isEmpty()) { + if (mediaInfo?.thumbUrl?.isNotEmpty() != true) { binding.videoThumbnail.visibility = View.GONE } else { // show the video thumbnail while the video loads... binding.videoThumbnail.visibility = View.VISIBLE - ViewUtil.loadImage(binding.videoThumbnail, mediaInfo!!.thumbUrl, roundedCorners = false, largeRoundedSize = false, force = true, listener = this) + ViewUtil.loadImage(binding.videoThumbnail, mediaInfo!!.thumbUrl, roundedCorners = false, force = true, listener = this) } binding.videoThumbnail.setOnClickListener(videoThumbnailClickListener) } private fun loadImage(url: String) { binding.imageView.visibility = View.INVISIBLE - updateProgressBar(true) - ViewUtil.loadImage(binding.imageView, url, roundedCorners = false, largeRoundedSize = false, force = true, listener = this) + onLoading(true) + ViewUtil.loadImage(binding.imageView, url, roundedCorners = false, force = true, listener = this) } override fun onLoadFailed(e: GlideException?, model: Any?, target: Target, isFirstResource: Boolean): Boolean { @@ -282,22 +268,17 @@ 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()] + callback()?.onShare(this@GalleryItemFragment, bitmap, + StringUtil.removeHTMLTags(viewModel.imageTitle.displayText), imageTitle) + } } } - private val shareSubject get() = StringUtil.removeHTMLTags(pageTitle?.displayText) - private fun saveImage() { mediaInfo?.let { callback()?.onDownload(this) } } @@ -307,7 +288,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener } companion object { - private const val ARG_GALLERY_ITEM = "galleryItem" + const val ARG_GALLERY_ITEM = "galleryItem" fun newInstance(pageTitle: PageTitle?, item: MediaListItem): GalleryItemFragment { return GalleryItemFragment().apply { diff --git a/app/src/main/java/org/wikipedia/gallery/GalleryItemViewModel.kt b/app/src/main/java/org/wikipedia/gallery/GalleryItemViewModel.kt new file mode 100644 index 00000000000..cfed177d766 --- /dev/null +++ b/app/src/main/java/org/wikipedia/gallery/GalleryItemViewModel.kt @@ -0,0 +1,47 @@ +package org.wikipedia.gallery + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.WikipediaApp +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.mwapi.MwQueryPage +import org.wikipedia.page.PageTitle +import org.wikipedia.util.FileUtil +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil + +class GalleryItemViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private var mediaListItem = savedStateHandle.get(GalleryItemFragment.ARG_GALLERY_ITEM)!! + private val pageTitle = savedStateHandle[Constants.ARG_TITLE] ?: PageTitle(mediaListItem.title, Constants.commonsWikiSite) + var imageTitle = PageTitle("File:${StringUtil.removeNamespace(mediaListItem.title)}", pageTitle.wikiSite) + var mediaPage: MwQueryPage? = null + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + loadMedia() + } + + private fun loadMedia() { + _uiState.value = Resource.Loading() + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + }) { + val wikiSite = if (mediaListItem.isInCommons) Constants.commonsWikiSite else imageTitle.wikiSite + val response = if (mediaListItem.isVideo) { + ServiceFactory.get(wikiSite).getVideoInfo(imageTitle.prefixedText, WikipediaApp.instance.appOrSystemLanguageCode) + } else { + ServiceFactory.get(wikiSite).getImageInfo(imageTitle.prefixedText, WikipediaApp.instance.appOrSystemLanguageCode) + } + mediaPage = response.query?.firstPage() + _uiState.value = Resource.Success(FileUtil.isVideo(mediaPage?.imageInfo()?.mime.orEmpty())) + } + } +} diff --git a/app/src/main/java/org/wikipedia/gallery/GalleryViewModel.kt b/app/src/main/java/org/wikipedia/gallery/GalleryViewModel.kt new file mode 100644 index 00000000000..c6c0db77f64 --- /dev/null +++ b/app/src/main/java/org/wikipedia/gallery/GalleryViewModel.kt @@ -0,0 +1,62 @@ +package org.wikipedia.gallery + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.commons.ImageTagsProvider +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.wikidata.Entities +import org.wikipedia.page.PageTitle +import org.wikipedia.util.Resource + +class GalleryViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + val pageTitle = savedStateHandle.get(Constants.ARG_TITLE) + val wikiSite = savedStateHandle.get(Constants.ARG_WIKISITE)!! + val revision = savedStateHandle[GalleryActivity.EXTRA_REVISION] ?: 0L + var initialFilename = savedStateHandle.get(GalleryActivity.EXTRA_FILENAME) + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + private val _descriptionState = MutableStateFlow(Resource>()) + val descriptionState = _descriptionState.asStateFlow() + + fun fetchGalleryItems() { + _uiState.value = Resource.Loading() + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + }) { + pageTitle?.let { + val response = ServiceFactory.getRest(it.wikiSite).getMediaList(it.prefixedText, revision) + _uiState.value = Resource.Success(response) + } + } + } + + fun fetchGalleryDescription(pageTitle: PageTitle) { + _descriptionState.value = Resource.Loading() + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _descriptionState.value = Resource.Error(throwable) + }) { + val firstEntity = async { ServiceFactory.get(Constants.commonsWikiSite).getEntitiesByTitleSuspend(pageTitle.prefixedText, Constants.COMMONS_DB_NAME).first } + val protectionInfoResponse = async { ServiceFactory.get(Constants.commonsWikiSite).getProtectionWithUserInfo(pageTitle.prefixedText) } + val isProtected = protectionInfoResponse.await().query?.isEditProtected == true + _descriptionState.value = Resource.Success(isProtected to firstEntity.await()) + } + } + + fun getCaptions(entity: Entities.Entity?): Map { + return entity?.getLabels()?.values?.associate { it.language to it.value }.orEmpty() + } + + fun getDepicts(entity: Entities.Entity?): List { + return ImageTagsProvider.getDepictsClaims(entity?.getStatements().orEmpty()) + } +} diff --git a/app/src/main/java/org/wikipedia/gallery/ImageInfo.kt b/app/src/main/java/org/wikipedia/gallery/ImageInfo.kt index 3b038e40182..bba3ff2b137 100644 --- a/app/src/main/java/org/wikipedia/gallery/ImageInfo.kt +++ b/app/src/main/java/org/wikipedia/gallery/ImageInfo.kt @@ -2,7 +2,12 @@ package org.wikipedia.gallery import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import org.wikipedia.json.JsonUtil +@Suppress("unused") @Serializable class ImageInfo { @@ -37,6 +42,9 @@ class ImageInfo { val mime = "*/*" + @SerialName("metadata") + val plainMetadata: List = emptyList() + @SerialName("extmetadata") val metadata: ExtMetadata? = null @@ -46,12 +54,11 @@ class ImageInfo { val width = 0 val height = 0 - @Suppress("KotlinConstantConditions") fun getBestDerivativeForSize(widthDp: Int): Derivative? { var derivative: Derivative? = null derivatives.forEach { if (it.width in 1.. derivative!!.width) && !it.type.contains("ogg") && !it.type.contains("ogv")) { + if ((derivative == null || it.width > derivative.width) && !it.type.contains("ogg") && !it.type.contains("ogv")) { derivative = it } } @@ -59,6 +66,16 @@ class ImageInfo { return derivative } + fun getMetadataTranslations(): List { + plainMetadata.firstOrNull { it.name == "translations" }?.let { meta -> + if (meta.value is JsonArray) { + val translations = JsonUtil.json.decodeFromJsonElement>(meta.value) + return translations.map { it.name } + } + } + return emptyList() + } + @Serializable class Derivative { val src = "" @@ -69,4 +86,10 @@ class ImageInfo { private val height = 0 private val bandwidth: Long = 0 } + + @Serializable + class MetadataItem { + val name = "" + val value: JsonElement? = null + } } diff --git a/app/src/main/java/org/wikipedia/gallery/ImageLicense.kt b/app/src/main/java/org/wikipedia/gallery/ImageLicense.kt index a7c4073be76..6c51c4a0cf7 100644 --- a/app/src/main/java/org/wikipedia/gallery/ImageLicense.kt +++ b/app/src/main/java/org/wikipedia/gallery/ImageLicense.kt @@ -21,19 +21,18 @@ class ImageLicense( private val isLicenseCCBySa: Boolean get() = (licenseName.replace("-", "").startsWith(CC_BY_SA, true) || licenseShortName.replace("-", "").startsWith(CC_BY_SA, true)) - @get:DrawableRes - val licenseIcon: Int - get() { - if (isLicensePD) { - return R.drawable.ic_license_pd - } - if (isLicenseCCBySa) { - return R.drawable.ic_license_by - } - return if (isLicenseCC) { - R.drawable.ic_license_cc - } else R.drawable.ic_license_cite + @DrawableRes + fun licenseIcon(): Int { + return if (isLicensePD) { + R.drawable.ic_license_pd + } else if (isLicenseCCBySa) { + R.drawable.ic_license_by + } else if (isLicenseCC) { + R.drawable.ic_license_cc + } else { + R.drawable.ic_license_cite } + } companion object { private const val CREATIVE_COMMONS_PREFIX = "cc" diff --git a/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt b/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt index 88ce9749d09..97a3f59e8c4 100644 --- a/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt +++ b/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt @@ -4,22 +4,24 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.drawable.Drawable import com.bumptech.glide.Glide +import com.bumptech.glide.load.resource.bitmap.BitmapTransformation 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?, transform: BitmapTransformation? = null, callback: Callback) { + fun interface Callback { + fun onSuccess(bitmap: Bitmap) + } - operator fun get(context: Context) { + init { Glide.with(context) .asBitmap() + .let { if (transform != null) it.transform(transform) else it } .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..94b652126d1 100644 --- a/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt +++ b/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt @@ -9,16 +9,18 @@ import org.wikipedia.util.UriUtil @Serializable @Parcelize -class MediaListItem constructor(val title: String = "", - val type: String = "", - val caption: TextInfo? = null, - val showInGallery: Boolean = false, - @SerialName("section_id") private val sectionId: Int = 0, - @SerialName("srcset") val srcSets: List = emptyList()) : +class MediaListItem(val title: String = "", + val type: String = "", + val caption: TextInfo? = null, + val showInGallery: Boolean = false, + @SerialName("section_id") private val sectionId: Int = 0, + @SerialName("srcset") val srcSets: List = emptyList()) : Parcelable { 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 19f31be7ed1..e6599d8cb3d 100644 --- a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt +++ b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt @@ -99,5 +99,8 @@ class HistoryEntry( const val SOURCE_FILE_PAGE = 39 const val SOURCE_SINGLE_WEBVIEW = 40 const val SOURCE_SUGGESTED_EDITS_RECENT_EDITS = 41 + const val SOURCE_FEED_PLACES = 42 + const val SOURCE_RABBIT_HOLE_SEARCH = 43 + const val SOURCE_RABBIT_HOLE_READING_LIST = 44 } } diff --git a/app/src/main/java/org/wikipedia/history/HistoryFragment.kt b/app/src/main/java/org/wikipedia/history/HistoryFragment.kt index fd881df0346..b8a79d573ff 100644 --- a/app/src/main/java/org/wikipedia/history/HistoryFragment.kt +++ b/app/src/main/java/org/wikipedia/history/HistoryFragment.kt @@ -17,24 +17,17 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updateMarginsRelative import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder -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.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import org.wikipedia.BackPressedHandler import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.FragmentUtil -import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentHistoryBinding import org.wikipedia.main.MainActivity import org.wikipedia.main.MainFragment @@ -43,6 +36,7 @@ import org.wikipedia.readinglist.database.ReadingList import org.wikipedia.settings.Prefs import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.Resource import org.wikipedia.util.ResourceUtil import org.wikipedia.util.log.L import org.wikipedia.views.DefaultViewHolder @@ -57,20 +51,14 @@ class HistoryFragment : Fragment(), BackPressedHandler { private var _binding: FragmentHistoryBinding? = null private val binding get() = _binding!! + private val viewModel: HistoryViewModel by viewModels() - private var currentSearchQuery: String? = null - private val disposables = CompositeDisposable() private val adapter = HistoryEntryItemAdapter() private val itemCallback = ItemCallback() private var actionMode: ActionMode? = null private val searchActionModeCallback = HistorySearchCallback() private val selectedEntries = mutableSetOf() - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - retainInstance = true - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentHistoryBinding.inflate(inflater, container, false) @@ -86,6 +74,24 @@ class HistoryFragment : Fragment(), BackPressedHandler { return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + viewModel.historyItems.observe(viewLifecycleOwner) { + if (it is Resource.Success) { + onLoadItemsFinished(it.data) + } else if (it is Resource.Error) { + onError(it.throwable) + } + } + + viewModel.deleteHistoryItemsAction.observe(viewLifecycleOwner) { + if (it is Resource.Success) { + onPagesDeleted() + } + } + } + private fun setUpScrollListener() { binding.historyList.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { @@ -97,7 +103,7 @@ class HistoryFragment : Fragment(), BackPressedHandler { override fun onResume() { super.onResume() - reloadHistoryItems() + viewModel.reloadHistoryItems() } override fun onPause() { @@ -106,7 +112,6 @@ class HistoryFragment : Fragment(), BackPressedHandler { } override fun onDestroyView() { - disposables.clear() binding.historyList.adapter = null binding.historyList.clearOnScrollListeners() adapter.clearList() @@ -140,22 +145,12 @@ class HistoryFragment : Fragment(), BackPressedHandler { callback()?.onLoadPage(entry) } - private fun onClearHistoryClick() { - lifecycleScope.launch { - try { - AppDatabase.instance.historyEntryDao().deleteAll() - } finally { - reloadHistoryItems() - } - } - } - private fun finishActionMode() { actionMode?.finish() } private fun beginMultiSelect() { - if (SearchActionModeCallback.`is`(actionMode)) { + if (SearchActionModeCallback.matches(actionMode)) { finishActionMode() } } @@ -175,11 +170,12 @@ class HistoryFragment : Fragment(), BackPressedHandler { } else { actionMode?.title = resources.getQuantityString(R.plurals.multi_items_selected, selectedCount, selectedCount) } - adapter.notifyDataSetChanged() + val position = adapter.getPosition(entry) + adapter.notifyItemChanged(position) } fun refresh() { - adapter.notifyDataSetChanged() + adapter.notifyItemRangeChanged(0, adapter.itemCount) if (!WikipediaApp.instance.isOnline && Prefs.showHistoryOfflineArticlesToast) { Toast.makeText(requireContext(), R.string.history_offline_articles_toast, Toast.LENGTH_SHORT).show() Prefs.showHistoryOfflineArticlesToast = false @@ -188,59 +184,45 @@ class HistoryFragment : Fragment(), BackPressedHandler { private fun unselectAllPages() { selectedEntries.clear() - adapter.notifyDataSetChanged() + adapter.notifyItemRangeChanged(0, adapter.itemCount) } private fun deleteSelectedPages() { - val selectedEntryList = mutableListOf() - selectedEntryList.addAll(selectedEntries) - runBlocking(Dispatchers.IO) { - for (entry in selectedEntries) { - AppDatabase.instance.historyEntryDao().delete(entry) - } - } + viewModel.deleteHistoryItems(selectedEntries.toList()) + } + + private fun onPagesDeleted() { + showDeleteItemsUndoSnackbar(selectedEntries.toList()) selectedEntries.clear() - if (selectedEntryList.isNotEmpty()) { - showDeleteItemsUndoSnackbar(selectedEntryList) - reloadHistoryItems() - } + viewModel.reloadHistoryItems() } private fun showDeleteItemsUndoSnackbar(entries: List) { val message = if (entries.size == 1) getString(R.string.history_item_deleted, entries[0].title.displayText) else getString(R.string.history_items_deleted, entries.size) val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), message) snackbar.setAction(R.string.history_item_delete_undo) { - lifecycleScope.launch(Dispatchers.Main) { - AppDatabase.instance.historyEntryDao().insert(entries) - reloadHistoryItems() - } + viewModel.insertHistoryItem(entries) } snackbar.show() } - private fun reloadHistoryItems() { - disposables.clear() - disposables.add(Observable.fromCallable { AppDatabase.instance.historyEntryWithImageDao().filterHistoryItems(currentSearchQuery.orEmpty()) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ items -> onLoadItemsFinished(items) }) { t -> - L.e(t) - onLoadItemsFinished(emptyList()) - }) - } - private fun onLoadItemsFinished(items: List) { val list = mutableListOf() - if (!SearchActionModeCallback.`is`(actionMode)) { + if (!SearchActionModeCallback.matches(actionMode)) { list.add(SearchBar()) } list.addAll(items) adapter.setList(list) - updateEmptyState(currentSearchQuery) + updateEmptyState(viewModel.searchQuery) requireActivity().invalidateOptionsMenu() } - private class HeaderViewHolder constructor(itemView: View) : DefaultViewHolder(itemView) { + private fun onError(throwable: Throwable) { + L.e(throwable) + onLoadItemsFinished(emptyList()) + } + + private class HeaderViewHolder(itemView: View) : DefaultViewHolder(itemView) { var headerText = itemView.findViewById(R.id.section_header_text)!! fun bindItem(date: String) { @@ -248,7 +230,7 @@ class HistoryFragment : Fragment(), BackPressedHandler { } } - private inner class SearchCardViewHolder constructor(itemView: View) : DefaultViewHolder(itemView) { + private inner class SearchCardViewHolder(itemView: View) : DefaultViewHolder(itemView) { private val historyFilterButton: ImageView private val clearHistoryButton: ImageView @@ -294,7 +276,7 @@ class HistoryFragment : Fragment(), BackPressedHandler { MaterialAlertDialogBuilder(requireContext()) .setTitle(R.string.dialog_title_clear_history) .setMessage(R.string.dialog_message_clear_history) - .setPositiveButton(R.string.dialog_message_clear_history_yes) { _, _ -> onClearHistoryClick() } + .setPositiveButton(R.string.dialog_message_clear_history_yes) { _, _ -> viewModel.deleteAllHistoryItems() } .setNegativeButton(R.string.dialog_message_clear_history_no, null).show() } else { deleteSelectedPages() @@ -305,7 +287,7 @@ class HistoryFragment : Fragment(), BackPressedHandler { } } - private inner class HistoryEntryItemHolder constructor(itemView: PageItemView) : DefaultViewHolder>(itemView), SwipeableItemTouchHelperCallback.Callback { + private inner class HistoryEntryItemHolder(itemView: PageItemView) : DefaultViewHolder>(itemView), SwipeableItemTouchHelperCallback.Callback { private lateinit var entry: HistoryEntry fun bindItem(entry: HistoryEntry) { @@ -316,7 +298,7 @@ class HistoryFragment : Fragment(), BackPressedHandler { view.setDescription(entry.title.description) view.setImageUrl(entry.title.thumbUrl) view.isSelected = selectedEntries.contains(entry) - PageAvailableOfflineHandler.check(entry.title) { available: Boolean -> view.setViewsGreyedOut(!available) } + PageAvailableOfflineHandler.check(lifecycleScope, entry.title) { view.setViewsGreyedOut(!it) } } override fun onSwipe() { @@ -346,13 +328,17 @@ class HistoryFragment : Fragment(), BackPressedHandler { fun setList(list: MutableList) { historyEntries = list - notifyDataSetChanged() + adapter.notifyDataSetChanged() } fun clearList() { historyEntries.clear() } + fun getPosition(entry: HistoryEntry): Int { + return historyEntries.indexOf(entry) + } + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DefaultViewHolder<*> { return when (viewType) { Companion.VIEW_TYPE_SEARCH_CARD -> { @@ -392,7 +378,7 @@ class HistoryFragment : Fragment(), BackPressedHandler { fun hideHeader() { if (historyEntries.isNotEmpty() && historyEntries[0] is SearchBar) { historyEntries.removeAt(0) - notifyDataSetChanged() + adapter.notifyItemRemoved(0) } } } @@ -425,14 +411,14 @@ class HistoryFragment : Fragment(), BackPressedHandler { } override fun onQueryChange(s: String) { - currentSearchQuery = s.trim() - reloadHistoryItems() + viewModel.searchQuery = s.trim() + viewModel.reloadHistoryItems() } override fun onDestroyActionMode(mode: ActionMode) { super.onDestroyActionMode(mode) - currentSearchQuery = "" - reloadHistoryItems() + viewModel.searchQuery = "" + viewModel.reloadHistoryItems() actionMode = null (requireParentFragment() as MainFragment).setBottomNavVisible(true) } diff --git a/app/src/main/java/org/wikipedia/history/HistoryViewModel.kt b/app/src/main/java/org/wikipedia/history/HistoryViewModel.kt new file mode 100644 index 00000000000..fc1671b08e0 --- /dev/null +++ b/app/src/main/java/org/wikipedia/history/HistoryViewModel.kt @@ -0,0 +1,64 @@ +package org.wikipedia.history + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.wikipedia.database.AppDatabase +import org.wikipedia.util.Resource +import org.wikipedia.util.SingleLiveData + +class HistoryViewModel : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + historyItems.postValue(Resource.Error(throwable)) + } + + var searchQuery: String? = null + + val historyItems = MutableLiveData(Resource>()) + val deleteHistoryItemsAction = SingleLiveData>() + + init { + reloadHistoryItems() + } + + fun reloadHistoryItems() { + viewModelScope.launch(handler) { + loadHistoryItems() + } + } + + private suspend fun loadHistoryItems() { + withContext(Dispatchers.IO) { + val items = AppDatabase.instance.historyEntryWithImageDao().filterHistoryItems(searchQuery.orEmpty()) + historyItems.postValue(Resource.Success(items)) + } + } + + fun deleteAllHistoryItems() { + viewModelScope.launch(handler) { + AppDatabase.instance.historyEntryDao().deleteAll() + historyItems.postValue(Resource.Success(emptyList())) + } + } + + fun deleteHistoryItems(entries: List) { + viewModelScope.launch(handler) { + entries.forEach { + AppDatabase.instance.historyEntryDao().delete(it) + } + deleteHistoryItemsAction.postValue(Resource.Success(true)) + } + } + + fun insertHistoryItem(entries: List) { + viewModelScope.launch(handler) { + AppDatabase.instance.historyEntryDao().insert(entries) + loadHistoryItems() + } + } +} diff --git a/app/src/main/java/org/wikipedia/history/SearchActionModeCallback.java b/app/src/main/java/org/wikipedia/history/SearchActionModeCallback.java deleted file mode 100644 index d5a207cbd3f..00000000000 --- a/app/src/main/java/org/wikipedia/history/SearchActionModeCallback.java +++ /dev/null @@ -1,58 +0,0 @@ -package org.wikipedia.history; - -import android.content.Context; -import android.view.Menu; -import android.view.MenuItem; - -import androidx.annotation.Nullable; -import androidx.appcompat.view.ActionMode; -import androidx.core.view.MenuItemCompat; - -import org.wikipedia.views.SearchActionProvider; - -public abstract class SearchActionModeCallback implements ActionMode.Callback { - public static final String ACTION_MODE_TAG = "searchActionMode"; - - public static boolean is(@Nullable ActionMode mode) { - return mode != null && ACTION_MODE_TAG.equals(mode.getTag()); - } - - @Override - public boolean onCreateActionMode(ActionMode mode, Menu menu) { - mode.setTag(ACTION_MODE_TAG); - MenuItem menuItem = menu.add(getSearchHintString()); - // Manually setup a action provider to be able to adjust the left margin of the search field. - MenuItemCompat.setActionProvider(menuItem, new SearchActionProvider(getParentContext(), getSearchHintString(), new SearchActionProvider.Callback() { - @Override - public void onQueryTextChange(String s) { - onQueryChange(s); - } - - @Override - public void onQueryTextFocusChange() { - } - })); - - return true; - } - - protected abstract String getSearchHintString(); - - protected abstract void onQueryChange(String s); - - protected abstract Context getParentContext(); - - @Override - public boolean onPrepareActionMode(ActionMode mode, Menu menu) { - return true; - } - - @Override - public boolean onActionItemClicked(ActionMode mode, MenuItem menuItem) { - return false; - } - - @Override - public void onDestroyActionMode(ActionMode mode) { - } -} diff --git a/app/src/main/java/org/wikipedia/history/SearchActionModeCallback.kt b/app/src/main/java/org/wikipedia/history/SearchActionModeCallback.kt new file mode 100644 index 00000000000..b9da0442d2c --- /dev/null +++ b/app/src/main/java/org/wikipedia/history/SearchActionModeCallback.kt @@ -0,0 +1,42 @@ +package org.wikipedia.history + +import android.content.Context +import android.view.Menu +import android.view.MenuItem +import androidx.appcompat.view.ActionMode +import androidx.core.view.MenuItemCompat +import org.wikipedia.views.SearchActionProvider + +abstract class SearchActionModeCallback : ActionMode.Callback { + + protected abstract fun getSearchHintString(): String + protected abstract fun onQueryChange(s: String) + protected abstract fun getParentContext(): Context + + override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { + mode.tag = ACTION_MODE_TAG + val menuItem = menu.add(getSearchHintString()) + // Manually setup a action provider to be able to adjust the left margin of the search field. + MenuItemCompat.setActionProvider(menuItem, SearchActionProvider(getParentContext(), getSearchHintString()) { onQueryChange(it) }) + return true + } + + override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean { + return true + } + + override fun onActionItemClicked(mode: ActionMode, menuItem: MenuItem): Boolean { + return false + } + + override fun onDestroyActionMode(mode: ActionMode) { + } + + companion object { + const val ACTION_MODE_TAG: String = "searchActionMode" + + fun matches(mode: ActionMode?): Boolean { + return ACTION_MODE_TAG == mode?.tag + } + } +} diff --git a/app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt b/app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt index 03a68b0b54b..4e77785d7d2 100644 --- a/app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt +++ b/app/src/main/java/org/wikipedia/history/db/HistoryEntryDao.kt @@ -14,6 +14,9 @@ interface HistoryEntryDao { @Query("SELECT * FROM HistoryEntry WHERE authority = :authority AND lang = :lang AND apiTitle = :apiTitle LIMIT 1") suspend fun findEntryBy(authority: String, lang: String, apiTitle: String): HistoryEntry? + @Query("SELECT * FROM HistoryEntry WHERE lang = :lang ORDER BY timestamp DESC LIMIT :count") + suspend fun getLastHistoryEntries(lang: String, count: Int): List + @Query("DELETE FROM HistoryEntry") suspend fun deleteAll() diff --git a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt index d29620cd79e..fc6e0b25410 100644 --- a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt +++ b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt @@ -41,14 +41,13 @@ interface HistoryEntryWithImageDao { else SearchResults(entries.take(3).map { SearchResult(toHistoryEntry(it).title, SearchResult.SearchResultType.HISTORY) }.toMutableList()) } + fun filterHistoryItemsWithoutTime(searchQuery: String = ""): List { + return findEntriesBySearchTerm("%${normalizedQuery(searchQuery)}%").map { toHistoryEntry(it) } + } + fun filterHistoryItems(searchQuery: String): List { val list = mutableListOf() - val normalizedQuery = StringUtils.stripAccents(searchQuery).lowercase(Locale.getDefault()) - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_") - - val entries = findEntriesBySearchTerm("%$normalizedQuery%") + val entries = findEntriesBySearchTerm("%${normalizedQuery(searchQuery)}%") for (i in entries.indices) { // Check the previous item, see if the times differ enough @@ -78,6 +77,13 @@ interface HistoryEntryWithImageDao { return entries.map { toHistoryEntry(it) } } + private fun normalizedQuery(searchQuery: String): String { + return StringUtils.stripAccents(searchQuery).lowercase(Locale.getDefault()) + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + } + private fun toHistoryEntry(entryWithImage: HistoryEntryWithImage): HistoryEntry { val entry = HistoryEntry(entryWithImage.authority, entryWithImage.lang, entryWithImage.apiTitle, entryWithImage.displayTitle, 0, entryWithImage.namespace, entryWithImage.timestamp, diff --git a/app/src/main/java/org/wikipedia/language/AppLanguageLookUpTable.kt b/app/src/main/java/org/wikipedia/language/AppLanguageLookUpTable.kt index c2bf7ff2c8c..43cfdd51d5d 100644 --- a/app/src/main/java/org/wikipedia/language/AppLanguageLookUpTable.kt +++ b/app/src/main/java/org/wikipedia/language/AppLanguageLookUpTable.kt @@ -2,7 +2,7 @@ package org.wikipedia.language import android.content.Context import org.wikipedia.R -import java.util.* +import java.util.Locale class AppLanguageLookUpTable(context: Context) { private val resources = context.resources @@ -50,12 +50,16 @@ class AppLanguageLookUpTable(context: Context) { fun getLocalizedName(code: String?): String? { var name = localizedNames.getOrNull(indexOfCode(code)) if (name.isNullOrEmpty() && !code.isNullOrEmpty()) { - if (code == Locale.CHINESE.language) { - name = Locale.CHINESE.getDisplayName(Locale.CHINESE) - } else if (code == NORWEGIAN_LEGACY_LANGUAGE_CODE) { - name = localizedNames.getOrNull(indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE)) - } else if (code == BELARUSIAN_TARASK_LANGUAGE_CODE) { - name = localizedNames.getOrNull(indexOfCode(BELARUSIAN_LEGACY_LANGUAGE_CODE)) + when (code) { + Locale.CHINESE.language -> { + name = Locale.CHINESE.getDisplayName(Locale.CHINESE) + } + NORWEGIAN_LEGACY_LANGUAGE_CODE -> { + name = localizedNames.getOrNull(indexOfCode(NORWEGIAN_BOKMAL_LANGUAGE_CODE)) + } + BELARUSIAN_TARASK_LANGUAGE_CODE -> { + name = localizedNames.getOrNull(indexOfCode(BELARUSIAN_LEGACY_LANGUAGE_CODE)) + } } } return name @@ -86,6 +90,12 @@ class AppLanguageLookUpTable(context: Context) { } companion object { + private const val TAIWAN_COUNTRY_CODE = "TW" + private const val HONG_KONG_COUNTRY_CODE = "HK" + private const val MACAU_COUNTRY_CODE = "MO" + private const val SINGAPORE_COUNTRY_CODE = "SG" + private const val MALAYSIA_COUNTRY_CODE = "MY" + private const val CHINA_COUNTRY_CODE = "CN" const val SIMPLIFIED_CHINESE_LANGUAGE_CODE = "zh-hans" const val TRADITIONAL_CHINESE_LANGUAGE_CODE = "zh-hant" const val CHINESE_CN_LANGUAGE_CODE = "zh-cn" @@ -102,5 +112,23 @@ class AppLanguageLookUpTable(context: Context) { const val BELARUSIAN_TARASK_LANGUAGE_CODE = "be-tarask" const val TEST_LANGUAGE_CODE = "test" const val FALLBACK_LANGUAGE_CODE = "en" // Must exist in preference_language_keys. + + fun chineseLocaleToWikiLanguageCode(locale: Locale): String { + // When build a Locale with a language tag that starts with "zh-", the script is empty. + // Fall back to Traditional Chinese if the script is not specified. + if (locale.script == "Hans" || locale.script == "Hant" || locale.toLanguageTag().startsWith("zh-")) { + when (locale.country) { + TAIWAN_COUNTRY_CODE -> return CHINESE_TW_LANGUAGE_CODE + HONG_KONG_COUNTRY_CODE -> return CHINESE_HK_LANGUAGE_CODE + MACAU_COUNTRY_CODE -> return CHINESE_MO_LANGUAGE_CODE + SINGAPORE_COUNTRY_CODE -> return CHINESE_SG_LANGUAGE_CODE + MALAYSIA_COUNTRY_CODE -> return CHINESE_MY_LANGUAGE_CODE + CHINA_COUNTRY_CODE -> return CHINESE_CN_LANGUAGE_CODE + } + return if (locale.script == "Hans") SIMPLIFIED_CHINESE_LANGUAGE_CODE + else TRADITIONAL_CHINESE_LANGUAGE_CODE + } + return TRADITIONAL_CHINESE_LANGUAGE_CODE + } } } diff --git a/app/src/main/java/org/wikipedia/language/AppLanguageState.kt b/app/src/main/java/org/wikipedia/language/AppLanguageState.kt index 072d1dbce4f..848dd3e9c5a 100644 --- a/app/src/main/java/org/wikipedia/language/AppLanguageState.kt +++ b/app/src/main/java/org/wikipedia/language/AppLanguageState.kt @@ -6,7 +6,7 @@ import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.settings.Prefs import org.wikipedia.util.ReleaseUtil -import java.util.* +import java.util.Locale class AppLanguageState(context: Context) { @@ -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() { @@ -59,6 +59,10 @@ class AppLanguageState(context: Context) { if (!Prefs.isShowDeveloperSettingsEnabled && !ReleaseUtil.isPreBetaRelease) { codes.remove(AppLanguageLookUpTable.TEST_LANGUAGE_CODE) } + if (!Prefs.isShowDeveloperSettingsEnabled) { + codes.remove(AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE) + codes.remove(AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE) + } return codes } @@ -143,7 +147,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/LangLinksActivity.kt b/app/src/main/java/org/wikipedia/language/LangLinksActivity.kt index fe78a2b4450..a39ce192cfb 100644 --- a/app/src/main/java/org/wikipedia/language/LangLinksActivity.kt +++ b/app/src/main/java/org/wikipedia/language/LangLinksActivity.kt @@ -1,13 +1,19 @@ package org.wikipedia.language import android.content.Context +import android.content.Intent import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.widget.TextView import androidx.activity.viewModels import androidx.appcompat.view.ActionMode 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 @@ -28,7 +34,7 @@ class LangLinksActivity : BaseActivity() { private var currentSearchQuery: String? = null private var actionMode: ActionMode? = null - private val viewModel: LangLinksViewModel by viewModels { LangLinksViewModel.Factory(intent.extras!!) } + private val viewModel: LangLinksViewModel by viewModels() public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -229,13 +235,13 @@ class LangLinksActivity : BaseActivity() { } } - private open inner class DefaultViewHolder constructor(itemView: View) : RecyclerView.ViewHolder(itemView) { + private open inner class DefaultViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { open fun bindItem(pageTitle: PageTitle) { itemView.findViewById(R.id.section_header_text).text = StringUtil.fromHtml(pageTitle.displayText) } } - private inner class LangLinksItemViewHolder constructor(itemView: View) : DefaultViewHolder(itemView), View.OnClickListener { + private inner class LangLinksItemViewHolder(itemView: View) : DefaultViewHolder(itemView), View.OnClickListener { private val localizedLanguageNameTextView = itemView.findViewById(R.id.localized_language_name) private val nonLocalizedLanguageNameTextView = itemView.findViewById(R.id.non_localized_language_name) private val articleTitleTextView = itemView.findViewById(R.id.language_subtitle) @@ -259,8 +265,7 @@ class LangLinksActivity : BaseActivity() { override fun onClick(v: View) { app.languageState.addMruLanguageCode(pageTitle.wikiSite.languageCode) - val historyEntry = HistoryEntry(pageTitle, HistoryEntry.SOURCE_LANGUAGE_LINK) - val intent = PageActivity.newIntentForCurrentTab(this@LangLinksActivity, historyEntry, pageTitle, false) + val intent = PageActivity.newIntentForCurrentTab(this@LangLinksActivity, HistoryEntry(pageTitle, HistoryEntry.SOURCE_LANGUAGE_LINK), pageTitle, false) setResult(ACTIVITY_RESULT_LANGLINK_SELECT, intent) DeviceUtil.hideSoftKeyboard(this@LangLinksActivity) finish() @@ -269,9 +274,12 @@ class LangLinksActivity : BaseActivity() { companion object { const val ACTIVITY_RESULT_LANGLINK_SELECT = 1 - const val ACTION_LANGLINKS_FOR_TITLE = "org.wikipedia.langlinks_for_title" - private const val VIEW_TYPE_HEADER = 0 private const val VIEW_TYPE_ITEM = 1 + + fun newIntent(context: Context, title: PageTitle): Intent { + return Intent(context, LangLinksActivity::class.java) + .putExtra(Constants.ARG_TITLE, title) + } } } diff --git a/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt b/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt index b44ac6fbf1a..d8857f47f5f 100644 --- a/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt +++ b/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt @@ -1,26 +1,24 @@ package org.wikipedia.language -import android.os.Bundle import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle 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 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 import org.wikipedia.util.log.L -class LangLinksViewModel(bundle: Bundle) : ViewModel() { - var pageTitle = bundle.parcelable(Constants.ARG_TITLE)!! +class LangLinksViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val pageTitle = savedStateHandle.get(Constants.ARG_TITLE)!! val languageEntries = MutableLiveData>>() val languageEntryVariantUpdate = SingleLiveData>() @@ -84,7 +82,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))) } } @@ -114,24 +112,21 @@ class LangLinksViewModel(bundle: Bundle) : ViewModel() { .ifEmpty { WikipediaApp.instance.languageState.getAppLanguageCanonicalName(code) } } - class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { - @Suppress("unchecked_cast") - override fun create(modelClass: Class): T { - return LangLinksViewModel(bundle) as T - } - } - companion object { - @JvmStatic fun addVariantEntriesIfNeeded(language: AppLanguageState, title: PageTitle, languageEntries: MutableList) { val parentLanguageCode = language.getDefaultLanguageCode(title.wikiSite.languageCode) if (parentLanguageCode != null) { val languageVariants = language.getLanguageVariants(parentLanguageCode) if (languageVariants != null) { for (languageCode in languageVariants) { + // Do not add zh-hant and zh-hans to the list + if (listOf(AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE, + AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE).contains(languageCode)) { + continue + } if (!title.wikiSite.languageCode.contains(languageCode)) { - val pageTitle = PageTitle(if (title.isMainPage) SiteInfoClient.getMainPageForLang(languageCode) else title.displayText, WikiSite.forLanguageCode(languageCode)) - pageTitle.text = StringUtil.removeNamespace(title.prefixedText) + val pageTitle = PageTitle(if (title.isMainPage) MainPageNameData.valueFor(languageCode) else title.prefixedText, WikiSite.forLanguageCode(languageCode)) + pageTitle.displayText = title.displayText 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..935cebf0427 100644 --- a/app/src/main/java/org/wikipedia/language/LanguageUtil.kt +++ b/app/src/main/java/org/wikipedia/language/LanguageUtil.kt @@ -7,15 +7,13 @@ 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 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) + private const val MAX_SUGGESTED_LANGUAGES = 8 - val availableLanguages: List + val suggestedLanguagesFromSystem: List get() { val languages = mutableListOf() @@ -36,7 +34,7 @@ object LanguageUtil { // Query the installed keyboard languages, and add them to the list, if they don't exist. val imm = WikipediaApp.instance.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - val ims = imm.enabledInputMethodList ?: emptyList() + val ims = imm.enabledInputMethodList val langTagList = mutableListOf() for (method in ims) { val submethods = imm.getEnabledInputMethodSubtypeList(method, true) ?: emptyList() @@ -76,10 +74,9 @@ object LanguageUtil { } } } - return languages + return languages.take(MAX_SUGGESTED_LANGUAGES) } - @JvmStatic fun localeToWikiLanguageCode(locale: Locale): String { // Convert deprecated language codes to modern ones. // See https://developer.android.com/reference/java/util/Locale.html @@ -88,27 +85,11 @@ object LanguageUtil { "in" -> "id" // Indonesian "ji" -> "yi" // Yiddish "yue" -> AppLanguageLookUpTable.CHINESE_YUE_LANGUAGE_CODE - "zh" -> chineseLanguageCodeToWikiLanguageCode(locale) + "zh" -> AppLanguageLookUpTable.chineseLocaleToWikiLanguageCode(locale) else -> locale.language } } - private fun chineseLanguageCodeToWikiLanguageCode(locale: Locale): String { - when (locale.script) { - "Hans" -> return AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE - "Hant" -> return AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE - } - - // Guess based on country. If the guess is incorrect, the user must explicitly choose the - // dialect in the app settings. - return if (isTraditionalChinesePredominantInCountry(locale.country)) AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE - else AppLanguageLookUpTable.SIMPLIFIED_CHINESE_LANGUAGE_CODE - } - - private fun isTraditionalChinesePredominantInCountry(country: String?): Boolean { - return TRADITIONAL_CHINESE_COUNTRY_CODES.contains(country) - } - val firstSelectedChineseVariant: String get() { val firstSelectedChineseLangCode = @@ -116,7 +97,7 @@ object LanguageUtil { isChineseVariant(it) } return firstSelectedChineseLangCode.orEmpty() - .ifEmpty { AppLanguageLookUpTable.TRADITIONAL_CHINESE_LANGUAGE_CODE } + .ifEmpty { AppLanguageLookUpTable.CHINESE_TW_LANGUAGE_CODE } } fun isChineseVariant(langCode: String): Boolean { diff --git a/app/src/main/java/org/wikipedia/language/LanguagesListActivity.kt b/app/src/main/java/org/wikipedia/language/LanguagesListActivity.kt index 55f81c13a33..a95ec5b601e 100644 --- a/app/src/main/java/org/wikipedia/language/LanguagesListActivity.kt +++ b/app/src/main/java/org/wikipedia/language/LanguagesListActivity.kt @@ -3,7 +3,11 @@ package org.wikipedia.language import android.content.Context import android.content.Intent import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.widget.TextView import androidx.activity.viewModels import androidx.appcompat.view.ActionMode @@ -19,7 +23,6 @@ import org.wikipedia.settings.languages.WikipediaLanguagesFragment import org.wikipedia.util.DeviceUtil import org.wikipedia.util.Resource import org.wikipedia.util.StringUtil -import java.util.* class LanguagesListActivity : BaseActivity() { private lateinit var binding: ActivityLanguagesListBinding 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/login/LoginActivity.kt b/app/src/main/java/org/wikipedia/login/LoginActivity.kt index a6667a6c188..1e6458df453 100644 --- a/app/src/main/java/org/wikipedia/login/LoginActivity.kt +++ b/app/src/main/java/org/wikipedia/login/LoginActivity.kt @@ -9,15 +9,17 @@ import android.os.Bundle import android.view.View import android.view.inputmethod.EditorInfo import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.isVisible +import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity +import org.wikipedia.auth.AccountUtil import org.wikipedia.auth.AccountUtil.updateAccount import org.wikipedia.createaccount.CreateAccountActivity import org.wikipedia.databinding.ActivityLoginBinding import org.wikipedia.extensions.parcelableExtra -import org.wikipedia.login.LoginClient.LoginFailedException import org.wikipedia.notifications.PollNotificationWorker import org.wikipedia.page.PageTitle import org.wikipedia.push.WikipediaFirebaseMessagingService.Companion.updateSubscription @@ -25,6 +27,7 @@ import org.wikipedia.readinglist.sync.ReadingListSyncAdapter import org.wikipedia.settings.Prefs import org.wikipedia.util.DeviceUtil import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil.visitInExternalBrowser import org.wikipedia.util.log.L import org.wikipedia.views.NonEmptyValidator @@ -79,9 +82,17 @@ class LoginActivity : BaseActivity() { Prefs.isSuggestedEditsHighestPriorityEnabled = true } + if (AccountUtil.isTemporaryAccount) { + binding.footerContainer.tempAccountInfoContainer.isVisible = true + binding.footerContainer.tempAccountInfoText.text = StringUtil.fromHtml(getString(R.string.temp_account_login_status, AccountUtil.userName)) + } else { + binding.footerContainer.tempAccountInfoContainer.isVisible = false + } + // always go to account creation before logging in, unless we arrived here through the // system account creation workflow - if (savedInstanceState == null && !intent.hasExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE)) { + if (savedInstanceState == null && !intent.hasExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE) && + intent.getBooleanExtra(CREATE_ACCOUNT_FIRST, true)) { startCreateAccountActivity() } @@ -98,7 +109,6 @@ class LoginActivity : BaseActivity() { override fun onStop() { binding.viewProgressBar.visibility = View.GONE - loginClient.cancel() super.onStop() } @@ -110,8 +120,8 @@ class LoginActivity : BaseActivity() { private fun setAllViewsClickListener() { binding.loginButton.setOnClickListener { validateThenLogin() } binding.loginCreateAccountButton.setOnClickListener { startCreateAccountActivity() } - binding.inflateLoginAndAccount.privacyPolicyLink.setOnClickListener { FeedbackUtil.showPrivacyPolicy(this) } - binding.inflateLoginAndAccount.forgotPasswordLink.setOnClickListener { + binding.footerContainer.privacyPolicyLink.setOnClickListener { FeedbackUtil.showPrivacyPolicy(this) } + binding.footerContainer.forgotPasswordLink.setOnClickListener { val title = PageTitle("Special:PasswordReset", WikipediaApp.instance.wikiSite) visitInExternalBrowser(this, Uri.parse(title.uri)) } @@ -156,6 +166,7 @@ class LoginActivity : BaseActivity() { Prefs.isReadingListSyncEnabled = true Prefs.readingListPagesDeletedIds = emptySet() Prefs.readingListsDeletedIds = emptySet() + Prefs.tempAccountWelcomeShown = false ReadingListSyncAdapter.manualSyncWithForce() PollNotificationWorker.schedulePollNotificationJob(this) Prefs.isPushNotificationOptionsSet = false @@ -169,10 +180,11 @@ class LoginActivity : BaseActivity() { val twoFactorCode = getText(binding.login2faText) showProgressBar(true) if (twoFactorCode.isNotEmpty() && !firstStepToken.isNullOrEmpty()) { - loginClient.login(WikipediaApp.instance.wikiSite, username, password, + loginClient.login(lifecycleScope, WikipediaApp.instance.wikiSite, username, password, null, twoFactorCode, firstStepToken!!, loginCallback) } else { - loginClient.request(WikipediaApp.instance.wikiSite, username, password, loginCallback) + loginClient.login(lifecycleScope, WikipediaApp.instance.wikiSite, username, password, + null, null, null, loginCallback) } } @@ -227,9 +239,9 @@ class LoginActivity : BaseActivity() { const val RESULT_LOGIN_SUCCESS = 1 const val RESULT_LOGIN_FAIL = 2 const val LOGIN_REQUEST_SOURCE = "login_request_source" + const val CREATE_ACCOUNT_FIRST = "create_account_first" const val SOURCE_NAV = "navigation" const val SOURCE_EDIT = "edit" - const val SOURCE_BLOCKED = "blocked" const val SOURCE_SYSTEM = "system" const val SOURCE_ONBOARDING = "onboarding" const val SOURCE_SETTINGS = "settings" @@ -237,10 +249,12 @@ class LoginActivity : BaseActivity() { const val SOURCE_READING_MANUAL_SYNC = "reading_lists_manual_sync" const val SOURCE_LOGOUT_BACKGROUND = "logout_background" const val SOURCE_SUGGESTED_EDITS = "suggestededits" + const val SOURCE_TALK = "talk" - fun newIntent(context: Context, source: String, token: String? = null): Intent { + fun newIntent(context: Context, source: String, createAccountFirst: Boolean = true): Intent { return Intent(context, LoginActivity::class.java) .putExtra(LOGIN_REQUEST_SOURCE, source) + .putExtra(CREATE_ACCOUNT_FIRST, createAccountFirst) } } } diff --git a/app/src/main/java/org/wikipedia/login/LoginClient.kt b/app/src/main/java/org/wikipedia/login/LoginClient.kt index 4524017452f..ea5d9eb9f5c 100644 --- a/app/src/main/java/org/wikipedia/login/LoginClient.kt +++ b/app/src/main/java/org/wikipedia/login/LoginClient.kt @@ -1,25 +1,19 @@ package org.wikipedia.login import android.widget.Toast -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.serialization.SerialName -import kotlinx.serialization.Serializable +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.Service import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite -import org.wikipedia.dataclient.mwapi.MwResponse import org.wikipedia.util.log.L import java.io.IOException class LoginClient { - private val disposables = CompositeDisposable() - interface LoginCallback { fun success(result: LoginResult) fun twoFactorPrompt(caught: Throwable, token: String?) @@ -27,150 +21,63 @@ class LoginClient { fun error(caught: Throwable) } - fun request(wiki: WikiSite, userName: String, password: String, cb: LoginCallback) { - cancel() - disposables.add(getLoginToken(wiki) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ loginToken -> - login(wiki, userName, password, null, null, loginToken, cb) - }, { caught -> cb.error(caught) })) - } - - fun login(wiki: WikiSite, userName: String, password: String, retypedPassword: String?, - twoFactorCode: String?, loginToken: String?, cb: LoginCallback) { - disposables.add(getLoginResponse(wiki, userName, password, retypedPassword, twoFactorCode, loginToken) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .flatMap { loginResponse -> - val loginResult = loginResponse.toLoginResult(wiki, password) - if (loginResult != null) { - if (loginResult.pass() && userName.isNotEmpty()) { - return@flatMap getExtendedInfo(wiki, loginResult) - } else if (LoginResult.STATUS_UI == loginResult.status) { - when (loginResult) { - is LoginOAuthResult -> cb.twoFactorPrompt(LoginFailedException(loginResult.message), loginToken) - is LoginResetPasswordResult -> cb.passwordResetPrompt(loginToken) - else -> cb.error(LoginFailedException(loginResult.message)) - } - } else { - cb.error(LoginFailedException(loginResult.message)) + fun login(coroutineScope: CoroutineScope, wiki: WikiSite, userName: String, password: String, + retypedPassword: String?, twoFactorCode: String?, token: String?, cb: LoginCallback) { + coroutineScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e("Login process failed. $throwable") + cb.error(throwable) + }) { + val loginToken = token ?: getLoginToken(wiki) + val loginResult = getLoginResponse(wiki, userName, password, retypedPassword, twoFactorCode, loginToken).toLoginResult(wiki, password) + if (loginResult != null) { + if (loginResult.pass() && userName.isNotEmpty()) { + ServiceFactory.get(wiki).getUserInfo().query?.userInfo?.let { + loginResult.userId = it.id + loginResult.groups = it.groups() + L.v("Found user ID " + it.id + " for " + wiki.subdomain()) + } + cb.success(loginResult) + } else if (LoginResult.STATUS_UI == loginResult.status) { + when (loginResult) { + is LoginOAuthResult -> cb.twoFactorPrompt(LoginFailedException(loginResult.message), loginToken) + is LoginResetPasswordResult -> cb.passwordResetPrompt(loginToken) + else -> cb.error(LoginFailedException(loginResult.message)) } } else { - cb.error(IOException("Login failed. Unexpected response.")) + cb.error(LoginFailedException(loginResult.message)) } - Observable.empty() + } else { + cb.error(IOException("Login failed. Unexpected response.")) } - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ loginResult -> - cb.success(loginResult) - }) { caught -> - L.e("Login process failed. $caught") - cb.error(caught) - }) + } } - fun loginBlocking(wiki: WikiSite, userName: String, password: String, twoFactorCode: String?): Observable { - return getLoginToken(wiki) - .flatMap { loginToken -> - getLoginResponse(wiki, userName, password, null, twoFactorCode, loginToken) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .map { loginResponse -> - val loginResult = loginResponse.toLoginResult(wiki, password) ?: throw IOException("Unexpected response when logging in.") - if (LoginResult.STATUS_UI == loginResult.status) { - if (loginResult is LoginOAuthResult) { - // TODO: Find a better way to boil up the warning about 2FA - Toast.makeText(WikipediaApp.instance, - R.string.login_2fa_other_workflow_error_msg, Toast.LENGTH_LONG).show() - } - throw LoginFailedException(loginResult.message) - } else if (!loginResult.pass() || loginResult.userName.isNullOrEmpty()) { - throw LoginFailedException(loginResult.message) - } - loginResponse + suspend fun loginBlocking(wiki: WikiSite, userName: String, password: String, twoFactorCode: String?): LoginResponse { + val loginToken = getLoginToken(wiki) + val loginResponse = getLoginResponse(wiki, userName, password, null, twoFactorCode, loginToken) + val loginResult = loginResponse.toLoginResult(wiki, password) ?: throw IOException("Unexpected response when logging in.") + if (LoginResult.STATUS_UI == loginResult.status) { + if (loginResult is LoginOAuthResult) { + // TODO: Find a better way to boil up the warning about 2FA + Toast.makeText(WikipediaApp.instance, + R.string.login_2fa_other_workflow_error_msg, Toast.LENGTH_LONG).show() } + throw LoginFailedException(loginResult.message) + } else if (!loginResult.pass() || loginResult.userName.isNullOrEmpty()) { + throw LoginFailedException(loginResult.message) + } + return loginResponse } - private fun getLoginToken(wiki: WikiSite): Observable { - return ServiceFactory.get(wiki).loginToken - .subscribeOn(Schedulers.io()) - .map { response -> - val loginToken = response.query?.loginToken() - if (loginToken.isNullOrEmpty()) { - throw RuntimeException("Received empty login token.") - } - loginToken - } + private suspend fun getLoginToken(wiki: WikiSite): String { + val response = ServiceFactory.get(wiki).getLoginToken() + return response.query?.loginToken() ?: throw RuntimeException("Received empty login token.") } - private fun getLoginResponse(wiki: WikiSite, userName: String, password: String, retypedPassword: String?, - twoFactorCode: String?, loginToken: String?): Observable { + private suspend fun getLoginResponse(wiki: WikiSite, userName: String, password: String, retypedPassword: String?, + twoFactorCode: String?, loginToken: String?): LoginResponse { return if (twoFactorCode.isNullOrEmpty() && retypedPassword.isNullOrEmpty()) ServiceFactory.get(wiki).postLogIn(userName, password, loginToken, Service.WIKIPEDIA_URL) else ServiceFactory.get(wiki).postLogIn(userName, password, retypedPassword, twoFactorCode, loginToken, true) } - - private fun getExtendedInfo(wiki: WikiSite, loginResult: LoginResult): Observable { - return ServiceFactory.get(wiki).userInfo - .subscribeOn(Schedulers.io()) - .map { response -> - val id = response.query?.userInfo!!.id - loginResult.userId = id - loginResult.groups = response.query?.userInfo!!.groups() - L.v("Found user ID " + id + " for " + wiki.subdomain()) - loginResult - } - } - - fun cancel() { - disposables.clear() - } - - @Serializable - class LoginResponse : MwResponse() { - - @SerialName("clientlogin") - private val clientLogin: ClientLogin? = null - - fun toLoginResult(site: WikiSite, password: String): LoginResult? { - return clientLogin?.toLoginResult(site, password) - } - - @Serializable - private class ClientLogin { - - private val status: String? = null - private val requests: List? = null - private val message: String? = null - @SerialName("username") - private val userName: String? = null - - fun toLoginResult(site: WikiSite, password: String): LoginResult { - var userMessage = message - if (LoginResult.STATUS_UI == status) { - if (requests != null) { - for (req in requests) { - if (req.id.orEmpty().endsWith("TOTPAuthenticationRequest")) { - return LoginOAuthResult(site, status, userName, password, message) - } else if (req.id.orEmpty().endsWith("PasswordAuthenticationRequest")) { - return LoginResetPasswordResult(site, status, userName, password, message) - } - } - } - } else if (LoginResult.STATUS_PASS != status && LoginResult.STATUS_FAIL != status) { - // TODO: String resource -- Looks like needed for others in this class too - userMessage = "An unknown error occurred." - } - return LoginResult(site, status!!, userName, password, userMessage) - } - } - - @Serializable - private class Request { - val id: String? = null - } - } - - class LoginFailedException(message: String?) : Throwable(message) } diff --git a/app/src/main/java/org/wikipedia/login/LoginFailedException.kt b/app/src/main/java/org/wikipedia/login/LoginFailedException.kt new file mode 100644 index 00000000000..9511a09d69a --- /dev/null +++ b/app/src/main/java/org/wikipedia/login/LoginFailedException.kt @@ -0,0 +1,3 @@ +package org.wikipedia.login + +class LoginFailedException(message: String?) : Exception(message) diff --git a/app/src/main/java/org/wikipedia/login/LoginResponse.kt b/app/src/main/java/org/wikipedia/login/LoginResponse.kt new file mode 100644 index 00000000000..2c25215d608 --- /dev/null +++ b/app/src/main/java/org/wikipedia/login/LoginResponse.kt @@ -0,0 +1,51 @@ +package org.wikipedia.login + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.mwapi.MwResponse + +@Serializable +class LoginResponse : MwResponse() { + + @SerialName("clientlogin") + private val clientLogin: ClientLogin? = null + + fun toLoginResult(site: WikiSite, password: String): LoginResult? { + return clientLogin?.toLoginResult(site, password) + } + + @Serializable + private class ClientLogin { + + private val status: String? = null + private val requests: List? = null + private val message: String? = null + @SerialName("username") + private val userName: String? = null + + fun toLoginResult(site: WikiSite, password: String): LoginResult { + var userMessage = message + if (LoginResult.STATUS_UI == status) { + if (requests != null) { + for (req in requests) { + if (req.id.orEmpty().endsWith("TOTPAuthenticationRequest")) { + return LoginOAuthResult(site, status, userName, password, message) + } else if (req.id.orEmpty().endsWith("PasswordAuthenticationRequest")) { + return LoginResetPasswordResult(site, status, userName, password, message) + } + } + } + } else if (LoginResult.STATUS_PASS != status && LoginResult.STATUS_FAIL != status) { + // TODO: String resource -- Looks like needed for others in this class too + userMessage = "An unknown error occurred." + } + return LoginResult(site, status!!, userName, password, userMessage) + } + } + + @Serializable + private class Request { + val id: String? = null + } +} diff --git a/app/src/main/java/org/wikipedia/login/ResetPasswordActivity.kt b/app/src/main/java/org/wikipedia/login/ResetPasswordActivity.kt index c4370c6f441..497408ce368 100644 --- a/app/src/main/java/org/wikipedia/login/ResetPasswordActivity.kt +++ b/app/src/main/java/org/wikipedia/login/ResetPasswordActivity.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.os.Bundle import android.view.View import android.view.inputmethod.EditorInfo +import androidx.lifecycle.lifecycleScope import com.google.android.material.textfield.TextInputLayout import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -16,7 +17,6 @@ import org.wikipedia.createaccount.CreateAccountActivity.Companion.validateInput import org.wikipedia.createaccount.CreateAccountActivity.ValidateResult import org.wikipedia.databinding.ActivityResetPasswordBinding import org.wikipedia.extensions.parcelableExtra -import org.wikipedia.login.LoginClient.LoginFailedException import org.wikipedia.util.DeviceUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.log.L @@ -99,7 +99,7 @@ class ResetPasswordActivity : BaseActivity() { if (loginClient == null) { loginClient = LoginClient() } - loginClient?.login(WikipediaApp.instance.wikiSite, userName, password, + loginClient?.login(lifecycleScope, WikipediaApp.instance.wikiSite, userName, password, retypedPassword, twoFactorCode, firstStepToken, loginCallback) } diff --git a/app/src/main/java/org/wikipedia/main/MainActivity.kt b/app/src/main/java/org/wikipedia/main/MainActivity.kt index 3f16ee5ce66..060e4661338 100644 --- a/app/src/main/java/org/wikipedia/main/MainActivity.kt +++ b/app/src/main/java/org/wikipedia/main/MainActivity.kt @@ -8,28 +8,40 @@ import android.view.View import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.Toolbar +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.activity.SingleFragmentActivity +import org.wikipedia.analytics.eventplatform.ContributionsDashboardEvent import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent +import org.wikipedia.auth.AccountUtil import org.wikipedia.databinding.ActivityMainBinding import org.wikipedia.dataclient.WikiSite +import org.wikipedia.donate.DonorStatus +import org.wikipedia.feed.FeedFragment import org.wikipedia.navtab.NavTab import org.wikipedia.onboarding.InitialOnboardingActivity import org.wikipedia.page.PageActivity import org.wikipedia.settings.Prefs +import org.wikipedia.usercontrib.ContributionsDashboardHelper import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.ResourceUtil +import org.wikipedia.views.DonorBadgeView class MainActivity : SingleFragmentActivity(), MainFragment.Callback { private lateinit var binding: ActivityMainBinding private var controlNavTabInFragment = false - private val onboardingLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { } + private val onboardingLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val fragment = fragment.currentFragment + if (it.resultCode == InitialOnboardingActivity.RESULT_LANGUAGE_CHANGED && fragment is FeedFragment) { + fragment.refresh() + } + } override fun inflateAndSetContentView() { binding = ActivityMainBinding.inflate(layoutInflater) @@ -40,13 +52,8 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba super.onCreate(savedInstanceState) setImageZoomHelper() - if (Prefs.isInitialOnboardingEnabled && savedInstanceState == null && !intent.hasExtra( - Constants.INTENT_EXTRA_PREVIEW_SAVED_READING_LISTS)) { - // Updating preference so the search multilingual tooltip - // is not shown again for first time users - Prefs.isMultilingualSearchTooltipShown = false - - // Use startActivityForResult to avoid preload the Feed contents before finishing the initial onboarding. + if (Prefs.isInitialOnboardingEnabled && savedInstanceState == null && + !intent.hasExtra(Constants.INTENT_EXTRA_PREVIEW_SAVED_READING_LISTS)) { onboardingLauncher.launch(InitialOnboardingActivity.newIntent(this)) } setNavigationBarColor(ResourceUtil.getThemedColor(this, R.attr.paper_color)) @@ -70,21 +77,41 @@ class MainActivity : SingleFragmentActivity(), MainFragment.Callba } override fun onTabChanged(tab: NavTab) { - if (tab == NavTab.EDITS) { - ImageRecommendationsEvent.logImpression("suggested_edit_dialog") - PatrollerExperienceEvent.logImpression("suggested_edits_dialog") - } if (tab == NavTab.EXPLORE) { binding.mainToolbarWordmark.visibility = View.VISIBLE binding.mainToolbar.title = "" + binding.toolbarTitle.isVisible = false + binding.donorBadge.isVisible = false controlNavTabInFragment = false } else { + binding.toolbarTitle.isVisible = true + binding.donorBadge.isVisible = false if (tab == NavTab.SEARCH && Prefs.showSearchTabTooltip) { FeedbackUtil.showTooltip(this, fragment.binding.mainNavTabLayout.findViewById(NavTab.SEARCH.id), getString(R.string.search_tab_tooltip), aboveOrBelow = true, autoDismiss = false) Prefs.showSearchTabTooltip = false } + var titleText = getString(tab.text) + if (tab == NavTab.EDITS) { + ImageRecommendationsEvent.logImpression("suggested_edit_dialog") + PatrollerExperienceEvent.logImpression("suggested_edits_dialog") + if (ContributionsDashboardHelper.contributionsDashboardEnabled) { + titleText = if (AccountUtil.isLoggedIn) { + AccountUtil.userName + } else { + getString(R.string.contributions_dashboard_logged_out_user) + } + binding.donorBadge.disableClickForDonor() + binding.donorBadge.setup(object : DonorBadgeView.Callback { + override fun onBecomeDonorClick() { + ContributionsDashboardEvent.logAction("donate_start_click", "contrib_dashboard", campaignId = ContributionsDashboardHelper.CAMPAIGN_ID) + launchDonateDialog(campaignId = ContributionsDashboardHelper.CAMPAIGN_ID) + } + }) + binding.donorBadge.isVisible = DonorStatus.donorStatus() != DonorStatus.UNKNOWN + } + } binding.mainToolbarWordmark.visibility = View.GONE - binding.mainToolbar.setTitle(tab.text) + binding.toolbarTitle.text = titleText controlNavTabInFragment = true } fragment.requestUpdateToolbarElevation() diff --git a/app/src/main/java/org/wikipedia/main/MainFragment.kt b/app/src/main/java/org/wikipedia/main/MainFragment.kt index dfb9273b729..aded43e3f71 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 @@ -25,20 +24,23 @@ import androidx.core.view.descendants import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.functions.Consumer +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.wikipedia.BackPressedHandler import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.activity.BaseActivity 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 +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.databinding.FragmentMainBinding import org.wikipedia.dataclient.WikiSite import org.wikipedia.events.ImportReadingListsEvent @@ -63,6 +65,7 @@ import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle import org.wikipedia.page.tabs.TabActivity +import org.wikipedia.places.PlacesActivity import org.wikipedia.random.RandomActivity import org.wikipedia.readinglist.ReadingListBehaviorsUtil import org.wikipedia.readinglist.ReadingListsFragment @@ -70,7 +73,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 @@ -101,7 +104,6 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. private val downloadReceiver = MediaDownloadReceiver() private val downloadReceiverCallback = MediaDownloadReceiverCallback() private val pageChangeCallback = PageChangeCallback() - private val disposables = CompositeDisposable() private var exclusiveTooltipRunnable: Runnable? = null // The permissions request API doesn't take a callback, so in the event we have to @@ -124,7 +126,21 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. _binding = FragmentMainBinding.inflate(inflater, container, false) requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) - disposables.add(WikipediaApp.instance.bus.subscribe(EventBusConsumer())) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + FlowEventBus.events.collectLatest { event -> + when (event) { + is LoggedOutInBackgroundEvent -> { + refreshContents() + } + is ImportReadingListsEvent -> { + maybeShowImportReadingListsNewInstallDialog() + } + } + } + } + } + binding.mainViewPager.isUserInputEnabled = false binding.mainViewPager.adapter = NavTabFragmentPagerAdapter(this) binding.mainViewPager.registerOnPageChangeCallback(pageChangeCallback) @@ -132,12 +148,11 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. it.maxLines = 2 } - FeedbackUtil.setButtonTooltip(binding.navMoreContainer) - binding.navMoreContainer.setOnClickListener { - ExclusiveBottomSheetPresenter.show(childFragmentManager, MenuNavTabDialog.newInstance()) - } - binding.mainNavTabLayout.setOnItemSelectedListener { item -> + if (item.order == NavTab.MORE.code()) { + ExclusiveBottomSheetPresenter.show(childFragmentManager, MenuNavTabDialog.newInstance()) + return@setOnItemSelectedListener false + } val fragment = currentFragment if (fragment is FeedFragment && item.order == 0) { fragment.scrollToTop() @@ -171,7 +186,6 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. downloadReceiver.register(requireContext(), downloadReceiverCallback) // reset the last-page-viewed timer Prefs.pageLastShown = 0 - maybeShowPlacesTooltip() } override fun onDestroyView() { @@ -179,7 +193,6 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. binding.mainViewPager.adapter = null binding.mainViewPager.unregisterOnPageChangeCallback(pageChangeCallback) _binding = null - disposables.dispose() super.onDestroyView() } @@ -202,7 +215,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) { @@ -301,6 +315,8 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. openSearchActivity(InvokeSource.APP_SHORTCUTS, null, null) } else if (intent.hasExtra(Constants.INTENT_APP_SHORTCUT_CONTINUE_READING)) { startActivity(PageActivity.newIntent(requireActivity())) + } else if (intent.hasExtra(Constants.INTENT_APP_SHORTCUT_PLACES)) { + startActivity(PlacesActivity.newIntent(requireActivity())) } else if (intent.hasExtra(Constants.INTENT_EXTRA_DELETE_READING_LIST)) { goToTab(NavTab.READING_LISTS) } else if (intent.hasExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB) && @@ -371,16 +387,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(lifecycleScope, requireContext(), bitmap, File(thumbUrl).name, + ShareUtil.getFeaturedImageShareSubject(requireContext(), card.age()), fullSizeUrl) + } } override fun onFeedDownloadImage(image: FeaturedImage) { @@ -421,7 +434,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. } override fun usernameClick() { - val pageTitle = PageTitle(UserAliasData.valueFor(WikipediaApp.instance.languageState.appLanguageCode), AccountUtil.userName.orEmpty(), WikipediaApp.instance.wikiSite) + val pageTitle = PageTitle(UserAliasData.valueFor(WikipediaApp.instance.languageState.appLanguageCode), AccountUtil.userName, WikipediaApp.instance.wikiSite) val entry = HistoryEntry(pageTitle, HistoryEntry.SOURCE_MAIN_PAGE) startActivity(PageActivity.newIntentForNewTab(requireContext(), entry, pageTitle)) } @@ -432,11 +445,9 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. override fun talkClick() { if (AccountUtil.isLoggedIn) { - AccountUtil.userName?.let { - startActivity(TalkTopicsActivity.newIntent(requireActivity(), - PageTitle(UserTalkAliasData.valueFor(WikipediaApp.instance.languageState.appLanguageCode), it, - WikiSite.forLanguageCode(WikipediaApp.instance.appOrSystemLanguageCode)), InvokeSource.NAV_MENU)) - } + startActivity(TalkTopicsActivity.newIntent(requireActivity(), + PageTitle(UserTalkAliasData.valueFor(WikipediaApp.instance.languageState.appLanguageCode), AccountUtil.userName, + WikiSite.forLanguageCode(WikipediaApp.instance.appOrSystemLanguageCode)), InvokeSource.NAV_MENU)) } } @@ -452,13 +463,17 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. override fun contribsClick() { if (AccountUtil.isLoggedIn) { - startActivity(UserContribListActivity.newIntent(requireActivity(), AccountUtil.userName.orEmpty())) + startActivity(UserContribListActivity.newIntent(requireActivity(), AccountUtil.userName)) } } + override fun donateClick(campaignId: String?) { + (requireActivity() as? BaseActivity)?.launchDonateDialog(campaignId = campaignId) + } + fun setBottomNavVisible(visible: Boolean) { binding.mainNavTabBorder.isVisible = visible - binding.mainNavTabContainer.isVisible = visible + binding.mainNavTabLayout.isVisible = visible } fun onGoOffline() { @@ -554,19 +569,6 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. } } - private fun maybeShowPlacesTooltip() { - if (Prefs.showOneTimePlacesMainNavOnboardingTooltip && Prefs.exploreFeedVisitCount > SHOW_PLACES_MAIN_NAV_TOOLTIP) { - enqueueTooltip { - 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") - } - } - } - } - private inner class PageChangeCallback : OnPageChangeCallback() { override fun onPageSelected(position: Int) { callback()?.onTabChanged(NavTab.of(position)) @@ -579,16 +581,6 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. } } - private inner class EventBusConsumer : Consumer { - override fun accept(event: Any) { - if (event is LoggedOutInBackgroundEvent) { - refreshContents() - } else if (event is ImportReadingListsEvent) { - maybeShowImportReadingListsNewInstallDialog() - } - } - } - private fun enqueueTooltip(runnable: Runnable) { if (exclusiveTooltipRunnable != null) { return @@ -610,7 +602,6 @@ 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 9cf8f48cce5..868c11df305 100644 --- a/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt +++ b/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt @@ -4,20 +4,20 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.isVisible import androidx.core.widget.ImageViewCompat import com.google.android.material.bottomsheet.BottomSheetBehavior -import org.wikipedia.BuildConfig import org.wikipedia.R -import org.wikipedia.WikipediaApp import org.wikipedia.activity.FragmentUtil import org.wikipedia.analytics.eventplatform.BreadCrumbLogEvent +import org.wikipedia.analytics.eventplatform.ContributionsDashboardEvent 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.usercontrib.ContributionsDashboardHelper import org.wikipedia.util.DimenUtil import org.wikipedia.util.ResourceUtil.getThemedColorStateList @@ -29,6 +29,7 @@ class MenuNavTabDialog : ExtendedBottomSheetDialogFragment() { fun settingsClick() fun watchlistClick() fun contribsClick() + fun donateClick(campaignId: String? = null) } private var _binding: ViewMainDrawerBinding? = null @@ -39,7 +40,7 @@ class MenuNavTabDialog : ExtendedBottomSheetDialogFragment() { binding.mainDrawerAccountContainer.setOnClickListener { BreadCrumbLogEvent.logClick(requireActivity(), binding.mainDrawerAccountContainer) - if (AccountUtil.isLoggedIn) { + if (AccountUtil.isLoggedIn && !AccountUtil.isTemporaryAccount) { callback()?.usernameClick() } else { callback()?.loginClick() @@ -78,10 +79,14 @@ class MenuNavTabDialog : ExtendedBottomSheetDialogFragment() { } binding.mainDrawerDonateContainer.setOnClickListener { - DonorExperienceEvent.logAction("donate_start_click", "more_menu") BreadCrumbLogEvent.logClick(requireActivity(), binding.mainDrawerDonateContainer) - CustomTabsUtil.openInCustomTab(requireContext(), getString(R.string.donate_url, - WikipediaApp.instance.languageState.systemLanguageCode, BuildConfig.VERSION_NAME)) + if (ContributionsDashboardHelper.contributionsDashboardEnabled) { + ContributionsDashboardEvent.logAction("donate_start_click", "more_menu", campaignId = ContributionsDashboardHelper.CAMPAIGN_ID) + callback()?.donateClick(campaignId = ContributionsDashboardHelper.CAMPAIGN_ID) + } else { + DonorExperienceEvent.logAction("donate_start_click", "more_menu") + callback()?.donateClick() + } dismiss() } @@ -101,18 +106,31 @@ class MenuNavTabDialog : ExtendedBottomSheetDialogFragment() { private fun updateState() { if (AccountUtil.isLoggedIn) { - binding.mainDrawerAccountAvatar.setImageResource(R.drawable.ic_baseline_person_24) - ImageViewCompat.setImageTintList(binding.mainDrawerAccountAvatar, getThemedColorStateList(requireContext(), R.attr.secondary_color)) - binding.mainDrawerAccountName.text = AccountUtil.userName - binding.mainDrawerAccountName.visibility = View.VISIBLE - binding.mainDrawerLoginButton.visibility = View.GONE + if (AccountUtil.isTemporaryAccount) { + binding.mainDrawerAccountAvatar.setImageResource(R.drawable.ic_login_24px) + ImageViewCompat.setImageTintList(binding.mainDrawerAccountAvatar, getThemedColorStateList(requireContext(), R.attr.progressive_color)) + binding.tempAccountName.text = AccountUtil.userName + binding.mainDrawerAccountName.isVisible = false + binding.mainDrawerLoginButton.textAlignment = View.TEXT_ALIGNMENT_TEXT_START + binding.mainDrawerLoginButton.text = getString(R.string.main_drawer_login) + binding.mainDrawerLoginButton.setTextColor(getThemedColorStateList(requireContext(), R.attr.progressive_color)) + binding.mainDrawerLoginButton.isVisible = true + } else { + binding.mainDrawerAccountAvatar.setImageResource(R.drawable.ic_baseline_person_24) + ImageViewCompat.setImageTintList(binding.mainDrawerAccountAvatar, getThemedColorStateList(requireContext(), R.attr.secondary_color)) + binding.mainDrawerAccountName.text = AccountUtil.userName + binding.mainDrawerAccountName.isVisible = true + binding.mainDrawerLoginButton.isVisible = false + } binding.mainDrawerTalkContainer.visibility = View.VISIBLE - binding.mainDrawerWatchlistContainer.visibility = View.VISIBLE + binding.mainDrawerTempAccountContainer.isVisible = AccountUtil.isTemporaryAccount + binding.mainDrawerWatchlistContainer.isVisible = !AccountUtil.isTemporaryAccount binding.mainDrawerContribsContainer.visibility = View.VISIBLE } else { binding.mainDrawerAccountAvatar.setImageResource(R.drawable.ic_login_24px) ImageViewCompat.setImageTintList(binding.mainDrawerAccountAvatar, getThemedColorStateList(requireContext(), R.attr.progressive_color)) binding.mainDrawerAccountName.visibility = View.GONE + binding.mainDrawerTempAccountContainer.isVisible = false binding.mainDrawerLoginButton.textAlignment = View.TEXT_ALIGNMENT_TEXT_START binding.mainDrawerLoginButton.text = getString(R.string.main_drawer_login) binding.mainDrawerLoginButton.setTextColor(getThemedColorStateList(requireContext(), R.attr.progressive_color)) diff --git a/app/src/main/java/org/wikipedia/navtab/NavTab.kt b/app/src/main/java/org/wikipedia/navtab/NavTab.kt index 18580889d49..cab7358185f 100644 --- a/app/src/main/java/org/wikipedia/navtab/NavTab.kt +++ b/app/src/main/java/org/wikipedia/navtab/NavTab.kt @@ -9,6 +9,7 @@ import org.wikipedia.history.HistoryFragment import org.wikipedia.model.EnumCode import org.wikipedia.readinglist.ReadingListsFragment import org.wikipedia.suggestededits.SuggestedEditsTasksFragment +import org.wikipedia.usercontrib.ContributionsDashboardHelper enum class NavTab constructor( @StringRes val text: Int, @@ -31,10 +32,18 @@ enum class NavTab constructor( return HistoryFragment.newInstance() } }, - EDITS(R.string.nav_item_suggested_edits, R.id.nav_tab_edits, R.drawable.selector_nav_edits) { + EDITS( + if (ContributionsDashboardHelper.contributionsDashboardEnabled) R.string.nav_item_contribute + else R.string.nav_item_suggested_edits, R.id.nav_tab_edits, R.drawable.selector_nav_edits + ) { override fun newInstance(): Fragment { return SuggestedEditsTasksFragment.newInstance() } + }, + MORE(R.string.nav_item_more, R.id.nav_tab_more, R.drawable.ic_menu_white_24dp) { + override fun newInstance(): Fragment { + return Fragment() + } }; abstract fun newInstance(): Fragment diff --git a/app/src/main/java/org/wikipedia/notifications/AnonymousNotificationHelper.kt b/app/src/main/java/org/wikipedia/notifications/AnonymousNotificationHelper.kt index 38dd26e3baf..f23cf7bebbd 100644 --- a/app/src/main/java/org/wikipedia/notifications/AnonymousNotificationHelper.kt +++ b/app/src/main/java/org/wikipedia/notifications/AnonymousNotificationHelper.kt @@ -1,6 +1,5 @@ package org.wikipedia.notifications -import io.reactivex.rxjava3.core.Observable import org.wikipedia.auth.AccountUtil import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite @@ -8,7 +7,7 @@ import org.wikipedia.dataclient.mwapi.MwQueryResponse import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs import org.wikipedia.util.DateUtil -import java.util.* +import java.util.Date import java.util.concurrent.TimeUnit object AnonymousNotificationHelper { @@ -20,11 +19,11 @@ object AnonymousNotificationHelper { } } - fun observableForAnonUserInfo(wikiSite: WikiSite): Observable { + suspend fun observableForAnonUserInfo(wikiSite: WikiSite): MwQueryResponse { return if (Date().time - Prefs.lastAnonEditTime < TimeUnit.DAYS.toMillis(NOTIFICATION_DURATION_DAYS)) { - ServiceFactory.get(wikiSite).userInfo + ServiceFactory.get(wikiSite).getUserInfo() } else { - Observable.just(MwQueryResponse()) + MwQueryResponse() } } diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt b/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt index 2c5f45ed808..8c6f1d40b1f 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 @@ -75,7 +76,6 @@ class NotificationActivity : BaseActivity() { private var actionMode: ActionMode? = null private val multiSelectActionModeCallback = MultiSelectCallback() private val searchActionModeCallback = SearchCallback() - private var linkHandler = NotificationLinkHandler(this) private var notificationActionOverflowView: NotificationActionsOverflowView? = null private val typefaceSansSerifBold = Typeface.create("sans-serif", Typeface.BOLD) @@ -138,8 +138,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) } } } @@ -150,7 +150,7 @@ class NotificationActivity : BaseActivity() { super.onResume() actionMode?.let { postprocessAndDisplay() - if (SearchActionModeCallback.`is`(it)) { + if (SearchActionModeCallback.matches(it)) { searchActionModeCallback.refreshProvider() } } @@ -317,7 +317,7 @@ class NotificationActivity : BaseActivity() { } private fun beginMultiSelect() { - if (SearchActionModeCallback.`is`(actionMode)) { + if (SearchActionModeCallback.matches(actionMode)) { finishActionMode() } if (!MultiSelectActionModeCallback.isTagType(actionMode)) { @@ -345,10 +345,11 @@ class NotificationActivity : BaseActivity() { private val selectedItems get() = notificationContainerList.filterNot { it.type == NotificationListItemContainer.ITEM_SEARCH_BAR }.filter { it.selected } - private inner class NotificationItemHolder constructor(val binding: ItemNotificationBinding) : + private inner class NotificationItemHolder(val binding: ItemNotificationBinding) : RecyclerView.ViewHolder(binding.root), View.OnClickListener, View.OnLongClickListener, SwipeableItemTouchHelperCallback.Callback { lateinit var container: NotificationListItemContainer + lateinit var linkHandler: NotificationLinkHandler var itemPosition = -1 init { @@ -367,6 +368,7 @@ class NotificationActivity : BaseActivity() { val primaryColor = ResourceUtil.getThemedColorStateList(this@NotificationActivity, R.attr.primary_color) val inactiveColor = ResourceUtil.getThemedColorStateList(this@NotificationActivity, R.attr.inactive_color) + this.linkHandler = NotificationLinkHandler(this@NotificationActivity, notificationCategory) binding.notificationItemImage.setImageResource(notificationCategory.iconResId) ImageViewCompat.setImageTintList(binding.notificationItemImage, if (n.isUnread) notificationColor else ResourceUtil.getThemedColorStateList(this@NotificationActivity, R.attr.placeholder_color)) @@ -581,15 +583,12 @@ class NotificationActivity : BaseActivity() { var searchAndFilterActionProvider: SearchAndFilterActionProvider? = null override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { adjustRefreshViewLayoutParams(true) - searchAndFilterActionProvider = SearchAndFilterActionProvider(this@NotificationActivity, searchHintString, + searchAndFilterActionProvider = SearchAndFilterActionProvider(this@NotificationActivity, getSearchHintString(), object : SearchAndFilterActionProvider.Callback { override fun onQueryTextChange(s: String) { onQueryChange(s) } - override fun onQueryTextFocusChange() { - } - override fun onFilterIconClick() { DeviceUtil.hideSoftKeyboard(this@NotificationActivity) startActivity(NotificationFilterActivity.newIntent(this@NotificationActivity)) @@ -604,7 +603,7 @@ class NotificationActivity : BaseActivity() { } }) - val menuItem = menu.add(searchHintString) + val menuItem = menu.add(getSearchHintString()) MenuItemCompat.setActionProvider(menuItem, searchAndFilterActionProvider) diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationCategory.kt b/app/src/main/java/org/wikipedia/notifications/NotificationCategory.kt index 19df7f755cd..555025982d2 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationCategory.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationCategory.kt @@ -16,13 +16,13 @@ private const val GROUP_WIKIPEDIA_NOTIFICATIONS: String = "WIKIPEDIA_NOTIFICATIO private const val GROUP_OTHER: String = "WIKIPEDIA_NOTIFICATIONS_OTHER" @Suppress("unused") -enum class NotificationCategory constructor(val id: String, - @StringRes val title: Int, - @StringRes val description: Int, - @DrawableRes val iconResId: Int = R.drawable.ic_settings_black_24dp, - @AttrRes val iconColor: Int = R.attr.progressive_color, - val importance: Int = NotificationManagerCompat.IMPORTANCE_DEFAULT, - val group: String? = GROUP_WIKIPEDIA_NOTIFICATIONS) : EnumCode { +enum class NotificationCategory(val id: String, + @StringRes val title: Int, + @StringRes val description: Int, + @DrawableRes val iconResId: Int = R.drawable.ic_settings_black_24dp, + @AttrRes val iconColor: Int = R.attr.progressive_color, + val importance: Int = NotificationManagerCompat.IMPORTANCE_DEFAULT, + val group: String? = GROUP_WIKIPEDIA_NOTIFICATIONS) : EnumCode { SYSTEM("system", R.string.preference_title_notification_system, R.string.preference_summary_notification_system, R.drawable.ic_settings_black_24dp), MILESTONE_EDIT("thank-you-edit", R.string.preference_title_notification_milestone, R.string.preference_summary_notification_milestone, R.drawable.ic_notification_milestone), // milestone EDIT_USER_TALK("edit-user-talk", R.string.preference_title_notification_user_talk, R.string.preference_summary_notification_user_talk, R.drawable.ic_notification_user_talk, importance = NotificationManagerCompat.IMPORTANCE_HIGH), diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationLinkHandler.kt b/app/src/main/java/org/wikipedia/notifications/NotificationLinkHandler.kt index debfc2779e9..dfd9e2a44af 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationLinkHandler.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationLinkHandler.kt @@ -11,7 +11,7 @@ import org.wikipedia.page.PageTitle import org.wikipedia.util.CustomTabsUtil import org.wikipedia.util.log.L -class NotificationLinkHandler constructor(context: Context) : LinkHandler(context) { +class NotificationLinkHandler(context: Context, private val category: NotificationCategory) : LinkHandler(context) { override fun onPageLinkClicked(anchor: String, linkText: String) { // ignore @@ -28,6 +28,11 @@ class NotificationLinkHandler constructor(context: Context) : LinkHandler(contex override lateinit var wikiSite: WikiSite override fun onInternalLinkClicked(title: PageTitle) { + // Make sure the login-failed links are opened in the external browser + if (category == NotificationCategory.LOGIN_FAIL) { + onExternalLinkClicked(Uri.parse(title.uri)) + return + } context.startActivity(PageActivity.newIntentForCurrentTab(context, HistoryEntry(title, HistoryEntry.SOURCE_NOTIFICATION), title)) } diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationPollBroadcastReceiver.kt b/app/src/main/java/org/wikipedia/notifications/NotificationPollBroadcastReceiver.kt index e8314a8073a..a9243c06653 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationPollBroadcastReceiver.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationPollBroadcastReceiver.kt @@ -16,6 +16,7 @@ import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.NotificationInteractionEvent import org.wikipedia.auth.AccountUtil +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.csrf.CsrfTokenClient import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory @@ -138,7 +139,7 @@ class NotificationPollBroadcastReceiver : BroadcastReceiver() { if (notificationsToDisplay.isNotEmpty()) { Prefs.notificationUnreadCount = notificationsToDisplay.size - WikipediaApp.instance.bus.post(UnreadNotificationsEvent()) + FlowEventBus.post(UnreadNotificationsEvent()) } if (notificationsToDisplay.size > 2) { @@ -158,7 +159,7 @@ class NotificationPollBroadcastReceiver : BroadcastReceiver() { suspend fun markRead(wiki: WikiSite, notifications: List, unread: Boolean) { withContext(Dispatchers.IO) { - val token = CsrfTokenClient.getToken(wiki).blockingSingle() + val token = CsrfTokenClient.getToken(wiki) notifications.windowed(50, partialWindows = true).forEach { window -> val idListStr = window.joinToString("|") ServiceFactory.get(wiki).markRead(token, if (unread) null else idListStr, if (unread) idListStr else null) diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationPresenter.kt b/app/src/main/java/org/wikipedia/notifications/NotificationPresenter.kt index 06bf3ec9b96..e9b65525ab8 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationPresenter.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationPresenter.kt @@ -1,37 +1,57 @@ package org.wikipedia.notifications -import android.app.NotificationManager +import android.Manifest import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageManager import android.graphics.* import android.net.Uri +import android.os.Build +import androidx.activity.result.ActivityResultLauncher import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.appcompat.view.ContextThemeWrapper import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat -import androidx.core.content.getSystemService import androidx.core.graphics.applyCanvas import androidx.core.graphics.createBitmap import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.auth.AccountUtil import org.wikipedia.dataclient.WikiSite import org.wikipedia.diff.ArticleEditDetailsActivity import org.wikipedia.notifications.db.Notification import org.wikipedia.page.PageTitle import org.wikipedia.richtext.RichTextUtil +import org.wikipedia.settings.Prefs import org.wikipedia.talk.TalkTopicsActivity import org.wikipedia.theme.Theme import org.wikipedia.util.* import org.wikipedia.util.log.L -import java.util.* +import java.util.Locale +import java.util.concurrent.TimeUnit object NotificationPresenter { + private var lastPermissionRequestTime = 0L + + fun maybeRequestPermission(context: Context, launcher: ActivityResultLauncher) { + val millisSinceLastRequest = System.currentTimeMillis() - lastPermissionRequestTime + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && + !Prefs.isInitialOnboardingEnabled && + AccountUtil.isLoggedIn && + (millisSinceLastRequest > TimeUnit.HOURS.toMillis(1) || millisSinceLastRequest < 0) && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + launcher.launch(Manifest.permission.POST_NOTIFICATIONS) + lastPermissionRequestTime = System.currentTimeMillis() + } + } + fun showNotification(context: Context, n: Notification, wikiSiteName: String, lang: String) { val notificationCategory = NotificationCategory.find(n.category) var activityIntent = addIntentExtras(NotificationActivity.newIntent(context), n.id, n.type) @@ -93,6 +113,9 @@ object NotificationPresenter { fun showNotification(context: Context, builder: NotificationCompat.Builder, id: Int, title: String, text: String, longText: CharSequence, lang: String?, @DrawableRes icon: Int, @ColorRes color: Int, bodyIntent: Intent) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + return + } builder.setContentIntent(PendingIntentCompat.getActivity(context, 0, bodyIntent, PendingIntent.FLAG_UPDATE_CURRENT, false)) .setLargeIcon(drawNotificationBitmap(context, color, icon, lang.orEmpty().uppercase(Locale.getDefault()))) .setSmallIcon(R.drawable.ic_wikipedia_w) @@ -100,13 +123,14 @@ object NotificationPresenter { .setContentTitle(title) .setContentText(text) .setStyle(NotificationCompat.BigTextStyle().bigText(longText)) - context.getSystemService()?.notify(id, builder.build()) + NotificationManagerCompat.from(context).notify(id, builder.build()) } private fun addAction(context: Context, builder: NotificationCompat.Builder, link: Notification.Link, n: Notification) { if (UriUtil.isDiffUrl(link.url)) { try { addActionForDiffLink(context, builder, link, n) + return } catch (e: Exception) { L.e(e) } diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt b/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt index 76f06c09310..4b8936a5ed9 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt @@ -1,17 +1,16 @@ package org.wikipedia.notifications -import org.wikipedia.Constants import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.ServiceFactory 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,12 +18,8 @@ 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() + val response = ServiceFactory.get(WikipediaApp.instance.wikiSite).unreadNotificationWikis() return response.query?.unreadNotificationWikis!! .mapNotNull { (key, wiki) -> wiki.source?.let { key to WikiSite(it.base) } }.toMap() } 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/notifications/PollNotificationWorker.kt b/app/src/main/java/org/wikipedia/notifications/PollNotificationWorker.kt index 3d17899c9bc..108a70e75a8 100644 --- a/app/src/main/java/org/wikipedia/notifications/PollNotificationWorker.kt +++ b/app/src/main/java/org/wikipedia/notifications/PollNotificationWorker.kt @@ -1,8 +1,12 @@ package org.wikipedia.notifications import android.content.Context -import androidx.work.* -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters import org.wikipedia.WikipediaApp import org.wikipedia.csrf.CsrfTokenClient import org.wikipedia.dataclient.ServiceFactory @@ -25,8 +29,10 @@ class PollNotificationWorker( } Result.success() } catch (t: Throwable) { - if (t is MwException && t.error.title == "login-required") { - assertLoggedIn() + if (t is MwException && t.error.key == "login-required") { + // Attempt to get a dummy CSRF token, which should automatically re-log us in explicitly, + // and should automatically log us out if the credentials are no longer valid. + CsrfTokenClient.getToken(WikipediaApp.instance.wikiSite) } L.e(t) Result.failure() @@ -53,14 +59,6 @@ class PollNotificationWorker( } } - private fun assertLoggedIn() { - // Attempt to get a dummy CSRF token, which should automatically re-log us in explicitly, - // and should automatically log us out if the credentials are no longer valid. - CsrfTokenClient.getToken(WikipediaApp.instance.wikiSite) - .subscribeOn(Schedulers.io()) - .subscribe() - } - companion object { fun schedulePollNotificationJob(context: Context) { val constraints = Constraints.Builder() diff --git a/app/src/main/java/org/wikipedia/notifications/db/NotificationDao.kt b/app/src/main/java/org/wikipedia/notifications/db/NotificationDao.kt index 18bf76838f7..33cb76a6278 100644 --- a/app/src/main/java/org/wikipedia/notifications/db/NotificationDao.kt +++ b/app/src/main/java/org/wikipedia/notifications/db/NotificationDao.kt @@ -1,6 +1,11 @@ package org.wikipedia.notifications.db -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow @Dao @@ -14,6 +19,9 @@ interface NotificationDao { @Delete suspend fun deleteNotification(notification: Notification) + @Query("DELETE FROM Notification") + fun deleteAll() + @Query("SELECT * FROM Notification") fun getAllNotifications(): List diff --git a/app/src/main/java/org/wikipedia/offline/db/OfflineObjectDao.kt b/app/src/main/java/org/wikipedia/offline/db/OfflineObjectDao.kt index fef251628d0..3fe1b036369 100644 --- a/app/src/main/java/org/wikipedia/offline/db/OfflineObjectDao.kt +++ b/app/src/main/java/org/wikipedia/offline/db/OfflineObjectDao.kt @@ -1,6 +1,11 @@ package org.wikipedia.offline.db -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Update import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.WikiSite import org.wikipedia.page.PageTitle @@ -83,24 +88,19 @@ interface OfflineObjectDao { } } - fun deleteObjectsForPageId(id: Long) { - val objects = mutableListOf() - val objUsedBy = getFromUsedById(id) - - objUsedBy.forEach { - if (it.usedBy.contains(id)) { - it.removeUsedBy(id) - objects.add(it) - } - } - - for (obj in objects) { - if (obj.usedBy.isEmpty()) { - // the object is now an orphan, so remove it! - deleteOfflineObject(obj) - deleteFilesForObject(obj) - } else { - updateOfflineObject(obj) + fun deleteObjectsForPageId(ids: List) { + ids.forEach { id -> + getFromUsedById(id).forEach { obj -> + if (obj.usedBy.contains(id)) { + obj.removeUsedBy(id) + if (obj.usedBy.isEmpty()) { + // the object is now an orphan, so remove it! + deleteOfflineObject(obj) + deleteFilesForObject(obj) + } else { + updateOfflineObject(obj) + } + } } } } diff --git a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt index aa4bc10c37f..c1be461eebe 100644 --- a/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt +++ b/app/src/main/java/org/wikipedia/onboarding/InitialOnboardingActivity.kt @@ -9,7 +9,7 @@ class InitialOnboardingActivity : SingleFragmentActivity = emptyList(), var pageProperties: PageProperties) { +class Page(val title: PageTitle, var sections: List
= emptyList(), val pageProperties: PageProperties) { val displayTitle = pageProperties.displayTitle val isMainPage = pageProperties.isMainPage - val isArticle = !isMainPage && title.namespace() === Namespace.MAIN + val isArticle = !isMainPage && pageProperties.namespace.main() val isProtected = !pageProperties.canEdit } diff --git a/app/src/main/java/org/wikipedia/page/PageActivity.kt b/app/src/main/java/org/wikipedia/page/PageActivity.kt index 6d8fad987a2..efa2d23dee1 100644 --- a/app/src/main/java/org/wikipedia/page/PageActivity.kt +++ b/app/src/main/java/org/wikipedia/page/PageActivity.kt @@ -1,9 +1,12 @@ package org.wikipedia.page import android.app.SearchManager +import android.app.assist.AssistContent import android.content.Context import android.content.Intent import android.graphics.Color +import android.net.Uri +import android.os.Build import android.os.Bundle import android.view.ActionMode import android.view.Gravity @@ -11,6 +14,7 @@ import android.view.KeyEvent import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.ViewGroup.MarginLayoutParams import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.app.ActivityCompat @@ -19,26 +23,39 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.children import androidx.core.view.isVisible -import androidx.core.view.updatePadding +import androidx.core.view.updateLayoutParams +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.preference.PreferenceManager import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.functions.Consumer +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.serialization.json.encodeToJsonElement import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity import org.wikipedia.activity.SingleWebViewActivity +import org.wikipedia.analytics.ABTest 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.eventplatform.RabbitHolesEvent import org.wikipedia.analytics.metricsplatform.ArticleLinkPreviewInteraction +import org.wikipedia.analytics.metricsplatform.RabbitHolesAnalyticsHelper import org.wikipedia.auth.AccountUtil import org.wikipedia.commons.FilePageActivity +import org.wikipedia.concurrency.FlowEventBus +import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.ActivityPageBinding +import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.donate.CampaignCollection import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.descriptions.DescriptionEditActivity import org.wikipedia.descriptions.DescriptionEditRevertHelpView @@ -50,15 +67,18 @@ import org.wikipedia.events.ChangeTextSizeEvent import org.wikipedia.extensions.parcelableExtra import org.wikipedia.gallery.GalleryActivity import org.wikipedia.history.HistoryEntry +import org.wikipedia.json.JsonUtil import org.wikipedia.language.LangLinksActivity +import org.wikipedia.navtab.NavTab import org.wikipedia.notifications.AnonymousNotificationHelper import org.wikipedia.notifications.NotificationActivity import org.wikipedia.page.linkpreview.LinkPreviewDialog import org.wikipedia.page.tabs.TabActivity import org.wikipedia.readinglist.ReadingListActivity +import org.wikipedia.readinglist.ReadingListsShareHelper 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 @@ -75,9 +95,11 @@ import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L import org.wikipedia.views.FrameLayoutNavMenuTriggerer import org.wikipedia.views.ObservableWebView +import org.wikipedia.views.SurveyDialog import org.wikipedia.views.ViewUtil import org.wikipedia.watchlist.WatchlistExpiry import java.util.Locale +import java.util.concurrent.TimeUnit class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.LoadPageCallback, FrameLayoutNavMenuTriggerer.Callback { @@ -92,14 +114,22 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo private var hasTransitionAnimation = false private var wasTransitionShown = false private val currentActionModes = mutableSetOf() - private val disposables = CompositeDisposable() private val isCabOpen get() = currentActionModes.isNotEmpty() private var exclusiveTooltipRunnable: Runnable? = null private var isTooltipShowing = false + private var suggestedSearchTerm: String? = null private val requestEditSectionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == EditHandler.RESULT_REFRESH_PAGE) { - FeedbackUtil.showMessage(this, R.string.edit_saved_successfully) + FeedbackUtil.makeSnackbar(this, getString(R.string.edit_saved_successfully)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isDestroyed) { + AccountUtil.maybeShowTempAccountWelcome(this@PageActivity) + } + } + }).show() + // and reload the page... pageFragment.model.title?.let { title -> pageFragment.model.curEntry?.let { entry -> @@ -155,7 +185,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo startActivity(FilePageActivity.newIntent(this, imageTitle)) } else if (action === DescriptionEditActivity.Action.ADD_CAPTION || action === DescriptionEditActivity.Action.TRANSLATE_CAPTION) { pageFragment.title?.let { pageTitle -> - startActivity(GalleryActivity.newIntent(this, pageTitle, imageTitle.prefixedText, wikiSite, 0, GalleryActivity.SOURCE_NON_LEAD_IMAGE)) + startActivity(GalleryActivity.newIntent(this, pageTitle, imageTitle.prefixedText, wikiSite, 0)) } } } @@ -163,13 +193,38 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo } } + private val requestSuggestedReadingListLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_CANCELED) { + RabbitHolesEvent.submit("impression", "reading_list_warn") + FeedbackUtil.showMessage(this, R.string.suggested_reading_list_back_cancel) + } + } + public override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) PreferenceManager.setDefaultValues(this, R.xml.preferences, false) binding = ActivityPageBinding.inflate(layoutInflater) setContentView(binding.root) - disposables.add(app.bus.subscribe(EventBusConsumer())) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + FlowEventBus.events.collectLatest { event -> + when (event) { + is ChangeTextSizeEvent -> { + pageFragment.updateFontSize() + } + is ArticleSavedOrDeletedEvent -> { + pageFragment.title?.run { + if (event.pages.any { it.apiTitle == prefixedText && it.wiki.languageCode == wikiSite.languageCode }) { + pageFragment.updateBookmarkAndMenuOptionsFromDao() + } + } + } + } + } + } + } + updateProgressBar(false) pageFragment = supportFragmentManager.findFragmentById(R.id.page_fragment) as PageFragment @@ -179,14 +234,14 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo supportActionBar?.setDisplayHomeAsUpEnabled(true) binding.pageToolbarButtonSearch.setOnClickListener { pageFragment.articleInteractionEvent?.logSearchWikipediaClick() - pageFragment.metricsPlatformArticleEventToolbarInteraction?.logSearchWikipediaClick() - startActivity(SearchActivity.newIntent(this@PageActivity, InvokeSource.TOOLBAR, null)) + pageFragment.metricsPlatformArticleEventToolbarInteraction.logSearchWikipediaClick() + startActivity(SearchActivity.newIntent(this@PageActivity, InvokeSource.TOOLBAR, null, + suggestedSearchQuery = suggestedSearchTerm)) } binding.pageToolbarButtonTabs.updateTabCount(false) binding.pageToolbarButtonTabs.setOnClickListener { pageFragment.articleInteractionEvent?.logTabsClick() - pageFragment.metricsPlatformArticleEventToolbarInteraction?.logTabsClick() - TabActivity.captureFirstTabBitmap(pageFragment.containerView, pageFragment.title?.prefixedText.orEmpty()) + pageFragment.metricsPlatformArticleEventToolbarInteraction.logTabsClick() requestBrowseTabLauncher.launch(TabActivity.newIntentFromPageActivity(this)) } toolbarHideHandler = ViewHideHandler(binding.pageToolbarContainer, null, Gravity.TOP) { isTooltipShowing } @@ -194,14 +249,14 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo binding.pageToolbarButtonShowOverflowMenu.setOnClickListener { pageFragment.showOverflowMenu(it) pageFragment.articleInteractionEvent?.logMoreClick() - pageFragment.metricsPlatformArticleEventToolbarInteraction?.logMoreClick() + pageFragment.metricsPlatformArticleEventToolbarInteraction.logMoreClick() Prefs.showOneTimeCustomizeToolbarTooltip = false } binding.pageToolbarButtonNotifications.isVisible = AccountUtil.isLoggedIn binding.pageToolbarButtonNotifications.setOnClickListener { pageFragment.articleInteractionEvent?.logNotificationClick() - pageFragment.metricsPlatformArticleEventToolbarInteraction?.logNotificationClick() + pageFragment.metricsPlatformArticleEventToolbarInteraction.logNotificationClick() if (AccountUtil.isLoggedIn) { startActivity(NotificationActivity.newIntent(this@PageActivity)) } else if (AnonymousNotificationHelper.isWithinAnonNotificationTime() && !Prefs.lastAnonNotificationLang.isNullOrEmpty()) { @@ -214,11 +269,15 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo // Navigation setup binding.navigationDrawer.setScrimColor(Color.TRANSPARENT) binding.containerWithNavTrigger.callback = this - ViewCompat.setOnApplyWindowInsetsListener(binding.navigationDrawer) { _, insets -> - val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - binding.pageToolbarContainer.updatePadding(top = systemWindowInsets.top) - pageFragment.updateInsets(systemWindowInsets) - insets + ViewCompat.setOnApplyWindowInsetsListener(binding.navigationDrawer) { view, insets -> + val insets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updateLayoutParams { + topMargin = insets.top + leftMargin = insets.left + bottomMargin = insets.bottom + rightMargin = insets.right + } + WindowInsetsCompat.CONSUMED } // WikiArticleCard setup @@ -272,7 +331,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo if (app.haveMainActivity) { onBackPressed() } else { - pageFragment.goToMainTab() + pageFragment.goToMainActivity(tab = NavTab.EXPLORE, tabExtra = Constants.INTENT_EXTRA_GO_TO_MAIN_TAB) } true } else -> super.onOptionsItemSelected(item) @@ -314,7 +373,6 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo } override fun onDestroy() { - disposables.clear() Prefs.hasVisitedArticlePage = true super.onDestroy() } @@ -357,7 +415,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo override fun onNavMenuSwipeRequest(gravity: Int) { if (!isCabOpen && gravity == Gravity.END) { pageFragment.articleInteractionEvent?.logTocSwipe() - pageFragment.metricsPlatformArticleEventToolbarInteraction?.logTocSwipe() + pageFragment.metricsPlatformArticleEventToolbarInteraction.logTocSwipe() pageFragment.sidePanelHandler.showToC() } } @@ -365,7 +423,8 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo override fun onPageLoadComplete() { removeTransitionAnimState() maybeShowThemeTooltip() - maybeShowPlacesTooltip() + + maybeStartRabbitHole() } override fun onPageDismissBottomSheet() { @@ -430,18 +489,14 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo } override fun onPageRequestLangLinks(title: PageTitle) { - val langIntent = Intent() - langIntent.setClass(this, LangLinksActivity::class.java) - langIntent.action = LangLinksActivity.ACTION_LANGLINKS_FOR_TITLE - langIntent.putExtra(Constants.ARG_TITLE, title) - requestHandleIntentLauncher.launch(langIntent) + requestHandleIntentLauncher.launch(LangLinksActivity.newIntent(this, title)) } - override fun onPageRequestGallery(title: PageTitle, fileName: String, wikiSite: WikiSite, revision: Long, source: Int, options: ActivityOptionsCompat?) { - if (source == GalleryActivity.SOURCE_LEAD_IMAGE) { - requestGalleryEditLauncher.launch(GalleryActivity.newIntent(this, title, fileName, title.wikiSite, revision, source), options) + override fun onPageRequestGallery(title: PageTitle, fileName: String, wikiSite: WikiSite, revision: Long, isLeadImage: Boolean, options: ActivityOptionsCompat?) { + if (isLeadImage) { + requestGalleryEditLauncher.launch(GalleryActivity.newIntent(this, title, fileName, title.wikiSite, revision), options) } else { - requestHandleIntentLauncher.launch(GalleryActivity.newIntent(this, title, fileName, title.wikiSite, revision, source), options) + requestHandleIntentLauncher.launch(GalleryActivity.newIntent(this, title, fileName, title.wikiSite, revision), options) } } @@ -490,15 +545,19 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo val language = wiki.languageCode.lowercase(Locale.getDefault()) 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. - DonorExperienceEvent.logImpression("webpay_processed") - startActivity(SingleWebViewActivity.newIntent(this@PageActivity, uri.toString(), - true, pageFragment.title, SingleWebViewActivity.PAGE_CONTENT_SOURCE_DONOR_EXPERIENCE)) - finish() - return + // treat it differently: + if (language == "thankyou" && uri.getQueryParameter("order_id") != null) { + CampaignCollection.addDonationResult(fromWeb = true) + // Check if the donation started from the app, but completed via web, in which case + // show it in a SingleWebViewActivity. + val campaign = uri.getQueryParameter("wmf_campaign") + if (campaign != null && campaign == "Android") { + DonorExperienceEvent.logAction("impression", "webpay_processed", wiki.languageCode) + startActivity(SingleWebViewActivity.newIntent(this@PageActivity, uri.toString(), + true, pageFragment.title, SingleWebViewActivity.PAGE_CONTENT_SOURCE_DONOR_EXPERIENCE)) + finish() + return + } } UriUtil.visitInExternalBrowser(this, it) finish() @@ -545,6 +604,9 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo * foreground tab. */ private fun loadPage(pageTitle: PageTitle?, entry: HistoryEntry?, position: TabPosition) { + + binding.pageToolbarButtonSearch.setText(R.string.search_hint) + if (isDestroyed || pageTitle == null || entry == null) { return } @@ -589,7 +651,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) } @@ -641,17 +703,16 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo private fun modifyMenu(mode: ActionMode) { val menu = mode.menu - val menuItemsList = menu.children.filter { - val title = it.title.toString() - !title.contains(getString(R.string.search_hint)) && - !(title.contains(getString(R.string.menu_text_select_define)) && - pageFragment.shareHandler.shouldEnableWiktionaryDialog()) - }.toList() - menu.clear() - mode.menuInflater.inflate(R.menu.menu_text_select, menu) - menuItemsList.forEach { - menu.add(it.groupId, it.itemId, Menu.NONE, it.title).setIntent(it.intent).icon = it.icon + + // Hide context items that are intended for showing in external apps. + menu.children.forEach { + if (it.title.toString().contains(getString(R.string.search_hint)) || + (it.title.toString().contains(getString(R.string.menu_text_select_define)) && pageFragment.shareHandler.shouldEnableWiktionaryDialog())) { + it.isVisible = false + } } + // Append our custom items to the context menu. + mode.menuInflater.inflate(R.menu.menu_text_select, menu) } private fun showDescriptionEditRevertDialog(qNumber: String) { @@ -662,35 +723,6 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo .show() } - 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) - } - } - } - private fun maybeShowThemeTooltip() { if (!Prefs.showOneTimeCustomizeToolbarTooltip) { return @@ -784,21 +816,111 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo updateNotificationsButton(true) } - private inner class EventBusConsumer : Consumer { - override fun accept(event: Any) { - when (event) { - is ChangeTextSizeEvent -> { - pageFragment.updateFontSize() - } - is ArticleSavedOrDeletedEvent -> { - if (!pageFragment.isAdded) { - return - } - pageFragment.title?.run { - if (event.pages.any { it.apiTitle == prefixedText && it.wiki.languageCode == wikiSite.languageCode }) { - pageFragment.updateBookmarkAndMenuOptionsFromDao() + override fun onProvideAssistContent(outContent: AssistContent) { + super.onProvideAssistContent(outContent) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + pageFragment.model.title?.let { + outContent.setWebUri(Uri.parse(it.uri)) + } + } + } + + private fun maybeStartRabbitHole() { + if (!RabbitHolesAnalyticsHelper.rabbitHolesEnabled || RabbitHolesAnalyticsHelper.abcTest.group == ABTest.GROUP_1) { + return + } + lifecycleScope.launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + pageFragment.title?.let { title -> + if (RabbitHolesAnalyticsHelper.abcTest.group == ABTest.GROUP_2) { + if (title.displayText != MainPageNameData.valueFor(title.wikiSite.languageCode)) { + val response = ServiceFactory.get(title.wikiSite).searchMoreLike("morelike:${title.prefixedText}", 3, 3) + response.query?.pages?.firstOrNull()?.let { page -> + applySuggestedSearchTerm(page.displayTitle(title.wikiSite.languageCode)) } } + } else if (RabbitHolesAnalyticsHelper.abcTest.group == ABTest.GROUP_3 && !Prefs.suggestedReadingListDialogShown) { + val historyEntries = AppDatabase.instance.historyEntryDao().getLastHistoryEntries(title.wikiSite.languageCode, 2) + .filter { it.displayTitle != MainPageNameData.valueFor(title.wikiSite.languageCode) } + if (historyEntries.size < 2) { + return@launch + } + + val pages = mutableListOf() + historyEntries.forEach { entry -> + val response = ServiceFactory.get(title.wikiSite).searchMoreLike("morelike:${entry.apiTitle}", 10, 10) + response.query?.pages?.filter { it.title != historyEntries[0].apiTitle && it.title != historyEntries[1].apiTitle }?.take(5)?.let { pages.addAll(it) } + } + + if (pages.isNotEmpty()) { + applySuggestedReadingList(historyEntries[0], historyEntries[1], pages) + + Prefs.suggestedReadingListDialogShown = true + RabbitHolesEvent.submit("impression", "reading_list_prompt") + + MaterialAlertDialogBuilder(this@PageActivity) + .setTitle(R.string.suggested_reading_list_dialog_title) + .setMessage(R.string.suggested_reading_list_dialog_body) + .setPositiveButton(R.string.suggested_reading_list_dialog_positive) { _, _ -> + RabbitHolesEvent.submit("enter_click", "reading_list_prompt") + requestSuggestedReadingListLauncher.launch(ReadingListActivity.newIntent(this@PageActivity, true, suggestedList = true)) + } + .setNegativeButton(R.string.suggested_reading_list_dialog_negative) { _, _ -> + RabbitHolesEvent.submit("ignore_click", "reading_list_prompt") + FeedbackUtil.showMessage(this@PageActivity, R.string.suggested_reading_list_later_snackbar) + } + .show() + } + } + } + } + maybeShowRabbitHolesSurvey() + } + + private fun applySuggestedSearchTerm(term: String) { + suggestedSearchTerm = term + binding.pageToolbarButtonSearch.text = term + } + + private fun applySuggestedReadingList(basedOnTitle1: HistoryEntry, basedOnTitle2: HistoryEntry, pages: List) { + val listItems = pages.map { + JsonUtil.json.encodeToJsonElement( + ReadingListsShareHelper.ExportedReadingListPage( + basedOnTitle1.title.wikiSite.languageCode, + it.displayTitle(basedOnTitle1.title.wikiSite.languageCode), + it.ns, + it.description, + it.thumbUrl() + ) + ) + } + val readingList = ReadingListsShareHelper.ExportedReadingList( + list = mapOf(basedOnTitle1.title.wikiSite.languageCode to listItems), + name = getString(R.string.suggested_reading_list_title), + description = getString(R.string.suggested_reading_list_description_multi, + StringUtil.fromHtml(basedOnTitle1.title.displayText).toString(), + StringUtil.fromHtml(basedOnTitle2.title.displayText).toString()) + ) + Prefs.importReadingListsDialogShown = false + Prefs.suggestedReadingListsData = JsonUtil.encodeToString(readingList) + } + + private fun maybeShowRabbitHolesSurvey() { + lifecycleScope.launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + delay(TimeUnit.SECONDS.toMillis(if (ReleaseUtil.isDevRelease) 1L else 10L)) + pageFragment.historyEntry?.let { + if (!Prefs.suggestedContentSurveyShown && it.source == HistoryEntry.SOURCE_RABBIT_HOLE_SEARCH) { + Prefs.suggestedContentSurveyShown = true + SurveyDialog.showFeedbackOptionsDialog( + this@PageActivity, + titleId = R.string.rabbit_holes_survey_dialog_title, + messageId = R.string.rabbit_holes_survey_dialog_body, + snackbarMessageId = R.string.survey_dialog_submitted_snackbar, + invokeSource = InvokeSource.RABBIT_HOLE_SEARCH + ) } } } diff --git a/app/src/main/java/org/wikipedia/page/PageAvailableOfflineHandler.kt b/app/src/main/java/org/wikipedia/page/PageAvailableOfflineHandler.kt index 3bddf9160e9..130a0a35af6 100644 --- a/app/src/main/java/org/wikipedia/page/PageAvailableOfflineHandler.kt +++ b/app/src/main/java/org/wikipedia/page/PageAvailableOfflineHandler.kt @@ -1,7 +1,8 @@ package org.wikipedia.page -import android.annotation.SuppressLint -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import org.wikipedia.WikipediaApp import org.wikipedia.database.AppDatabase import org.wikipedia.readinglist.database.ReadingListPage @@ -16,19 +17,16 @@ object PageAvailableOfflineHandler { callback.onFinish(WikipediaApp.instance.isOnline || (page.offline && !page.saving)) } - @SuppressLint("CheckResult") - fun check(pageTitle: PageTitle, callback: Callback) { + fun check(lifeCycleScope: CoroutineScope, pageTitle: PageTitle, callback: Callback) { if (WikipediaApp.instance.isOnline) { callback.onFinish(true) return } - CoroutineScope(Dispatchers.Main).launch(CoroutineExceptionHandler { _, exception -> - run { - callback.onFinish(false) - L.w(exception) - } + lifeCycleScope.launch(CoroutineExceptionHandler { _, exception -> + callback.onFinish(false) + L.w(exception) }) { - val readingListPage = withContext(Dispatchers.IO) { AppDatabase.instance.readingListPageDao().findPageInAnyList(pageTitle) } + val readingListPage = AppDatabase.instance.readingListPageDao().findPageInAnyList(pageTitle) callback.onFinish(readingListPage != null && readingListPage.offline && !readingListPage.saving) } } diff --git a/app/src/main/java/org/wikipedia/page/PageFragment.kt b/app/src/main/java/org/wikipedia/page/PageFragment.kt index c10f38d5460..e984ec79dd9 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragment.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragment.kt @@ -24,7 +24,6 @@ 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 @@ -34,10 +33,6 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import com.google.android.material.textview.MaterialTextView -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import kotlinx.serialization.json.float @@ -50,6 +45,7 @@ import org.wikipedia.Constants.InvokeSource import org.wikipedia.LongPressHandler import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.activity.BaseActivity import org.wikipedia.activity.FragmentUtil.getCallback import org.wikipedia.analytics.eventplatform.ArticleFindInPageInteractionEvent import org.wikipedia.analytics.eventplatform.ArticleInteractionEvent @@ -76,6 +72,7 @@ import org.wikipedia.dataclient.okhttp.HttpStatusException import org.wikipedia.dataclient.okhttp.OkHttpWebViewClient import org.wikipedia.descriptions.DescriptionEditActivity import org.wikipedia.diff.ArticleEditDetailsActivity +import org.wikipedia.donate.DonorHistoryActivity import org.wikipedia.edit.EditHandler import org.wikipedia.gallery.GalleryActivity import org.wikipedia.history.HistoryEntry @@ -102,6 +99,7 @@ import org.wikipedia.settings.Prefs import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.talk.TalkTopicsActivity import org.wikipedia.theme.ThemeChooserDialog +import org.wikipedia.usercontrib.ContributionsDashboardHelper import org.wikipedia.util.ActiveTimer import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil @@ -139,7 +137,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi fun onPageCloseActionMode() fun onPageRequestEditSection(sectionId: Int, sectionAnchor: String?, title: PageTitle, highlightText: String?) fun onPageRequestLangLinks(title: PageTitle) - fun onPageRequestGallery(title: PageTitle, fileName: String, wikiSite: WikiSite, revision: Long, source: Int, options: ActivityOptionsCompat?) + fun onPageRequestGallery(title: PageTitle, fileName: String, wikiSite: WikiSite, revision: Long, isLeadImage: Boolean, options: ActivityOptionsCompat?) fun onPageRequestAddImageTags(mwQueryPage: MwQueryPage, invokeSource: InvokeSource) fun onPageRequestEditDescription(text: String?, title: PageTitle, sourceSummary: PageSummaryForEdit?, targetSummary: PageSummaryForEdit?, action: DescriptionEditActivity.Action, invokeSource: InvokeSource) @@ -149,7 +147,6 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi val binding get() = _binding!! private val activeTimer = ActiveTimer() - private val disposables = CompositeDisposable() private val scrollTriggerListener = WebViewScrollTriggerListener() private val pageRefreshListener = OnRefreshListener { refreshPage() } private val pageActionItemCallback = PageActionItemCallback() @@ -190,7 +187,6 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi val title get() = model.title val page get() = model.page val historyEntry get() = model.curEntry - val containerView get() = binding.pageContentsContainer val isLoading get() = bridge.isLoading val leadImageEditLang get() = leadImagesHandler.callToActionEditLang @@ -198,7 +194,6 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi _binding = FragmentPageBinding.inflate(inflater, container, false) webView = binding.pageWebView initWebViewListeners() - binding.pageRefreshContainer.setColorSchemeResources(ResourceUtil.getThemedAttributeId(requireContext(), R.attr.progressive_color)) binding.pageRefreshContainer.scrollableChild = webView binding.pageRefreshContainer.setOnRefreshListener(pageRefreshListener) val swipeOffset = DimenUtil.getContentTopOffsetPx(requireActivity()) + REFRESH_SPINNER_ADDITIONAL_OFFSET @@ -234,6 +229,14 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi bottomBarHideHandler.setScrollView(webView) bottomBarHideHandler.enabled = Prefs.readingFocusModeEnabled + webView.addOnScrollChangeListener { _, scrollY, _ -> + if (scrollY > (DimenUtil.roundedDpToPx(webView.contentHeight.toFloat()) - (DimenUtil.displayHeightPx * 2)) && + !model.isReadMoreLoaded) { + bridge.execute(JavaScriptActionHandler.appendReadMode(model)) + model.isReadMoreLoaded = true + } + } + editHandler = EditHandler(this, bridge) sidePanelHandler = SidePanelHandler(this, bridge) leadImagesHandler = LeadImagesHandler(this, webView, binding.pageHeaderView, callback()) @@ -263,7 +266,6 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi bridge.cleanup() sidePanelHandler.log() leadImagesHandler.dispose() - disposables.clear() webView.clearAllListeners() (webView.parent as ViewGroup).removeView(webView) Prefs.isSuggestedEditsHighestPriorityEnabled = false @@ -397,6 +399,11 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi // tells us the page is finished loading. In such a case, we must infer that the // page has now loaded and trigger the remaining logic ourselves. if ("true" != pcsExists) { + if (WikipediaApp.instance.currentTheme.isDark) { + // TODO: remove when mobile web supports automatic dark mode through + // the `prefers-color-scheme` media query. + bridge.execute(JavaScriptActionHandler.mobileWebSetDarkMode()) + } onPageSetupEvent() bridge.onMetadataReady() bridge.onPcsReady() @@ -406,10 +413,6 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } } - override fun onReceivedError(view: WebView, errorCode: Int, description: String, failingUrl: String) { - onPageLoadError(RuntimeException(description)) - } - override fun onReceivedHttpError(view: WebView, request: WebResourceRequest, errorResponse: WebResourceResponse) { if (!request.url.toString().contains(RestService.PAGE_HTML_ENDPOINT)) { // If the request is anything except the main mobile-html content request, then @@ -450,6 +453,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi sidePanelHandler.setupForNewPage(page) sidePanelHandler.setEnabled(true) + model.isReadMoreLoaded = false } } bridge.evaluate(JavaScriptActionHandler.getProtection()) { value -> @@ -458,6 +462,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } model.page?.let { page -> page.pageProperties.protection = JsonUtil.decodeFromString(value) + updateQuickActionsAndMenuOptions() } } } @@ -627,7 +632,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi return@post } model.title?.let { - callback()?.onPageRequestGallery(it, fileName, it.wikiSite, revision, GalleryActivity.SOURCE_NON_LEAD_IMAGE, options) + callback()?.onPageRequestGallery(it, fileName, it.wikiSite, revision, false, options) } } } @@ -674,18 +679,26 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi val availableCampaign = campaignList.find { campaign -> campaign.assets[app.appOrSystemLanguageCode] != null } availableCampaign?.let { if (!Prefs.announcementShownDialogs.contains(it.id)) { - DonorExperienceEvent.logImpression("article_banner", - it.id, pageTitle.wikiSite.languageCode) + DonorExperienceEvent.logAction("impression", "article_banner", pageTitle.wikiSite.languageCode, it.id) val dialog = CampaignDialog(requireActivity(), it) dialog.setCancelable(false) dialog.show() + return@launch } } + maybeShowContributionsDashboardDialog() } } } } + private fun maybeShowContributionsDashboardDialog() { + if (!Prefs.contributionsDashboardEntryDialogShown && ContributionsDashboardHelper.contributionsDashboardEnabled) { + ContributionsDashboardHelper.showEntryDialog(requireActivity()) + Prefs.contributionsDashboardEntryDialogShown = true + } + } + private fun showFindReferenceInPage(referenceAnchor: String, backLinksList: List, referenceText: String) { @@ -893,11 +906,6 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } } - fun updateInsets(insets: Insets) { - val swipeOffset = DimenUtil.getContentTopOffsetPx(requireActivity()) + insets.top + REFRESH_SPINNER_ADDITIONAL_OFFSET - binding.pageRefreshContainer.setProgressViewOffset(false, -swipeOffset, swipeOffset) - } - fun onPageMetadataLoaded(redirectedFrom: String? = null) { updateQuickActionsAndMenuOptions() if (model.page == null) { @@ -915,12 +923,13 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } model.readingListPage?.let { page -> model.title?.let { title -> - disposables.add(Completable.fromAction { - page.thumbUrl.equals(title.thumbUrl, true) + lifecycleScope.launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { if (!page.thumbUrl.equals(title.thumbUrl, true) || !page.description.equals(title.description, true)) { AppDatabase.instance.readingListPageDao().updateMetadataByTitle(page, title.description, title.thumbUrl) } - }.subscribeOn(Schedulers.io()).subscribe()) + } } } if (!errorState) { @@ -1018,7 +1027,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } fun updateFontSize() { - webView.settings.defaultFontSize = app.getFontSize(requireActivity().window).toInt() + webView.settings.defaultFontSize = app.getFontSize().toInt() } fun updateQuickActionsAndMenuOptions() { @@ -1027,19 +1036,18 @@ 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)) + var enabled = model.page != null && (!model.shouldLoadAsMobileWeb || (model.shouldLoadAsMobileWeb && pageActionItem.isAvailableOnMobileWeb)) 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 + enabled = enabled && AccountUtil.isLoggedIn } 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) + it.setCompoundDrawablesWithIntrinsicBounds(0, PageActionItem.editArticleIcon(model.page?.pageProperties?.canEdit != true), 0, 0) } PageActionItem.VIEW_ON_MAP -> { val geoAvailable = model.page?.pageProperties?.geo != null @@ -1047,11 +1055,10 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi it.setTextColor(tintColor) TextViewCompat.setCompoundDrawableTintList(it, tintColor) } - else -> { - it.isEnabled = enabled - it.alpha = if (enabled) 1f else 0.5f - } + else -> { } } + it.isEnabled = enabled + it.alpha = if (enabled) 1f else 0.5f } sidePanelHandler.setEnabled(false) requireActivity().invalidateOptionsMenu() @@ -1059,15 +1066,11 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi fun updateBookmarkAndMenuOptionsFromDao() { title?.let { - disposables.add( - Completable.fromAction { model.readingListPage = AppDatabase.instance.readingListPageDao().findPageInAnyList(it) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { - updateQuickActionsAndMenuOptions() - requireActivity().invalidateOptionsMenu() - } - .subscribe()) + lifecycleScope.launch { + model.readingListPage = AppDatabase.instance.readingListPageDao().findPageInAnyList(it) + updateQuickActionsAndMenuOptions() + requireActivity().invalidateOptionsMenu() + } } } @@ -1236,29 +1239,23 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi fun updateWatchlist() { title?.let { - disposables.add(ServiceFactory.get(it.wikiSite).watchToken - .subscribeOn(Schedulers.io()) - .flatMap { response -> - val watchToken = response.query?.watchToken() - if (watchToken.isNullOrEmpty()) { - throw RuntimeException("Received empty watch token.") + lifecycleScope.launch(CoroutineExceptionHandler { _, throwable -> + L.d(throwable) + }) { + val token = ServiceFactory.get(it.wikiSite).getWatchToken().query?.watchToken() ?: throw RuntimeException("Received empty watch token.") + val watch = ServiceFactory.get(it.wikiSite).watch(if (model.isWatched) 1 else null, null, it.prefixedText, WatchlistExpiry.NEVER.expiry, token) + watch.getFirst()?.let { firstWatch -> + if (model.isWatched) { + WatchlistAnalyticsHelper.logRemovedFromWatchlistSuccess(it, requireContext()) + } else { + WatchlistAnalyticsHelper.logAddedToWatchlistSuccess(it, requireContext()) } - ServiceFactory.get(it.wikiSite).postWatch(if (model.isWatched) 1 else null, null, it.prefixedText, WatchlistExpiry.NEVER.expiry, watchToken) + model.isWatched = firstWatch.watched + updateWatchlistExpiry(WatchlistExpiry.NEVER) + showWatchlistSnackbar() } - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { updateQuickActionsAndMenuOptions() } - .subscribe({ watchPostResponse -> - watchPostResponse.getFirst()?.let { watch -> - if (model.isWatched) { - WatchlistAnalyticsHelper.logRemovedFromWatchlistSuccess(it, requireContext()) - } else { - WatchlistAnalyticsHelper.logAddedToWatchlistSuccess(it, requireContext()) - } - model.isWatched = watch.watched - updateWatchlistExpiry(WatchlistExpiry.NEVER) - showWatchlistSnackbar() - } - }) { caught -> L.d(caught) }) + updateQuickActionsAndMenuOptions() + } } } @@ -1270,11 +1267,11 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi PageActionOverflowView(requireContext()).show(anchor, pageActionItemCallback, currentTab, model) } - fun goToMainTab() { - startActivity(MainActivity.newIntent(requireContext()) + fun goToMainActivity(tab: NavTab, tabExtra: String) { + startActivity(MainActivity.newIntent(requireActivity()) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) .putExtra(Constants.INTENT_RETURN_TO_MAIN, true) - .putExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.EXPLORE.code())) + .putExtra(tabExtra, tab.code())) requireActivity().finish() } @@ -1464,7 +1461,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } override fun onExploreSelected() { - goToMainTab() + goToMainActivity(tab = NavTab.EXPLORE, tabExtra = Constants.INTENT_EXTRA_GO_TO_MAIN_TAB) articleInteractionEvent?.logExploreClick() metricsPlatformArticleEventToolbarInteraction.logExploreClick() } @@ -1500,6 +1497,18 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi articleInteractionEvent?.logForwardClick() metricsPlatformArticleEventToolbarInteraction.logForwardClick() } + + override fun onDonorSelected() { + goToMainActivity(tab = NavTab.EDITS, tabExtra = Constants.INTENT_EXTRA_GO_TO_SE_TAB) + } + + override fun onBecomeDonorSelected() { + (requireActivity() as? BaseActivity)?.launchDonateDialog(campaignId = ContributionsDashboardHelper.CAMPAIGN_ID) + } + + override fun onUpdateDonorStatusSelected() { + startActivity(DonorHistoryActivity.newIntent(requireContext(), goBackToContributeTab = true)) + } } companion object { diff --git a/app/src/main/java/org/wikipedia/page/PageFragmentLoadState.kt b/app/src/main/java/org/wikipedia/page/PageFragmentLoadState.kt index 8c8dc5d5c6a..b3b35437072 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragmentLoadState.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragmentLoadState.kt @@ -1,13 +1,9 @@ package org.wikipedia.page import android.widget.Toast -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.async import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -42,21 +38,13 @@ class PageFragmentLoadState(private var model: PageViewModel, private var leadImagesHandler: LeadImagesHandler, private var currentTab: Tab) { - private fun interface ErrorCallback { - fun call(error: Throwable) - } - - private var networkErrorCallback: ErrorCallback? = null - private val app = WikipediaApp.instance - private val disposables = CompositeDisposable() - fun load(pushBackStack: Boolean) { if (pushBackStack && model.title != null && model.curEntry != null) { // update the topmost entry in the backstack, before we start overwriting things. updateCurrentBackStackItem() currentTab.pushBackStackItem(PageBackStackItem(model.title!!, model.curEntry!!)) } - pageLoadCheckReadingLists() + pageLoad() } fun loadFromBackStack(isRefresh: Boolean = false) { @@ -121,87 +109,78 @@ class PageFragmentLoadState(private var model: PageViewModel, if (!fragment.isAdded) { return } - val callback = networkErrorCallback - networkErrorCallback = null fragment.requireActivity().invalidateOptionsMenu() - callback?.call(caught) - } - - private fun pageLoadCheckReadingLists() { - model.title?.let { - disposables.clear() - disposables.add(Completable.fromAction { model.readingListPage = AppDatabase.instance.readingListPageDao().findPageInAnyList(it) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { pageLoadFromNetwork { fragment.onPageLoadError(it) } } - .subscribe()) - } + fragment.onPageLoadError(caught) } - private fun pageLoadFromNetwork(errorCallback: ErrorCallback) { + private fun pageLoad() { model.title?.let { title -> - fragment.updateQuickActionsAndMenuOptions() - networkErrorCallback = errorCallback - if (!fragment.isAdded) { - return - } - fragment.requireActivity().invalidateOptionsMenu() - fragment.callback()?.onPageUpdateProgressBar(true) - model.page = null - val delayLoadHtml = title.prefixedText.contains(":") - if (!delayLoadHtml) { - bridge.resetHtml(title) - } - if (title.namespace() === Namespace.SPECIAL) { - // Short-circuit the entire process of fetching the Summary, since Special: pages - // are not supported in RestBase. - bridge.resetHtml(title) - leadImagesHandler.loadLeadImage() - fragment.requireActivity().invalidateOptionsMenu() - fragment.onPageMetadataLoaded() - return - } - disposables.add(Observable.zip(ServiceFactory.getRest(title.wikiSite) - .getSummaryResponse(title.prefixedText, null, model.cacheControl.toString(), - if (model.isInReadingList) OfflineCacheInterceptor.SAVE_HEADER_SAVE else null, - title.wikiSite.languageCode, UriUtil.encodeURL(title.prefixedText)), - if (app.isOnline && AccountUtil.isLoggedIn) ServiceFactory.get(title.wikiSite).getWatchedInfo(title.prefixedText) - else if (app.isOnline && !AccountUtil.isLoggedIn) AnonymousNotificationHelper.observableForAnonUserInfo(title.wikiSite) - else Observable.just(MwQueryResponse())) { first, second -> Pair(first, second) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ pair -> - val pageSummaryResponse = pair.first - val watchedResponse = pair.second - val isWatched = watchedResponse.query?.firstPage()?.watched ?: false - val hasWatchlistExpiry = watchedResponse.query?.firstPage()?.hasWatchlistExpiry() ?: false - if (pageSummaryResponse.body() == null) { - throw RuntimeException("Summary response was invalid.") - } - val redirectedFrom = if (pageSummaryResponse.raw().priorResponse?.isRedirect == true) model.title?.displayText else null - createPageModel(pageSummaryResponse, isWatched, hasWatchlistExpiry) - if (OfflineCacheInterceptor.SAVE_HEADER_SAVE == pageSummaryResponse.headers()[OfflineCacheInterceptor.SAVE_HEADER]) { - showPageOfflineMessage(pageSummaryResponse.headers().getInstant("date")) - } - if (delayLoadHtml) { - bridge.resetHtml(title) - } - fragment.onPageMetadataLoaded(redirectedFrom) + fragment.lifecycleScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e("Page details network error: ", throwable) + commonSectionFetchOnCatch(throwable) + }) { + model.readingListPage = AppDatabase.instance.readingListPageDao().findPageInAnyList(title) - if (AnonymousNotificationHelper.shouldCheckAnonNotifications(watchedResponse)) { - checkAnonNotifications(title) - } - }) { - L.e("Page details network response error: ", it) - commonSectionFetchOnCatch(it) + fragment.updateQuickActionsAndMenuOptions() + fragment.requireActivity().invalidateOptionsMenu() + fragment.callback()?.onPageUpdateProgressBar(true) + model.page = null + val delayLoadHtml = title.prefixedText.contains(":") + if (!delayLoadHtml) { + bridge.resetHtml(title) + } + if (title.namespace() === Namespace.SPECIAL) { + // Short-circuit the entire process of fetching the Summary, since Special: pages + // are not supported in RestBase. + bridge.resetHtml(title) + leadImagesHandler.loadLeadImage() + fragment.requireActivity().invalidateOptionsMenu() + fragment.onPageMetadataLoaded() + return@launch + } + + val pageSummaryRequest = async { + ServiceFactory.getRest(title.wikiSite).getSummaryResponse(title.prefixedText, null, model.cacheControl.toString(), + if (model.isInReadingList) OfflineCacheInterceptor.SAVE_HEADER_SAVE else null, title.wikiSite.languageCode, UriUtil.encodeURL(title.prefixedText)) + } + val watchedRequest = async { + if (WikipediaApp.instance.isOnline && AccountUtil.isLoggedIn) { + ServiceFactory.get(title.wikiSite).getWatchedStatus(title.prefixedText) + } else if (WikipediaApp.instance.isOnline && !AccountUtil.isLoggedIn) { + AnonymousNotificationHelper.observableForAnonUserInfo(title.wikiSite) + } else { + MwQueryResponse() } - ) + } + + val pageSummaryResponse = pageSummaryRequest.await() + val watchedResponse = watchedRequest.await() + val isWatched = watchedResponse.query?.firstPage()?.watched ?: false + val hasWatchlistExpiry = watchedResponse.query?.firstPage()?.hasWatchlistExpiry() ?: false + if (pageSummaryResponse.body() == null) { + throw RuntimeException("Summary response was invalid.") + } + val redirectedFrom = if (pageSummaryResponse.raw().priorResponse?.isRedirect == true) model.title?.displayText else null + createPageModel(pageSummaryResponse, isWatched, hasWatchlistExpiry) + if (OfflineCacheInterceptor.SAVE_HEADER_SAVE == pageSummaryResponse.headers()[OfflineCacheInterceptor.SAVE_HEADER]) { + showPageOfflineMessage(pageSummaryResponse.headers().getInstant("date")) + } + if (delayLoadHtml) { + bridge.resetHtml(title) + } + fragment.onPageMetadataLoaded(redirectedFrom) + + if (AnonymousNotificationHelper.shouldCheckAnonNotifications(watchedResponse)) { + checkAnonNotifications(title) + } + } } } private fun checkAnonNotifications(title: PageTitle) { - CoroutineScope(Dispatchers.Main).launch { - val response = ServiceFactory.get(title.wikiSite).getLastModified(UserTalkAliasData.valueFor(title.wikiSite.languageCode) + ":" + Prefs.lastAnonUserWithMessages) + fragment.lifecycleScope.launch { + val response = ServiceFactory.get(title.wikiSite) + .getLastModified(UserTalkAliasData.valueFor(title.wikiSite.languageCode) + ":" + Prefs.lastAnonUserWithMessages) if (AnonymousNotificationHelper.anonTalkPageHasRecentMessage(response, title)) { fragment.showAnonNotification() } @@ -236,7 +215,7 @@ class PageFragmentLoadState(private var model: PageViewModel, title.fragment = response.raw().request.url.fragment } if (title.description.isNullOrEmpty()) { - app.appSessionEvent.noDescription() + WikipediaApp.instance.appSessionEvent.noDescription() } if (!title.isMainPage) { title.displayText = page?.displayTitle.orEmpty() @@ -245,18 +224,21 @@ class PageFragmentLoadState(private var model: PageViewModel, fragment.requireActivity().invalidateOptionsMenu() // Update our history entry, in case the Title was changed (i.e. normalized) - val curEntry = model.curEntry - curEntry?.let { - model.curEntry = HistoryEntry(title, it.source, timestamp = it.timestamp) - model.curEntry!!.referrer = it.referrer + model.curEntry?.let { + model.curEntry = HistoryEntry(title, it.source, timestamp = it.timestamp).apply { + referrer = it.referrer + } } // Update our tab list to prevent ZH variants issue. - app.tabList.getOrNull(app.tabCount - 1)?.setBackStackPositionTitle(title) + WikipediaApp.instance.tabList.getOrNull(WikipediaApp.instance.tabCount - 1)?.setBackStackPositionTitle(title) // Save the thumbnail URL to the DB val pageImage = PageImage(title, pageSummary?.thumbnailUrl) - Completable.fromAction { AppDatabase.instance.pageImagesDao().insertPageImage(pageImage) }.subscribeOn(Schedulers.io()).subscribe() + + fragment.lifecycleScope.launch { + AppDatabase.instance.pageImagesDao().insertPageImage(pageImage) + } title.thumbUrl = pageImage.imageName } } diff --git a/app/src/main/java/org/wikipedia/page/PageProperties.kt b/app/src/main/java/org/wikipedia/page/PageProperties.kt index 1e6b1508466..d2ffea2b9df 100644 --- a/app/src/main/java/org/wikipedia/page/PageProperties.kt +++ b/app/src/main/java/org/wikipedia/page/PageProperties.kt @@ -17,7 +17,7 @@ import java.util.Date @Parcelize @TypeParceler() -data class PageProperties constructor( +data class PageProperties( val pageId: Int = 0, val namespace: Namespace, val revisionId: Long = 0, @@ -65,9 +65,6 @@ data class PageProperties constructor( descriptionSource = pageSummary.descriptionSource ) - constructor(title: PageTitle, isMainPage: Boolean) : this(namespace = title.namespace(), - displayTitle = title.displayText, isMainPage = isMainPage) - private val isLoggedInUserAllowedToEdit: Boolean get() = protection?.run { AccountUtil.isMemberOf(editRoles) } ?: false } 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/PageViewModel.kt b/app/src/main/java/org/wikipedia/page/PageViewModel.kt index 4959c737b35..c945216acc2 100644 --- a/app/src/main/java/org/wikipedia/page/PageViewModel.kt +++ b/app/src/main/java/org/wikipedia/page/PageViewModel.kt @@ -13,6 +13,7 @@ class PageViewModel { var hasWatchlistExpiry = false var isWatched = false var forceNetwork = false + var isReadMoreLoaded = false val isInReadingList get() = readingListPage != null val cacheControl get() = if (forceNetwork) OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK else OkHttpConnectionFactory.CACHE_CONTROL_NONE val shouldLoadAsMobileWeb get() = 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 7346d006011..c527193c2d2 100644 --- a/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt +++ b/app/src/main/java/org/wikipedia/page/action/PageActionItem.kt @@ -108,6 +108,9 @@ enum class PageActionItem constructor(val id: Int, fun onEditArticleSelected() fun onViewOnMapSelected() fun forwardClick() + fun onDonorSelected() + fun onBecomeDonorSelected() + fun onUpdateDonorStatusSelected() } companion object { diff --git a/app/src/main/java/org/wikipedia/page/campaign/CampaignDialog.kt b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialog.kt index 0080e09ff48..0061f8e163c 100644 --- a/app/src/main/java/org/wikipedia/page/campaign/CampaignDialog.kt +++ b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialog.kt @@ -5,9 +5,11 @@ import android.content.Context import androidx.appcompat.app.AlertDialog import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.activity.BaseActivity import org.wikipedia.analytics.eventplatform.DonorExperienceEvent import org.wikipedia.dataclient.donate.Campaign import org.wikipedia.settings.Prefs +import org.wikipedia.usercontrib.ContributionsDashboardHelper import org.wikipedia.util.CustomTabsUtil import org.wikipedia.util.FeedbackUtil import java.time.Duration @@ -21,11 +23,10 @@ class CampaignDialog internal constructor(private val context: Context, val camp init { val campaignView = CampaignDialogView(context) - campaignView.campaignAssets = campaign.assets[WikipediaApp.instance.appOrSystemLanguageCode] campaignView.callback = this val dateDiff = Duration.between(Instant.ofEpochMilli(Prefs.announcementPauseTime), Instant.now()) campaignView.showNeutralButton = dateDiff.toDays() >= 1 && campaign.endDateTime?.isAfter(LocalDateTime.now().plusDays(1)) == true - campaignView.setupViews() + campaignView.setupViews(campaign.id, campaign.assets[WikipediaApp.instance.appOrSystemLanguageCode]) setView(campaignView) } @@ -45,13 +46,23 @@ class CampaignDialog internal constructor(private val context: Context, val camp override fun onPositiveAction(url: String) { DonorExperienceEvent.logAction("donate_start_click", "article_banner", campaignId = campaign.id) val customTabUrl = Prefs.announcementCustomTabTestUrl.orEmpty().ifEmpty { url } - CustomTabsUtil.openInCustomTab(context, customTabUrl) - dismissDialog() + if (context is BaseActivity) { + context.launchDonateDialog(campaign.id, customTabUrl) + dismissDialog(false) + } else { + CustomTabsUtil.openInCustomTab(context, customTabUrl) + dismissDialog() + } } override fun onNegativeAction() { DonorExperienceEvent.logAction("already_donated_click", "article_banner", campaignId = campaign.id) - FeedbackUtil.showMessage(context as Activity, R.string.donation_campaign_donated_snackbar) + if (!Prefs.contributionsDashboardEntryDialogShown && ContributionsDashboardHelper.contributionsDashboardEnabled) { + ContributionsDashboardHelper.showDonationCompletedDialog(context) + Prefs.contributionsDashboardEntryDialogShown = true + } else { + FeedbackUtil.showMessage(context as Activity, R.string.donation_campaign_donated_snackbar) + } dismissDialog() } 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 ab951e035fa..a3207717374 100644 --- a/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt +++ b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt @@ -9,6 +9,7 @@ import androidx.core.view.isVisible import org.wikipedia.analytics.eventplatform.DonorExperienceEvent import org.wikipedia.databinding.DialogCampaignBinding import org.wikipedia.dataclient.donate.Campaign +import org.wikipedia.dataclient.donate.CampaignCollection import org.wikipedia.page.LinkMovementMethodExt import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.StringUtil @@ -24,10 +25,9 @@ class CampaignDialogView(context: Context) : FrameLayout(context) { private val binding = DialogCampaignBinding.inflate(LayoutInflater.from(context), this, true) var showNeutralButton = true - var campaignAssets: Campaign.Assets? = null var callback: Callback? = null - fun setupViews() { + fun setupViews(campaignId: String, campaignAssets: Campaign.Assets?) { campaignAssets?.let { if (!it.text.isNullOrEmpty()) { binding.contentText.movementMethod = LinkMovementMethodCompat.getInstance() @@ -58,7 +58,8 @@ class CampaignDialogView(context: Context) : FrameLayout(context) { binding.positiveButton.text = positiveButton.title positiveButton.url?.let { url -> binding.positiveButton.setOnClickListener { - callback?.onPositiveAction(url) + val formattedUrl = url.replace("\$platform;", "Android").replace("\$formattedId;", CampaignCollection.getFormattedCampaignId(campaignId)) + callback?.onPositiveAction(formattedUrl) } } diff --git a/app/src/main/java/org/wikipedia/page/customize/CustomizeToolbarViewModel.kt b/app/src/main/java/org/wikipedia/page/customize/CustomizeToolbarViewModel.kt index 61678a51694..a2a490072c8 100644 --- a/app/src/main/java/org/wikipedia/page/customize/CustomizeToolbarViewModel.kt +++ b/app/src/main/java/org/wikipedia/page/customize/CustomizeToolbarViewModel.kt @@ -88,7 +88,7 @@ class CustomizeToolbarViewModel : ViewModel() { // Add swapped item to list list.add(pair.first.size) // Add to "Menu" order list and remove the last item of toolbar - pair.second.add(0, pair.first.removeLast()) + pair.second.add(0, pair.first.removeAt(pair.first.lastIndex)) } return list } diff --git a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryItemView.kt b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryItemView.kt index 320d7d702fd..fb137b3d3be 100644 --- a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryItemView.kt +++ b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryItemView.kt @@ -56,7 +56,7 @@ class EditHistoryItemView(context: Context) : FrameLayout(context) { } else { binding.diffText.setTextColor(ResourceUtil.getThemedColor(context, R.attr.destructive_color)) } - val userIcon = if (itemRevision.isAnon) R.drawable.ic_anonymous_ooui else R.drawable.ic_user_avatar + val userIcon = if (itemRevision.isAnon) R.drawable.ic_anonymous_ooui else if (itemRevision.isTemp) R.drawable.ic_temp_account else R.drawable.ic_user_avatar binding.userNameText.setIconResource(userIcon) if (itemRevision.comment.isEmpty()) { 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 296e7132654..46c9ab92976 100644 --- a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt +++ b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt @@ -19,8 +19,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState import androidx.paging.LoadStateAdapter -import androidx.paging.PagingData -import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager @@ -32,6 +30,7 @@ import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.activity.BaseActivity +import org.wikipedia.adapter.PagingDataAdapterPatched import org.wikipedia.analytics.eventplatform.EditHistoryInteractionEvent import org.wikipedia.databinding.ActivityEditHistoryBinding import org.wikipedia.databinding.ViewEditHistoryEmptyMessagesBinding @@ -65,7 +64,7 @@ class EditHistoryListActivity : BaseActivity() { private val editHistoryEmptyMessagesAdapter = EmptyMessagesAdapter() private val loadHeader = LoadingItemAdapter { editHistoryListAdapter.retry() } private val loadFooter = LoadingItemAdapter { editHistoryListAdapter.retry() } - private val viewModel: EditHistoryListViewModel by viewModels { EditHistoryListViewModel.Factory(intent.extras!!) } + private val viewModel: EditHistoryListViewModel by viewModels() private var actionMode: ActionMode? = null private val searchActionModeCallback = SearchCallback() private var editHistoryInteractionEvent: EditHistoryInteractionEvent? = null @@ -103,7 +102,7 @@ class EditHistoryListActivity : BaseActivity() { binding.editHistoryRefreshContainer.setOnRefreshListener { viewModel.clearCache() - editHistoryListAdapter.reload() + editHistoryListAdapter.refresh() } binding.editHistoryRecycler.layoutManager = LinearLayoutManager(this) @@ -141,7 +140,7 @@ class EditHistoryListActivity : BaseActivity() { } launch { viewModel.editHistoryFlow.collectLatest { - editHistoryListAdapter.submitData(it) + editHistoryListAdapter.submitData(lifecycleScope, it) } } } @@ -229,7 +228,7 @@ class EditHistoryListActivity : BaseActivity() { EditHistoryFilterOverflowView(this@EditHistoryListActivity).show(anchorView, editCountsValue.data) { editHistoryInteractionEvent?.logFilterSelection(Prefs.editHistoryFilterType.ifEmpty { EditCount.EDIT_TYPE_ALL }) setupAdapters() - editHistoryListAdapter.reload() + editHistoryListAdapter.refresh() editHistorySearchBarAdapter.notifyItemChanged(0) actionMode?.let { searchActionModeCallback.updateFilterIconAndText() @@ -302,13 +301,7 @@ class EditHistoryListActivity : BaseActivity() { } private inner class EditHistoryListAdapter : - PagingDataAdapter(EditHistoryDiffCallback()) { - - fun reload() { - submitData(lifecycle, PagingData.empty()) - viewModel.editHistorySource?.invalidate() - } - + PagingDataAdapterPatched(EditHistoryDiffCallback()) { override fun getItemViewType(position: Int): Int { return if (getItem(position) is EditHistoryListViewModel.EditHistorySeparator) { VIEW_TYPE_SEPARATOR @@ -481,15 +474,12 @@ class EditHistoryListActivity : BaseActivity() { val searchBarFilterIcon get() = searchAndFilterActionProvider?.filterIcon override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - searchAndFilterActionProvider = SearchAndFilterActionProvider(this@EditHistoryListActivity, searchHintString, + searchAndFilterActionProvider = SearchAndFilterActionProvider(this@EditHistoryListActivity, getSearchHintString(), object : SearchAndFilterActionProvider.Callback { override fun onQueryTextChange(s: String) { onQueryChange(s) } - override fun onQueryTextFocusChange() { - } - override fun onFilterIconClick() { showFilterOverflowMenu() } @@ -503,7 +493,7 @@ class EditHistoryListActivity : BaseActivity() { } }) - val menuItem = menu.add(searchHintString) + val menuItem = menu.add(getSearchHintString()) MenuItemCompat.setActionProvider(menuItem, searchAndFilterActionProvider) @@ -517,14 +507,14 @@ class EditHistoryListActivity : BaseActivity() { override fun onQueryChange(s: String) { viewModel.currentQuery = s setupAdapters() - editHistoryListAdapter.reload() + editHistoryListAdapter.refresh() } override fun onDestroyActionMode(mode: ActionMode) { super.onDestroyActionMode(mode) actionMode = null viewModel.currentQuery = "" - editHistoryListAdapter.reload() + editHistoryListAdapter.refresh() viewModel.actionModeActive = false setupAdapters() } diff --git a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListViewModel.kt b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListViewModel.kt index 55fff29a902..c8f5c148f70 100644 --- a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListViewModel.kt +++ b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListViewModel.kt @@ -1,9 +1,8 @@ package org.wikipedia.page.edithistory -import android.os.Bundle import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import androidx.paging.* import kotlinx.coroutines.* @@ -14,7 +13,6 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.dataclient.restbase.EditCount import org.wikipedia.dataclient.restbase.Metrics -import org.wikipedia.extensions.parcelable import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs import org.wikipedia.util.DateUtil @@ -22,12 +20,12 @@ import org.wikipedia.util.Resource import org.wikipedia.util.log.L import retrofit2.HttpException import java.io.IOException -import java.util.* +import java.util.Calendar -class EditHistoryListViewModel(bundle: Bundle) : ViewModel() { +class EditHistoryListViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { val editHistoryStatsData = MutableLiveData>() - var pageTitle = bundle.parcelable(Constants.ARG_TITLE)!! + val pageTitle = savedStateHandle.get(Constants.ARG_TITLE)!! var pageId = -1 private set var comparing = false @@ -39,13 +37,11 @@ class EditHistoryListViewModel(bundle: Bundle) : ViewModel() { var currentQuery = "" var actionModeActive = false - var editHistorySource: EditHistoryPagingSource? = null private val cachedRevisions = mutableListOf() private var cachedContinueKey: String? = null val editHistoryFlow = Pager(PagingConfig(pageSize = 50), pagingSourceFactory = { - editHistorySource = EditHistoryPagingSource(pageTitle) - editHistorySource!! + EditHistoryPagingSource(pageTitle) }).flow.map { pagingData -> val anonEditsOnly = Prefs.editHistoryFilterType == EditCount.EDIT_TYPE_ANONYMOUS val userEditsOnly = Prefs.editHistoryFilterType == EditCount.EDIT_TYPE_EDITORS @@ -55,8 +51,8 @@ class EditHistoryListViewModel(bundle: Bundle) : ViewModel() { null }.filter { when { - anonEditsOnly -> { it.isAnon } - userEditsOnly -> { !it.isAnon } + anonEditsOnly -> { it.isAnon || it.isTemp } + userEditsOnly -> { !it.isAnon && !it.isTemp } else -> { true } } }.filter { @@ -192,13 +188,6 @@ class EditHistoryListViewModel(bundle: Bundle) : ViewModel() { class EditHistoryStats(val revision: MwQueryPage.Revision, val metrics: List, val allEdits: EditCount, val userEdits: EditCount, val anonEdits: EditCount, val botEdits: EditCount) : EditHistoryItemModel() - class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { - @Suppress("unchecked_cast") - override fun create(modelClass: Class): T { - return EditHistoryListViewModel(bundle) as T - } - } - companion object { const val SELECT_INACTIVE = 0 const val SELECT_NONE = 1 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..7b28de43c22 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,11 @@ 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.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.ImageEditType import org.wikipedia.Constants.InvokeSource @@ -26,6 +27,7 @@ import org.wikipedia.settings.Prefs import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.util.DimenUtil import org.wikipedia.util.StringUtil +import org.wikipedia.util.log.L import org.wikipedia.views.ObservableWebView class LeadImagesHandler(private val parentFragment: PageFragment, @@ -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 (!WikipediaApp.instance.isOnline || !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()) + handlerJob = parentFragment.viewLifecycleOwner.lifecycleScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + }) { + lastImageTitleForCallToAction = imageTitle + val isProtected = ServiceFactory.get(Constants.commonsWikiSite) + .getProtectionWithUserInfo(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).getImageInfo(imageTitle, Constants.COMMONS_DB_NAME).query?.firstPage() + } + val labelMap = firstEntity.await()?.getLabels()?.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] - 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)) { - imageEditType = ImageEditType.ADD_CAPTION_TRANSLATION - captionTargetPageTitle = PageTitle(imageTitle, WikiSite(Service.COMMONS_URL, lang)) - break - } + WikipediaApp.instance.languageState.appLanguageCodes.firstOrNull { lang -> !labelMap.containsKey(lang) }?.run { + imageEditType = ImageEditType.ADD_CAPTION_TRANSLATION + 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 } - finalizeCallToAction() - lastImageTitleForCallToAction = imageTitle } - ) + finalizeCallToAction() + } } } @@ -232,14 +229,14 @@ class LeadImagesHandler(private val parentFragment: PageFragment, leadImageUrl!!, true) GalleryActivity.setTransitionInfo(hitInfo) val options = ActivityOptionsCompat.makeSceneTransitionAnimation(activity, pageHeaderView.imageView, activity.getString(R.string.transition_page_gallery)) - callback?.onPageRequestGallery(it, filename, wiki, parentFragment.revision, GalleryActivity.SOURCE_LEAD_IMAGE, options) + callback?.onPageRequestGallery(it, filename, wiki, parentFragment.revision, true, options) } } } } 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 0fd12ec2f11..0201945cac2 100644 --- a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt +++ b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt @@ -12,7 +12,10 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.PopupMenu import androidx.core.app.ActivityOptionsCompat import androidx.core.os.bundleOf +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -25,9 +28,12 @@ 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.auth.AccountUtil 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 @@ -73,7 +79,7 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV private var overlayView: LinkPreviewOverlayView? = null private var navigateSuccess = false private var revision: Long = 0 - private val viewModel: LinkPreviewViewModel by viewModels { LinkPreviewViewModel.Factory(requireArguments()) } + private val viewModel: LinkPreviewViewModel by viewModels() private val menuListener = PopupMenu.OnMenuItemClickListener { item -> return@OnMenuItemClickListener when (item.itemId) { @@ -136,7 +142,7 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV options = ActivityOptionsCompat.makeSceneTransitionAnimation(requireActivity(), view, requireActivity().getString(R.string.transition_page_gallery)) } requestGalleryLauncher.launch(GalleryActivity.newIntent(requireContext(), viewModel.pageTitle, - imageName, viewModel.pageTitle.wikiSite, revision, GalleryActivity.SOURCE_LINK_PREVIEW), options) + imageName, viewModel.pageTitle.wikiSite, revision), options) } private val requestGalleryLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { @@ -145,12 +151,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 { @@ -188,7 +208,7 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV popupMenu.inflate(R.menu.menu_link_preview) popupMenu.menu.findItem(R.id.menu_link_preview_add_to_list).isVisible = !viewModel.fromPlaces popupMenu.menu.findItem(R.id.menu_link_preview_share_page).isVisible = !viewModel.fromPlaces - popupMenu.menu.findItem(R.id.menu_link_preview_watch).isVisible = viewModel.fromPlaces + popupMenu.menu.findItem(R.id.menu_link_preview_watch).isVisible = viewModel.fromPlaces && AccountUtil.isLoggedIn popupMenu.menu.findItem(R.id.menu_link_preview_watch).title = getString(if (viewModel.isWatched) R.string.menu_page_unwatch else R.string.menu_page_watch) popupMenu.menu.findItem(R.id.menu_link_preview_open_in_new_tab).isVisible = viewModel.fromPlaces popupMenu.menu.findItem(R.id.menu_link_preview_view_on_map).isVisible = !viewModel.fromPlaces && viewModel.location != null @@ -240,7 +260,8 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV override fun onResume() { super.onResume() - val containerView = requireDialog().findViewById(R.id.container) + + val containerView = requireDialog().findViewById(android.R.id.content) if (overlayView == null && containerView != null) { LinkPreviewOverlayView(requireContext()).let { overlayView = it @@ -268,6 +289,12 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV it.showTertiaryButton(false) } containerView.addView(it) + + ViewCompat.setOnApplyWindowInsetsListener(it) { view, insets -> + val systemWindowInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.updateLayoutParams { bottomMargin = systemWindowInsets.bottom } + insets + } } } } @@ -374,23 +401,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) 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 7d7ec9fe267..e43e164c3a7 100644 --- a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewViewModel.kt +++ b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewViewModel.kt @@ -1,9 +1,8 @@ package org.wikipedia.page.linkpreview import android.location.Location -import android.os.Bundle +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.async @@ -14,21 +13,20 @@ 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 import org.wikipedia.history.HistoryEntry import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs import org.wikipedia.util.log.L import org.wikipedia.watchlist.WatchlistExpiry -class LinkPreviewViewModel(bundle: Bundle) : ViewModel() { +class LinkPreviewViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { private val _uiState = MutableStateFlow(LinkPreviewViewState.Loading) val uiState = _uiState.asStateFlow() - val historyEntry = bundle.parcelable(LinkPreviewDialog.ARG_ENTRY)!! + val historyEntry = savedStateHandle.get(LinkPreviewDialog.ARG_ENTRY)!! var pageTitle = historyEntry.title - var location = bundle.parcelable(LinkPreviewDialog.ARG_LOCATION) + var location = savedStateHandle.get(LinkPreviewDialog.ARG_LOCATION) val fromPlaces = historyEntry.source == HistoryEntry.SOURCE_PLACES - val lastKnownLocation = bundle.parcelable(LinkPreviewDialog.ARG_LAST_KNOWN_LOCATION) + val lastKnownLocation = savedStateHandle.get(LinkPreviewDialog.ARG_LAST_KNOWN_LOCATION) var isInReadingList = false var isWatched = false @@ -43,7 +41,7 @@ class LinkPreviewViewModel(bundle: Bundle) : ViewModel() { _uiState.value = LinkPreviewViewState.Error(throwable) }) { val summaryCall = async { ServiceFactory.getRest(pageTitle.wikiSite) - .getSummaryResponseSuspend(pageTitle.prefixedText, null, null, null, null, null) } + .getSummaryResponse(pageTitle.prefixedText) } val watchedCall = async { if (fromPlaces && AccountUtil.isLoggedIn) ServiceFactory.get(pageTitle.wikiSite).getWatchedStatus(pageTitle.prefixedText) else null } @@ -84,7 +82,7 @@ class LinkPreviewViewModel(bundle: Bundle) : ViewModel() { L.w("Failed to fetch gallery collection.", throwable) }) { val mediaList = ServiceFactory.getRest(pageTitle.wikiSite) - .getMediaListSuspend(pageTitle.prefixedText, revision) + .getMediaList(pageTitle.prefixedText, revision) val maxImages = 10 val items = mediaList.getItems("image", "video").asReversed() val titleList = @@ -93,7 +91,7 @@ class LinkPreviewViewModel(bundle: Bundle) : ViewModel() { else { val response = ServiceFactory.get( pageTitle.wikiSite - ).getImageInfoSuspend( + ).getImageInfo( titleList.joinToString("|"), pageTitle.wikiSite.languageCode ) @@ -131,11 +129,4 @@ class LinkPreviewViewModel(bundle: Bundle) : ViewModel() { } } } - - class Factory(private val bunble: Bundle) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return LinkPreviewViewModel(bunble) as T - } - } } diff --git a/app/src/main/java/org/wikipedia/page/tabs/SwipeableTabTouchHelperCallback.kt b/app/src/main/java/org/wikipedia/page/tabs/SwipeableTabTouchHelperCallback.kt new file mode 100644 index 00000000000..f0c2616770c --- /dev/null +++ b/app/src/main/java/org/wikipedia/page/tabs/SwipeableTabTouchHelperCallback.kt @@ -0,0 +1,65 @@ +package org.wikipedia.page.tabs + +import android.content.Context +import android.graphics.Canvas +import android.graphics.Paint +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView +import org.wikipedia.R +import org.wikipedia.util.ResourceUtil +import kotlin.math.abs + +class SwipeableTabTouchHelperCallback(context: Context) : ItemTouchHelper.Callback() { + + interface Callback { + fun onSwipe() + fun isSwipeable(): Boolean + } + + private val swipeBackgroundPaint = Paint() + private val itemBackgroundPaint = Paint() + var swipeableEnabled = false + + init { + swipeBackgroundPaint.style = Paint.Style.FILL + swipeBackgroundPaint.color = ResourceUtil.getThemedColor(context, R.attr.background_color) + itemBackgroundPaint.style = Paint.Style.FILL + itemBackgroundPaint.color = swipeBackgroundPaint.color + } + + override fun isLongPressDragEnabled(): Boolean { + return false + } + + override fun isItemViewSwipeEnabled(): Boolean { + return swipeableEnabled + } + + override fun getMovementFlags(recyclerView: RecyclerView, holder: RecyclerView.ViewHolder): Int { + val dragFlags = 0 // ItemTouchHelper.UP | ItemTouchHelper.DOWN; + val swipeFlags = if (holder is Callback && holder.isSwipeable()) ItemTouchHelper.START or ItemTouchHelper.END else 0 + return makeMovementFlags(dragFlags, swipeFlags) + } + + override fun onMove(recyclerView: RecyclerView, source: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + return source.itemViewType == target.itemViewType + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, i: Int) { + if (viewHolder is Callback) { + viewHolder.onSwipe() + } + } + + override fun onChildDraw(canvas: Canvas, recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, + dx: Float, dy: Float, actionState: Int, isCurrentlyActive: Boolean) { + if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) { + canvas.drawRect(0f, viewHolder.itemView.top.toFloat(), viewHolder.itemView.width.toFloat(), (viewHolder.itemView.top + viewHolder.itemView.height).toFloat(), swipeBackgroundPaint) + canvas.drawRect(dx, viewHolder.itemView.top.toFloat(), viewHolder.itemView.width + dx, (viewHolder.itemView.top + viewHolder.itemView.height).toFloat(), itemBackgroundPaint) + viewHolder.itemView.translationX = dx + viewHolder.itemView.alpha = 1 - abs(dx) / viewHolder.itemView.width + } else { + super.onChildDraw(canvas, recyclerView, viewHolder, dx, dy, actionState, isCurrentlyActive) + } + } +} 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 a28b3aea6ae..1d911ea7a79 100644 --- a/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt +++ b/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt @@ -2,21 +2,16 @@ package org.wikipedia.page.tabs import android.content.Context import android.content.Intent -import android.graphics.Bitmap import android.os.Bundle -import android.view.* -import android.widget.ImageView +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.widget.TextView -import androidx.appcompat.widget.AppCompatImageView -import androidx.core.view.drawToBitmap import androidx.core.view.isVisible +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.RecyclerView 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 -import de.mrapp.android.util.logging.LogLevel import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R @@ -31,16 +26,18 @@ import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.PageActivity import org.wikipedia.readinglist.AddToReadingListDialog import org.wikipedia.settings.Prefs -import org.wikipedia.util.* +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L +import org.wikipedia.views.WikiCardView class TabActivity : BaseActivity() { private lateinit var binding: ActivityTabsBinding private val app: WikipediaApp = WikipediaApp.instance - private val tabListener = TabListener() private var launchedFromPageActivity = false private var cancelled = true - private var tabUpdatedTimeMillis: Long = 0 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -49,77 +46,12 @@ class TabActivity : BaseActivity() { binding.tabCountsView.updateTabCount(false) binding.tabCountsView.setOnClickListener { onBackPressed() } 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 { - if (viewType == 1) { - val view = AppCompatImageView(this@TabActivity) - view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT) - view.scaleType = ImageView.ScaleType.CENTER_CROP - view.setImageBitmap(FIRST_TAB_BITMAP) - view.setPadding(0, if (topTabLeadImageEnabled()) 0 else -DimenUtil.getToolbarHeightPx(this@TabActivity), 0, 0) - return view - } - return inflater.inflate(R.layout.item_tab_contents, parent, false) - } - - override fun onShowTab(context: Context, tabSwitcher: TabSwitcher, view: View, - tab: de.mrapp.android.tabswitcher.Tab, index: Int, viewType: Int, savedInstanceState: Bundle?) { - val tabIndex = app.tabCount - index - 1 - if (viewType == 1 || tabIndex < 0 || app.tabList[tabIndex] == null) { - return - } - val titleText = view.findViewById(R.id.tab_article_title) - val descriptionText = view.findViewById(R.id.tab_article_description) - val title = app.tabList[tabIndex].backStackPositionTitle - titleText.text = StringUtil.fromHtml(title!!.displayText) - if (title.description.isNullOrEmpty()) { - descriptionText.visibility = View.GONE - } else { - descriptionText.text = title.description - descriptionText.visibility = View.VISIBLE - } - L10nUtil.setConditionalLayoutDirection(view, title.wikiSite.languageCode) - } - - override fun getViewType(tab: de.mrapp.android.tabswitcher.Tab, index: Int): Int { - return if (FIRST_TAB_BITMAP_TITLE == app.tabList[app.tabCount - index - 1]?.backStackPositionTitle?.prefixedText) { - 1 - } else { - 0 - } - } - - override fun getViewTypeCount(): Int { - return 2 - } - } - for (i in app.tabList.indices) { - val tabIndex = app.tabList.size - i - 1 - if (app.tabList[tabIndex].backStack.isEmpty()) { - continue - } - val tab = de.mrapp.android.tabswitcher.Tab(StringUtil.fromHtml(app.tabList[tabIndex].backStackPositionTitle?.displayText)) - tab.setIcon(R.drawable.ic_image_black_24dp) - tab.setIconTint(ResourceUtil.getThemedColor(this, R.attr.secondary_color)) - tab.setTitleTextColor(ResourceUtil.getThemedColor(this, R.attr.secondary_color)) - tab.setCloseButtonIcon(R.drawable.ic_close_black_24dp) - tab.setCloseButtonIconTint(ResourceUtil.getThemedColor(this, R.attr.secondary_color)) - tab.isCloseable = true - tab.parameters = Bundle() - binding.tabSwitcher.addTab(tab) - } - 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 - } + binding.tabRecyclerView.adapter = TabItemAdapter() + val touchCallback = SwipeableTabTouchHelperCallback(this) + touchCallback.swipeableEnabled = true + val itemTouchHelper = ItemTouchHelper(touchCallback) + itemTouchHelper.attachToRecyclerView(binding.tabRecyclerView) launchedFromPageActivity = intent.hasExtra(LAUNCHED_FROM_PAGE_ACTIVITY) setStatusBarColor(ResourceUtil.getThemedColor(this, android.R.attr.colorBackground)) @@ -137,12 +69,6 @@ class TabActivity : BaseActivity() { } } - override fun onDestroy() { - binding.tabSwitcher.removeListener(tabListener) - clearFirstTabBitmap() - super.onDestroy() - } - override fun onPause() { super.onPause() app.commitTabState() @@ -169,7 +95,13 @@ class TabActivity : BaseActivity() { MaterialAlertDialogBuilder(this).run { setMessage(R.string.close_all_tabs_confirm) setPositiveButton(R.string.close_all_tabs_confirm_yes) { _, _ -> - binding.tabSwitcher.clear() + L.d("All tabs removed.") + val appTabs = app.tabList.toMutableList() + app.tabList.clear() + binding.tabCountsView.updateTabCount(false) + binding.tabRecyclerView.adapter?.notifyItemRangeRemoved(0, appTabs.size) + setResult(RESULT_LOAD_FROM_BACKSTACK) + showUndoAllSnackbar(appTabs) cancelled = false } setNegativeButton(R.string.close_all_tabs_confirm_no, null) @@ -202,14 +134,6 @@ class TabActivity : BaseActivity() { AddToReadingListDialog.newInstance(titlesList, InvokeSource.TABS_ACTIVITY)) } - private fun topTabLeadImageEnabled(): Boolean { - if (app.tabCount > 0) { - val pageTitle = app.tabList[app.tabCount - 1].backStackPositionTitle - return pageTitle != null && !pageTitle.isMainPage && !pageTitle.thumbUrl.isNullOrEmpty() - } - return false - } - private fun openNewTab() { cancelled = false if (launchedFromPageActivity) { @@ -220,83 +144,37 @@ class TabActivity : BaseActivity() { finish() } - private fun showUndoSnackbar(tab: de.mrapp.android.tabswitcher.Tab, index: Int, appTab: Tab, appTabIndex: Int) { + private fun showUndoSnackbar(index: Int, appTab: Tab, adapterPosition: Int) { appTab.backStackPositionTitle?.let { FeedbackUtil.makeSnackbar(this, getString(R.string.tab_item_closed, it.displayText)).run { setAction(R.string.reading_list_item_delete_undo) { - app.tabList.add(appTabIndex, appTab) - binding.tabSwitcher.addTab(tab, index) + app.tabList.add(index, appTab) + binding.tabRecyclerView.adapter?.notifyItemInserted(adapterPosition) + binding.tabCountsView.updateTabCount(false) + if (adapterPosition == 0 && app.tabCount > 1) { + binding.tabRecyclerView.adapter?.notifyItemChanged(1) + } } show() } } } - private fun showUndoAllSnackbar(tabs: Array, appTabs: MutableList) { + private fun showUndoAllSnackbar(appTabs: MutableList) { FeedbackUtil.makeSnackbar(this, getString(R.string.all_tab_items_closed)).run { setAction(R.string.reading_list_item_delete_undo) { app.tabList.addAll(appTabs) - binding.tabSwitcher.addAllTabs(tabs) appTabs.clear() - } - show() - } - } - - private inner class TabListener : TabSwitcherListener { - override fun onSwitcherShown(tabSwitcher: TabSwitcher) {} - override fun onSwitcherHidden(tabSwitcher: TabSwitcher) {} - override fun onSelectionChanged(tabSwitcher: TabSwitcher, index: Int, selectedTab: de.mrapp.android.tabswitcher.Tab?) { - if (app.tabList.isNotEmpty() && index < app.tabList.size) { - val tabIndex = app.tabList.size - index - 1 - L.d("Tab selected: $index") - if (tabIndex < app.tabList.size - 1) { - val tab = app.tabList.removeAt(tabIndex) - app.tabList.add(tab) - } - binding.tabCountsView.updateTabCount(false) - cancelled = false - val tabUpdateDebounceMillis = 250 - if (System.currentTimeMillis() - tabUpdatedTimeMillis > tabUpdateDebounceMillis) { - if (launchedFromPageActivity) { - setResult(RESULT_LOAD_FROM_BACKSTACK) - } else { - startActivity(PageActivity.newIntent(this@TabActivity)) - } - finish() - } - } - } - - override fun onTabAdded(tabSwitcher: TabSwitcher, index: Int, tab: de.mrapp.android.tabswitcher.Tab, animation: Animation) { - binding.tabCountsView.updateTabCount(false) - tabUpdatedTimeMillis = System.currentTimeMillis() - } - - override fun onTabRemoved(tabSwitcher: TabSwitcher, index: Int, tab: de.mrapp.android.tabswitcher.Tab, animation: Animation) { - if (app.tabList.isNotEmpty() && index < app.tabList.size) { - val tabIndex = app.tabList.size - index - 1 - val appTab = app.tabList.removeAt(tabIndex) + binding.tabRecyclerView.adapter?.notifyItemRangeInserted(0, app.tabList.size) binding.tabCountsView.updateTabCount(false) - setResult(RESULT_LOAD_FROM_BACKSTACK) - showUndoSnackbar(tab, index, appTab, tabIndex) - tabUpdatedTimeMillis = System.currentTimeMillis() } - } - - override fun onAllTabsRemoved(tabSwitcher: TabSwitcher, tabs: Array, animation: Animation) { - L.d("All tabs removed.") - val appTabs = app.tabList.toMutableList() - app.tabList.clear() - binding.tabCountsView.updateTabCount(false) - setResult(RESULT_LOAD_FROM_BACKSTACK) - showUndoAllSnackbar(tabs, appTabs) - tabUpdatedTimeMillis = System.currentTimeMillis() + show() } } private fun goToMainTab() { - startActivity(MainActivity.newIntent(this) + startActivity( + MainActivity.newIntent(this) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) .putExtra(Constants.INTENT_RETURN_TO_MAIN, true) .putExtra(Constants.INTENT_EXTRA_GO_TO_MAIN_TAB, NavTab.EXPLORE.code())) @@ -319,35 +197,86 @@ class TabActivity : BaseActivity() { } } - companion object { - private const val LAUNCHED_FROM_PAGE_ACTIVITY = "launchedFromPageActivity" - private var FIRST_TAB_BITMAP: Bitmap? = null - private var FIRST_TAB_BITMAP_TITLE = "" - const val RESULT_LOAD_FROM_BACKSTACK = 10 - const val RESULT_NEW_TAB = 11 + private fun adapterPositionToTabIndex(adapterPosition: Int): Int { + return app.tabList.size - adapterPosition - 1 + } - fun captureFirstTabBitmap(view: View, title: String) { - clearFirstTabBitmap() - try { - if (view.isLaidOut) { - FIRST_TAB_BITMAP = view.drawToBitmap(Bitmap.Config.RGB_565) - FIRST_TAB_BITMAP_TITLE = title - } - } catch (e: OutOfMemoryError) { - // don't worry about it + private open inner class TabViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener, SwipeableTabTouchHelperCallback.Callback { + open fun bindItem(tab: Tab, position: Int) { + itemView.findViewById(R.id.tabArticleTitle).text = StringUtil.fromHtml(tab.backStackPositionTitle?.displayText.orEmpty()) + itemView.findViewById(R.id.tabArticleDescription).text = StringUtil.fromHtml(tab.backStackPositionTitle?.description.orEmpty()) + itemView.findViewById(R.id.tabContainer).setOnClickListener(this) + itemView.findViewById(R.id.tabCloseButton).setOnClickListener(this) + itemView.findViewById(R.id.tabCardView).run { + strokeWidth = DimenUtil.roundedDpToPx(if (position == 0) 1.5f else 1f) + strokeColor = ResourceUtil.getThemedColor(context, if (position == 0) R.attr.progressive_color else R.attr.border_color) } } - private fun clearFirstTabBitmap() { - FIRST_TAB_BITMAP_TITLE = "" - FIRST_TAB_BITMAP?.run { - if (!isRecycled) { - recycle() + override fun onClick(v: View) { + val adapterPosition = bindingAdapterPosition + val index = adapterPositionToTabIndex(adapterPosition) + if (index < 0 || index >= app.tabList.size) { + return + } + if (v.id == R.id.tabContainer) { + if (index < app.tabList.size - 1) { + val tab = app.tabList.removeAt(index) + app.tabList.add(tab) + } + cancelled = false + if (launchedFromPageActivity) { + setResult(RESULT_LOAD_FROM_BACKSTACK) + } else { + startActivity(PageActivity.newIntent(this@TabActivity)) } - FIRST_TAB_BITMAP = null + finish() + } else if (v.id == R.id.tabCloseButton) { + doCloseTab() } } + override fun onSwipe() { + doCloseTab() + } + + override fun isSwipeable(): Boolean { + return true + } + + private fun doCloseTab() { + val adapterPosition = bindingAdapterPosition + val index = adapterPositionToTabIndex(adapterPosition) + val appTab = app.tabList.removeAt(index) + binding.tabCountsView.updateTabCount(false) + bindingAdapter?.notifyItemRemoved(adapterPosition) + if (adapterPosition == 0) { + bindingAdapter?.notifyItemChanged(0) + } + setResult(RESULT_LOAD_FROM_BACKSTACK) + showUndoSnackbar(index, appTab, adapterPosition) + } + } + + private inner class TabItemAdapter : RecyclerView.Adapter() { + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TabViewHolder { + return TabViewHolder(layoutInflater.inflate(R.layout.item_tab_contents, parent, false)) + } + + override fun getItemCount(): Int { + return app.tabList.size + } + + override fun onBindViewHolder(holder: TabViewHolder, position: Int) { + holder.bindItem(app.tabList[adapterPositionToTabIndex(position)], position) + } + } + + companion object { + private const val LAUNCHED_FROM_PAGE_ACTIVITY = "launchedFromPageActivity" + const val RESULT_LOAD_FROM_BACKSTACK = 10 + const val RESULT_NEW_TAB = 11 + fun newIntent(context: Context): Intent { return Intent(context, TabActivity::class.java) } diff --git a/app/src/main/java/org/wikipedia/pageimages/db/PageImageDao.kt b/app/src/main/java/org/wikipedia/pageimages/db/PageImageDao.kt index 222899fdf24..7fd60def511 100644 --- a/app/src/main/java/org/wikipedia/pageimages/db/PageImageDao.kt +++ b/app/src/main/java/org/wikipedia/pageimages/db/PageImageDao.kt @@ -8,7 +8,10 @@ import androidx.room.Query @Dao interface PageImageDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - fun insertPageImage(pageImage: PageImage) + fun insertPageImageSync(pageImage: PageImage) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertPageImage(pageImage: PageImage) @Query("SELECT * FROM PageImage") fun getAllPageImages(): List diff --git a/app/src/main/java/org/wikipedia/places/PlacesActivity.kt b/app/src/main/java/org/wikipedia/places/PlacesActivity.kt index 95d692fc3de..f2d1678c59e 100644 --- a/app/src/main/java/org/wikipedia/places/PlacesActivity.kt +++ b/app/src/main/java/org/wikipedia/places/PlacesActivity.kt @@ -3,12 +3,19 @@ package org.wikipedia.places import android.content.Context import android.content.Intent import android.location.Location +import android.os.Bundle import org.wikipedia.Constants import org.wikipedia.activity.SingleFragmentActivity import org.wikipedia.extensions.parcelableExtra import org.wikipedia.page.PageTitle class PlacesActivity : SingleFragmentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + disableFitsSystemWindows() + } + public override fun createFragment(): PlacesFragment { return PlacesFragment.newInstance(intent.parcelableExtra(Constants.ARG_TITLE), intent.parcelableExtra(EXTRA_LOCATION)) } diff --git a/app/src/main/java/org/wikipedia/places/PlacesFragment.kt b/app/src/main/java/org/wikipedia/places/PlacesFragment.kt index e8d30fff10f..944f612f31f 100644 --- a/app/src/main/java/org/wikipedia/places/PlacesFragment.kt +++ b/app/src/main/java/org/wikipedia/places/PlacesFragment.kt @@ -13,7 +13,6 @@ import android.graphics.PorterDuffXfermode import android.graphics.Rect import android.graphics.RectF import android.graphics.drawable.BitmapDrawable -import android.graphics.drawable.Drawable import android.location.Location import android.os.Bundle import android.view.Gravity @@ -36,31 +35,33 @@ import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition -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.maplibre.android.MapLibre +import org.maplibre.android.camera.CameraPosition +import org.maplibre.android.camera.CameraUpdateFactory +import org.maplibre.android.geometry.LatLng +import org.maplibre.android.location.LocationComponentActivationOptions +import org.maplibre.android.location.modes.CameraMode +import org.maplibre.android.location.modes.RenderMode +import org.maplibre.android.maps.MapLibreMap +import org.maplibre.android.maps.MapLibreMap.CancelableCallback +import org.maplibre.android.maps.Style +import org.maplibre.android.module.http.HttpRequestImpl +import org.maplibre.android.plugins.annotation.ClusterOptions +import org.maplibre.android.plugins.annotation.Symbol +import org.maplibre.android.plugins.annotation.SymbolManager +import org.maplibre.android.plugins.annotation.SymbolOptions +import org.maplibre.android.style.expressions.Expression +import org.maplibre.android.style.layers.PropertyFactory.circleColor +import org.maplibre.android.style.layers.PropertyFactory.circleOpacity +import org.maplibre.android.style.layers.PropertyFactory.circleRadius +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeColor +import org.maplibre.android.style.layers.PropertyFactory.circleStrokeWidth +import org.maplibre.android.style.layers.PropertyFactory.textAllowOverlap +import org.maplibre.android.style.layers.PropertyFactory.textColor +import org.maplibre.android.style.layers.PropertyFactory.textField +import org.maplibre.android.style.layers.PropertyFactory.textFont +import org.maplibre.android.style.layers.PropertyFactory.textIgnorePlacement +import org.maplibre.android.style.layers.PropertyFactory.textSize import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -68,8 +69,10 @@ 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.dataclient.page.NearbyPage 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 @@ -90,26 +93,26 @@ import org.wikipedia.util.Resource import org.wikipedia.util.ResourceUtil import org.wikipedia.util.StringUtil import org.wikipedia.util.TabUtil +import org.wikipedia.util.WhiteBackgroundTransformation 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 { +class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPreviewDialog.DismissCallback, MapLibreMap.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 val viewModel: PlacesFragmentViewModel by viewModels() - private var mapboxMap: MapboxMap? = null + private var mapboxMap: MapLibreMap? = null private var symbolManager: SymbolManager? = null - private val annotationCache = ArrayDeque() + private val annotationCache = ArrayDeque() private var lastCheckedId = R.id.mapViewButton private var lastLocation: Location? = null private var lastLocationQueried: Location? = null @@ -121,6 +124,8 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi private lateinit var markerPaintSrcIn: Paint private lateinit var markerBorderPaint: Paint private val markerRect = Rect(0, 0, MARKER_SIZE, MARKER_SIZE) + private val whiteBackgroundTransformation = WhiteBackgroundTransformation() + private val searchRadius get() = mapboxMap?.let { latitudeDiffToMeters(it.projection.visibleRegion.latLngBounds.latitudeSpan / 2) @@ -133,7 +138,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi permissions.getOrDefault(Manifest.permission.ACCESS_COARSE_LOCATION, false) -> { PlacesEvent.logAction("location_permission_granted", "map_view") startLocationTracking() - goToLocation(viewModel.location) + goToLocation(viewModel.location ?: getDefaultLocation()) } else -> { PlacesEvent.logAction("location_permission_denied", "map_view") @@ -171,10 +176,11 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi super.onCreate(savedInstanceState) setupMarkerPaints() markerBitmapBase = Bitmap.createBitmap(MARKER_SIZE, MARKER_SIZE, Bitmap.Config.ARGB_8888).applyCanvas { - drawMarker(this) + val bitmap = ResourceUtil.bitmapFromVectorDrawable(requireContext(), R.drawable.ic_w_logo_circle) + drawMarker(this, bitmap) } - Mapbox.getInstance(requireActivity().applicationContext) + MapLibre.getInstance(requireActivity().applicationContext) HttpRequestImpl.setOkHttpClient(OkHttpConnectionFactory.client) @@ -380,8 +386,12 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi viewModel.location?.let { goToLocation(it) } ?: run { - val lastLocationAndZoomLevel = Prefs.placesLastLocationAndZoomLevel - goToLocation(lastLocationAndZoomLevel?.first, lastLocationAndZoomLevel?.second ?: lastZoom) + if (Prefs.placesDefaultLocationLatLng != null) { + goToLocation(getDefaultLocation()) + } else { + 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)) @@ -397,8 +407,17 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi FeedbackUtil.showError(requireActivity(), it.throwable) } } + } - maybeShowSurvey() + private fun getDefaultLocation(): Location? { + return Prefs.placesDefaultLocationLatLng?.let { defaultLocationString -> + val defaultLocationStrings = defaultLocationString.split(",").map { it.toDouble() } + val defaultLocation = Location("").apply { + latitude = defaultLocationStrings[0] + longitude = defaultLocationStrings[1] + } + return defaultLocation + } } private fun updateToggleViews(isMapVisible: Boolean) { @@ -444,7 +463,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi private fun setupMarkerPaints() { markerPaintSrc = Paint().apply { isAntiAlias = true - color = ResourceUtil.getThemedColor(requireContext(), R.attr.success_color) + color = ResourceUtil.getThemedColor(requireContext(), R.attr.secondary_color) xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC) } markerPaintSrcIn = Paint().apply { @@ -472,28 +491,28 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi 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))) + private fun setUpSymbolManagerWithClustering(mapboxMap: MapLibreMap, style: Style) { - symbolManager = SymbolManager(binding.mapView, mapboxMap, style, null, null, clusterOptions) + 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( + textField(Expression.toString(Expression.get(POINT_COUNT))), + textSize(Expression.literal(22f)), + textColor(ResourceUtil.getThemedColor(requireContext(), R.attr.paper_color)), textFont(CLUSTER_FONT_STACK), textIgnorePlacement(true), - textAllowOverlap(true) + textAllowOverlap(true), ) } style.getLayer(CLUSTER_CIRCLE_LAYER_ID)?.apply { this.setProperties( - circleColor(ContextCompat.getColor(requireActivity(), ResourceUtil.getThemedAttributeId(requireContext(), R.attr.success_color))), + circleRadius(24f), + circleColor(ContextCompat.getColor(requireActivity(), ResourceUtil.getThemedAttributeId(requireContext(), R.attr.secondary_color))), + circleOpacity(0.8f), circleStrokeColor(ResourceUtil.getThemedColor(requireContext(), R.attr.paper_color)), circleStrokeWidth(2.0f), ) @@ -592,7 +611,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi viewModel.fetchNearbyPages(latLng.latitude, latLng.longitude, searchRadius, ITEMS_PER_REQUEST) } - private fun updateMapMarkers(pages: List) { + private fun updateMapMarkers(pages: List) { symbolManager?.let { manager -> pages.filter { @@ -625,6 +644,18 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi } } binding.listRecyclerView.adapter = RecyclerViewAdapter(pages) + + if (pages.isEmpty() && Prefs.placesLastLocationAndZoomLevel == null) { + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.places_empty_message_snackbar)).run { + setAction(R.string.dialog_close_description) { + dismiss() + } + show() + } + lastLocation?.let { + Prefs.placesLastLocationAndZoomLevel = Pair(it, lastZoom) + } + } } private fun haveLocationPermissions(): Boolean { @@ -669,35 +700,28 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi } } - private fun queueImageForAnnotation(page: PlacesFragmentViewModel.NearbyPage) { + private fun queueImageForAnnotation(page: NearbyPage) { val url = page.pageTitle.thumbUrl if (!Prefs.isImageDownloadEnabled || url.isNullOrEmpty()) { return } - Glide.with(requireContext()) - .asBitmap() - .load(url) - .into(object : CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - if (!isAdded) { - return - } - annotationCache.find { it.pageId == page.pageId }?.let { - val bmp = getMarkerBitmap(resource) - it.bitmap = bmp + ImagePipelineBitmapGetter(requireContext(), url, whiteBackgroundTransformation) { 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)) + mapboxMap?.style?.addImage(url, BitmapDrawable(resources, bmp)) - it.annotation?.let { annotation -> - annotation.iconImage = url - symbolManager?.update(annotation) - } - } + it.annotation?.let { annotation -> + annotation.iconImage = url + symbolManager?.update(annotation) } - - override fun onLoadCleared(placeholder: Drawable?) {} - }) + } + } } private fun getMarkerBitmap(thumbnailBitmap: Bitmap): Bitmap { @@ -759,16 +783,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi 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() { + private inner class RecyclerViewAdapter(val nearbyPages: List) : RecyclerView.Adapter() { override fun getItemCount(): Int { return nearbyPages.size } @@ -785,7 +800,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi private inner class RecyclerViewItemHolder(val binding: ItemPlacesListBinding) : RecyclerView.ViewHolder(binding.root), View.OnClickListener, View.OnLongClickListener { - private lateinit var page: PlacesFragmentViewModel.NearbyPage + private lateinit var page: NearbyPage init { itemView.setOnClickListener(this) @@ -793,7 +808,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi DeviceUtil.setContextClickAsLongClick(itemView) } - fun bindItem(page: PlacesFragmentViewModel.NearbyPage, locationForDistance: Location?) { + fun bindItem(page: NearbyPage, locationForDistance: Location?) { this.page = page binding.listItemTitle.text = StringUtil.fromHtml(page.pageTitle.displayText) binding.listItemDescription.text = StringUtil.fromHtml(page.pageTitle.description) @@ -803,9 +818,6 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi } page.pageTitle.thumbUrl?.let { ViewUtil.loadImage(binding.listItemThumbnail, it, circleShape = true) - binding.listItemThumbnail.isVisible = true - } ?: run { - binding.listItemThumbnail.isVisible = false } } @@ -846,7 +858,6 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi 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") diff --git a/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt b/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt index 3bd7a3354e2..46a0f287ceb 100644 --- a/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt +++ b/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt @@ -1,37 +1,29 @@ package org.wikipedia.places -import android.graphics.Bitmap import android.location.Location -import android.os.Bundle import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle 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.dataclient.page.NearbyPage 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() { - +class PlacesFragmentViewModel(savedStateHandle: SavedStateHandle) : 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 location: Location? = savedStateHandle[PlacesActivity.EXTRA_LOCATION] + var highlightedPageTitle: PageTitle? = savedStateHandle[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)) @@ -52,28 +44,4 @@ class PlacesFragmentViewModel(bundle: Bundle) : ViewModel() { 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/PagerTransformer.kt b/app/src/main/java/org/wikipedia/random/PagerTransformer.kt new file mode 100644 index 00000000000..3018c382404 --- /dev/null +++ b/app/src/main/java/org/wikipedia/random/PagerTransformer.kt @@ -0,0 +1,77 @@ +package org.wikipedia.random + +import android.view.View +import androidx.viewpager2.widget.ViewPager2 +import org.wikipedia.util.DimenUtil + +class PagerTransformer(private val rtl: Boolean) : ViewPager2.PageTransformer { + override fun transformPage(view: View, position: Float) { + if (!rtl) { + when { + position < -1 -> { // [-Infinity,-1) + // This page is way off-screen to the left. + view.rotation = 0f + view.translationX = 0f + view.translationZ = -position + } + position <= 0 -> { // [-1,0] + val factor = position * 45f + view.rotation = factor + view.translationX = view.width * position / 2 + view.alpha = 1f + view.translationZ = -position + } + position <= 1 -> { // (0,1] + // keep it in place (undo the default translation) + view.translationX = -(view.width * position) + // but move it slightly down + view.translationY = DimenUtil.roundedDpToPx(12f) * position + view.translationZ = -position + // and make it translucent + view.alpha = 1f - position * 0.5f + // view.setAlpha(1f); + view.rotation = 0f + } + else -> { // (1,+Infinity] + // This page is way off-screen to the right. + view.rotation = 0f + view.translationX = 0f + view.translationZ = -position + } + } + } else { + when { + position > 1 -> { // (1,+Infinity] + // This page is way off-screen to the right. + view.rotation = 0f + view.translationX = 0f + view.translationZ = -position + } + position > 0 -> { // (0,1] + // keep it in place (undo the default translation) + view.translationX = view.width * position + // but move it slightly down + view.translationY = DimenUtil.roundedDpToPx(12f) * position + view.translationZ = -position + // and make it translucent + view.alpha = 1f - position * 0.5f + // view.setAlpha(1f); + view.rotation = 0f + } + position >= -1 -> { // [-1,0] + val factor = position * 45f + view.rotation = -factor + view.translationX = -(view.width * position / 2) + view.alpha = 1f + view.translationZ = -position + } + else -> { // [-Infinity,-1) + // This page is way off-screen to the left. + view.rotation = 0f + view.translationX = 0f + view.translationZ = -position + } + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/random/RandomFragment.kt b/app/src/main/java/org/wikipedia/random/RandomFragment.kt index 5f6450199f9..2a370e62452 100644 --- a/app/src/main/java/org/wikipedia/random/RandomFragment.kt +++ b/app/src/main/java/org/wikipedia/random/RandomFragment.kt @@ -9,63 +9,41 @@ 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.flow.collectLatest +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.concurrency.FlowEventBus 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 import org.wikipedia.readinglist.LongPressMenu import org.wikipedia.readinglist.ReadingListBehaviorsUtil 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() + 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) @@ -73,8 +51,6 @@ class RandomFragment : Fragment() { FeedbackUtil.setButtonTooltip(binding.randomNextButton, binding.randomSaveButton) - wikiSite = requireArguments().parcelable(Constants.ARG_WIKISITE)!! - binding.randomItemPager.offscreenPageLimit = 2 binding.randomItemPager.adapter = RandomItemAdapter(this) binding.randomItemPager.setPageTransformer(PagerTransformer(resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL)) @@ -84,13 +60,38 @@ 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) { + viewModel.uiState.collect { + when (it) { + is Resource.Success -> setSaveButton() + is Resource.Error -> L.w(it.throwable) + } + } + } + } - updateSaveShareButton() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + FlowEventBus.events.collectLatest { event -> + when (event) { + is ArticleSavedOrDeletedEvent -> { + topTitle?.let { title -> + event.pages.firstOrNull { it.apiTitle == title.prefixedText && it.wiki.languageCode == title.wikiSite.languageCode }.let { + updateSaveButton(title) + } + } + } + } + } + } + } + + updateSaveButton() updateBackButton(DEFAULT_PAGER_TAB) if (savedInstanceState != null && binding.randomItemPager.currentItem == DEFAULT_PAGER_TAB && topTitle != null) { - updateSaveShareButton(topTitle) + updateSaveButton(topTitle) } return view @@ -98,11 +99,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,25 +128,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 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) } } } @@ -163,10 +163,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) { @@ -175,38 +172,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 - } - - val d = Observable.fromCallable { - AppDatabase.instance.readingListPageDao().findPageInAnyList(title) != null + 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 } - .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? { @@ -221,7 +204,7 @@ class RandomFragment : Fragment() { } override fun createFragment(position: Int): Fragment { - return RandomItemFragment.newInstance(wikiSite) + return RandomItemFragment.newInstance(viewModel.wikiSite) } } @@ -235,12 +218,12 @@ 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) { @@ -249,18 +232,16 @@ class RandomFragment : Fragment() { } } - private inner class EventBusConsumer : Consumer { - override fun accept(event: Any) { - if (event is ArticleSavedOrDeletedEvent) { - if (!isAdded || topTitle == null) { - return - } - for (page in event.pages) { - if (page.apiTitle == topTitle?.prefixedText && page.wiki.languageCode == topTitle?.wikiSite?.languageCode) { - updateSaveShareButton(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..fcb91d03048 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() - 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..4236c83b545 --- /dev/null +++ b/app/src/main/java/org/wikipedia/random/RandomItemViewModel.kt @@ -0,0 +1,37 @@ +package org.wikipedia.random + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.util.Resource + +class RandomItemViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + val wikiSite = savedStateHandle.get(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) + } + } +} 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..e418c29bff2 --- /dev/null +++ b/app/src/main/java/org/wikipedia/random/RandomViewModel.kt @@ -0,0 +1,33 @@ +package org.wikipedia.random + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +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.page.PageTitle +import org.wikipedia.util.Resource + +class RandomViewModel(savedStateHandle: SavedStateHandle) : ViewModel() { + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + val wikiSite = savedStateHandle.get(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) + } + } +} diff --git a/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt b/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt index dc3d4e751fc..6f64027acf6 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 @@ -91,7 +90,7 @@ open class AddToReadingListDialog : ExtendedBottomSheetDialogFragment() { displayedLists.clear() displayedLists.addAll(readingLists) if (!showDefaultList && displayedLists.isNotEmpty()) { - displayedLists.removeAt(0) + displayedLists.removeIf { it.isDefault } } ReadingList.sort(displayedLists, Prefs.getReadingListSortMode(ReadingList.SORT_BY_NAME_ASC)) adapter.notifyDataSetChanged() @@ -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 ebaa0871ea1..5bf043f6c6d 100644 --- a/app/src/main/java/org/wikipedia/readinglist/LongPressMenu.kt +++ b/app/src/main/java/org/wikipedia/readinglist/LongPressMenu.kt @@ -9,13 +9,12 @@ import android.view.MenuItem import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.PopupMenu -import kotlinx.coroutines.CoroutineScope -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.extensions.coroutineScope import org.wikipedia.history.HistoryEntry import org.wikipedia.readinglist.database.ReadingList import org.wikipedia.readinglist.database.ReadingListPage @@ -28,6 +27,7 @@ import org.wikipedia.util.StringUtil class LongPressMenu( private val anchorView: View, private val existsInAnyList: Boolean = true, + private val openPageInPlaces: Boolean = false, private var menuRes: Int = R.menu.menu_long_press, private val location: Location? = null, private val callback: Callback? = null @@ -35,6 +35,7 @@ class LongPressMenu( interface Callback { fun onOpenLink(entry: HistoryEntry) { /* ignore by default */ } fun onOpenInNewTab(entry: HistoryEntry) { /* ignore by default */ } + fun onOpenInPlaces(entry: HistoryEntry, location: Location) { /* ignore by default */ } fun onAddRequest(entry: HistoryEntry, addToDefault: Boolean) fun onMoveRequest(page: ReadingListPage?, entry: HistoryEntry) fun onRemoveRequest() { /* ignore by default */ } @@ -45,13 +46,10 @@ class LongPressMenu( fun show(entry: HistoryEntry?) { entry?.let { - CoroutineScope(Dispatchers.Main).launch { + anchorView.coroutineScope().launch { listsContainingPage = AppDatabase.instance.readingListDao().getListsFromPageOccurrences( AppDatabase.instance.readingListPageDao().getAllPageOccurrences(it.title) ) - if (!anchorView.isAttachedToWindow) { - return@launch - } this@LongPressMenu.entry = it if (!existsInAnyList) { this@LongPressMenu.menuRes = R.menu.menu_reading_list_page_toggle @@ -89,6 +87,9 @@ class LongPressMenu( saveItem.isVisible = it.isEmpty() saveItem.isEnabled = it.isEmpty() } + val showOpenPageInPlaces = openPageInPlaces && location != null + menu.menu.findItem(R.id.menu_long_press_open_in_places)?.isVisible = showOpenPageInPlaces + menu.menu.findItem(R.id.menu_long_press_open_page)?.isVisible = !showOpenPageInPlaces menu.menu.findItem(R.id.menu_long_press_get_directions)?.isVisible = location != null menu.show() } @@ -131,6 +132,12 @@ class LongPressMenu( entry?.let { callback?.onOpenLink(it) } true } + R.id.menu_long_press_open_in_places -> { + location?.let { location -> + entry?.let { callback?.onOpenInPlaces(it, location) } + } + true + } R.id.menu_long_press_open_in_new_tab -> { sendPlacesEvent("new_tab_click") entry?.let { callback?.onOpenInNewTab(it) } diff --git a/app/src/main/java/org/wikipedia/readinglist/MoveToReadingListDialog.kt b/app/src/main/java/org/wikipedia/readinglist/MoveToReadingListDialog.kt index c6b11e52308..ee8d1588fbb 100644 --- a/app/src/main/java/org/wikipedia/readinglist/MoveToReadingListDialog.kt +++ b/app/src/main/java/org/wikipedia/readinglist/MoveToReadingListDialog.kt @@ -10,7 +10,6 @@ import android.widget.TextView import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineExceptionHandler -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -30,7 +29,7 @@ class MoveToReadingListDialog : AddToReadingListDialog() { parentView.findViewById(R.id.dialog_title).setText(R.string.reading_list_move_to) val sourceReadingListId = requireArguments().getLong(SOURCE_READING_LIST_ID) - CoroutineScope(Dispatchers.Main).launch(CoroutineExceptionHandler { _, exception -> + lifecycleScope.launch(CoroutineExceptionHandler { _, exception -> L.w(exception) }) { sourceReadingList = AppDatabase.instance.readingListDao().getListById(sourceReadingListId, false) diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListActivity.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListActivity.kt index 123a6d5867b..1bb5c139edf 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListActivity.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListActivity.kt @@ -35,6 +35,8 @@ class ReadingListActivity : SingleFragmentActivity() { super.onBackPressed() if (intent.getBooleanExtra(EXTRA_READING_LIST_PREVIEW, false)) { ReadingListsAnalyticsHelper.logReceiveCancel(this, fragment.readingList) + } else if (intent.getBooleanExtra(EXTRA_READING_LIST_SUGGESTED, false)) { + setResult(RESULT_CANCELED) } } @@ -42,6 +44,8 @@ class ReadingListActivity : SingleFragmentActivity() { private const val EXTRA_READING_LIST_TITLE = "readingListTitle" const val EXTRA_READING_LIST_ID = "readingListId" const val EXTRA_READING_LIST_PREVIEW = "previewReadingList" + const val EXTRA_READING_LIST_SUGGESTED = "suggestedReadingList" + const val EXTRA_READING_LIST_SUGGESTED_SAVE = "suggestedReadingListSave" fun newIntent(context: Context, list: ReadingList): Intent { return Intent(context, ReadingListActivity::class.java) @@ -49,9 +53,11 @@ class ReadingListActivity : SingleFragmentActivity() { .putExtra(EXTRA_READING_LIST_ID, list.id) } - fun newIntent(context: Context, preview: Boolean): Intent { + fun newIntent(context: Context, preview: Boolean, suggestedList: Boolean = false, suggestedListSave: Boolean = false): Intent { return Intent(context, ReadingListActivity::class.java) .putExtra(EXTRA_READING_LIST_PREVIEW, preview) + .putExtra(EXTRA_READING_LIST_SUGGESTED, suggestedList) + .putExtra(EXTRA_READING_LIST_SUGGESTED_SAVE, suggestedListSave) } } } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListBehaviorsUtil.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListBehaviorsUtil.kt index a18bbd2cace..12f5a093219 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListBehaviorsUtil.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListBehaviorsUtil.kt @@ -8,10 +8,10 @@ import android.text.Spanned import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.apache.commons.lang3.StringUtils @@ -46,9 +46,6 @@ object ReadingListBehaviorsUtil { private var allReadingLists = listOf() - // Kotlin coroutine - private val dispatcher: CoroutineDispatcher = Dispatchers.IO - private val scope = CoroutineScope(Dispatchers.Main) private val exceptionHandler = CoroutineExceptionHandler { _, exception -> L.w(exception) } fun getListsContainPage(readingListPage: ReadingListPage) = @@ -119,11 +116,13 @@ object ReadingListBehaviorsUtil { .show() } - fun deletePages(activity: Activity, listsContainPage: List, readingListPage: ReadingListPage, snackbarCallback: SnackbarCallback, callback: Callback) { + fun deletePages(activity: AppCompatActivity, listsContainPage: List, readingListPage: ReadingListPage, snackbarCallback: SnackbarCallback, callback: Callback) { if (listsContainPage.size > 1) { - scope.launch(exceptionHandler) { - val pages = withContext(dispatcher) { AppDatabase.instance.readingListPageDao().getAllPageOccurrences(ReadingListPage.toPageTitle(readingListPage)) } - val lists = withContext(dispatcher) { AppDatabase.instance.readingListDao().getListsFromPageOccurrences(pages) } + activity.lifecycleScope.launch(exceptionHandler) { + val lists = withContext(Dispatchers.IO) { + val pages = AppDatabase.instance.readingListPageDao().getAllPageOccurrences(ReadingListPage.toPageTitle(readingListPage)) + AppDatabase.instance.readingListDao().getListsFromPageOccurrences(pages) + } RemoveFromReadingListsDialog(lists).deleteOrShowDialog(activity) { list, page -> showDeletePageFromListsUndoSnackbar(activity, list, page, snackbarCallback) callback.onCompleted() @@ -137,6 +136,15 @@ object ReadingListBehaviorsUtil { } } + fun updateReadingListPage(item: ReadingListPage) { + MainScope().launch(exceptionHandler) { + withContext(Dispatchers.IO) { + AppDatabase.instance.readingListDao().updateLists(getListsContainPage(item), false) + AppDatabase.instance.readingListPageDao().updateReadingListPage(item) + } + } + } + fun renameReadingList(activity: Activity, readingList: ReadingList?, callback: Callback) { if (readingList == null) { return @@ -255,14 +263,16 @@ object ReadingListBehaviorsUtil { } } - fun togglePageOffline(activity: Activity, page: ReadingListPage?, callback: Callback) { + fun togglePageOffline(activity: AppCompatActivity, page: ReadingListPage?, callback: Callback) { if (page == null) { return } if (page.offline) { - scope.launch(exceptionHandler) { - val pages = withContext(dispatcher) { AppDatabase.instance.readingListPageDao().getAllPageOccurrences(ReadingListPage.toPageTitle(page)) } - val lists = withContext(dispatcher) { AppDatabase.instance.readingListDao().getListsFromPageOccurrences(pages) } + activity.lifecycleScope.launch(exceptionHandler) { + val lists = withContext(Dispatchers.IO) { + val pages = AppDatabase.instance.readingListPageDao().getAllPageOccurrences(ReadingListPage.toPageTitle(page)) + AppDatabase.instance.readingListDao().getListsFromPageOccurrences(pages) + } if (lists.size > 1) { MaterialAlertDialogBuilder(activity) .setTitle(R.string.reading_list_confirm_remove_article_from_offline_title) @@ -295,7 +305,7 @@ object ReadingListBehaviorsUtil { fun addToDefaultList(activity: Activity, title: PageTitle, addToDefault: Boolean, invokeSource: InvokeSource, listener: DialogInterface.OnDismissListener? = null) { if (addToDefault) { // If the title is a redirect, resolve it before saving to the reading list. - (activity as AppCompatActivity).lifecycleScope.launch(CoroutineExceptionHandler { _, t -> L.e(t) }) { + (activity as AppCompatActivity).lifecycleScope.launch(exceptionHandler) { var finalPageTitle = title try { ServiceFactory.get(title.wikiSite).getInfoByPageIdsOrTitles(null, title.prefixedText) @@ -365,10 +375,10 @@ object ReadingListBehaviorsUtil { return StringUtil.fromHtml(result) } - fun searchListsAndPages(searchQuery: String?, callback: SearchCallback) { - scope.launch(exceptionHandler) { - allReadingLists = withContext(dispatcher) { AppDatabase.instance.readingListDao().getAllLists() } - val list = withContext(dispatcher) { applySearchQuery(searchQuery, allReadingLists) } + fun searchListsAndPages(coroutineScope: CoroutineScope, searchQuery: String?, callback: SearchCallback) { + coroutineScope.launch(exceptionHandler) { + allReadingLists = withContext(Dispatchers.IO) { AppDatabase.instance.readingListDao().getAllLists() } + val list = withContext(Dispatchers.IO) { applySearchQuery(searchQuery, allReadingLists) } if (searchQuery.isNullOrEmpty()) { ReadingList.sortGenericList(list, Prefs.getReadingListSortMode(ReadingList.SORT_BY_NAME_ASC)) } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt index eb53efd046d..2fdff9826e2 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt @@ -5,7 +5,12 @@ import android.content.Intent import android.graphics.Color import android.os.Build import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity @@ -18,6 +23,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView @@ -25,17 +31,17 @@ import androidx.recyclerview.widget.SimpleItemAnimator import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.functions.Consumer -import io.reactivex.rxjava3.schedulers.Schedulers -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Runnable +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R -import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.eventplatform.RabbitHolesEvent import org.wikipedia.analytics.eventplatform.ReadingListsAnalyticsHelper +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentReadingListBinding import org.wikipedia.events.PageDownloadEvent @@ -45,17 +51,24 @@ import org.wikipedia.main.MainActivity import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.PageActivity import org.wikipedia.page.PageAvailableOfflineHandler -import org.wikipedia.page.PageAvailableOfflineHandler.check import org.wikipedia.readinglist.database.ReadingList 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.DeviceUtil +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.ShareUtil import org.wikipedia.util.log.L -import org.wikipedia.views.* +import org.wikipedia.views.CircularProgressBar +import org.wikipedia.views.DefaultViewHolder +import org.wikipedia.views.DrawableItemDecoration +import org.wikipedia.views.MultiSelectActionModeCallback import org.wikipedia.views.MultiSelectActionModeCallback.Companion.isTagType +import org.wikipedia.views.PageItemView +import org.wikipedia.views.SwipeableItemTouchHelperCallback class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDialog.Callback { @@ -65,8 +78,11 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial private lateinit var touchCallback: SwipeableItemTouchHelperCallback private lateinit var headerView: ReadingListItemView private var previewSaveDialog: AlertDialog? = null - private val disposables = CompositeDisposable() private var isPreview: Boolean = false + + private var isSuggested: Boolean = false + private var isSuggestedSave: Boolean = false + private var readingListId: Long = 0 private val adapter = ReadingListPageItemAdapter() private var actionMode: ActionMode? = null @@ -92,14 +108,42 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial DeviceUtil.updateStatusBarTheme(requireActivity(), binding.readingListToolbar, true) touchCallback = SwipeableItemTouchHelperCallback(requireContext()) ItemTouchHelper(touchCallback).attachToRecyclerView(binding.readingListRecyclerView) + isPreview = requireArguments().getBoolean(ReadingListActivity.EXTRA_READING_LIST_PREVIEW, false) + isSuggested = requireActivity().intent.getBooleanExtra(ReadingListActivity.EXTRA_READING_LIST_SUGGESTED, false) + isSuggestedSave = requireActivity().intent.getBooleanExtra(ReadingListActivity.EXTRA_READING_LIST_SUGGESTED_SAVE, false) + readingListId = requireArguments().getLong(ReadingListActivity.EXTRA_READING_LIST_ID, -1) requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) setToolbar() setHeaderView() setRecyclerView() setSwipeRefreshView() - disposables.add(WikipediaApp.instance.bus.subscribe(EventBusConsumer())) + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + FlowEventBus.events.collectLatest { event -> + when (event) { + is ReadingListSyncEvent -> { + updateReadingListData() + } + is PageDownloadEvent -> { + val pagePosition = getPagePositionInList(event.page) + if (pagePosition != -1 && displayedLists[pagePosition] is ReadingListPage) { + (displayedLists[pagePosition] as ReadingListPage).downloadProgress = event.page.downloadProgress + adapter.notifyItemChanged(pagePosition + 1) + } + } + } + } + } + } + + if (isSuggested) { + RabbitHolesEvent.submit("impression", "reading_list") + binding.readingListSwipeRefresh.isEnabled = false + } + return binding.root } @@ -107,11 +151,9 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial super.onResume() updateReadingListData() ReadingListsAnalyticsHelper.logListShown(requireContext(), readingList?.pages?.size ?: 0) - ReadingListsShareSurveyHelper.maybeShowSurvey(requireActivity()) } override fun onDestroyView() { - disposables.clear() previewSaveDialog?.dismiss() binding.readingListRecyclerView.adapter = null binding.readingListAppBar.removeOnOffsetChangedListener(appBarListener) @@ -142,7 +184,6 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial val sortOptionsItem = menu.findItem(R.id.menu_sort_options) val iconColor = if (toolbarExpanded) AppCompatResources.getColorStateList(requireContext(), android.R.color.white) else ResourceUtil.getThemedColorStateList(requireContext(), R.attr.primary_color) - menu.findItem(R.id.menu_reading_list_share)?.isVisible = ReadingListsShareHelper.shareEnabled() MenuItemCompat.setIconTintList(searchItem, iconColor) MenuItemCompat.setIconTintList(sortOptionsItem, iconColor) readingList?.let { @@ -234,30 +275,26 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial headerView.setOverflowViewVisibility(View.VISIBLE) headerView.setPreviewMode(isPreview) - if (isPreview) { - headerView.previewSaveButton.setOnClickListener { + if (isPreview || isSuggested) { + headerView.saveClickListener = View.OnClickListener { previewSaveDialog() } return } - if (ReadingListsShareHelper.shareEnabled()) { - headerView.shareButton.isVisible = true - if (!Prefs.readingListShareTooltipShown) { - enqueueTooltip { - FeedbackUtil.showTooltip( - requireActivity(), - headerView.shareButton, - getString(R.string.reading_list_share_menu_tooltip), - aboveOrBelow = false, - autoDismiss = true, - showDismissButton = true - ) - Prefs.readingListShareTooltipShown = true - } + headerView.shareButton.isVisible = true + if (!Prefs.readingListShareTooltipShown) { + enqueueTooltip { + FeedbackUtil.showTooltip( + requireActivity(), + headerView.shareButton, + getString(R.string.reading_list_share_menu_tooltip), + aboveOrBelow = false, + autoDismiss = true, + showDismissButton = true + ) + Prefs.readingListShareTooltipShown = true } - } else { - headerView.shareButton.isVisible = false } } @@ -269,7 +306,6 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial } private fun setSwipeRefreshView() { - binding.readingListSwipeRefresh.setColorSchemeResources(ResourceUtil.getThemedAttributeId(requireContext(), R.attr.progressive_color)) binding.readingListSwipeRefresh.setOnRefreshListener { ReadingListsFragment.refreshSync(this, binding.readingListSwipeRefresh) } if (RemoteConfig.config.disableReadingListSync) { binding.readingListSwipeRefresh.isEnabled = false @@ -281,39 +317,42 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial private fun update(readingList: ReadingList? = this.readingList) { readingList?.let { binding.readingListEmptyText.visibility = if (it.pages.isEmpty()) View.VISIBLE else View.GONE - headerView.setReadingList(it, ReadingListItemView.Description.DETAIL) + headerView.setReadingList(it, ReadingListItemView.Description.DETAIL, isSuggested = isSuggested, isSingle = true) binding.readingListHeader.setReadingList(it) ReadingList.sort(readingList, Prefs.getReadingListPageSortMode(ReadingList.SORT_BY_NAME_ASC)) setSearchQuery() 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 } + + if (isSuggested && isSuggestedSave) { + isSuggestedSave = false + previewSaveDialog() + } } } private fun updateReadingListData() { if (isPreview) { if (readingList == null) { - val encodedJson = Prefs.receiveReadingListsData - if (!encodedJson.isNullOrEmpty()) { - lifecycleScope.launch(CoroutineExceptionHandler { _, throwable -> - L.e(throwable) - FeedbackUtil.showError(requireActivity(), throwable) - requireActivity().finish() - }) { - withContext(Dispatchers.Main) { - readingList = ReadingListsReceiveHelper.receiveReadingLists(requireContext(), encodedJson) - readingList?.let { - ReadingListsAnalyticsHelper.logReceivePreview(requireContext(), it) - binding.searchEmptyView.setEmptyText(getString(R.string.search_reading_list_no_results, it.title)) - } - update() + lifecycleScope.launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + FeedbackUtil.showError(requireActivity(), throwable) + requireActivity().finish() + }) { + val json = Prefs.suggestedReadingListsData + if (!json.isNullOrEmpty()) { + readingList = ReadingListsReceiveHelper.receiveReadingLists(requireContext(), json, encoded = !isSuggested) + readingList?.let { + ReadingListsAnalyticsHelper.logReceivePreview(requireContext(), it) + binding.searchEmptyView.setEmptyText(getString(R.string.search_reading_list_no_results, it.title)) } + update() } } } else { @@ -364,7 +403,7 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial adapter.notifyDataSetChanged() updateEmptyState(query) } else { - ReadingListBehaviorsUtil.searchListsAndPages(query) { lists -> + ReadingListBehaviorsUtil.searchListsAndPages(lifecycleScope, query) { lists -> displayedLists = lists adapter.notifyDataSetChanged() updateEmptyState(query) @@ -409,7 +448,7 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial } private fun beginMultiSelect() { - if (SearchActionModeCallback.`is`(actionMode)) { + if (SearchActionModeCallback.matches(actionMode)) { finishActionMode() } if (!isTagType(actionMode)) { @@ -446,7 +485,7 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial val savedPages = it.pages.toMutableList() var readingListTitle = getString(R.string.reading_list_name_sample) - view.setContentType(it, savedPages, object : ReadingListPreviewSaveDialogView.Callback { + view.setContentType(it, savedPages, if (isSuggested) getString(R.string.suggested_reading_list_title) else null, object : ReadingListPreviewSaveDialogView.Callback { override fun onError() { previewSaveDialog?.getButton(AlertDialog.BUTTON_POSITIVE)?.isEnabled = false } @@ -457,8 +496,14 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial } }) + if (isSuggested) { + RabbitHolesEvent.submit("save_start_click", "reading_list") + } + previewSaveDialog = MaterialAlertDialogBuilder(requireContext()) .setPositiveButton(R.string.reading_lists_preview_save_dialog_save) { _, _ -> + RabbitHolesEvent.submit("save_click", "reading_list") + it.pages.clear() it.pages.addAll(savedPages) it.listTitle = readingListTitle @@ -466,6 +511,11 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial it.id = AppDatabase.instance.readingListDao().insertReadingList(it) AppDatabase.instance.readingListPageDao().addPagesToList(it, it.pages, true) Prefs.readingListRecentReceivedId = it.id + + if (isSuggested) { + Prefs.suggestedReadingListsData = null + } + requireActivity().startActivity(MainActivity.newIntent(requireContext()) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP).putExtra(Constants.INTENT_EXTRA_PREVIEW_SAVED_READING_LISTS, true)) requireActivity().finish() @@ -534,7 +584,7 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial override fun onToggleItemOffline(pageId: Long) { val page = getPageById(pageId) ?: return - ReadingListBehaviorsUtil.togglePageOffline(requireActivity(), page) { + ReadingListBehaviorsUtil.togglePageOffline(requireActivity() as AppCompatActivity, page) { adapter.notifyDataSetChanged() update() } @@ -569,7 +619,7 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial val page = getPageById(pageId) ?: return readingList?.let { val listsContainPage = if (currentSearchQuery.isNullOrEmpty()) listOf(it) else ReadingListBehaviorsUtil.getListsContainPage(page) - ReadingListBehaviorsUtil.deletePages(requireActivity(), listsContainPage, page, { updateReadingListData() }, { + ReadingListBehaviorsUtil.deletePages(requireActivity() as AppCompatActivity, listsContainPage, page, { updateReadingListData() }, { update() }) } @@ -596,11 +646,11 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial actionMode == null && appBarLayout.totalScrollRange + verticalOffset > appBarLayout.totalScrollRange / 2) (requireActivity() as ReadingListActivity).updateNavigationBarColor() // prevent swiping when collapsing the view - binding.readingListSwipeRefresh.isEnabled = verticalOffset == 0 + binding.readingListSwipeRefresh.isEnabled = (verticalOffset == 0 && !isSuggested) } } - private inner class ReadingListItemHolder constructor(itemView: ReadingListItemView) : DefaultViewHolder(itemView) { + private inner class ReadingListItemHolder(itemView: ReadingListItemView) : DefaultViewHolder(itemView) { fun bindItem(readingList: ReadingList) { view.setReadingList(readingList, ReadingListItemView.Description.SUMMARY) view.setPreviewMode(isPreview) @@ -610,7 +660,7 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial override val view get() = itemView as ReadingListItemView } - private inner class ReadingListPageItemHolder constructor(itemView: PageItemView) : DefaultViewHolder>(itemView), SwipeableItemTouchHelperCallback.Callback { + private inner class ReadingListPageItemHolder(itemView: PageItemView) : DefaultViewHolder>(itemView), SwipeableItemTouchHelperCallback.Callback { private lateinit var page: ReadingListPage fun bindItem(page: ReadingListPage) { this.page = page @@ -626,7 +676,7 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial view.setActionHint(R.string.reading_list_article_make_offline) view.setSearchQuery(currentSearchQuery) view.setListItemImageDimensions(imageDimension, imageDimension) - check(page, PageAvailableOfflineHandler.Callback { available -> view.setViewsGreyedOut(!available) }) + PageAvailableOfflineHandler.check(page) { view.setViewsGreyedOut(!it) } if (!currentSearchQuery.isNullOrEmpty()) { view.setTitleMaxLines(2) view.setTitleEllipsis() @@ -644,7 +694,7 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial } readingList?.let { if (currentSearchQuery.isNullOrEmpty()) { - ReadingListBehaviorsUtil.deletePages(requireActivity(), listOf(it), page, { updateReadingListData() }, { + ReadingListBehaviorsUtil.deletePages(requireActivity() as AppCompatActivity, listOf(it), page, { updateReadingListData() }, { update() }) } @@ -799,13 +849,14 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial if (isTagType(actionMode)) { toggleSelectPage(item) } else if (item != null) { + if (isSuggested) { + RabbitHolesEvent.submit("navigate", "reading_list") + } + val title = ReadingListPage.toPageTitle(item) - val entry = HistoryEntry(title, HistoryEntry.SOURCE_READING_LIST) + val entry = HistoryEntry(title, if (isSuggested) HistoryEntry.SOURCE_RABBIT_HOLE_READING_LIST else HistoryEntry.SOURCE_READING_LIST) item.touch() - Completable.fromAction { - AppDatabase.instance.readingListDao().updateLists(ReadingListBehaviorsUtil.getListsContainPage(item), false) - AppDatabase.instance.readingListPageDao().updateReadingListPage(item) - }.subscribeOn(Schedulers.io()).subscribe() + ReadingListBehaviorsUtil.updateReadingListPage(item) startActivity(PageActivity.newIntentForCurrentTab(requireContext(), entry, entry.title)) } } @@ -937,20 +988,6 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial } } - private inner class EventBusConsumer : Consumer { - override fun accept(event: Any) { - if (event is ReadingListSyncEvent) { - updateReadingListData() - } else if (event is PageDownloadEvent) { - val pagePosition = getPagePositionInList(event.page) - if (pagePosition != -1 && displayedLists[pagePosition] is ReadingListPage) { - (displayedLists[pagePosition] as ReadingListPage).downloadProgress = event.page.downloadProgress - adapter.notifyItemChanged(pagePosition + 1) - } - } - } - } - private fun getPagePositionInList(page: ReadingListPage): Int { return displayedLists.indexOfFirst { it is ReadingListPage && it.id == page.id } } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt index dc7877a09a9..69439401096 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt @@ -1,6 +1,7 @@ package org.wikipedia.readinglist import android.content.Context +import android.net.Uri import android.util.AttributeSet import android.view.* import androidx.annotation.StyleRes @@ -35,10 +36,12 @@ class ReadingListItemView : ConstraintLayout { private val binding = ItemReadingListBinding.inflate(LayoutInflater.from(context), this) private var readingList: ReadingList? = null private val imageViews = listOf(binding.itemImage1, binding.itemImage2, binding.itemImage3, binding.itemImage4) + private var isSuggested = false + private var isSingle = false var callback: Callback? = null + var saveClickListener: OnClickListener? = null val shareButton get() = binding.itemShareButton val listTitle get() = binding.itemTitle - val previewSaveButton get() = binding.itemPreviewSaveButton constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) @@ -47,6 +50,7 @@ class ReadingListItemView : ConstraintLayout { init { layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) setPadding(0, DimenUtil.roundedDpToPx(16f), 0, DimenUtil.roundedDpToPx(16f)) + clipToPadding = false setBackgroundResource(ResourceUtil.getThemedAttributeId(context, androidx.appcompat.R.attr.selectableItemBackground)) isClickable = true isFocusable = true @@ -60,6 +64,9 @@ class ReadingListItemView : ConstraintLayout { } setOnLongClickListener { view -> + if (isSuggested) { + return@setOnLongClickListener false + } readingList?.let { PopupMenu(context, view, Gravity.END).let { menu -> menu.menuInflater.inflate(R.menu.menu_reading_list_item, menu.menu) @@ -69,7 +76,6 @@ class ReadingListItemView : ConstraintLayout { } menu.menu.findItem(R.id.menu_reading_list_select).title = context.getString(if (it.selected) R.string.reading_list_menu_unselect else R.string.reading_list_menu_select) - menu.menu.findItem(R.id.menu_reading_list_share).isVisible = ReadingListsShareHelper.shareEnabled() menu.setOnMenuItemClickListener(OverflowMenuClickListener(it)) menu.show() } @@ -103,10 +109,22 @@ class ReadingListItemView : ConstraintLayout { } } + binding.itemPreviewSaveButton.setOnClickListener { + saveClickListener?.onClick(it) + } + binding.itemSaveButtonSecondary.setOnClickListener { + saveClickListener?.onClick(it) + } + + binding.experimentAboutLabel.setOnClickListener { + UriUtil.visitInExternalBrowser(context, Uri.parse(context.getString(R.string.rabbit_holes_wiki_url))) + } + FeedbackUtil.setButtonTooltip(binding.itemShareButton, binding.itemOverflowMenu) } - fun setReadingList(readingList: ReadingList, description: Description, selectMode: Boolean = false, newImport: Boolean = false) { + fun setReadingList(readingList: ReadingList, description: Description, selectMode: Boolean = false, + newImport: Boolean = false, isSuggested: Boolean = false, isSingle: Boolean = false) { this.readingList = readingList val isDetailView = description == Description.DETAIL binding.itemDescription.maxLines = if (isDetailView) Int.MAX_VALUE else resources.getInteger(R.integer.reading_list_description_summary_view_max_lines) @@ -117,6 +135,13 @@ class ReadingListItemView : ConstraintLayout { if (binding.itemImage1.visibility == VISIBLE) { updateThumbnails() } + + this.isSuggested = isSuggested + this.isSingle = isSingle + binding.experimentLabel.isVisible = isSuggested + binding.experimentAboutLabel.isVisible = isSuggested && isSingle + binding.itemSaveButtonSecondary.isVisible = isSuggested && !isSingle + binding.backgroundShape.isVisible = isSuggested && !isSingle } fun setThumbnailVisible(visible: Boolean) { @@ -202,7 +227,7 @@ class ReadingListItemView : ConstraintLayout { return readingList.sizeBytesFromPages / 1.coerceAtLeast(resources.getInteger(R.integer.reading_list_item_size_bytes_per_unit)).toFloat() } - private inner class OverflowMenuClickListener constructor(private val list: ReadingList?) : PopupMenu.OnMenuItemClickListener { + private inner class OverflowMenuClickListener(private val list: ReadingList?) : PopupMenu.OnMenuItemClickListener { override fun onMenuItemClick(item: MenuItem): Boolean { BreadCrumbLogEvent.logClick(context, item) when (item.itemId) { diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListPreviewSaveDialogView.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListPreviewSaveDialogView.kt index 9012d4c1308..8e77a259e51 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListPreviewSaveDialogView.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListPreviewSaveDialogView.kt @@ -54,11 +54,12 @@ class ReadingListPreviewSaveDialogView : FrameLayout { } } - fun setContentType(readingList: ReadingList, savedReadingListPages: MutableList, callback: Callback) { + fun setContentType(readingList: ReadingList, savedReadingListPages: MutableList, + readingListTitle: String?, callback: Callback) { this.readingList = readingList this.savedReadingListPages = savedReadingListPages this.callback = callback - val defaultListTitle = context.getString(R.string.reading_lists_preview_header_title).plus(" " + DateUtil.getShortDayWithTimeString(Date())) + val defaultListTitle = readingListTitle ?: context.getString(R.string.reading_lists_preview_header_title).plus(" " + DateUtil.getShortDayWithTimeString(Date())) binding.readingListTitleLayout.editText?.setText(defaultListTitle) validateTitleAndList() binding.recyclerView.adapter = ReadingListItemAdapter() diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListSyncBehaviorDialogs.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListSyncBehaviorDialogs.kt index bbfc9d70edb..c2a09320ea4 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListSyncBehaviorDialogs.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListSyncBehaviorDialogs.kt @@ -3,7 +3,7 @@ package org.wikipedia.readinglist import android.app.Activity import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.wikipedia.R -import org.wikipedia.WikipediaApp +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.databinding.DialogWithCheckboxBinding import org.wikipedia.events.ReadingListsEnableSyncStatusEvent import org.wikipedia.login.LoginActivity @@ -42,7 +42,7 @@ object ReadingListSyncBehaviorDialogs { .setNegativeButton(R.string.reading_list_prompt_turned_sync_on_dialog_no_thanks, null) .setOnDismissListener { Prefs.showReadingListSyncEnablePrompt = !binding.dialogCheckbox.isChecked - WikipediaApp.instance.bus.post(ReadingListsEnableSyncStatusEvent()) + FlowEventBus.post(ReadingListsEnableSyncStatusEvent()) } .show() } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListsFragment.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListsFragment.kt index b3d6d0c2829..e14e76999b5 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListsFragment.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListsFragment.kt @@ -5,7 +5,12 @@ import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle -import android.view.* +import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity @@ -16,22 +21,28 @@ import androidx.core.text.color import androidx.core.view.MenuItemCompat import androidx.core.view.isVisible import androidx.fragment.app.Fragment +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout -import com.google.android.material.snackbar.Snackbar -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.functions.Consumer -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R -import org.wikipedia.WikipediaApp import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.ABTest +import org.wikipedia.analytics.eventplatform.RabbitHolesEvent import org.wikipedia.analytics.eventplatform.ReadingListsAnalyticsHelper +import org.wikipedia.analytics.metricsplatform.RabbitHolesAnalyticsHelper import org.wikipedia.auth.AccountUtil +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentReadingListsBinding import org.wikipedia.events.ArticleSavedOrDeletedEvent @@ -49,16 +60,28 @@ import org.wikipedia.readinglist.sync.ReadingListSyncAdapter import org.wikipedia.readinglist.sync.ReadingListSyncEvent import org.wikipedia.settings.Prefs import org.wikipedia.settings.RemoteConfig -import org.wikipedia.util.* +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ReleaseUtil +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.ShareUtil +import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L -import org.wikipedia.views.* +import org.wikipedia.views.CircularProgressBar +import org.wikipedia.views.DefaultViewHolder +import org.wikipedia.views.DrawableItemDecoration +import org.wikipedia.views.MultiSelectActionModeCallback import org.wikipedia.views.MultiSelectActionModeCallback.Companion.isTagType +import org.wikipedia.views.PageItemView +import org.wikipedia.views.ReadingListsOverflowView +import org.wikipedia.views.SurveyDialog +import java.util.concurrent.TimeUnit class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, ReadingListItemActionsDialog.Callback { private var _binding: FragmentReadingListsBinding? = null private val binding get() = _binding!! private var displayedLists = listOf() - private val disposables = CompositeDisposable() private val adapter = ReadingListAdapter() private val readingListItemCallback = ReadingListItemCallback() private val readingListPageItemCallback = ReadingListPageItemCallback() @@ -72,6 +95,9 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin private var recentPreviewSavedReadingList: ReadingList? = null private var shouldShowImportedSnackbar = false + private var suggestedReadingList: ReadingList? = null + private val suggestedReadingListAdapter = SuggestedReadingListAdapter() + val filePickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == AppCompatActivity.RESULT_OK) { it.data?.data?.let { uri -> @@ -89,17 +115,40 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin _binding = FragmentReadingListsBinding.inflate(inflater, container, false) binding.searchEmptyView.setEmptyText(R.string.search_reading_lists_no_results) binding.recyclerView.layoutManager = LinearLayoutManager(context) - binding.recyclerView.adapter = adapter + binding.recyclerView.adapter = ConcatAdapter(suggestedReadingListAdapter, adapter) binding.recyclerView.addItemDecoration(DrawableItemDecoration(requireContext(), R.attr.list_divider)) setUpScrollListener() - disposables.add(WikipediaApp.instance.bus.subscribe(EventBusConsumer())) - binding.swipeRefreshLayout.setColorSchemeResources(ResourceUtil.getThemedAttributeId(requireContext(), R.attr.progressive_color)) binding.swipeRefreshLayout.setOnRefreshListener { refreshSync(this, binding.swipeRefreshLayout) } if (RemoteConfig.config.disableReadingListSync) { binding.swipeRefreshLayout.isEnabled = false } binding.searchEmptyView.visibility = View.GONE enableLayoutTransition(true) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + FlowEventBus.events.collectLatest { event -> + when (event) { + is ReadingListSyncEvent -> { + binding.recyclerView.post { + if (isAdded) { + updateLists(currentSearchQuery, !currentSearchQuery.isNullOrEmpty() || recentPreviewSavedReadingList != null) + } + } + } + is ArticleSavedOrDeletedEvent -> { + if (event.isAdded) { + if (Prefs.readingListsPageSaveCount < SAVE_COUNT_LIMIT) { + showReadingListsSyncDialog() + Prefs.readingListsPageSaveCount += 1 + } + } + } + } + } + } + } + return binding.root } @@ -113,7 +162,6 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin } override fun onDestroyView() { - disposables.clear() binding.recyclerView.adapter = null binding.recyclerView.clearOnScrollListeners() _binding = null @@ -122,9 +170,13 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin override fun onResume() { super.onResume() + + if (RabbitHolesAnalyticsHelper.abcTest.group == ABTest.GROUP_3) { + RabbitHolesEvent.submit("impression", "reading_list") + } + updateLists() ReadingListsAnalyticsHelper.logListsShown(requireContext(), displayedLists.size) - ReadingListsShareSurveyHelper.maybeShowSurvey(requireActivity()) requireActivity().invalidateOptionsMenu() } @@ -135,7 +187,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin override fun onToggleItemOffline(pageId: Long) { val page = getPageById(pageId) ?: return - ReadingListBehaviorsUtil.togglePageOffline(requireActivity(), page) { this.updateLists() } + ReadingListBehaviorsUtil.togglePageOffline(requireActivity() as AppCompatActivity, page) { this.updateLists() } } override fun onShareItem(pageId: Long) { @@ -161,7 +213,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin override fun onDeleteItem(pageId: Long) { val page = getPageById(pageId) ?: return - ReadingListBehaviorsUtil.deletePages(requireActivity(), ReadingListBehaviorsUtil.getListsContainPage(page), page, { this.updateLists() }) { this.updateLists() } + ReadingListBehaviorsUtil.deletePages(requireActivity() as AppCompatActivity, ReadingListBehaviorsUtil.getListsContainPage(page), page, { this.updateLists() }) { this.updateLists() } } private fun getPageById(id: Long): ReadingListPage? { @@ -196,6 +248,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin override fun selectListClick() { beginMultiSelect() adapter.notifyDataSetChanged() + suggestedReadingListAdapter.notifyDataSetChanged() } override fun refreshClick() { @@ -239,7 +292,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin private fun updateLists(searchQuery: String?, forcedRefresh: Boolean) { maybeShowOnboarding(searchQuery) - ReadingListBehaviorsUtil.searchListsAndPages(searchQuery) { lists -> + ReadingListBehaviorsUtil.searchListsAndPages(lifecycleScope, searchQuery) { lists -> val result = DiffUtil.calculateDiff(object : DiffUtil.Callback() { override fun getOldListSize(): Int { return lists.size @@ -265,10 +318,6 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin (displayedLists[oldItemPosition] as ReadingList).compareTo(lists[newItemPosition])) } }) - // If the number of lists has changed, just invalidate everything, as a - // simple way to get the bottom item margin to apply to the correct item. - val invalidateAll = (importMode || forcedRefresh || displayedLists.size != lists.size || - (!currentSearchQuery.isNullOrEmpty() && !searchQuery.isNullOrEmpty() && currentSearchQuery != searchQuery)) // if the default list is empty, then removes it. if (lists.size == 1 && lists[0] is ReadingList && @@ -277,27 +326,43 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin lists.removeAt(0) } - // Asynchronous update fo lists affects the multiselect process - if (!isTagType(actionMode)) { - displayedLists = lists - } + // If the number of lists has changed, just invalidate everything, as a + // simple way to get the bottom item margin to apply to the correct item. + val invalidateAll = (importMode || forcedRefresh || displayedLists.size != lists.size || + (!currentSearchQuery.isNullOrEmpty() && !searchQuery.isNullOrEmpty() && currentSearchQuery != searchQuery)) - if (invalidateAll) { - adapter.notifyDataSetChanged() - } else { - result.dispatchUpdatesTo(adapter) - } + lifecycleScope.launch { + suggestedReadingList = null + if (RabbitHolesAnalyticsHelper.rabbitHolesEnabled && + RabbitHolesAnalyticsHelper.abcTest.group == ABTest.GROUP_3 && !Prefs.suggestedReadingListsData.isNullOrEmpty()) { + Prefs.suggestedReadingListsData?.let { json -> + suggestedReadingList = ReadingListsReceiveHelper.receiveReadingLists(requireContext(), json, encoded = false) + } + } - recentPreviewSavedReadingList = displayedLists.filterIsInstance() - .find { it.id == Prefs.readingListRecentReceivedId }?.also { shouldShowImportedSnackbar = true } + // Asynchronous update of lists affects the multiselect process + if (!isTagType(actionMode)) { + displayedLists = lists + } - binding.swipeRefreshLayout.isRefreshing = false - maybeShowListLimitMessage() - updateEmptyState(searchQuery) - maybeDeleteListFromIntent() - maybeShowPreviewSavedReadingListsSnackbar() - currentSearchQuery = searchQuery - maybeTurnOffImportMode(lists.filterIsInstance().toMutableList()) + if (invalidateAll) { + adapter.notifyDataSetChanged() + } else { + result.dispatchUpdatesTo(adapter) + } + suggestedReadingListAdapter.notifyDataSetChanged() + + recentPreviewSavedReadingList = displayedLists.filterIsInstance() + .find { it.id == Prefs.readingListRecentReceivedId }?.also { shouldShowImportedSnackbar = true } + + binding.swipeRefreshLayout.isRefreshing = false + maybeShowListLimitMessage() + updateEmptyState(searchQuery) + maybeDeleteListFromIntent() + maybeShowPreviewSavedReadingListsSnackbar() + currentSearchQuery = searchQuery + maybeTurnOffImportMode(lists.filterIsInstance().toMutableList()) + } } } @@ -319,7 +384,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin if (searchQuery.isNullOrEmpty()) { binding.searchEmptyView.visibility = View.GONE setUpEmptyContainer() - setEmptyContainerVisibility(displayedLists.isEmpty() && !binding.onboardingView.isVisible) + setEmptyContainerVisibility(displayedLists.isEmpty() && !binding.onboardingView.isVisible && suggestedReadingListAdapter.itemCount == 0) } else { binding.searchEmptyView.visibility = if (displayedLists.isEmpty()) View.VISIBLE else View.GONE setEmptyContainerVisibility(false) @@ -351,16 +416,52 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin sortListsBy(position) } - private inner class ReadingListItemHolder constructor(itemView: ReadingListItemView) : DefaultViewHolder(itemView) { - fun bindItem(readingList: ReadingList) { - view.setReadingList(readingList, ReadingListItemView.Description.SUMMARY, selectMode, readingList.id == recentPreviewSavedReadingList?.id) + private inner class SuggestedReadingListAdapter : RecyclerView.Adapter>() { + override fun getItemCount(): Int { + return if (suggestedReadingList != null && !selectMode && actionMode == null) 1 else 0 + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DefaultViewHolder<*> { + return ReadingListItemHolder(ReadingListItemView(requireContext())) + } + + override fun onBindViewHolder(holder: DefaultViewHolder<*>, pos: Int) { + if (holder is ReadingListItemHolder && suggestedReadingList != null) { + holder.bindItem(suggestedReadingList!!, true) + } + } + + override fun onViewAttachedToWindow(holder: DefaultViewHolder<*>) { + super.onViewAttachedToWindow(holder) + if (holder is ReadingListItemHolder) { + holder.view.callback = readingListItemCallback + } + } + + override fun onViewDetachedFromWindow(holder: DefaultViewHolder<*>) { + if (holder is ReadingListItemHolder) { + holder.view.callback = null + } + super.onViewDetachedFromWindow(holder) + } + } + + private inner class ReadingListItemHolder(itemView: ReadingListItemView) : DefaultViewHolder(itemView) { + fun bindItem(readingList: ReadingList, isSuggested: Boolean = false) { + view.setReadingList(readingList, ReadingListItemView.Description.SUMMARY, selectMode, + newImport = readingList.id == recentPreviewSavedReadingList?.id, isSuggested = isSuggested) view.setSearchQuery(currentSearchQuery) + if (isSuggested) { + view.saveClickListener = View.OnClickListener { + startActivity(ReadingListActivity.newIntent(requireActivity(), true, suggestedList = true, suggestedListSave = true)) + } + } } override val view get() = itemView as ReadingListItemView } - private inner class ReadingListPageItemHolder constructor(itemView: PageItemView) : DefaultViewHolder>(itemView) { + private inner class ReadingListPageItemHolder(itemView: PageItemView) : DefaultViewHolder>(itemView) { fun bindItem(page: ReadingListPage) { view.item = page view.setTitle(page.displayTitle) @@ -378,16 +479,16 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin view.setActionHint(R.string.reading_list_article_make_offline) view.setSearchQuery(currentSearchQuery) view.setUpChipGroup(ReadingListBehaviorsUtil.getListsContainPage(page)) - PageAvailableOfflineHandler.check(page) { available -> view.setViewsGreyedOut(!available) } + PageAvailableOfflineHandler.check(page) { view.setViewsGreyedOut(!it) } } } private inner class ReadingListAdapter : RecyclerView.Adapter>() { override fun getItemViewType(position: Int): Int { return if (displayedLists[position] is ReadingList) { - Companion.VIEW_TYPE_ITEM + VIEW_TYPE_ITEM } else { - Companion.VIEW_TYPE_PAGE_ITEM + VIEW_TYPE_PAGE_ITEM } } @@ -396,7 +497,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DefaultViewHolder<*> { - return if (viewType == Companion.VIEW_TYPE_ITEM) { + return if (viewType == VIEW_TYPE_ITEM) { ReadingListItemHolder(ReadingListItemView(requireContext())) } else { ReadingListPageItemHolder(PageItemView(requireContext())) @@ -436,7 +537,11 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin toggleSelectList(readingList) } else { actionMode?.finish() - startActivity(ReadingListActivity.newIntent(requireContext(), readingList)) + if (readingList == suggestedReadingList) { + startActivity(ReadingListActivity.newIntent(requireActivity(), true, suggestedList = true)) + } else { + startActivity(ReadingListActivity.newIntent(requireContext(), readingList)) + } } } @@ -511,10 +616,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin val title = ReadingListPage.toPageTitle(it) val entry = HistoryEntry(title, HistoryEntry.SOURCE_READING_LIST) it.touch() - Completable.fromAction { - AppDatabase.instance.readingListDao().updateLists(ReadingListBehaviorsUtil.getListsContainPage(it), false) - AppDatabase.instance.readingListPageDao().updateReadingListPage(it) - }.subscribeOn(Schedulers.io()).subscribe() + ReadingListBehaviorsUtil.updateReadingListPage(item) startActivity(PageActivity.newIntentForCurrentTab(requireContext(), entry, entry.title)) } } @@ -586,6 +688,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin val deleteIconColor = ResourceUtil.getThemedColorStateList(requireContext(), androidx.appcompat.R.attr.colorError) deleteItem.isEnabled = false MenuItemCompat.setIconTintList(deleteItem, deleteIconColor) + suggestedReadingListAdapter.notifyDataSetChanged() return true } @@ -693,6 +796,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin if (isAdded) { (requireParentFragment() as MainFragment).setBottomNavVisible(false) } + suggestedReadingListAdapter.notifyDataSetChanged() return super.onCreateActionMode(mode, menu) } @@ -720,25 +824,6 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin } } - private inner class EventBusConsumer : Consumer { - override fun accept(event: Any) { - if (event is ReadingListSyncEvent) { - binding.recyclerView.post { - if (isAdded) { - updateLists(currentSearchQuery, !currentSearchQuery.isNullOrEmpty() || recentPreviewSavedReadingList != null) - } - } - } else if (event is ArticleSavedOrDeletedEvent) { - if (event.isAdded) { - if (Prefs.readingListsPageSaveCount < SAVE_COUNT_LIMIT) { - showReadingListsSyncDialog() - Prefs.readingListsPageSaveCount = Prefs.readingListsPageSaveCount + 1 - } - } - } - } - } - private fun showReadingListsSyncDialog() { if (!Prefs.isReadingListSyncEnabled) { if (AccountUtil.isLoggedIn) { @@ -755,15 +840,6 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin if (shouldShowImportedSnackbar) { ReadingListsAnalyticsHelper.logReceiveFinish(requireContext(), recentPreviewSavedReadingList) FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.reading_lists_preview_saved_snackbar)) - .addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return - } - ReadingListsReceiveSurveyHelper.activateSurvey() - ReadingListsReceiveSurveyHelper.maybeShowSurvey(requireActivity()) - } - }) .setAction(R.string.suggested_edits_article_cta_snackbar_action) { recentPreviewSavedReadingList?.let { startActivity(ReadingListActivity.newIntent(requireContext(), it)) @@ -773,6 +849,28 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin shouldShowImportedSnackbar = false Prefs.receiveReadingListsData = null Prefs.readingListRecentReceivedId = -1L + maybeShowRabbitHolesSurvey() + } + } + + private fun maybeShowRabbitHolesSurvey() { + if (Prefs.suggestedContentSurveyShown) { + return + } + lifecycleScope.launch(CoroutineExceptionHandler { _, t -> + L.e(t) + }) { + delay(TimeUnit.SECONDS.toMillis(if (ReleaseUtil.isDevRelease) 1L else 10L)) + if (!Prefs.suggestedContentSurveyShown) { + Prefs.suggestedContentSurveyShown = true + SurveyDialog.showFeedbackOptionsDialog( + requireActivity(), + titleId = R.string.rabbit_holes_reading_list_survey_dialog_title, + messageId = R.string.rabbit_holes_reading_list_survey_dialog_body, + snackbarMessageId = R.string.survey_dialog_submitted_snackbar, + invokeSource = InvokeSource.RABBIT_HOLE_READING_LIST + ) + } } } @@ -781,7 +879,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin binding.onboardingView.isVisible = false return } - if (AccountUtil.isLoggedIn && !Prefs.isReadingListSyncEnabled && + if ((AccountUtil.isLoggedIn && !AccountUtil.isTemporaryAccount) && !Prefs.isReadingListSyncEnabled && Prefs.isReadingListSyncReminderEnabled && !RemoteConfig.config.disableReadingListSync) { binding.onboardingView.setMessageTitle(getString(R.string.reading_lists_sync_reminder_title)) binding.onboardingView.setMessageText(StringUtil.fromHtml(getString(R.string.reading_lists_sync_reminder_text)).toString()) @@ -792,7 +890,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin Prefs.isReadingListSyncReminderEnabled = false }, false) binding.onboardingView.isVisible = true - } else if (!AccountUtil.isLoggedIn && Prefs.isReadingListLoginReminderEnabled && !RemoteConfig.config.disableReadingListSync) { + } else if ((!AccountUtil.isLoggedIn || AccountUtil.isTemporaryAccount) && Prefs.isReadingListLoginReminderEnabled && !RemoteConfig.config.disableReadingListSync) { binding.onboardingView.setMessageTitle(getString(R.string.reading_list_login_reminder_title)) binding.onboardingView.setMessageText(getString(R.string.reading_lists_login_reminder_text)) binding.onboardingView.setImageResource(ResourceUtil.getThemedAttributeId(requireContext(), R.attr.sync_reading_list_prompt_drawable), true) @@ -814,12 +912,10 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin private fun onListsImportResult(uri: Uri) { binding.swipeRefreshLayout.isRefreshing = true - val inputStr = activity?.contentResolver?.openInputStream(uri) - inputStr?.let { inputStream -> + activity?.contentResolver?.openInputStream(uri)?.use { inputStream -> val inputString = inputStream.bufferedReader().use { it.readText() } ReadingListsExportImportHelper.importLists(activity as BaseActivity, inputString) importMode = true - inputStream.close() } } @@ -834,7 +930,7 @@ class ReadingListsFragment : Fragment(), SortReadingListsDialog.Callback, Readin } fun refreshSync(fragment: Fragment, swipeRefreshLayout: SwipeRefreshLayout) { - if (!AccountUtil.isLoggedIn) { + if (!AccountUtil.isLoggedIn || AccountUtil.isTemporaryAccount) { ReadingListSyncBehaviorDialogs.promptLogInToSyncDialog(fragment.requireActivity()) swipeRefreshLayout.isRefreshing = false } else { diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListsReceiveHelper.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListsReceiveHelper.kt index 98e1bdf9bc6..c6ec716ec7f 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListsReceiveHelper.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListsReceiveHelper.kt @@ -3,6 +3,9 @@ package org.wikipedia.readinglist import android.content.Context import android.util.Base64 +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.int import org.wikipedia.R import org.wikipedia.dataclient.Service @@ -10,6 +13,8 @@ import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.json.JsonUtil +import org.wikipedia.page.Namespace +import org.wikipedia.readinglist.ReadingListsShareHelper.ExportedReadingListPage import org.wikipedia.readinglist.database.ReadingList import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.util.DateUtil @@ -19,8 +24,8 @@ import java.util.* object ReadingListsReceiveHelper { - suspend fun receiveReadingLists(context: Context, encodedJson: String): ReadingList { - val readingListData = getExportedReadingLists(encodedJson) + suspend fun receiveReadingLists(context: Context, json: String, encoded: Boolean): ReadingList { + val readingListData = getExportedReadingLists(json, encoded) val listTitle = readingListData?.name.orEmpty().ifEmpty { context.getString(R.string.reading_lists_preview_header_title) } val listDescription = readingListData?.description.orEmpty().ifEmpty { DateUtil.getTimeAndDateString(context, Date()) } val listPages = mutableListOf() @@ -29,8 +34,23 @@ object ReadingListsReceiveHelper { readingListData?.list?.forEach { map -> val wikiSite = WikiSite.forLanguageCode(map.key) map.value.chunked(ReadingListsShareHelper.API_MAX_SIZE).forEach { list -> - val listOfTitles = list.filter { it.isString }.map { it.content } - val listOfIds = list.filter { !it.isString }.map { it.int } + + val listOfTitles = list.filter { it is JsonPrimitive && it.isString }.map { (it as JsonPrimitive).content } + val listOfIds = list.filter { it is JsonPrimitive && !it.isString }.map { (it as JsonPrimitive).int } + val listOfPages = list.filter { it is JsonObject }.map { JsonUtil.json.decodeFromJsonElement(it as JsonObject) } + + listOfPages.forEach { + val readingListPage = ReadingListPage( + wikiSite, + Namespace.of(it.ns), + it.title, + StringUtil.addUnderscores(it.title), + it.description, + ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl.orEmpty(), Service.PREFERRED_THUMB_SIZE), + lang = wikiSite.languageCode + ) + listPages.add(readingListPage) + } val pages = mutableListOf() if (listOfIds.isNotEmpty()) { @@ -64,7 +84,7 @@ object ReadingListsReceiveHelper { return readingList } - private fun getExportedReadingLists(encodedJson: String): ReadingListsShareHelper.ExportedReadingLists? { - return JsonUtil.decodeFromString(String(Base64.decode(encodedJson, Base64.NO_WRAP))) + private fun getExportedReadingLists(json: String, encoded: Boolean): ReadingListsShareHelper.ExportedReadingList? { + return JsonUtil.decodeFromString(if (encoded) String(Base64.decode(json, Base64.NO_WRAP)) else json) } } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListsReceiveSurveyHelper.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListsReceiveSurveyHelper.kt deleted file mode 100644 index 95b73216fc0..00000000000 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListsReceiveSurveyHelper.kt +++ /dev/null @@ -1,96 +0,0 @@ -package org.wikipedia.readinglist - -import android.app.Activity -import android.content.Context -import android.widget.TextView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.wikipedia.R -import org.wikipedia.WikipediaApp -import org.wikipedia.analytics.eventplatform.ReadingListsAnalyticsHelper -import org.wikipedia.page.LinkMovementMethodExt -import org.wikipedia.settings.Prefs -import org.wikipedia.util.CustomTabsUtil -import org.wikipedia.util.StringUtil -import java.time.LocalDate -import java.time.Month - -object ReadingListsReceiveSurveyHelper { - private const val MODE_INACTIVE = 0 - private const val MODE_ACTIVE = 1 - private const val MODE_OVERRIDE = 2 - - fun activateSurvey() { - if (!isActive()) { - Prefs.readingListReceiveSurveyMode = MODE_ACTIVE - } - } - - fun maybeShowSurvey(activity: Activity) { - if (shouldShowSurvey(activity)) { - showSurveyDialog(activity) - } - } - - fun shouldShowSurvey(activity: Activity): Boolean { - return !activity.isDestroyed && !Prefs.readingListReceiveSurveyDialogShown && - (Prefs.readingListReceiveSurveyMode == MODE_OVERRIDE || (isActive() && fallsWithinDateRange())) - } - - private fun showSurveyDialog(activity: Activity) { - Prefs.readingListReceiveSurveyDialogShown = true - - val dialog = MaterialAlertDialogBuilder(activity) - .setTitle(activity.getString(R.string.reading_list_share_survey_title)) - .setMessage(StringUtil.fromHtml(activity.getString(R.string.reading_list_share_survey_body) + - "

" + - activity.getString(R.string.privacy_policy_description) + "")) - .setPositiveButton(R.string.talk_snackbar_survey_action_text) { _, _ -> takeUserToSurvey(activity) } - .setNegativeButton(R.string.reading_list_prompt_turned_sync_on_dialog_no_thanks, null) - .setCancelable(false) - .create() - dialog.show() - dialog.findViewById(android.R.id.message)?.movementMethod = LinkMovementMethodExt { url -> - CustomTabsUtil.openInCustomTab(activity, url) - } - ReadingListsAnalyticsHelper.logSurveyShown(activity) - } - - private fun isActive(): Boolean { - return Prefs.readingListReceiveSurveyMode != MODE_INACTIVE - } - - private fun fallsWithinDateRange(): Boolean { - return LocalDate.now() < LocalDate.of(2023, Month.APRIL, 17) - } - - private fun takeUserToSurvey(context: Context) { - CustomTabsUtil.openInCustomTab(context, getLanguageSpecificUrl()) - } - - private fun getLanguageSpecificUrl(): String { - return when (WikipediaApp.instance.languageState.appLanguageCode) { - "ar" -> "https://docs.google.com/forms/d/e/1FAIpQLSeKCRBtnF4V1Gwv2aRsJi8GppfofbiECU6XseZbVRbYijynfg/viewform?usp=sf_link" - "bn" -> "https://docs.google.com/forms/d/e/1FAIpQLSeY25GeA8dFOKlVCNpHc5zTUIYUeB3W6fntTitTIQRjl7BCQw/viewform?usp=sf_link" - "fr" -> "https://docs.google.com/forms/d/e/1FAIpQLSe_EXLDJxk-9y0ux-c9LERNou7CqhzoSZfL952PKH8bqCGMpA/viewform?usp=sf_link" - "de" -> "https://docs.google.com/forms/d/e/1FAIpQLSfS2-gQJtCUnFMJl-C0BdrWNxpb-PeXjoDeCR4z80gSCoA-RA/viewform?usp=sf_link" - "hi" -> "https://docs.google.com/forms/d/e/1FAIpQLSdnjiMH4L9eIpwuk3JLdsjKirvQ5GvLwp_8aaLKiESf-zhtHA/viewform?usp=sf_link" - "pt" -> "https://docs.google.com/forms/d/e/1FAIpQLSfbRhbf-cqmZC-vn1S_OTdsJ0zpiVW7vfFpWQgZtzQbU0dZEw/viewform?usp=sf_link" - "es" -> "https://docs.google.com/forms/d/e/1FAIpQLSelTK2ZeuEOk2T9P-E5OeKZoE9VvmCXLx9v3lc-A-onWXSsog/viewform?usp=sf_link" - "ur" -> "https://docs.google.com/forms/d/e/1FAIpQLSdPcGIn049-8g-JgxJ8lFRa8UGg4xcWdL6Na18GuDCUD8iUXA/viewform?usp=sf_link" - else -> "https://docs.google.com/forms/d/e/1FAIpQLSf7W1Hs20HcP-Ho4T_Rlr8hdpT4oKxYQJD3rdE5RCINl5l6RQ/viewform?usp=sf_link" - } - } - - private fun getLanguageSpecificPrivacyPolicyUrl(): String { - return when (WikipediaApp.instance.languageState.appLanguageCode) { - "ar" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/ar" - "bn" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/bn" - "fr" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/fr" - "de" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/de" - "hi" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/hi" - "pt" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/pt-br" - "es" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/es" - else -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement" - } - } -} diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListsShareHelper.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListsShareHelper.kt index 076d8d5a793..7c7a62f491d 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListsShareHelper.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListsShareHelper.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonPrimitive import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -17,8 +18,6 @@ import org.wikipedia.json.JsonUtil import org.wikipedia.readinglist.database.ReadingList import org.wikipedia.settings.Prefs import org.wikipedia.util.FeedbackUtil -import org.wikipedia.util.GeoUtil -import org.wikipedia.util.ReleaseUtil import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L @@ -27,12 +26,6 @@ object ReadingListsShareHelper { const val API_MAX_SIZE = 50 const val PROVENANCE_PARAM = "rlsa1" - fun shareEnabled(): Boolean { - return ReleaseUtil.isPreBetaRelease || - (listOf("EG", "DZ", "MA", "KE", "CG", "AO", "GH", "NG", "IN", "BD", "PK", "LK", "NP").contains(GeoUtil.geoIPCountry.orEmpty()) && - listOf("en", "ar", "hi", "fr", "bn", "es", "pt", "de", "ur", "arz", "si", "sw", "fa", "ne", "te").contains(WikipediaApp.instance.appOrSystemLanguageCode)) - } - fun shareReadingList(activity: AppCompatActivity, readingList: ReadingList?) { if (readingList == null) { return @@ -69,8 +62,6 @@ object ReadingListsShareHelper { .putExtra(Intent.EXTRA_TEXT, activity.getString(R.string.reading_list_share_message_v2) + " " + finalUrl) .setType("text/plain") activity.startActivity(intent) - - ReadingListsShareSurveyHelper.activateSurvey() } } @@ -79,15 +70,24 @@ object ReadingListsShareHelper { pageIdMap.keys.forEach { key -> projectUrlMap[key] = pageIdMap[key]!!.values.map { JsonPrimitive(it) } } // TODO: for now we're not transmitting the free-form Name and Description of a reading list. - val exportedReadingLists = ExportedReadingLists(projectUrlMap) // , readingList.title, readingList.description) - return Base64.encodeToString(JsonUtil.encodeToString(exportedReadingLists)!!.toByteArray(), Base64.NO_WRAP) + val exportedReadingList = ExportedReadingList(projectUrlMap) // , readingList.title, readingList.description) + return Base64.encodeToString(JsonUtil.encodeToString(exportedReadingList)!!.toByteArray(), Base64.NO_WRAP) } @Suppress("unused") @Serializable - class ExportedReadingLists( - val list: Map>, + class ExportedReadingList( + val list: Map> = emptyMap(), val name: String? = null, val description: String? = null ) + + @Serializable + class ExportedReadingListPage( + val lang: String, + val title: String, + val ns: Int, + val description: String? = null, + val thumbUrl: String? = null + ) } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListsShareSurveyHelper.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListsShareSurveyHelper.kt deleted file mode 100644 index b9eca65788a..00000000000 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListsShareSurveyHelper.kt +++ /dev/null @@ -1,96 +0,0 @@ -package org.wikipedia.readinglist - -import android.app.Activity -import android.content.Context -import android.widget.TextView -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import org.wikipedia.R -import org.wikipedia.WikipediaApp -import org.wikipedia.analytics.eventplatform.ReadingListsAnalyticsHelper -import org.wikipedia.page.LinkMovementMethodExt -import org.wikipedia.settings.Prefs -import org.wikipedia.util.CustomTabsUtil -import org.wikipedia.util.StringUtil -import java.time.LocalDate -import java.time.Month - -object ReadingListsShareSurveyHelper { - private const val MODE_INACTIVE = 0 - private const val MODE_ACTIVE = 1 - private const val MODE_OVERRIDE = 2 - - fun activateSurvey() { - if (!isActive()) { - Prefs.readingListShareSurveyMode = MODE_ACTIVE - } - } - - fun maybeShowSurvey(activity: Activity) { - if (shouldShowSurvey(activity)) { - showSurveyDialog(activity) - } - } - - fun shouldShowSurvey(activity: Activity): Boolean { - return !activity.isDestroyed && !Prefs.readingListShareSurveyDialogShown && - (Prefs.readingListShareSurveyMode == MODE_OVERRIDE || (isActive() && ReadingListsShareHelper.shareEnabled() && fallsWithinDateRange())) - } - - private fun showSurveyDialog(activity: Activity) { - Prefs.readingListShareSurveyDialogShown = true - - val dialog = MaterialAlertDialogBuilder(activity) - .setTitle(activity.getString(R.string.reading_list_share_survey_title)) - .setMessage(StringUtil.fromHtml(activity.getString(R.string.reading_list_share_survey_body) + - "

" + - activity.getString(R.string.privacy_policy_description) + "")) - .setPositiveButton(R.string.talk_snackbar_survey_action_text) { _, _ -> takeUserToSurvey(activity) } - .setNegativeButton(R.string.reading_list_prompt_turned_sync_on_dialog_no_thanks, null) - .setCancelable(false) - .create() - dialog.show() - dialog.findViewById(android.R.id.message)?.movementMethod = LinkMovementMethodExt { url -> - CustomTabsUtil.openInCustomTab(activity, url) - } - ReadingListsAnalyticsHelper.logSurveyShown(activity) - } - - private fun isActive(): Boolean { - return Prefs.readingListShareSurveyMode != MODE_INACTIVE - } - - private fun fallsWithinDateRange(): Boolean { - return LocalDate.now() < LocalDate.of(2023, Month.APRIL, 17) - } - - private fun takeUserToSurvey(context: Context) { - CustomTabsUtil.openInCustomTab(context, getLanguageSpecificUrl()) - } - - private fun getLanguageSpecificUrl(): String { - return when (WikipediaApp.instance.languageState.appLanguageCode) { - "ar" -> "https://docs.google.com/forms/d/e/1FAIpQLSdZaFN5hm76xsFuJWlrNT1VFfUV14T0Yg9uA0o11579GfPszg/viewform?usp=sf_link" - "bn" -> "https://docs.google.com/forms/d/e/1FAIpQLSeR_K5IYCQhuLgA6CCUdaSY71m6T7H0TiVaZ8rJ4nSYlUVCqA/viewform?usp=sf_link" - "fr" -> "https://docs.google.com/forms/d/e/1FAIpQLSdKUCL5zAzsa87cKpcxZjmnzFc2NhaCH9W2Xn6DdXRZTwZ-0g/viewform?usp=sf_link" - "de" -> "https://docs.google.com/forms/d/e/1FAIpQLSf6zbrkwe7lVLJtKBJBkjlLxjcpXtHVKMeUHF_POgMJsFAPLA/viewform?usp=sf_link" - "hi" -> "https://docs.google.com/forms/d/e/1FAIpQLSdEtYzoNsmztbk05NtH82c3GDaEYn_-5aYdMa3NTO-FVWb_7A/viewform?usp=sf_link" - "pt" -> "https://docs.google.com/forms/d/e/1FAIpQLSdwTvojzJRV1FT9apLXF9ck68Knq2qzVaaJQMbzaTvub_icWA/viewform?usp=sf_link" - "es" -> "https://docs.google.com/forms/d/e/1FAIpQLScYsLE48ZjJynHhu6IgP6eR_PPuxjS78ejo_Ii9ysTfTxF9EQ/viewform?usp=sf_link" - "ur" -> "https://docs.google.com/forms/d/e/1FAIpQLSfFdgf_Fr7slHsBanC8hzX34nN-R5nlP6_-DSjBdJHFYe8nng/viewform?usp=sf_link" - else -> "https://docs.google.com/forms/d/e/1FAIpQLScnNlch1dLsxOdKU8oLupaTluW0pmXeNqMxdoX2pj6gJaOgVw/viewform?usp=sf_link" - } - } - - private fun getLanguageSpecificPrivacyPolicyUrl(): String { - return when (WikipediaApp.instance.languageState.appLanguageCode) { - "ar" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/ar" - "bn" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/bn" - "fr" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/fr" - "de" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/de" - "hi" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/hi" - "pt" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/pt-br" - "es" -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement/es" - else -> "https://foundation.wikimedia.org/wiki/Legal:Feedback_form_for_sharing_reading_lists_Privacy_Statement" - } - } -} diff --git a/app/src/main/java/org/wikipedia/readinglist/db/ReadingListDao.kt b/app/src/main/java/org/wikipedia/readinglist/db/ReadingListDao.kt index 957b4cde45e..3a378bb62c4 100644 --- a/app/src/main/java/org/wikipedia/readinglist/db/ReadingListDao.kt +++ b/app/src/main/java/org/wikipedia/readinglist/db/ReadingListDao.kt @@ -52,7 +52,7 @@ interface ReadingListDao { val lists = getListsWithoutContents() val pages = AppDatabase.instance.readingListPageDao().getAllPagesToBeSynced() pages.forEach { page -> - lists.first { it.id == page.listId }.apply { this.pages.add(page) } + lists.firstOrNull { it.id == page.listId }?.apply { this.pages.add(page) } } return lists } diff --git a/app/src/main/java/org/wikipedia/readinglist/db/ReadingListPageDao.kt b/app/src/main/java/org/wikipedia/readinglist/db/ReadingListPageDao.kt index 657bcb23c29..95e84c7b4b4 100644 --- a/app/src/main/java/org/wikipedia/readinglist/db/ReadingListPageDao.kt +++ b/app/src/main/java/org/wikipedia/readinglist/db/ReadingListPageDao.kt @@ -1,8 +1,14 @@ package org.wikipedia.readinglist.db -import androidx.room.* +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import androidx.room.Update import org.apache.commons.lang3.StringUtils -import org.wikipedia.WikipediaApp +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.dataclient.WikiSite import org.wikipedia.events.ArticleSavedOrDeletedEvent import org.wikipedia.page.Namespace @@ -43,7 +49,7 @@ interface ReadingListPageDao { apiTitle: String, listId: Long, excludedStatus: Long): ReadingListPage? @Query("SELECT * FROM ReadingListPage WHERE wiki = :wiki AND lang = :lang AND namespace = :ns AND apiTitle = :apiTitle AND status != :excludedStatus") - fun getPageByParams(wiki: WikiSite, lang: String, ns: Namespace, + suspend fun getPageByParams(wiki: WikiSite, lang: String, ns: Namespace, apiTitle: String, excludedStatus: Long): ReadingListPage? @Query("SELECT * FROM ReadingListPage WHERE wiki = :wiki AND lang = :lang AND namespace = :ns AND apiTitle = :apiTitle AND status != :excludedStatus") @@ -59,8 +65,8 @@ interface ReadingListPageDao { @Query("UPDATE ReadingListPage SET status = :newStatus WHERE status = :oldStatus AND offline = :offline") fun updateStatus(oldStatus: Long, newStatus: Long, offline: Boolean) - @Query("SELECT * FROM ReadingListPage ORDER BY RANDOM() LIMIT 1") - fun getRandomPage(): ReadingListPage? + @Query("SELECT * FROM ReadingListPage WHERE lang = :lang ORDER BY RANDOM() LIMIT 1") + fun getRandomPage(lang: String): ReadingListPage? @Query("SELECT * FROM ReadingListPage WHERE UPPER(displayTitle) LIKE UPPER(:term) ESCAPE '\\'") fun findPageBySearchTerm(term: String): List @@ -98,7 +104,7 @@ interface ReadingListPageDao { for (page in pages) { insertPageIntoDb(list, page) } - WikipediaApp.instance.bus.post(ArticleSavedOrDeletedEvent(true, *pages.toTypedArray())) + FlowEventBus.post(ArticleSavedOrDeletedEvent(true, *pages.toTypedArray())) SavedPageSyncService.enqueue() } @@ -126,11 +132,11 @@ interface ReadingListPageDao { } } - fun updateMetadataByTitle(pageProto: ReadingListPage, description: String?, thumbUrl: String?) { + suspend fun updateMetadataByTitle(pageProto: ReadingListPage, description: String?, thumbUrl: String?) { updateThumbAndDescriptionByName(pageProto.lang, pageProto.apiTitle, thumbUrl, description) } - fun findPageInAnyList(title: PageTitle): ReadingListPage? { + suspend fun findPageInAnyList(title: PageTitle): ReadingListPage? { return getPageByParams( title.wikiSite, title.wikiSite.languageCode, title.namespace(), title.prefixedText, ReadingListPage.STATUS_QUEUE_FOR_DELETE @@ -171,7 +177,7 @@ interface ReadingListPageDao { if (queueForSync) { ReadingListSyncAdapter.manualSyncWithDeletePages(list, pages) } - WikipediaApp.instance.bus.post(ArticleSavedOrDeletedEvent(false, *pages.toTypedArray())) + FlowEventBus.post(ArticleSavedOrDeletedEvent(false, *pages.toTypedArray())) SavedPageSyncService.enqueue() } @@ -251,7 +257,7 @@ interface ReadingListPageDao { page.status = ReadingListPage.STATUS_QUEUE_FOR_SAVE insertPageIntoDb(list, page) } - WikipediaApp.instance.bus.post(ArticleSavedOrDeletedEvent(true, page)) + FlowEventBus.post(ArticleSavedOrDeletedEvent(true, page)) SavedPageSyncService.enqueue() if (queueForSync) { @@ -269,7 +275,7 @@ interface ReadingListPageDao { private fun addPageToList(list: ReadingList, title: PageTitle) { val protoPage = ReadingListPage(title) insertPageIntoDb(list, protoPage) - WikipediaApp.instance.bus.post(ArticleSavedOrDeletedEvent(true, protoPage)) + FlowEventBus.post(ArticleSavedOrDeletedEvent(true, protoPage)) } private fun insertPageIntoDb(list: ReadingList, page: ReadingListPage) { diff --git a/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListClient.kt b/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListClient.kt index 07c63070559..d3cd46362b2 100644 --- a/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListClient.kt +++ b/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListClient.kt @@ -176,7 +176,7 @@ class ReadingListClient(private val wiki: WikiSite) { } fun isErrorType(t: Throwable?, errorType: String): Boolean { - return t is HttpStatusException && t.serviceError?.title?.contains(errorType) == true + return t is HttpStatusException && t.serviceError?.key?.contains(errorType) == true } fun isServiceError(t: Throwable?): Boolean { 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 d239afbc1a8..1dbce3862c8 100644 --- a/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListSyncAdapter.kt +++ b/app/src/main/java/org/wikipedia/readinglist/sync/ReadingListSyncAdapter.kt @@ -2,10 +2,20 @@ package org.wikipedia.readinglist.sync import android.content.* import android.os.Bundle -import androidx.core.app.JobIntentService import androidx.core.os.bundleOf +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.csrf.CsrfTokenClient import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.WikiSite @@ -23,413 +33,415 @@ import org.wikipedia.settings.RemoteConfig import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L -class ReadingListSyncAdapter : JobIntentService() { +class ReadingListSyncAdapter(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { - override fun onHandleWork(intent: Intent) { - val extras = intent.extras!! - if (RemoteConfig.config.disableReadingListSync || !AccountUtil.isLoggedIn || + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + val extras = getBooleanExtraFromData(inputData) + if (RemoteConfig.config.disableReadingListSync || !AccountUtil.isLoggedIn || AccountUtil.isTemporaryAccount || !(Prefs.isReadingListSyncEnabled || Prefs.isReadingListsRemoteDeletePending)) { - L.d("Skipping sync of reading lists.") - if (extras.containsKey(SYNC_EXTRAS_REFRESHING)) { - SavedPageSyncService.sendSyncEvent() + L.d("Skipping sync of reading lists.") + if (extras.containsKey(SYNC_EXTRAS_REFRESHING)) { + SavedPageSyncService.sendSyncEvent() + } + return@withContext Result.success() } - return - } - L.d("Begin sync of reading lists...") - val csrfToken = mutableListOf() - val listIdsDeleted = Prefs.readingListsDeletedIds.toMutableSet() - val pageIdsDeleted = Prefs.readingListPagesDeletedIds.toMutableSet() - var allLocalLists: MutableList? = null - val wiki = WikipediaApp.instance.wikiSite - val client = ReadingListClient(wiki) - val readingListSyncNotification = ReadingListSyncNotification.instance - val lastSyncTime = Prefs.readingListsLastSyncTime.orEmpty() - var shouldSendSyncEvent = extras.containsKey(SYNC_EXTRAS_REFRESHING) - var shouldRetry = false - var shouldRetryWithForce = false - try { - IN_PROGRESS = true - var syncEverything = false - if (extras.containsKey(SYNC_EXTRAS_FORCE_FULL_SYNC) || + L.d("Begin sync of reading lists...") + val listIdsDeleted = Prefs.readingListsDeletedIds.toMutableSet() + val pageIdsDeleted = Prefs.readingListPagesDeletedIds.toMutableSet() + var allLocalLists: MutableList? = null + val wiki = WikipediaApp.instance.wikiSite + val client = ReadingListClient(wiki) + val readingListSyncNotification = ReadingListSyncNotification.instance + val lastSyncTime = Prefs.readingListsLastSyncTime.orEmpty() + var shouldSendSyncEvent = extras.containsKey(SYNC_EXTRAS_REFRESHING) + var shouldRetry = false + var shouldRetryWithForce = false + try { + IN_PROGRESS = true + var syncEverything = false + if (extras.containsKey(SYNC_EXTRAS_FORCE_FULL_SYNC) || Prefs.isReadingListsRemoteDeletePending || Prefs.isReadingListsRemoteSetupPending) { - // reset the remote ID on all lists, since they will need to be recreated next time. - L.d("Resetting all lists to un-synced.") - syncEverything = true - AppDatabase.instance.readingListDao().markAllListsUnsynced() - AppDatabase.instance.readingListPageDao().markAllPagesUnsynced() - allLocalLists = AppDatabase.instance.readingListDao().getAllLists().toMutableList() - } - if (Prefs.isReadingListsRemoteDeletePending) { - // Are we scheduled for a teardown? If so, delete everything and bail. - L.d("Tearing down remote lists...") - client.tearDown(getCsrfToken(wiki, csrfToken)) - Prefs.isReadingListsRemoteDeletePending = false - return - } else if (Prefs.isReadingListsRemoteSetupPending) { - // ...Or are we scheduled for setup? - client.setup(getCsrfToken(wiki, csrfToken)) - Prefs.isReadingListsRemoteSetupPending = false - } + // reset the remote ID on all lists, since they will need to be recreated next time. + L.d("Resetting all lists to un-synced.") + syncEverything = true + AppDatabase.instance.readingListDao().markAllListsUnsynced() + AppDatabase.instance.readingListPageDao().markAllPagesUnsynced() + allLocalLists = AppDatabase.instance.readingListDao().getAllLists().toMutableList() + } - // ----------------------------------------------- - // PHASE 1: Sync from remote to local. - // ----------------------------------------------- - var remoteListsModified = mutableListOf() - var remoteEntriesModified = mutableListOf() - if (lastSyncTime.isEmpty()) { - syncEverything = true - } + val csrfToken = CsrfTokenClient.getToken(wiki) + + if (Prefs.isReadingListsRemoteDeletePending) { + // Are we scheduled for a teardown? If so, delete everything and bail. + L.d("Tearing down remote lists...") + client.tearDown(csrfToken) + Prefs.isReadingListsRemoteDeletePending = false + return@withContext Result.success() + } else if (Prefs.isReadingListsRemoteSetupPending) { + // ...Or are we scheduled for setup? + client.setup(csrfToken) + Prefs.isReadingListsRemoteSetupPending = false + } - 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 - } + // ----------------------------------------------- + // PHASE 1: Sync from remote to local. + // ----------------------------------------------- + var remoteListsModified = mutableListOf() + var remoteEntriesModified = mutableListOf() + if (lastSyncTime.isEmpty()) { + syncEverything = true } - } - if (syncEverything) { - if (allLocalLists == null) { - allLocalLists = AppDatabase.instance.readingListDao().getAllLists().toMutableList() + 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 + } + } } - } else { + if (allLocalLists == null) { - allLocalLists = AppDatabase.instance.readingListDao().getAllListsWithUnsyncedPages().toMutableList() + allLocalLists = if (syncEverything) { + AppDatabase.instance.readingListDao().getAllLists().toMutableList() + } else { + AppDatabase.instance.readingListDao().getAllListsWithUnsyncedPages().toMutableList() + } } - } - // Perform a quick check for whether we'll need to sync all lists - for (remoteEntry in remoteEntriesModified) { - // find the list to which this entry belongs... - val eigenLocalList = allLocalLists.find { it.remoteId == remoteEntry.listId } - val eigenRemoteList = remoteListsModified.find { it.id == remoteEntry.listId } + // Perform a quick check for whether we'll need to sync all lists + for (remoteEntry in remoteEntriesModified) { + // find the list to which this entry belongs... + val eigenLocalList = allLocalLists.find { it.remoteId == remoteEntry.listId } + val eigenRemoteList = remoteListsModified.find { it.id == remoteEntry.listId } - if (eigenLocalList == null && eigenRemoteList == null) { - L.w("Remote entry belongs to an unknown local list. Falling back to full sync.") - syncEverything = true - break + if (eigenLocalList == null && eigenRemoteList == null) { + L.w("Remote entry belongs to an unknown local list. Falling back to full sync.") + syncEverything = true + break + } + } + if (syncEverything) { + allLocalLists = AppDatabase.instance.readingListDao().getAllLists().toMutableList() + L.d("Fetching all lists from server...") + remoteListsModified = client.allLists as MutableList } - } - if (syncEverything) { - allLocalLists = AppDatabase.instance.readingListDao().getAllLists().toMutableList() - L.d("Fetching all lists from server...") - remoteListsModified = client.allLists as MutableList - } - // Notify any event consumers that reading lists are, in fact, enabled. - WikipediaApp.instance.bus.post(ReadingListsEnabledStatusEvent()) - - // setup syncing indicator for remote to local - val remoteItemsTotal = remoteListsModified.size - - // First, update our list hierarchy to match the remote hierarchy. - for ((remoteItemsSynced, remoteList) in remoteListsModified.withIndex()) { - readingListSyncNotification.setNotificationProgress(applicationContext, remoteItemsTotal, remoteItemsSynced) - // Find the remote list in our local lists... - var localList: ReadingList? = null - var upsertNeeded = false - for (list in allLocalLists) { - if (list.isDefault && remoteList.isDefault) { - localList = list - if (list.remoteId != remoteList.id) { - list.remoteId = remoteList.id + // Notify any event consumers that reading lists are, in fact, enabled. + FlowEventBus.post(ReadingListsEnabledStatusEvent()) + + // setup syncing indicator for remote to local + val remoteItemsTotal = remoteListsModified.size + + // First, update our list hierarchy to match the remote hierarchy. + for ((remoteItemsSynced, remoteList) in remoteListsModified.withIndex()) { + readingListSyncNotification.setNotificationProgress(applicationContext, remoteItemsTotal, remoteItemsSynced) + // Find the remote list in our local lists... + var localList: ReadingList? = null + var upsertNeeded = false + for (list in allLocalLists) { + if (list.isDefault && remoteList.isDefault) { + localList = list + if (list.remoteId != remoteList.id) { + list.remoteId = remoteList.id + upsertNeeded = true + } + break + } + if (list.remoteId == remoteList.id) { + localList = list + break + } else if (StringUtil.normalizedEquals(list.title, remoteList.name())) { + localList = list + localList.remoteId = remoteList.id upsertNeeded = true + break } - break } - if (list.remoteId == remoteList.id) { - localList = list - break - } else if (StringUtil.normalizedEquals(list.title, remoteList.name())) { - localList = list - localList.remoteId = remoteList.id - upsertNeeded = true - break - } - } - if (remoteList.isDefault && localList != null && !localList.isDefault) { - L.logRemoteError(RuntimeException("Unexpected: remote default list corresponds to local non-default list.")) - localList = AppDatabase.instance.readingListDao().getDefaultList() - } - if (remoteList.isDeleted) { - if (localList != null && !localList.isDefault) { - L.d("Deleting local list " + localList.title) - AppDatabase.instance.readingListDao().deleteList(localList, false) - AppDatabase.instance.readingListPageDao().markPagesForDeletion(localList, localList.pages, false) - allLocalLists.remove(localList) - shouldSendSyncEvent = true + if (remoteList.isDefault && localList != null && !localList.isDefault) { + L.logRemoteError(RuntimeException("Unexpected: remote default list corresponds to local non-default list.")) + localList = AppDatabase.instance.readingListDao().getDefaultList() } - continue - } - if (localList == null) { - // A new list needs to be created locally. - L.d("Creating local list " + remoteList.name()) - localList = if (remoteList.isDefault) { - L.logRemoteError(RuntimeException("Unexpected: local default list no longer matches remote.")) - AppDatabase.instance.readingListDao().getDefaultList() - } else { - AppDatabase.instance.readingListDao().createList(remoteList.name(), remoteList.description()) + if (remoteList.isDeleted) { + if (localList != null && !localList.isDefault) { + L.d("Deleting local list " + localList.title) + AppDatabase.instance.readingListDao().deleteList(localList, false) + AppDatabase.instance.readingListPageDao().markPagesForDeletion(localList, localList.pages, false) + allLocalLists.remove(localList) + shouldSendSyncEvent = true + } + continue } - localList.remoteId = remoteList.id - allLocalLists.add(localList) - upsertNeeded = true - } else { - if (!localList.isDefault && !StringUtil.normalizedEquals(localList.title, remoteList.name())) { - localList.title = remoteList.name() + if (localList == null) { + // A new list needs to be created locally. + L.d("Creating local list " + remoteList.name()) + localList = if (remoteList.isDefault) { + L.logRemoteError(RuntimeException("Unexpected: local default list no longer matches remote.")) + AppDatabase.instance.readingListDao().getDefaultList() + } else { + AppDatabase.instance.readingListDao().createList(remoteList.name(), remoteList.description()) + } + localList.remoteId = remoteList.id + allLocalLists.add(localList) upsertNeeded = true + } else { + if (!localList.isDefault && !StringUtil.normalizedEquals(localList.title, remoteList.name())) { + localList.title = remoteList.name() + upsertNeeded = true + } + if (!localList.isDefault && !StringUtil.normalizedEquals(localList.description, remoteList.description())) { + localList.description = remoteList.description() + upsertNeeded = true + } } - if (!localList.isDefault && !StringUtil.normalizedEquals(localList.description, remoteList.description())) { - localList.description = remoteList.description() - upsertNeeded = true + if (upsertNeeded) { + L.d("Updating info for local list " + localList.title) + localList.dirty = false + AppDatabase.instance.readingListDao().updateList(localList, false) + shouldSendSyncEvent = true } - } - if (upsertNeeded) { - L.d("Updating info for local list " + localList.title) - localList.dirty = false - AppDatabase.instance.readingListDao().updateList(localList, false) - shouldSendSyncEvent = true - } - if (syncEverything) { - L.d("Fetching all pages in remote list " + remoteList.name()) - client.getListEntries(remoteList.id).forEach { - // TODO: optimization opportunity -- create/update local pages in bulk. - createOrUpdatePage(localList, it) + if (syncEverything) { + L.d("Fetching all pages in remote list " + remoteList.name()) + client.getListEntries(remoteList.id).forEach { + // TODO: optimization opportunity -- create/update local pages in bulk. + createOrUpdatePage(localList, it) + } + shouldSendSyncEvent = true } - shouldSendSyncEvent = true } - } - if (!syncEverything) { - for (remoteEntry in remoteEntriesModified) { - // find the list to which this entry belongs... - val eigenList = allLocalLists.find { it.remoteId == remoteEntry.listId } - if (eigenList == null) { - L.w("Remote entry belongs to an unknown local list.") - continue - } - shouldSendSyncEvent = true - if (remoteEntry.isDeleted) { - deletePageByTitle(eigenList, pageTitleFromRemoteEntry(remoteEntry)) - } else { - createOrUpdatePage(eigenList, remoteEntry) + if (!syncEverything) { + for (remoteEntry in remoteEntriesModified) { + // find the list to which this entry belongs... + val eigenList = allLocalLists.find { it.remoteId == remoteEntry.listId } + if (eigenList == null) { + L.w("Remote entry belongs to an unknown local list.") + continue + } + shouldSendSyncEvent = true + if (remoteEntry.isDeleted) { + deletePageByTitle(eigenList, pageTitleFromRemoteEntry(remoteEntry)) + } else { + createOrUpdatePage(eigenList, remoteEntry) + } } } - } - // ----------------------------------------------- - // PHASE 2: Sync from local to remote. - // ----------------------------------------------- - - // Do any remote lists need to be deleted? - val listIdsToDelete = mutableListOf() - listIdsToDelete.addAll(listIdsDeleted) - for (id in listIdsToDelete) { - L.d("Deleting remote list id $id") - try { - client.deleteList(getCsrfToken(wiki, csrfToken), id) - } catch (t: Throwable) { - L.w(t) - if (!client.isServiceError(t) && !client.isUnavailableError(t)) { - throw t + // ----------------------------------------------- + // PHASE 2: Sync from local to remote. + // ----------------------------------------------- + + // Do any remote lists need to be deleted? + val listIdsToDelete = mutableListOf() + listIdsToDelete.addAll(listIdsDeleted) + for (id in listIdsToDelete) { + L.d("Deleting remote list id $id") + try { + client.deleteList(csrfToken, id) + } catch (t: Throwable) { + L.w(t) + if (!client.isServiceError(t) && !client.isUnavailableError(t)) { + throw t + } } + listIdsDeleted.remove(id) } - listIdsDeleted.remove(id) - } - // Do any remote pages need to be deleted? - val pageIdsToDelete = mutableSetOf() - pageIdsToDelete.addAll(pageIdsDeleted) - - // Determine if any articles need to be de-duplicated (because of bugs in previous sync inconsistencies) - if (syncEverything) { - allLocalLists.forEach { list -> - val distinct = list.pages.distinctBy { pageTitleFromRemoteEntry(remoteEntryFromLocalPage(it)) } - val toRemove = list.pages.toMutableSet() - toRemove.removeAll(distinct.toSet()) - if (toRemove.isNotEmpty()) { - toRemove.forEach { - AppDatabase.instance.readingListPageDao().deleteReadingListPage(it) + // Do any remote pages need to be deleted? + val pageIdsToDelete = pageIdsDeleted.toMutableSet() + + // Determine if any articles need to be de-duplicated (because of bugs in previous sync inconsistencies) + if (syncEverything) { + allLocalLists.forEach { list -> + val distinct = list.pages.distinctBy { pageTitleFromRemoteEntry(remoteEntryFromLocalPage(it)) } + val toRemove = list.pages.toMutableSet() + toRemove.removeAll(distinct.toSet()) + if (toRemove.isNotEmpty()) { + toRemove.forEach { + AppDatabase.instance.readingListPageDao().deleteReadingListPage(it) + } + pageIdsToDelete.addAll(createIdsForDeletion(list, toRemove)) } - pageIdsToDelete.addAll(createIdsForDeletion(list, toRemove)) } } - } - for (id in pageIdsToDelete) { - L.d("Deleting remote page id $id") - val listAndPageId = id.split(":").toTypedArray() - try { - // TODO: optimization opportunity once server starts supporting batch deletes. - client.deletePageFromList(getCsrfToken(wiki, csrfToken), listAndPageId[0].toLong(), listAndPageId[1].toLong()) - } catch (t: Throwable) { - L.w(t) - if (!client.isServiceError(t) && !client.isUnavailableError(t)) { - throw t + for (id in pageIdsToDelete) { + L.d("Deleting remote page id $id") + val listAndPageId = id.split(":").toTypedArray() + try { + // TODO: optimization opportunity once server starts supporting batch deletes. + client.deletePageFromList(csrfToken, listAndPageId[0].toLong(), listAndPageId[1].toLong()) + } catch (t: Throwable) { + L.w(t) + if (!client.isServiceError(t) && !client.isUnavailableError(t)) { + throw t + } } + pageIdsDeleted.remove(id) } - pageIdsDeleted.remove(id) - } - // setup syncing indicator for local to remote - val localItemsTotal = allLocalLists.size - - // Determine whether any remote lists need to be created or updated - for ((localItemsSynced, localList) in allLocalLists.withIndex()) { - readingListSyncNotification.setNotificationProgress(applicationContext, localItemsTotal, localItemsSynced) - val remoteList = RemoteReadingList(name = localList.title, description = localList.description) - var upsertNeeded = false - if (localList.remoteId > 0) { - if (!localList.isDefault && localList.dirty) { - // Update remote metadata for this list. - L.d("Updating info for remote list " + remoteList.name()) - client.updateList(getCsrfToken(wiki, csrfToken), localList.remoteId, remoteList) + // setup syncing indicator for local to remote + val localItemsTotal = allLocalLists.size + + // Determine whether any remote lists need to be created or updated + for ((localItemsSynced, localList) in allLocalLists.withIndex()) { + readingListSyncNotification.setNotificationProgress(applicationContext, localItemsTotal, localItemsSynced) + val remoteList = RemoteReadingList(name = localList.title, description = localList.description) + var upsertNeeded = false + if (localList.remoteId > 0) { + if (!localList.isDefault && localList.dirty) { + // Update remote metadata for this list. + L.d("Updating info for remote list " + remoteList.name()) + client.updateList(csrfToken, localList.remoteId, remoteList) + upsertNeeded = true + } + } else if (!localList.isDefault) { + // This list needs to be created remotely. + L.d("Creating remote list " + remoteList.name()) + val id = client.createList(csrfToken, remoteList) + localList.remoteId = id upsertNeeded = true } - } else if (!localList.isDefault) { - // This list needs to be created remotely. - L.d("Creating remote list " + remoteList.name()) - val id = client.createList(getCsrfToken(wiki, csrfToken), remoteList) - localList.remoteId = id - upsertNeeded = true - } - if (upsertNeeded) { - localList.dirty = false - AppDatabase.instance.readingListDao().updateList(localList, false) - } - } - for (localList in allLocalLists) { - val localPages = localList.pages.filter { it.remoteId < 1 } - val newEntries = localPages.map { remoteEntryFromLocalPage(it) } - // Note: newEntries.size() is guaranteed to be equal to localPages.size() - if (newEntries.isEmpty()) { - continue + if (upsertNeeded) { + localList.dirty = false + AppDatabase.instance.readingListDao().updateList(localList, false) + } } - var tryOneAtATime = false - try { - if (localPages.size == 1) { - L.d("Creating new remote page " + localPages[0].displayTitle) - localPages[0].remoteId = client.addPageToList(getCsrfToken(wiki, csrfToken), localList.remoteId, newEntries[0]) - AppDatabase.instance.readingListPageDao().updateReadingListPage(localPages[0]) - } else { - L.d("Creating " + newEntries.size + " new remote pages") - val ids = client.addPagesToList(getCsrfToken(wiki, csrfToken), localList.remoteId, newEntries) - for (i in ids.indices) { - localPages[i].remoteId = ids[i] + for (localList in allLocalLists) { + val localPages = localList.pages.filter { it.remoteId < 1 } + val newEntries = localPages.map { remoteEntryFromLocalPage(it) } + // Note: newEntries.size() is guaranteed to be equal to localPages.size() + if (newEntries.isEmpty()) { + continue + } + var tryOneAtATime = false + try { + if (localPages.size == 1) { + L.d("Creating new remote page " + localPages[0].displayTitle) + localPages[0].remoteId = client.addPageToList(csrfToken, localList.remoteId, newEntries[0]) + AppDatabase.instance.readingListPageDao().updateReadingListPage(localPages[0]) + } else { + L.d("Creating " + newEntries.size + " new remote pages") + val ids = client.addPagesToList(csrfToken, localList.remoteId, newEntries) + for (i in ids.indices) { + localPages[i].remoteId = ids[i] + } + AppDatabase.instance.readingListPageDao().updatePages(localPages) + } + } catch (t: Throwable) { + // TODO: optimization opportunity -- if the server can return the ID + // of the existing page(s), then we wouldn't need to do a hard sync. + + // If the page already exists in the remote list, this means that + // the contents of this list have diverged from the remote list, + // so let's force a full sync. + if (client.isErrorType(t, "duplicate-page")) { + shouldRetryWithForce = true + break + } else if (client.isErrorType(t, "entry-limit")) { + // TODO: handle more meaningfully than ignoring, for now. + } else if (client.isErrorType(t, "no-such-project")) { + // Something is malformed in the page domain, but we don't know which page + // in the batch caused the error. Therefore, let's retry uploading the pages + // one at a time, and single out the one that fails. + tryOneAtATime = true + } else { + throw t + } + } + if (tryOneAtATime) { + for (i in localPages.indices) { + val localPage = localPages[i] + try { + L.d("Creating new remote page " + localPage.displayTitle) + localPage.remoteId = client.addPageToList(csrfToken, localList.remoteId, newEntries[i]) + } catch (t: Throwable) { + if (client.isErrorType(t, "duplicate-page")) { + shouldRetryWithForce = true + break + } else if (client.isErrorType(t, "entry-limit")) { + // TODO: handle more meaningfully than ignoring, for now. + } else if (client.isErrorType(t, "no-such-project")) { + // Ignore the error, and give this malformed page a bogus remoteID, + // so that we won't try syncing it again. + localPage.remoteId = Int.MAX_VALUE.toLong() + // ...and also log it: + L.logRemoteError(RuntimeException("Attempted to sync malformed page: ${localPage.wiki}, ${localPage.displayTitle}")) + } else { + throw t + } + } } AppDatabase.instance.readingListPageDao().updatePages(localPages) } - } catch (t: Throwable) { - // TODO: optimization opportunity -- if the server can return the ID - // of the existing page(s), then we wouldn't need to do a hard sync. - - // If the page already exists in the remote list, this means that - // the contents of this list have diverged from the remote list, - // so let's force a full sync. - if (client.isErrorType(t, "duplicate-page")) { - shouldRetryWithForce = true - break - } else if (client.isErrorType(t, "entry-limit")) { - // TODO: handle more meaningfully than ignoring, for now. - } else if (client.isErrorType(t, "no-such-project")) { - // Something is malformed in the page domain, but we don't know which page - // in the batch caused the error. Therefore, let's retry uploading the pages - // one at a time, and single out the one that fails. - tryOneAtATime = true + } + } catch (t: Throwable) { + var errorMsg = t + if (client.isErrorType(t, "not-set-up")) { + Prefs.isReadingListSyncEnabled = false + if (lastSyncTime.isEmpty()) { + // This means that it's our first time attempting to sync, and we see that + // syncing isn't enabled on the server. So, let's prompt the user to enable it: + FlowEventBus.post(ReadingListsEnableDialogEvent()) } else { - throw t + // This can only mean that our reading lists have been torn down (disabled) by + // another client, so we need to notify the user of this development. + FlowEventBus.post(ReadingListsNoLongerSyncedEvent()) } } - if (tryOneAtATime) { - for (i in localPages.indices) { - val localPage = localPages[i] - try { - L.d("Creating new remote page " + localPage.displayTitle) - localPage.remoteId = client.addPageToList(getCsrfToken(wiki, csrfToken), localList.remoteId, newEntries[i]) - } catch (t: Throwable) { - if (client.isErrorType(t, "duplicate-page")) { - shouldRetryWithForce = true - break - } else if (client.isErrorType(t, "entry-limit")) { - // TODO: handle more meaningfully than ignoring, for now. - } else if (client.isErrorType(t, "no-such-project")) { - // Ignore the error, and give this malformed page a bogus remoteID, - // so that we won't try syncing it again. - localPage.remoteId = Int.MAX_VALUE.toLong() - // ...and also log it: - L.logRemoteError(RuntimeException("Attempted to sync malformed page: ${localPage.wiki}, ${localPage.displayTitle}")) - } else { - throw t - } - } + if (client.isErrorType(t, "notloggedin")) { + try { + L.d("Server doesn't believe we're logged in, so logging in...") + CsrfTokenClient.getToken(wiki) + shouldRetry = true + } catch (caught: Throwable) { + errorMsg = caught } - AppDatabase.instance.readingListPageDao().updatePages(localPages) - } - } - } catch (t: Throwable) { - var errorMsg = t - if (client.isErrorType(t, "not-set-up")) { - Prefs.isReadingListSyncEnabled = false - if (lastSyncTime.isEmpty()) { - // This means that it's our first time attempting to sync, and we see that - // syncing isn't enabled on the server. So, let's prompt the user to enable it: - WikipediaApp.instance.bus.post(ReadingListsEnableDialogEvent()) - } else { - // This can only mean that our reading lists have been torn down (disabled) by - // another client, so we need to notify the user of this development. - WikipediaApp.instance.bus.post(ReadingListsNoLongerSyncedEvent()) } - } - if (client.isErrorType(t, "notloggedin")) { - try { - L.d("Server doesn't believe we're logged in, so logging in...") - getCsrfToken(wiki, csrfToken) - shouldRetry = true - } catch (caught: Throwable) { - errorMsg = caught + L.w(errorMsg) + } finally { + Prefs.readingListsLastSyncTime = client.lastDateHeader?.toString() ?: lastSyncTime + Prefs.readingListsDeletedIds = listIdsDeleted + Prefs.readingListPagesDeletedIds = pageIdsDeleted + readingListSyncNotification.cancelNotification(applicationContext) + if (shouldSendSyncEvent) { + SavedPageSyncService.sendSyncEvent(extras.containsKey(SYNC_EXTRAS_REFRESHING)) } - } - L.w(errorMsg) - } finally { - Prefs.readingListsLastSyncTime = client.lastDateHeader?.toString() ?: lastSyncTime - Prefs.readingListsDeletedIds = listIdsDeleted - Prefs.readingListPagesDeletedIds = pageIdsDeleted - readingListSyncNotification.cancelNotification(applicationContext) - if (shouldSendSyncEvent) { - SavedPageSyncService.sendSyncEvent(extras.containsKey(SYNC_EXTRAS_REFRESHING)) - } - if ((shouldRetry || shouldRetryWithForce) && !extras.containsKey(SYNC_EXTRAS_RETRYING)) { - val b = Bundle() - b.putAll(extras) - b.putBoolean(SYNC_EXTRAS_RETRYING, true) - if (shouldRetryWithForce) { - b.putBoolean(SYNC_EXTRAS_FORCE_FULL_SYNC, true) + if ((shouldRetry || shouldRetryWithForce) && !extras.containsKey(SYNC_EXTRAS_RETRYING)) { + val b = Bundle() + b.putAll(extras) + b.putBoolean(SYNC_EXTRAS_RETRYING, true) + if (shouldRetryWithForce) { + b.putBoolean(SYNC_EXTRAS_FORCE_FULL_SYNC, true) + } + manualSync(b) } - manualSync(b) + IN_PROGRESS = false + SavedPageSyncService.enqueue() + L.d("Finished sync of reading lists.") } - IN_PROGRESS = false - SavedPageSyncService.enqueue() - L.d("Finished sync of reading lists.") + Result.success() } } - @Throws(Throwable::class) - private fun getCsrfToken(wiki: WikiSite, tokenList: MutableList): String { - if (tokenList.size == 0) { - tokenList.add(CsrfTokenClient.getToken(wiki).blockingSingle()) + private fun getBooleanExtraFromData(inputData: Data): Bundle { + val extras = Bundle() + inputData.keyValueMap.forEach { + extras.putBoolean(it.key, it.value as Boolean) } - return tokenList[0] + return extras } private fun createOrUpdatePage(listForPage: ReadingList, @@ -478,8 +490,7 @@ class ReadingListSyncAdapter : JobIntentService() { } companion object { - // Unique job ID for this service (do not duplicate). - private const val JOB_ID = 1001 + private const val WORK_NAME = "readingListSyncAdapter" private const val SYNC_EXTRAS_FORCE_FULL_SYNC = "forceFullSync" private const val SYNC_EXTRAS_REFRESHING = "refreshing" private const val SYNC_EXTRAS_RETRYING = "retrying" @@ -538,9 +549,21 @@ class ReadingListSyncAdapter : JobIntentService() { extras.putBoolean(ContentResolver.SYNC_EXTRAS_MANUAL, true) extras.putBoolean(ContentResolver.SYNC_EXTRAS_EXPEDITED, true) - enqueueWork(WikipediaApp.instance, ReadingListSyncAdapter::class.java, - JOB_ID, Intent(WikipediaApp.instance, ReadingListSyncAdapter::class.java) - .putExtras(extras)) + // Convert the Bundle to a Data object + val dataBuilder = Data.Builder() + extras.keySet().forEach { + dataBuilder.putBoolean(it, extras.getBoolean(it)) + } + + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val workRequest = OneTimeWorkRequestBuilder() + .setConstraints(constraints) + .setInputData(dataBuilder.build()) + .build() + WorkManager.getInstance(WikipediaApp.instance) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest) } } } diff --git a/app/src/main/java/org/wikipedia/readinglist/sync/SyncedReadingLists.kt b/app/src/main/java/org/wikipedia/readinglist/sync/SyncedReadingLists.kt index 73be271fd99..89e7fc79467 100644 --- a/app/src/main/java/org/wikipedia/readinglist/sync/SyncedReadingLists.kt +++ b/app/src/main/java/org/wikipedia/readinglist/sync/SyncedReadingLists.kt @@ -3,8 +3,6 @@ package org.wikipedia.readinglist.sync import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import java.text.Normalizer -import java.time.Instant -import java.util.* @Serializable data class SyncedReadingLists(val lists: List? = null, @@ -17,8 +15,6 @@ data class SyncedReadingLists(val lists: List? = null, @SerialName("default") val isDefault: Boolean = false, private val name: String, private val description: String? = null, - val created: String = Instant.now().toString(), - val updated: String = Instant.now().toString(), @SerialName("deleted") val isDeleted: Boolean = false ) { fun name(): String = Normalizer.normalize(name, Normalizer.Form.NFC) @@ -31,8 +27,6 @@ data class SyncedReadingLists(val lists: List? = null, val listId: Long = -1, private val project: String, private val title: String, - val created: String = Instant.now().toString(), - val updated: String = Instant.now().toString(), @SerialName("deleted") val isDeleted: Boolean = false ) { fun project(): String = Normalizer.normalize(project, Normalizer.Form.NFC) @@ -40,9 +34,7 @@ data class SyncedReadingLists(val lists: List? = null, } @Serializable - data class RemoteReadingListEntryBatch(val entries: List) { - val batch: Array = entries.toTypedArray() - } + data class RemoteReadingListEntryBatch(val batch: List) @Serializable class RemoteIdResponse { diff --git a/app/src/main/java/org/wikipedia/recurring/DailyEventTask.kt b/app/src/main/java/org/wikipedia/recurring/DailyEventTask.kt index 5957c8c6f63..c2b004ad3b7 100644 --- a/app/src/main/java/org/wikipedia/recurring/DailyEventTask.kt +++ b/app/src/main/java/org/wikipedia/recurring/DailyEventTask.kt @@ -1,22 +1,21 @@ package org.wikipedia.recurring -import android.content.Context import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.DailyStatsEvent import org.wikipedia.analytics.eventplatform.EventPlatformClient -import java.util.* +import java.util.Date import java.util.concurrent.TimeUnit -class DailyEventTask(context: Context) : RecurringTask() { - override val name = context.getString(R.string.preference_key_daily_event_time_task_name) +class DailyEventTask(private val app: WikipediaApp) : RecurringTask() { + override val name = app.getString(R.string.preference_key_daily_event_time_task_name) override fun shouldRun(lastRun: Date): Boolean { return millisSinceLastRun(lastRun) > TimeUnit.DAYS.toMillis(1) } - override fun run(lastRun: Date) { - DailyStatsEvent.log(WikipediaApp.instance) + override suspend fun run(lastRun: Date) { + DailyStatsEvent.log(app) EventPlatformClient.refreshStreamConfigs() } } diff --git a/app/src/main/java/org/wikipedia/recurring/RecurringTask.kt b/app/src/main/java/org/wikipedia/recurring/RecurringTask.kt index 9e6cf400e73..1d346eb024e 100644 --- a/app/src/main/java/org/wikipedia/recurring/RecurringTask.kt +++ b/app/src/main/java/org/wikipedia/recurring/RecurringTask.kt @@ -2,7 +2,7 @@ package org.wikipedia.recurring import org.wikipedia.settings.Prefs import org.wikipedia.util.log.L -import java.util.* +import java.util.Date import kotlin.math.max import kotlin.math.min @@ -17,7 +17,7 @@ import kotlin.math.min * last run times are tracked automatically by the base class. */ abstract class RecurringTask { - fun runIfNecessary() { + suspend fun runIfNecessary() { val lastRunDate = lastRunDate val lastExecutionLog = "$name. Last execution was $lastRunDate." if (shouldRun(lastRunDate)) { @@ -30,7 +30,7 @@ abstract class RecurringTask { } protected abstract fun shouldRun(lastRun: Date): Boolean - protected abstract fun run(lastRun: Date) + protected abstract suspend fun run(lastRun: Date) protected abstract val name: String diff --git a/app/src/main/java/org/wikipedia/recurring/RecurringTasksExecutor.kt b/app/src/main/java/org/wikipedia/recurring/RecurringTasksExecutor.kt index 98692ef878b..6e5f53f0704 100644 --- a/app/src/main/java/org/wikipedia/recurring/RecurringTasksExecutor.kt +++ b/app/src/main/java/org/wikipedia/recurring/RecurringTasksExecutor.kt @@ -1,26 +1,26 @@ package org.wikipedia.recurring -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.launch import org.wikipedia.WikipediaApp import org.wikipedia.alphaupdater.AlphaUpdateChecker import org.wikipedia.settings.RemoteConfigRefreshTask import org.wikipedia.util.ReleaseUtil +import org.wikipedia.util.log.L -class RecurringTasksExecutor(private val app: WikipediaApp) { +class RecurringTasksExecutor() { fun run() { - Completable.fromAction { - val allTasks = arrayOf( // Has list of all rotating tasks that need to be run - RemoteConfigRefreshTask(), - DailyEventTask(app), - TalkOfflineCleanupTask(app) - ) - for (task in allTasks) { - task.runIfNecessary() - } + val app = WikipediaApp.instance + MainScope().launch(CoroutineExceptionHandler { _, throwable -> + L.e(throwable) + }) { + RemoteConfigRefreshTask().runIfNecessary() + DailyEventTask(app).runIfNecessary() + TalkOfflineCleanupTask(app).runIfNecessary() if (ReleaseUtil.isAlphaRelease) { AlphaUpdateChecker(app).runIfNecessary() } - }.subscribeOn(Schedulers.io()).subscribe() + } } } diff --git a/app/src/main/java/org/wikipedia/recurring/TalkOfflineCleanupTask.kt b/app/src/main/java/org/wikipedia/recurring/TalkOfflineCleanupTask.kt index 00d255d19dc..9d7b3b1ee44 100644 --- a/app/src/main/java/org/wikipedia/recurring/TalkOfflineCleanupTask.kt +++ b/app/src/main/java/org/wikipedia/recurring/TalkOfflineCleanupTask.kt @@ -1,10 +1,12 @@ package org.wikipedia.recurring import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.wikipedia.R import org.wikipedia.database.AppDatabase import java.io.File -import java.util.* +import java.util.Date import java.util.concurrent.TimeUnit class TalkOfflineCleanupTask(context: Context) : RecurringTask() { @@ -14,15 +16,17 @@ class TalkOfflineCleanupTask(context: Context) : RecurringTask() { return millisSinceLastRun(lastRun) > TimeUnit.DAYS.toMillis(CLEANUP_MAX_AGE_DAYS) } - override fun run(lastRun: Date) { - AppDatabase.instance.offlineObjectDao() - .searchForOfflineObjects(CLEANUP_URL_SEARCH_KEY) - .filter { - (absoluteTime - File(it.path + ".0").lastModified()) > TimeUnit.DAYS.toMillis(CLEANUP_MAX_AGE_DAYS) - }.forEach { - AppDatabase.instance.offlineObjectDao().deleteOfflineObject(it) - AppDatabase.instance.offlineObjectDao().deleteFilesForObject(it) - } + override suspend fun run(lastRun: Date) { + withContext(Dispatchers.IO) { + AppDatabase.instance.offlineObjectDao() + .searchForOfflineObjects(CLEANUP_URL_SEARCH_KEY) + .filter { + (absoluteTime - File(it.path + ".0").lastModified()) > TimeUnit.DAYS.toMillis(CLEANUP_MAX_AGE_DAYS) + }.forEach { + AppDatabase.instance.offlineObjectDao().deleteOfflineObject(it) + AppDatabase.instance.offlineObjectDao().deleteFilesForObject(it) + } + } } companion object { diff --git a/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt b/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt index 7db11d37458..3d52ac91076 100644 --- a/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt +++ b/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt @@ -13,6 +13,7 @@ import android.text.Html.TagHandler import android.text.Spannable import android.text.Spanned import android.text.style.LeadingMarginSpan +import android.text.style.ParagraphStyle import android.text.style.TypefaceSpan import android.text.style.URLSpan import android.widget.TextView @@ -21,11 +22,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 +147,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") { @@ -209,9 +202,19 @@ class CustomHtmlParser(private val handler: TagHandler) : TagHandler, ContentHan if (listParents.last() == "ol") { val count = (if (listItemCounts.size > 0) listItemCounts.pop() else 0) + 1 listItemCounts.push(count) - val spans = output.getSpans(output.length) - if (spans.isNotEmpty()) { - val span = spans.last() + // TODO: improve this logic to no longer require explicitly inserting the count + // into the output text. This requires manual and fragile manipulation of any + // existing spans that may be present in the output text. + val paragraphSpans = output.getSpans(output.length) + var lastLeadingSpan: LeadingMarginSpan? = null + paragraphSpans.forEach { + if (it !is LeadingMarginSpan) { + output.removeSpan(it) + } else { + lastLeadingSpan = it + } + } + lastLeadingSpan?.let { span -> val spanStart = output.getSpanStart(span) output.removeSpan(span) output.insert(spanStart, "$count. ") @@ -263,6 +266,10 @@ class CustomHtmlParser(private val handler: TagHandler) : TagHandler, ContentHan .replace("‏", "\u200F") .replace("&", "&") + // Add tag in the or tags to make the text 3 times smaller + sourceStr = sourceStr.replace("", "").replace("", "") + .replace("", "").replace("", "") + // TODO: Investigate if it's necessary to inject a dummy tag at the beginning of the // text, since there are reports that XmlReader ignores the first tag by default? // This would become something like "$sourceStr".parseAsHtml(...) diff --git a/app/src/main/java/org/wikipedia/savedpages/SavedPageSyncService.kt b/app/src/main/java/org/wikipedia/savedpages/SavedPageSyncService.kt index 674c425b56b..a896f5a6922 100644 --- a/app/src/main/java/org/wikipedia/savedpages/SavedPageSyncService.kt +++ b/app/src/main/java/org/wikipedia/savedpages/SavedPageSyncService.kt @@ -1,24 +1,28 @@ package org.wikipedia.savedpages -import android.content.Intent -import androidx.core.app.JobIntentService -import androidx.core.os.postDelayed -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.schedulers.Schedulers +import android.content.Context +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.Request import okio.Buffer import okio.Sink import okio.Timeout import org.wikipedia.WikipediaApp +import org.wikipedia.concurrency.FlowEventBus import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.RestService import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.okhttp.HttpStatusException import org.wikipedia.dataclient.okhttp.OfflineCacheInterceptor -import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK -import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory.client +import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.events.PageDownloadEvent import org.wikipedia.gallery.MediaList @@ -33,268 +37,253 @@ import org.wikipedia.util.log.L import org.wikipedia.views.CircularProgressBar import retrofit2.Response import java.io.IOException +import java.util.concurrent.TimeUnit -class SavedPageSyncService : JobIntentService() { +class SavedPageSyncService(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { private val savedPageSyncNotification = SavedPageSyncNotification.instance - val app: WikipediaApp = WikipediaApp.instance - override fun onHandleWork(intent: Intent) { - if (ReadingListSyncAdapter.inProgress()) { - // Reading list sync was started in the meantime, so bail. - return - } - val pagesToSave = AppDatabase.instance.readingListPageDao().getAllPagesToBeForcedSave().toMutableList() - if ((!Prefs.isDownloadOnlyOverWiFiEnabled || DeviceUtil.isOnWiFi) && - Prefs.isDownloadingReadingListArticlesEnabled) { - pagesToSave.addAll(AppDatabase.instance.readingListPageDao().getAllPagesToBeSaved()) - } - val pagesToUnSave = AppDatabase.instance.readingListPageDao().getAllPagesToBeUnsaved() - val pagesToDelete = AppDatabase.instance.readingListPageDao().getAllPagesToBeDeleted() - var shouldSendSyncEvent = false - try { - for (page in pagesToDelete) { - deletePageContents(page) + override suspend fun doWork(): Result { + return withContext(Dispatchers.IO) { + if (ReadingListSyncAdapter.inProgress()) { + // Reading list sync was started in the meantime, so bail. + return@withContext Result.success() } - for (page in pagesToUnSave) { - deletePageContents(page) + val pagesToSave = AppDatabase.instance.readingListPageDao().getAllPagesToBeForcedSave().toMutableList() + if ((!Prefs.isDownloadOnlyOverWiFiEnabled || DeviceUtil.isOnWiFi) && + Prefs.isDownloadingReadingListArticlesEnabled) { + pagesToSave.addAll(AppDatabase.instance.readingListPageDao().getAllPagesToBeSaved()) } - } catch (e: Exception) { - L.e("Error while deleting page: " + e.message) - } finally { - if (pagesToDelete.isNotEmpty()) { - AppDatabase.instance.readingListPageDao().purgeDeletedPages() - shouldSendSyncEvent = true + val pagesToUnSave = AppDatabase.instance.readingListPageDao().getAllPagesToBeUnsaved() + val pagesToDelete = AppDatabase.instance.readingListPageDao().getAllPagesToBeDeleted() + var shouldSendSyncEvent = false + try { + AppDatabase.instance.offlineObjectDao().deleteObjectsForPageId(pagesToDelete.map { it.id }) + AppDatabase.instance.offlineObjectDao().deleteObjectsForPageId(pagesToUnSave.map { it.id }) + } catch (e: Exception) { + L.e("Error while deleting page: " + e.message) + } finally { + if (pagesToDelete.isNotEmpty()) { + AppDatabase.instance.readingListPageDao().purgeDeletedPages() + shouldSendSyncEvent = true + } + if (pagesToUnSave.isNotEmpty()) { + AppDatabase.instance.readingListPageDao().resetUnsavedPageStatus() + shouldSendSyncEvent = true + } } - if (pagesToUnSave.isNotEmpty()) { - AppDatabase.instance.readingListPageDao().resetUnsavedPageStatus() + val itemsTotal = pagesToSave.size + if (itemsTotal > 0) { shouldSendSyncEvent = true } - } - val itemsTotal = pagesToSave.size - if (itemsTotal > 0) { - shouldSendSyncEvent = true - } - var itemsSaved = 0 - try { - savePages(pagesToSave) - } finally { - if (savedPageSyncNotification.isSyncPaused()) { - savedPageSyncNotification.setNotificationPaused(applicationContext, itemsTotal, itemsSaved) - } else { - savedPageSyncNotification.cancelNotification(applicationContext) - savedPageSyncNotification.setSyncCanceled(false) - if (shouldSendSyncEvent) { - sendSyncEvent() + var itemsSaved = 0 + try { + savePages(pagesToSave) + } finally { + if (savedPageSyncNotification.isSyncPaused()) { + savedPageSyncNotification.setNotificationPaused(applicationContext, itemsTotal, itemsSaved) + } else { + savedPageSyncNotification.cancelNotification(applicationContext) + savedPageSyncNotification.setSyncCanceled(false) + if (shouldSendSyncEvent) { + sendSyncEvent() + } } - } - }.also { itemsSaved = it } - } - - private fun deletePageContents(page: ReadingListPage) { - Completable.fromAction { AppDatabase.instance.offlineObjectDao().deleteObjectsForPageId(page.id) }.subscribeOn(Schedulers.io()) - .subscribe({}) { obj -> L.e(obj) } + }.also { itemsSaved = it } + Result.success() + } } - private fun savePages(queue: MutableList): Int { - val itemsTotal = queue.size - var itemsSaved = 0 - while (queue.isNotEmpty()) { - - // Pick off the DB row that we'll be working on... - val page = queue.removeAt(0) - if (savedPageSyncNotification.isSyncPaused()) { - // Remaining transactions will be picked up again when the service is resumed. - break - } else if (savedPageSyncNotification.isSyncCanceled()) { - // Mark remaining pages as online-only! - queue.add(page) - AppDatabase.instance.readingListPageDao().markPagesForOffline(queue, offline = false, forcedSave = false) - break - } - savedPageSyncNotification.setNotificationProgress(applicationContext, itemsTotal, itemsSaved) - var success = false - var totalSize = 0L - try { - // Lengthy operation during which the db state may change... - totalSize = savePageFor(page) - success = true - } catch (e: InterruptedException) { - // fall through - } catch (e: Exception) { - // This can be an IOException from the storage media, or several types - // of network exceptions from malformed URLs, timeouts, etc. - L.e(e) + private suspend fun savePages(queue: MutableList): Int { + return withContext(Dispatchers.IO) { + val itemsTotal = queue.size + var itemsSaved = 0 + while (queue.isNotEmpty()) { - // If we're offline, or if there's a transient network error, then don't do - // anything. Otherwise... - if (!ThrowableUtil.isOffline(e) && !ThrowableUtil.isTimeout(e) && !ThrowableUtil.isNetworkError(e)) { - // If it's not a transient network error (e.g. a 404 status response), it implies - // that there's no way to fetch the page next time, or ever, therefore let's mark - // it as "successful" so that it won't be retried again. + // Pick off the DB row that we'll be working on... + val page = queue.removeAt(0) + if (savedPageSyncNotification.isSyncPaused()) { + // Remaining transactions will be picked up again when the service is resumed. + break + } else if (savedPageSyncNotification.isSyncCanceled()) { + // Mark remaining pages as online-only! + queue.add(page) + AppDatabase.instance.readingListPageDao().markPagesForOffline(queue, offline = false, forcedSave = false) + break + } + savedPageSyncNotification.setNotificationProgress(applicationContext, itemsTotal, itemsSaved) + var success = false + var totalSize = 0L + try { + // Lengthy operation during which the db state may change... + totalSize = savePageFor(page) success = true - if (e !is HttpStatusException) { - // And if it's something other than an HTTP status, let's log it and see what it is. - L.logRemoteError(e) + } catch (e: InterruptedException) { + // fall through + } catch (e: Exception) { + // This can be an IOException from the storage media, or several types + // of network exceptions from malformed URLs, timeouts, etc. + L.e(e) + + // If we're offline, or if there's a transient network error, then don't do + // anything. Otherwise... + if (!ThrowableUtil.isOffline(e) && !ThrowableUtil.isTimeout(e) && !ThrowableUtil.isNetworkError(e)) { + // If it's not a transient network error (e.g. a 404 status response), it implies + // that there's no way to fetch the page next time, or ever, therefore let's mark + // it as "successful" so that it won't be retried again. + success = true + if (e !is HttpStatusException) { + // And if it's something other than an HTTP status, let's log it and see what it is. + L.logRemoteError(e) + } } } + if (ReadingListSyncAdapter.inProgress()) { + // Reading list sync was started in the meantime, so bail. + break + } + if (success) { + page.status = ReadingListPage.STATUS_SAVED + page.sizeBytes = totalSize + AppDatabase.instance.readingListPageDao().updateReadingListPage(page) + itemsSaved++ + sendSyncEvent() + } } - if (ReadingListSyncAdapter.inProgress()) { - // Reading list sync was started in the meantime, so bail. - break - } - if (success) { - page.status = ReadingListPage.STATUS_SAVED - page.sizeBytes = totalSize - AppDatabase.instance.readingListPageDao().updateReadingListPage(page) - itemsSaved++ - sendSyncEvent() - } + itemsSaved } - return itemsSaved } @Throws(Exception::class) - private fun savePageFor(page: ReadingListPage): Long { - val pageTitle = ReadingListPage.toPageTitle(page) - var pageSize = 0L - var exception: Exception? = null - reqPageSummary(pageTitle) - .flatMap { rsp -> - val revision = if (rsp.body() != null) rsp.body()!!.revision else 0 - Observable.zip(Observable.just(rsp), - reqMediaList(pageTitle, revision), - reqMobileHTML(pageTitle)) { summaryRsp, mediaListRsp, mobileHTMLRsp -> - page.downloadProgress = MEDIA_LIST_PROGRESS - app.bus.post(PageDownloadEvent(page)) - val fileUrls = mutableSetOf() + private suspend fun savePageFor(page: ReadingListPage): Long { + return withContext(Dispatchers.IO) { + val pageTitle = ReadingListPage.toPageTitle(page) - // download css and javascript assets - mobileHTMLRsp.body?.let { - fileUrls.addAll(PageComponentsUrlParser.parse(it.string(), - pageTitle.wikiSite).filter { url -> url.isNotEmpty() }) - } - if (Prefs.isImageDownloadEnabled) { - // download thumbnail and lead image - if (!summaryRsp.body()!!.thumbnailUrl.isNullOrEmpty()) { - page.thumbUrl = UriUtil.resolveProtocolRelativeUrl(pageTitle.wikiSite, - summaryRsp.body()!!.thumbnailUrl!!) - persistPageThumbnail(pageTitle, page.thumbUrl!!) - fileUrls.add(UriUtil.resolveProtocolRelativeUrl( - ImageUrlUtil.getUrlForPreferredSize(page.thumbUrl!!, DimenUtil.calculateLeadImageWidth()))) - } + // API calls + val summaryResponse = reqPageSummary(pageTitle) + val revision = summaryResponse.body()?.revision ?: 0 + val mediaListResponse = reqMediaList(pageTitle, revision) + val mobileHTMLResponse = reqMobileHTML(pageTitle) - // download article images - for (item in mediaListRsp.body()!!.getItems("image")) { - item.srcSets.let { - fileUrls.add(item.getImageUrl(DimenUtil.densityScalar)) - } - } - } - page.displayTitle = summaryRsp.body()!!.displayTitle - page.description = summaryRsp.body()!!.description - reqSaveFiles(page, pageTitle, fileUrls) - val totalSize = AppDatabase.instance.offlineObjectDao().getTotalBytesForPageId(page.id) - L.i("Saved page " + pageTitle.prefixedText + " (" + totalSize + ")") - totalSize + page.downloadProgress = MEDIA_LIST_PROGRESS + FlowEventBus.post(PageDownloadEvent(page)) + + val fileUrls = mutableSetOf() + // download css and javascript assets + mobileHTMLResponse.body?.let { + fileUrls.addAll(PageComponentsUrlParser.parse(it.string(), + pageTitle.wikiSite).filter { url -> url.isNotEmpty() }) + } + if (Prefs.isImageDownloadEnabled) { + // download thumbnail and lead image + if (!summaryResponse.body()?.thumbnailUrl.isNullOrEmpty()) { + page.thumbUrl = UriUtil.resolveProtocolRelativeUrl(pageTitle.wikiSite, summaryResponse.body()?.thumbnailUrl.orEmpty()) + AppDatabase.instance.pageImagesDao().insertPageImageSync(PageImage(pageTitle, page.thumbUrl.orEmpty())) + fileUrls.add(UriUtil.resolveProtocolRelativeUrl( + ImageUrlUtil.getUrlForPreferredSize(page.thumbUrl.orEmpty(), DimenUtil.calculateLeadImageWidth()))) + } + + // download article images + for (item in mediaListResponse.body()?.getItems("image").orEmpty()) { + item.srcSets.let { + fileUrls.add(item.getImageUrl(DimenUtil.densityScalar)) } } - .subscribeOn(Schedulers.io()) - .blockingSubscribe({ size -> pageSize = size }) { t -> exception = t as Exception } + } + page.displayTitle = summaryResponse.body()?.displayTitle.orEmpty() + page.description = summaryResponse.body()?.description.orEmpty() - exception?.let { - throw it - } + reqSaveFiles(page, pageTitle, fileUrls) + + val totalSize = AppDatabase.instance.offlineObjectDao().getTotalBytesForPageId(page.id) + + L.i("Saved page " + pageTitle.prefixedText + " (" + totalSize + ")") - return pageSize + totalSize + } } - private fun reqPageSummary(pageTitle: PageTitle): Observable> { - return ServiceFactory.getRest(pageTitle.wikiSite).getSummaryResponse(pageTitle.prefixedText, - null, CACHE_CONTROL_FORCE_NETWORK.toString(), + private suspend fun reqPageSummary(pageTitle: PageTitle): Response { + return withContext(Dispatchers.IO) { + ServiceFactory.getRest(pageTitle.wikiSite).getSummaryResponse(pageTitle.prefixedText, + null, OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK.toString(), OfflineCacheInterceptor.SAVE_HEADER_SAVE, pageTitle.wikiSite.languageCode, UriUtil.encodeURL(pageTitle.prefixedText)) + } } - private fun reqMediaList(pageTitle: PageTitle, revision: Long): Observable> { - return ServiceFactory.getRest(pageTitle.wikiSite).getMediaListResponse(pageTitle.prefixedText, - revision, CACHE_CONTROL_FORCE_NETWORK.toString(), + private suspend fun reqMediaList(pageTitle: PageTitle, revision: Long): Response { + return withContext(Dispatchers.IO) { + ServiceFactory.getRest(pageTitle.wikiSite).getMediaListResponse(pageTitle.prefixedText, + revision, OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK.toString(), OfflineCacheInterceptor.SAVE_HEADER_SAVE, pageTitle.wikiSite.languageCode, UriUtil.encodeURL(pageTitle.prefixedText)) + } } - private fun reqMobileHTML(pageTitle: PageTitle): Observable { - val request: Request = makeUrlRequest(pageTitle.wikiSite, + private suspend fun reqMobileHTML(pageTitle: PageTitle): okhttp3.Response { + val request = makeUrlRequest(pageTitle.wikiSite, ServiceFactory.getRestBasePath(pageTitle.wikiSite) + RestService.PAGE_HTML_ENDPOINT + UriUtil.encodeURL(pageTitle.prefixedText), pageTitle).build() - return Observable.create { emitter -> - try { - if (!emitter.isDisposed) { - emitter.onNext(client.newCall(request).execute()) - emitter.onComplete() - } - } catch (e: Exception) { - if (!emitter.isDisposed) { - emitter.onError(e) - } - } + return withContext(Dispatchers.IO) { + OkHttpConnectionFactory.client.newCall(request).execute() } } @Throws(IOException::class, InterruptedException::class) - private fun reqSaveFiles(page: ReadingListPage, pageTitle: PageTitle, urls: Set) { + private suspend fun reqSaveFiles(page: ReadingListPage, pageTitle: PageTitle, urls: Set) { val numOfImages = urls.size var percentage = MEDIA_LIST_PROGRESS.toFloat() val updateRate = (CircularProgressBar.MAX_PROGRESS - percentage) / numOfImages - for (url in urls) { - if (savedPageSyncNotification.isSyncPaused() || savedPageSyncNotification.isSyncCanceled()) { - throw InterruptedException("Sync paused or cancelled.") - } - try { - reqSaveUrl(pageTitle, page.wiki, url) - percentage += updateRate - page.downloadProgress = percentage.toInt() - app.bus.post(PageDownloadEvent(page)) - } catch (e: Exception) { - if (isRetryable(e)) { - throw e + + withContext(Dispatchers.IO) { + for (url in urls) { + if (savedPageSyncNotification.isSyncPaused() || savedPageSyncNotification.isSyncCanceled()) { + throw InterruptedException("Sync paused or cancelled.") + } + try { + reqSaveUrl(pageTitle, page.wiki, url) + percentage += updateRate + page.downloadProgress = percentage.toInt() + FlowEventBus.post(PageDownloadEvent(page)) + } catch (e: Exception) { + if (isRetryable(e)) { + throw e + } } } + page.downloadProgress = CircularProgressBar.MAX_PROGRESS + FlowEventBus.post(PageDownloadEvent(page)) } - page.downloadProgress = CircularProgressBar.MAX_PROGRESS - app.bus.post(PageDownloadEvent(page)) } @Throws(IOException::class) - private fun reqSaveUrl(pageTitle: PageTitle, wiki: WikiSite, url: String) { + private suspend fun reqSaveUrl(pageTitle: PageTitle, wiki: WikiSite, url: String) { val request = makeUrlRequest(wiki, url, pageTitle).build() - val rsp = client.newCall(request).execute() + withContext(Dispatchers.IO) { + OkHttpConnectionFactory.client.newCall(request).execute().use { response -> + // Read the entirety of the response, so that it's written to cache by the interceptor. + response.body?.source()?.readAll(object : Sink { + override fun write(source: Buffer, byteCount: Long) {} + override fun flush() {} + override fun timeout(): Timeout { + return Timeout() + } - // Read the entirety of the response, so that it's written to cache by the interceptor. - rsp.body!!.source().readAll(object : Sink { - override fun write(source: Buffer, byteCount: Long) {} - override fun flush() {} - override fun timeout(): Timeout { - return Timeout() + override fun close() {} + }) } - - override fun close() {} - }) - rsp.body!!.close() + } } private fun makeUrlRequest(wiki: WikiSite, url: String, pageTitle: PageTitle): Request.Builder { - return Request.Builder().cacheControl(CACHE_CONTROL_FORCE_NETWORK).url(UriUtil.resolveProtocolRelativeUrl(wiki, url)) - .addHeader("Accept-Language", app.getAcceptLanguage(pageTitle.wikiSite)) + return Request.Builder().cacheControl(OkHttpConnectionFactory.CACHE_CONTROL_FORCE_NETWORK).url(UriUtil.resolveProtocolRelativeUrl(wiki, url)) + .addHeader("Accept-Language", WikipediaApp.instance.getAcceptLanguage(pageTitle.wikiSite)) .addHeader(OfflineCacheInterceptor.SAVE_HEADER, OfflineCacheInterceptor.SAVE_HEADER_SAVE) .addHeader(OfflineCacheInterceptor.LANG_HEADER, pageTitle.wikiSite.languageCode) .addHeader(OfflineCacheInterceptor.TITLE_HEADER, UriUtil.encodeURL(pageTitle.prefixedText)) } - private fun persistPageThumbnail(title: PageTitle, url: String) { - AppDatabase.instance.pageImagesDao().insertPageImage(PageImage(title, url)) - } - private fun isRetryable(t: Throwable): Boolean { // "Retryable" in this case refers to exceptions that will be rethrown up to the // outer exception handler, so that the entire page can be retried on the next pass @@ -307,28 +296,29 @@ class SavedPageSyncService : JobIntentService() { } companion object { - // Unique job ID for this service (do not duplicate). - private const val JOB_ID = 1000 - private const val ENQUEUE_DELAY_MILLIS = 2000L - private const val TOKEN = "syncSavedPages" - const val SUMMARY_PROGRESS = 10 + private const val WORK_NAME = "savePageSyncService" + private const val ENQUEUE_DELAY = 2L const val MEDIA_LIST_PROGRESS = 30 fun enqueue() { if (ReadingListSyncAdapter.inProgress()) { return } - WikipediaApp.instance.mainThreadHandler.removeCallbacksAndMessages(TOKEN) - WikipediaApp.instance.mainThreadHandler.postDelayed(ENQUEUE_DELAY_MILLIS, TOKEN) { - enqueueWork(WikipediaApp.instance, SavedPageSyncService::class.java, JOB_ID, - Intent(WikipediaApp.instance, SavedPageSyncService::class.java)) - } + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() + val workRequest = OneTimeWorkRequestBuilder() + .setInitialDelay(ENQUEUE_DELAY, TimeUnit.SECONDS) + .setConstraints(constraints) + .build() + WorkManager.getInstance(WikipediaApp.instance) + .enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, workRequest) } fun sendSyncEvent(showMessage: Boolean = false) { // Note: this method posts from a background thread but subscribers expect events to be // received on the main thread. - WikipediaApp.instance.bus.post(ReadingListSyncEvent(showMessage)) + FlowEventBus.post(ReadingListSyncEvent(showMessage)) } } } diff --git a/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt b/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt index 520ac58200f..7838659a91a 100644 --- a/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt +++ b/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt @@ -1,20 +1,25 @@ package org.wikipedia.search +import android.graphics.Typeface import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.eventplatform.RabbitHolesEvent import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentSearchRecentBinding import org.wikipedia.dataclient.ServiceFactory @@ -36,12 +41,13 @@ 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) } var callback: Callback? = null val recentSearchList = mutableListOf() + private var suggestedSearchTerm: String? = null override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = FragmentSearchRecentBinding.inflate(inflater, container, false) @@ -61,12 +67,19 @@ class RecentSearchesFragment : Fragment() { binding.addLanguagesButton.setOnClickListener { onAddLangButtonClick() } binding.recentSearchesRecycler.layoutManager = LinearLayoutManager(requireActivity()) binding.namespacesRecycler.layoutManager = LinearLayoutManager(requireActivity(), RecyclerView.HORIZONTAL, false) - binding.recentSearchesRecycler.adapter = RecentSearchAdapter() + + suggestedSearchTerm = requireActivity().intent.getStringExtra(SearchActivity.EXTRA_SUGGESTED_QUERY) + binding.recentSearchesRecycler.adapter = if (suggestedSearchTerm.isNullOrEmpty()) RecentSearchAdapter() else ConcatAdapter(SuggestedSearchAdapter(), RecentSearchAdapter()) + binding.recentSearchesHeaderContainer.isVisible = suggestedSearchTerm.isNullOrEmpty() + val touchCallback = SwipeableItemTouchHelperCallback(requireContext()) touchCallback.swipeableEnabled = true val itemTouchHelper = ItemTouchHelper(touchCallback) itemTouchHelper.attachToRecyclerView(binding.recentSearchesRecycler) setButtonTooltip(binding.recentSearchesDeleteButton) + + RabbitHolesEvent.submit("impression", "search") + return binding.root } @@ -102,14 +115,13 @@ class RecentSearchesFragment : Fragment() { callback?.onAddLanguageClicked() } - fun onLangCodeChanged() { + fun reloadRecentSearches() { lifecycleScope.launch(coroutineExceptionHandler) { updateList() } } suspend fun updateList() { - val searches: List val nsMap: Map val langCode = callback?.getLangCode().orEmpty() @@ -122,30 +134,38 @@ class RecentSearchesFragment : Fragment() { } } - searches = AppDatabase.instance.recentSearchDao().getRecentSearches() + val searches: List = AppDatabase.instance.recentSearchDao().getRecentSearches() recentSearchList.clear() - recentSearchList.addAll(searches) + recentSearchList.addAll(if (suggestedSearchTerm.isNullOrEmpty()) searches else searches.filter { it.text != suggestedSearchTerm }) + val searchesEmpty = recentSearchList.isEmpty() && suggestedSearchTerm.isNullOrEmpty() binding.namespacesRecycler.adapter = NamespaceAdapter() - binding.recentSearchesRecycler.adapter?.notifyDataSetChanged() - - val searchesEmpty = recentSearchList.isEmpty() + if (binding.recentSearchesRecycler.adapter is ConcatAdapter) { + (binding.recentSearchesRecycler.adapter as ConcatAdapter).adapters.forEach { + it.notifyDataSetChanged() + } + } else { + binding.recentSearchesRecycler.adapter?.notifyDataSetChanged() + } binding.searchEmptyContainer.isInvisible = !searchesEmpty updateSearchEmptyView(searchesEmpty) binding.recentSearches.isInvisible = searchesEmpty } - private inner class RecentSearchItemViewHolder constructor(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener, SwipeableItemTouchHelperCallback.Callback { + private open inner class RecentSearchItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener, SwipeableItemTouchHelperCallback.Callback { private lateinit var recentSearch: RecentSearch - fun bindItem(position: Int) { + open fun bindItem(position: Int) { recentSearch = recentSearchList[position] itemView.setOnClickListener(this) (itemView as TextView).text = recentSearch.text } override fun onClick(v: View) { + RabbitHolesEvent.submit(if (this is SuggestedSearchItemViewHolder) "suggestion_click" else "recent_search_click", "search", + source = if (this is SuggestedSearchItemViewHolder) "suggested" else "default") + callback?.switchToSearch((v as TextView).text.toString()) } @@ -159,6 +179,57 @@ class RecentSearchesFragment : Fragment() { override fun isSwipeable(): Boolean { return true } } + private inner class SuggestedSearchItemViewHolder(itemView: View) : RecentSearchItemViewHolder(itemView) { + override fun bindItem(position: Int) { + (itemView as TextView).apply { + setOnClickListener(this@SuggestedSearchItemViewHolder) + setBackgroundResource(R.drawable.shape_suggested_search_term) + setTypeface(null, Typeface.NORMAL) + text = suggestedSearchTerm + } + } + + override fun isSwipeable(): Boolean { return false } + } + + private inner class SuggestedSearchHeadingViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { + fun bindItem(position: Int) { + (itemView as TextView).apply { + setBackgroundColor(ResourceUtil.getThemedColor(requireContext(), R.attr.paper_color)) + setTypeface(null, Typeface.BOLD) + text = if (position == 0) getString(R.string.recent_searches_related_to_reading) else getString(R.string.recent_searches_title) + } + } + } + + private inner class SuggestedSearchAdapter : RecyclerView.Adapter() { + override fun getItemCount(): Int { + // 1) "Suggested search" heading, 2) Suggested query, 3) "Recent searches" heading + return if (recentSearchList.isEmpty()) 2 else 3 + } + + override fun getItemViewType(position: Int): Int { + return if (position == 1) LIST_TYPE_ITEM else LIST_TYPE_HEADING + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { + val view = LayoutInflater.from(parent.context).inflate(R.layout.item_search_recent, parent, false) + return if (viewType == LIST_TYPE_HEADING) { + SuggestedSearchHeadingViewHolder(view) + } else { + SuggestedSearchItemViewHolder(view) + } + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, pos: Int) { + if (holder is SuggestedSearchItemViewHolder) { + holder.bindItem(pos) + } else if (holder is SuggestedSearchHeadingViewHolder) { + holder.bindItem(pos) + } + } + } + private inner class RecentSearchAdapter : RecyclerView.Adapter() { override fun getItemCount(): Int { return recentSearchList.size @@ -173,13 +244,15 @@ class RecentSearchesFragment : Fragment() { } } - private inner class NamespaceItemViewHolder constructor(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener { + private inner class NamespaceItemViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener { fun bindItem(ns: Namespace?) { val isHeader = ns == null - itemView.setOnClickListener(this) - (itemView as TextView).text = if (isHeader) getString(R.string.search_namespaces) else namespaceMap[callback?.getLangCode()]?.get(ns).orEmpty() + ":" - itemView.isEnabled = !isHeader - itemView.setTextColor(ResourceUtil.getThemedColor(requireContext(), if (isHeader) R.attr.primary_color else R.attr.progressive_color)) + (itemView as TextView).apply { + setOnClickListener(this@NamespaceItemViewHolder) + text = if (isHeader) getString(R.string.search_namespaces) else namespaceMap[callback?.getLangCode()]?.get(ns).orEmpty() + ":" + isEnabled = !isHeader + setTextColor(ResourceUtil.getThemedColor(requireContext(), if (isHeader) R.attr.primary_color else R.attr.progressive_color)) + } } override fun onClick(v: View) { @@ -200,4 +273,9 @@ class RecentSearchesFragment : Fragment() { holder.bindItem(namespaceHints.getOrNull(pos - 1)) } } + + companion object { + const val LIST_TYPE_HEADING = 0 + const val LIST_TYPE_ITEM = 1 + } } diff --git a/app/src/main/java/org/wikipedia/search/SearchActivity.kt b/app/src/main/java/org/wikipedia/search/SearchActivity.kt index 6aefb377653..e6c34dc32d0 100644 --- a/app/src/main/java/org/wikipedia/search/SearchActivity.kt +++ b/app/src/main/java/org/wikipedia/search/SearchActivity.kt @@ -27,13 +27,15 @@ class SearchActivity : SingleFragmentActivity() { const val QUERY_EXTRA = "query" const val EXTRA_RETURN_LINK = "returnLink" const val EXTRA_RETURN_LINK_TITLE = "returnLinkTitle" + const val EXTRA_SUGGESTED_QUERY = "suggestedQuery" const val RESULT_LINK_SUCCESS = 1 - fun newIntent(context: Context, source: InvokeSource, query: String?, returnLink: Boolean = false): Intent { + fun newIntent(context: Context, source: InvokeSource, query: String?, returnLink: Boolean = false, suggestedSearchQuery: String? = null): Intent { return Intent(context, SearchActivity::class.java) .putExtra(Constants.INTENT_EXTRA_INVOKE_SOURCE, source) .putExtra(QUERY_EXTRA, query) .putExtra(EXTRA_RETURN_LINK, returnLink) + .putExtra(EXTRA_SUGGESTED_QUERY, suggestedSearchQuery) } } } diff --git a/app/src/main/java/org/wikipedia/search/SearchFragment.kt b/app/src/main/java/org/wikipedia/search/SearchFragment.kt index 9c385d286b0..26112e318ef 100644 --- a/app/src/main/java/org/wikipedia/search/SearchFragment.kt +++ b/app/src/main/java/org/wikipedia/search/SearchFragment.kt @@ -9,10 +9,12 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.TextView 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 @@ -22,6 +24,7 @@ import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.PlacesEvent +import org.wikipedia.analytics.eventplatform.RabbitHolesEvent import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentSearchBinding import org.wikipedia.history.HistoryEntry @@ -48,6 +51,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche private var langBtnClicked = false private var isSearchActive = false private var query: String? = null + private var suggestedSearchQuery: String? = null private var returnLink = false private lateinit var recentSearchesFragment: RecentSearchesFragment private lateinit var searchResultsFragment: SearchResultsFragment @@ -89,6 +93,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche } } Prefs.selectedLanguagePositionInSearch = position + setUpLanguageScroll(Prefs.selectedLanguagePositionInSearch) } } @@ -100,6 +105,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche invokeSource = requireArguments().getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as InvokeSource query = requireArguments().getString(ARG_QUERY) returnLink = requireArguments().getBoolean(SearchActivity.EXTRA_RETURN_LINK, false) + suggestedSearchQuery = requireActivity().intent.getStringExtra(SearchActivity.EXTRA_SUGGESTED_QUERY) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { @@ -118,6 +124,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche binding.searchLangButton.setOnClickListener { onLangButtonClick() } initSearchView() if (invokeSource == InvokeSource.PLACES) { + Prefs.selectedLanguagePositionInSearch = app.languageState.appLanguageCodes.indexOf(Prefs.placesWikiCode) PlacesEvent.logImpression("search_view") } return binding.root @@ -128,6 +135,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) } @@ -157,23 +165,10 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche binding.searchLanguageScrollView.setUpLanguageScrollTabData(app.languageState.appLanguageCodes, pos, this) binding.searchLangButton.visibility = View.GONE } else { - maybeShowMultilingualSearchTooltip() binding.searchLanguageScrollViewContainer.visibility = View.GONE binding.searchLangButton.visibility = View.VISIBLE initLangButton() - recentSearchesFragment.onLangCodeChanged() - } - } - - private fun maybeShowMultilingualSearchTooltip() { - if (Prefs.isMultilingualSearchTooltipShown) { - binding.searchLangButton.postDelayed({ - if (isAdded) { - FeedbackUtil.showTooltip(requireActivity(), binding.searchLangButton, getString(R.string.tool_tip_lang_button), - aboveOrBelow = false, autoDismiss = false) - } - }, 500) - Prefs.isMultilingualSearchTooltipShown = false + recentSearchesFragment.reloadRecentSearches() } } @@ -214,7 +209,11 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche requireActivity().setResult(SearchActivity.RESULT_LINK_SUCCESS, intent) requireActivity().finish() } else { - val historyEntry = HistoryEntry(item, HistoryEntry.SOURCE_SEARCH) + + RabbitHolesEvent.submit("navigate", "search", + source = if (query == suggestedSearchQuery) "suggested" else "default") + + val historyEntry = HistoryEntry(item, if (query == suggestedSearchQuery) HistoryEntry.SOURCE_RABBIT_HOLE_SEARCH else HistoryEntry.SOURCE_SEARCH) startActivity(if (inNewTab) PageActivity.newIntentForNewTab(requireContext(), historyEntry, historyEntry.title) else PageActivity.newIntentForCurrentTab(requireContext(), historyEntry, historyEntry.title, false)) } @@ -274,7 +273,9 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche // automatically trigger the showing of the corresponding search results. if (!query.isNullOrBlank()) { binding.searchCabView.setQuery(query, false) - binding.searchCabView.selectAllQueryTexts() + if (suggestedSearchQuery.isNullOrEmpty()) { + binding.searchCabView.selectAllQueryTexts() + } } } @@ -311,7 +312,20 @@ 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) + + binding.searchCabView.queryHint = if (suggestedSearchQuery.isNullOrEmpty()) + getString(if (invokeSource == InvokeSource.PLACES) R.string.places_search_hint + else R.string.search_hint) else suggestedSearchQuery + + binding.searchCabView.setOnEditorActionListener(TextView.OnEditorActionListener { _, _, _ -> + if (suggestedSearchQuery.isNullOrEmpty()) { + false + } else { + query = suggestedSearchQuery + openSearch() + true + } + }) // remove focus line from search plate val searchEditPlate = binding.searchCabView @@ -345,7 +359,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche } searchLanguageCode = selectedLanguageCode searchResultsFragment.setLayoutDirection(searchLanguageCode) - recentSearchesFragment.onLangCodeChanged() + recentSearchesFragment.reloadRecentSearches() startSearch(query, false) } @@ -359,9 +373,6 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche private const val PANEL_SEARCH_RESULTS = 1 private const val INTENT_DELAY_MILLIS = 500L const val RESULT_LANG_CHANGED = 1 - const val LANG_BUTTON_TEXT_SIZE_LARGER = 12 - const val LANG_BUTTON_TEXT_SIZE_MEDIUM = 10 - const val LANG_BUTTON_TEXT_SIZE_SMALLER = 8 fun newInstance(source: InvokeSource, query: String?, returnLink: Boolean = false): SearchFragment = SearchFragment().apply { diff --git a/app/src/main/java/org/wikipedia/search/SearchResultsFragment.kt b/app/src/main/java/org/wikipedia/search/SearchResultsFragment.kt index 472890c2064..e11650d497f 100644 --- a/app/src/main/java/org/wikipedia/search/SearchResultsFragment.kt +++ b/app/src/main/java/org/wikipedia/search/SearchResultsFragment.kt @@ -11,7 +11,6 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState -import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.ConcatAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.LinearLayoutManager @@ -23,6 +22,7 @@ import org.wikipedia.LongPressHandler import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.FragmentUtil.getCallback +import org.wikipedia.adapter.PagingDataAdapterPatched import org.wikipedia.analytics.eventplatform.PlacesEvent import org.wikipedia.databinding.FragmentSearchResultsBinding import org.wikipedia.databinding.ItemSearchNoResultsBinding @@ -68,7 +68,7 @@ class SearchResultsFragment : Fragment() { launch { viewModel.searchResultsFlow.collectLatest { binding.searchResultsList.visibility = View.VISIBLE - searchResultsAdapter.submitData(it) + searchResultsAdapter.submitData(lifecycleScope, it) } } launch { @@ -128,7 +128,6 @@ class SearchResultsFragment : Fragment() { binding.searchResultsList.visibility = View.GONE binding.searchErrorView.visibility = View.GONE binding.searchErrorView.visibility = View.GONE - viewModel.clearResults() } private inner class SearchResultsFragmentLongPressHandler(private val lastPositionRequested: Int) : LongPressMenu.Callback { @@ -161,45 +160,48 @@ class SearchResultsFragment : Fragment() { } } - private inner class SearchResultsAdapter : PagingDataAdapter>(SearchResultsDiffCallback()) { + private inner class SearchResultsAdapter : PagingDataAdapterPatched>(SearchResultsDiffCallback()) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DefaultViewHolder { return SearchResultItemViewHolder(ItemSearchResultBinding.inflate(layoutInflater, parent, false)) } override fun onBindViewHolder(holder: DefaultViewHolder, pos: Int) { - getItem(pos)?.let { - (holder as SearchResultItemViewHolder).bindItem(pos, it) + if (pos in 0..() { override fun onBindViewHolder(holder: NoSearchResultItemViewHolder, position: Int) { - holder.bindItem(position, viewModel.resultsCount[position]) + holder.bindItem(viewModel.countsPerLanguageCode[position]) } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoSearchResultItemViewHolder { return NoSearchResultItemViewHolder(ItemSearchNoResultsBinding.inflate(layoutInflater, parent, false)) } - override fun getItemCount(): Int { return viewModel.resultsCount.size } + override fun getItemCount(): Int { return viewModel.countsPerLanguageCode.size } } private inner class NoSearchResultItemViewHolder(val itemBinding: ItemSearchNoResultsBinding) : DefaultViewHolder(itemBinding.root) { 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) { + fun bindItem(resultPair: Pair) { + val langCode = resultPair.first + val resultCount = resultPair.second + if (resultCount == 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) - itemBinding.languageCode.visibility = if (viewModel.resultsCount.size == 1) View.GONE else View.VISIBLE + itemBinding.resultsText.text = if (resultCount == 0) getString(R.string.search_results_count_zero) else resources.getQuantityString(R.plurals.search_results_count, resultCount, resultCount) + itemBinding.resultsText.setTextColor(if (resultCount == 0) secondaryColorStateList else accentColorStateList) + itemBinding.languageCode.visibility = if (viewModel.countsPerLanguageCode.size == 1) View.GONE else View.VISIBLE itemBinding.languageCode.setLangCode(langCode) - itemBinding.languageCode.setTextColor(if (resultsCount == 0) secondaryColorStateList else accentColorStateList) - itemBinding.languageCode.setBackgroundTint(if (resultsCount == 0) secondaryColorStateList else accentColorStateList) - view.isEnabled = resultsCount > 0 + itemBinding.languageCode.setTextColor(if (resultCount == 0) secondaryColorStateList else accentColorStateList) + itemBinding.languageCode.setBackgroundTint(if (resultCount == 0) secondaryColorStateList else accentColorStateList) + view.isEnabled = resultCount > 0 view.setOnClickListener { if (!isAdded) { return@setOnClickListener diff --git a/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt b/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt index 10dccdd32ed..a035a1e0e1e 100644 --- a/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt +++ b/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt @@ -7,14 +7,12 @@ 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.CancellationException 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 @@ -28,34 +26,20 @@ class SearchResultsViewModel : ViewModel() { private val batchSize = 20 private val delayMillis = 200L - private val totalResults = mutableListOf() - var resultsCount = mutableListOf() + var countsPerLanguageCode = 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, invokeSource) - }.flow.debounce(delayMillis).map { pagingData -> - pagingData.filter { searchResult -> - totalResults.find { it.pageTitle.prefixedText == searchResult.pageTitle.prefixedText } == null - }.map { - totalResults.add(it) - it - } - }.cachedIn(viewModelScope) - - fun clearResults() { - resultsCount.clear() - totalResults.clear() - } + SearchResultsPagingSource(searchTerm, languageCode, countsPerLanguageCode, invokeSource) + }.flow.debounce(delayMillis).cachedIn(viewModelScope) class SearchResultsPagingSource( private val searchTerm: String?, private val languageCode: String?, - private var resultsCount: MutableList?, - private var totalResults: MutableList?, + private var countsPerLanguageCode: MutableList>, private var invokeSource: Constants.InvokeSource ) : PagingSource() { @@ -72,7 +56,7 @@ class SearchResultsViewModel : ViewModel() { var response: MwQueryResponse? = null val resultList = mutableListOf() if (prefixSearch) { - if (searchTerm.length > 2 && invokeSource != Constants.InvokeSource.PLACES) { + if (searchTerm.length >= 2 && invokeSource != Constants.InvokeSource.PLACES) { withContext(Dispatchers.IO) { listOf(async { getSearchResultsFromTabs(searchTerm) @@ -109,14 +93,12 @@ class SearchResultsViewModel : ViewModel() { } if (resultList.isEmpty() && response?.continuation == null) { - resultsCount?.clear() + countsPerLanguageCode.clear() WikipediaApp.instance.languageState.appLanguageCodes.forEach { langCode -> - if (langCode == languageCode) { - resultsCount?.add(0) - } else { + var countResultSize = 0 + if (langCode != languageCode) { val prefixSearchResponse = ServiceFactory.get(WikiSite.forLanguageCode(langCode)) .prefixSearch(searchTerm, params.loadSize, 0) - var countResultSize = 0 prefixSearchResponse.query?.pages?.let { countResultSize = it.size } @@ -125,16 +107,14 @@ class SearchResultsViewModel : ViewModel() { .fullTextSearch(searchTerm, params.loadSize, null) countResultSize = fullTextSearchResponse.query?.pages?.size ?: 0 } - resultsCount?.add(countResultSize) } - } - // make a singleton list if all results are empty. - if (resultsCount?.sum() == 0) { - resultsCount = mutableListOf(0) + countsPerLanguageCode.add(langCode to countResultSize) } } return LoadResult.Page(resultList.distinctBy { it.pageTitle.prefixedText }, null, continuation) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { LoadResult.Error(e) } @@ -142,17 +122,14 @@ class SearchResultsViewModel : ViewModel() { override fun getRefreshKey(state: PagingState): Int? { prefixSearch = true - totalResults?.clear() return null } private fun getSearchResultsFromTabs(searchTerm: String): SearchResults { - if (searchTerm.length >= 2) { - WikipediaApp.instance.tabList.forEach { tab -> - tab.backStackPositionTitle?.let { - if (StringUtil.fromHtml(it.displayText).contains(searchTerm, true)) { - return SearchResults(mutableListOf(SearchResult(it, SearchResult.SearchResultType.TAB_LIST))) - } + WikipediaApp.instance.tabList.forEach { tab -> + tab.backStackPositionTitle?.let { + if (StringUtil.fromHtml(it.displayText).contains(searchTerm, true)) { + return SearchResults(mutableListOf(SearchResult(it, SearchResult.SearchResultType.TAB_LIST))) } } } diff --git a/app/src/main/java/org/wikipedia/settings/AboutActivity.kt b/app/src/main/java/org/wikipedia/settings/AboutActivity.kt index 821d2d42174..f2334efdb3f 100644 --- a/app/src/main/java/org/wikipedia/settings/AboutActivity.kt +++ b/app/src/main/java/org/wikipedia/settings/AboutActivity.kt @@ -31,9 +31,6 @@ class AboutActivity : BaseActivity() { binding.aboutContainer.descendants.filterIsInstance().forEach { it.movementMethod = LinkMovementMethodCompat.getInstance() } - binding.sendFeedbackText.setOnClickListener { - FeedbackUtil.composeFeedbackEmail(this, "Android App ${BuildConfig.VERSION_NAME} Feedback") - } } private class AboutLogoClickListener : View.OnClickListener { diff --git a/app/src/main/java/org/wikipedia/settings/AppIconDialog.kt b/app/src/main/java/org/wikipedia/settings/AppIconDialog.kt new file mode 100644 index 00000000000..b2b35b64838 --- /dev/null +++ b/app/src/main/java/org/wikipedia/settings/AppIconDialog.kt @@ -0,0 +1,147 @@ +package org.wikipedia.settings + +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.content.ContextCompat +import androidx.recyclerview.widget.RecyclerView +import com.google.android.flexbox.AlignItems +import com.google.android.flexbox.FlexDirection +import com.google.android.flexbox.FlexboxLayoutManager +import com.google.android.flexbox.JustifyContent +import org.wikipedia.LauncherController +import org.wikipedia.LauncherIcon +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.eventplatform.ContributionsDashboardEvent +import org.wikipedia.appshortcuts.AppShortcuts +import org.wikipedia.databinding.DialogAppIconBinding +import org.wikipedia.databinding.ItemAppIconBinding +import org.wikipedia.page.ExtendedBottomSheetDialogFragment +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ResourceUtil + +class AppIconDialog : ExtendedBottomSheetDialogFragment() { + private var _binding: DialogAppIconBinding? = null + private val binding get() = _binding!! + + private val appIconAdapter: AppIconAdapter by lazy { + AppIconAdapter().apply { + onItemClickListener { selectedIcon -> + if (selectedIcon == LauncherIcon.DEFAULT) { + ContributionsDashboardEvent.logAction("default_icon_select", "contrib_icon_set") + } else { + ContributionsDashboardEvent.logAction("contrib_icon_select", "contrib_icon_set") + } + Prefs.currentSelectedAppIcon = selectedIcon.key + LauncherController.setIcon(selectedIcon) + AppShortcuts.setShortcuts(requireContext()) + updateIcons(selectedIcon) + dismiss() + FeedbackUtil.makeSnackbar(requireActivity(), + WikipediaApp.instance.getString(R.string.contributions_dashboard_app_icon_updated)).show() + } + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _binding = DialogAppIconBinding.inflate(inflater, container, false) + return binding.root + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + ContributionsDashboardEvent.logAction("impression", "contrib_icon_set") + setupRecyclerView() + } + + private fun setupRecyclerView() { + val layoutManager = FlexboxLayoutManager(requireContext()).apply { + flexDirection = FlexDirection.ROW + justifyContent = JustifyContent.CENTER + alignItems = AlignItems.CENTER + } + binding.appIconRecyclerView.apply { + this.layoutManager = layoutManager + adapter = appIconAdapter + } + appIconAdapter.updateItems(LauncherIcon.initialValues()) + } + + private fun updateIcons(selectedIcon: LauncherIcon) { + val currentSelectedIcon = if (Prefs.currentSelectedAppIcon != null) Prefs.currentSelectedAppIcon + else selectedIcon.key + + LauncherIcon.entries.forEach { + it.isSelected = it.key == currentSelectedIcon + } + appIconAdapter.updateItems(LauncherIcon.entries) + } + + private class AppIconAdapter : RecyclerView.Adapter() { + private var list = mutableListOf() + private var onItemClickListener: ((LauncherIcon) -> Unit)? = null + + fun onItemClickListener(onItemClickListener: (LauncherIcon) -> Unit) { + this.onItemClickListener = onItemClickListener + } + + fun updateItems(newList: List) { + list.clear() + list.addAll(newList) + notifyItemRangeChanged(0, list.size) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppIconViewHolder { + val view = ItemAppIconBinding.inflate(LayoutInflater.from(parent.context), parent, false) + return AppIconViewHolder(view) + } + + override fun getItemCount(): Int = list.size + + override fun onBindViewHolder(holder: AppIconViewHolder, position: Int) { + val item = list[position] + holder.bind(item) + } + + private inner class AppIconViewHolder(val binding: ItemAppIconBinding) : RecyclerView.ViewHolder(binding.root) { + fun bind(item: LauncherIcon) { + binding.appIcon.apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + foreground = ContextCompat.getDrawable(binding.root.context, item.foreground) + } else { + setImageDrawable(ContextCompat.getDrawable(binding.root.context, item.foreground)) + } + background = ContextCompat.getDrawable(binding.root.context, item.background) + setOnClickListener { + onItemClickListener?.invoke(item) + } + val strokeColor = if (item.isSelected) { + R.attr.progressive_color + } else R.attr.border_color + val newStrokeWidth = if (item.isSelected) 2f else 1f + this.strokeColor = ResourceUtil.getThemedColorStateList(context, strokeColor) + this.strokeWidth = DimenUtil.dpToPx(newStrokeWidth) + } + } + } + } + + override fun onDestroyView() { + super.onDestroyView() + _binding = null + } + + companion object { + fun newInstance(): AppIconDialog { + return AppIconDialog() + } + } +} diff --git a/app/src/main/java/org/wikipedia/settings/BasePreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/BasePreferenceLoader.kt index a84557430b6..9b425684f49 100644 --- a/app/src/main/java/org/wikipedia/settings/BasePreferenceLoader.kt +++ b/app/src/main/java/org/wikipedia/settings/BasePreferenceLoader.kt @@ -6,15 +6,15 @@ import androidx.annotation.XmlRes import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat -internal abstract class BasePreferenceLoader(private val preferenceHost: PreferenceFragmentCompat) : PreferenceLoader { +internal abstract class BasePreferenceLoader(protected val fragment: PreferenceFragmentCompat) : PreferenceLoader { fun findPreference(@StringRes key: Int): Preference { - return preferenceHost.findPreference((activity.getString((key))))!! + return fragment.findPreference((activity.getString((key))))!! } protected fun loadPreferences(@XmlRes id: Int) { - preferenceHost.addPreferencesFromResource(id) + fragment.addPreferencesFromResource(id) } protected val activity: Activity - get() = preferenceHost.requireActivity() + get() = fragment.requireActivity() } diff --git a/app/src/main/java/org/wikipedia/settings/DeveloperSettingsPreferenceLoader.kt b/app/src/main/java/org/wikipedia/settings/DeveloperSettingsPreferenceLoader.kt index 5340221cd03..df7cf876199 100644 --- a/app/src/main/java/org/wikipedia/settings/DeveloperSettingsPreferenceLoader.kt +++ b/app/src/main/java/org/wikipedia/settings/DeveloperSettingsPreferenceLoader.kt @@ -1,11 +1,12 @@ package org.wikipedia.settings import android.content.DialogInterface +import android.widget.Toast +import androidx.lifecycle.lifecycleScope import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -14,7 +15,6 @@ import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.UserContributionEvent import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.WikiSite -import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.history.HistoryEntry import org.wikipedia.notifications.NotificationPollBroadcastReceiver import org.wikipedia.page.PageActivity @@ -75,50 +75,44 @@ internal class DeveloperSettingsPreferenceLoader(fragment: PreferenceFragmentCom true } findPreference(R.string.preference_key_missing_description_test).onPreferenceClickListener = Preference.OnPreferenceClickListener { - EditingSuggestionsProvider.getNextArticleWithMissingDescription(WikipediaApp.instance.wikiSite, 10) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ summary: PageSummary -> - MaterialAlertDialogBuilder(activity) - .setTitle(fromHtml(summary.displayTitle)) - .setMessage(fromHtml(summary.extract)) - .setPositiveButton("Go") { _: DialogInterface, _: Int -> - val title = summary.getPageTitle(WikipediaApp.instance.wikiSite) - activity.startActivity(PageActivity.newIntentForNewTab(activity, HistoryEntry(title, HistoryEntry.SOURCE_INTERNAL_LINK), title)) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - ) { throwable: Throwable -> - MaterialAlertDialogBuilder(activity) - .setMessage(throwable.message) - .setPositiveButton(android.R.string.ok, null) - .show() - } + fragment.lifecycleScope.launch(CoroutineExceptionHandler { _, caught -> + MaterialAlertDialogBuilder(activity) + .setMessage(caught.message) + .setPositiveButton(android.R.string.ok, null) + .show() + }) { + val summary = EditingSuggestionsProvider.getNextArticleWithMissingDescription(WikipediaApp.instance.wikiSite) + MaterialAlertDialogBuilder(fragment.requireActivity()) + .setTitle(fromHtml(summary.displayTitle)) + .setMessage(fromHtml(summary.extract)) + .setPositiveButton("Go") { _: DialogInterface, _: Int -> + val title = summary.getPageTitle(WikipediaApp.instance.wikiSite) + fragment.requireActivity().startActivity(PageActivity.newIntentForNewTab(fragment.requireActivity(), HistoryEntry(title, HistoryEntry.SOURCE_INTERNAL_LINK), title)) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } true } findPreference(R.string.preference_key_missing_description_test2).onPreferenceClickListener = Preference.OnPreferenceClickListener { - EditingSuggestionsProvider.getNextArticleWithMissingDescription(WikipediaApp.instance.wikiSite, - WikipediaApp.instance.languageState.appLanguageCodes[1], true, 10) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ (_, second) -> - MaterialAlertDialogBuilder(activity) - .setTitle(fromHtml(second.displayTitle)) - .setMessage(fromHtml(second.description)) - .setPositiveButton("Go") { _: DialogInterface, _: Int -> - val title = second.getPageTitle(WikiSite.forLanguageCode(WikipediaApp.instance.languageState.appLanguageCodes[1])) - activity.startActivity(PageActivity.newIntentForNewTab(activity, HistoryEntry(title, HistoryEntry.SOURCE_INTERNAL_LINK), title)) - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } - ) { throwable: Throwable -> - MaterialAlertDialogBuilder(activity) - .setMessage(throwable.message) - .setPositiveButton(android.R.string.ok, null) - .show() - } + fragment.lifecycleScope.launch(CoroutineExceptionHandler { _, caught -> + MaterialAlertDialogBuilder(activity) + .setMessage(caught.message) + .setPositiveButton(android.R.string.ok, null) + .show() + }) { + val summary = EditingSuggestionsProvider.getNextArticleWithMissingDescription(WikipediaApp.instance.wikiSite, + WikipediaApp.instance.languageState.appLanguageCodes[1]) + MaterialAlertDialogBuilder(fragment.requireActivity()) + .setTitle(fromHtml(summary.second.displayTitle)) + .setMessage(fromHtml(summary.second.extract)) + .setPositiveButton("Go") { _: DialogInterface, _: Int -> + val title = summary.second.getPageTitle(WikipediaApp.instance.wikiSite) + fragment.requireActivity().startActivity(PageActivity.newIntentForNewTab(fragment.requireActivity(), HistoryEntry(title, HistoryEntry.SOURCE_INTERNAL_LINK), title)) + } + .setNegativeButton(android.R.string.cancel, null) + .show() + } true } findPreference(R.string.preference_key_announcement_shown_dialogs).summary = activity.getString(R.string.preferences_developer_announcement_reset_shown_dialogs_summary, Prefs.announcementShownDialogs.size) @@ -143,9 +137,15 @@ internal class DeveloperSettingsPreferenceLoader(fragment: PreferenceFragmentCom findPreference(R.string.preference_developer_clear_all_talk_topics).onPreferenceClickListener = Preference.OnPreferenceClickListener { CoroutineScope(Dispatchers.Main).launch { AppDatabase.instance.talkPageSeenDao().deleteAll() + Toast.makeText(activity, "Reset complete.", Toast.LENGTH_SHORT).show() } true } + findPreference(R.string.preference_developer_clear_last_location_and_zoom_level).onPreferenceClickListener = Preference.OnPreferenceClickListener { + Prefs.placesLastLocationAndZoomLevel = null + Toast.makeText(activity, "Reset complete.", Toast.LENGTH_SHORT).show() + true + } findPreference(R.string.preference_key_memory_leak_test).onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _: Preference, _: Any? -> setupLeakCanary() true @@ -198,7 +198,7 @@ internal class DeveloperSettingsPreferenceLoader(fragment: PreferenceFragmentCom return toString().trim().toIntOrNull() ?: defaultValue } - private class TestException constructor(message: String?) : RuntimeException(message) + private class TestException(message: String?) : RuntimeException(message) companion object { private const val TEXT_OF_TEST_READING_LIST = "Test reading list" diff --git a/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt b/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt index 0bb1e66c255..94ae933b7b3 100644 --- a/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt +++ b/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt @@ -5,6 +5,7 @@ import android.content.Context import android.util.AttributeSet import android.view.View import android.widget.Button +import android.widget.ImageView import android.widget.TextView import androidx.core.view.isVisible import androidx.preference.Preference @@ -14,8 +15,8 @@ import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.activity.SingleWebViewActivity import org.wikipedia.auth.AccountUtil -import org.wikipedia.util.ReleaseUtil import org.wikipedia.util.StringUtil +import java.util.concurrent.TimeUnit @Suppress("unused") class LogoutPreference : Preference { @@ -33,19 +34,32 @@ class LogoutPreference : Preference { super.onBindViewHolder(holder) holder.itemView.isClickable = false holder.itemView.findViewById(R.id.accountName).text = AccountUtil.userName - holder.itemView.findViewById