diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e9136bb4b92..02a644fb13b 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 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_phab.yml b/.github/workflows/android_phab.yml new file mode 100644 index 00000000000..e27ccfe91ca --- /dev/null +++ b/.github/workflows/android_phab.yml @@ -0,0 +1,24 @@ +name: Post to Phabricator + +on: + pull_request: + types: [opened, closed] + +jobs: + post_to_phab: + runs-on: ubuntu-latest + steps: + - name: Post to Phabricator when pull request is opened or closed + if: ${{ github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'closed') }} + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + message="${{ github.actor }} ${{ github.event.action }} ${{ github.event.pull_request._links.html.href }}" + echo -e "${PR_BODY}" | grep -oEi "(^Bug:\s*T[0-9]+)|(^([*]*phabricator[*]*:[*]*\s*)?https:\/\/phabricator\.wikimedia\.org\/T[0-9]+)" | grep -oEi "T[0-9]+" | while IFS= read -r line; do + echo "Processing: $line" + curl https://phabricator.wikimedia.org/api/maniphest.edit \ + -d api.token=${{ secrets.PHAB_BOT_API_KEY }} \ + -d transactions[0][type]=comment \ + -d transactions[0][value]="${message}" \ + -d objectIdentifier=${line} + done diff --git a/.github/workflows/android_pr.yml b/.github/workflows/android_pr.yml index bf5855f1d79..f0e68f1ba9d 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@v3 - uses: actions/setup-java@v4 with: distribution: 'temurin' 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 bb740c44807..a3137bd2b2d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,7 +37,7 @@ android { applicationId 'org.wikipedia' minSdk 21 targetSdk 34 - versionCode 50475 + versionCode 50488 testApplicationId 'org.wikipedia.test' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunnerArguments clearPackageData: 'true' @@ -65,13 +65,16 @@ android { buildConfig true } + androidResources { + generateLocaleConfig = true + } + sourceSets { - prod { java.srcDirs += 'src/extra/java' } - beta { java.srcDirs += 'src/extra/java' } - alpha { java.srcDirs += 'src/extra/java' } - dev { java.srcDirs += 'src/extra/java' } - custom { java.srcDirs += 'src/extra/java' } + [ prod, beta, alpha, dev, custom ].forEach { + it.java.srcDirs += 'src/extra/java' + it.res.srcDirs += 'src/extra/res' + } androidTest { assets.srcDirs += files("$projectDir/schemas".toString()) @@ -169,16 +172,17 @@ dependencies { // Debug with ./gradlew -q app:dependencies --configuration compile String okHttpVersion = '4.12.0' - String retrofitVersion = '2.9.0' + String retrofitVersion = '2.11.0' String glideVersion = '4.16.0' String mockitoVersion = '5.2.0' - String leakCanaryVersion = '2.13' - String kotlinCoroutinesVersion = '1.7.3' - String firebaseMessagingVersion = '23.4.1' + String leakCanaryVersion = '2.14' + String kotlinCoroutinesVersion = '1.8.1' + String firebaseMessagingVersion = '24.0.0' String mlKitVersion = '17.0.5' + String googlePayVersion = '19.3.0' String roomVersion = "2.6.1" String espressoVersion = '3.5.1' - String serialization_version = '1.6.2' + String serialization_version = '1.6.3' String metricsVersion = '2.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' @@ -188,12 +192,12 @@ dependencies { 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 'com.google.android.material:material:1.12.0' implementation 'androidx.appcompat:appcompat:1.6.1' - implementation "androidx.core:core-ktx:1.12.0" - implementation "androidx.browser:browser:1.7.0" + implementation 'androidx.core:core-ktx:1.13.1' + implementation "androidx.browser:browser:1.8.0" implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation "androidx.fragment:fragment-ktx:1.6.2" + implementation 'androidx.fragment:fragment-ktx:1.7.0' 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" @@ -225,7 +229,7 @@ dependencies { implementation 'com.github.skydoves:balloon:1.6.4' implementation "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:1.0.0" - implementation 'org.maplibre.gl:android-sdk:10.2.0' + implementation 'org.maplibre.gl:android-sdk:10.3.0' implementation 'org.maplibre.gl:android-plugin-annotation-v9:2.0.2' implementation("androidx.room:room-runtime:$roomVersion") @@ -248,12 +252,19 @@ dependencies { devImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" customImplementation "com.google.firebase:firebase-messaging-ktx:$firebaseMessagingVersion" + // For integrating with Google Pay for donations + prodImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + betaImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + alphaImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + devImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + customImplementation "com.google.android.gms:play-services-wallet:$googlePayVersion" + debugImplementation "com.squareup.leakcanary:leakcanary-android:$leakCanaryVersion" implementation "com.squareup.leakcanary:plumber-android:$leakCanaryVersion" testImplementation 'junit:junit:4.13.2' testImplementation "org.mockito:mockito-inline:$mockitoVersion" - testImplementation 'org.robolectric:robolectric:4.11.1' + testImplementation 'org.robolectric:robolectric:4.12.1' testImplementation "com.squareup.okhttp3:okhttp:$okHttpVersion" testImplementation "com.squareup.okhttp3:mockwebserver:$okHttpVersion" testImplementation 'org.hamcrest:hamcrest:2.2' 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..fe81b64d467 --- /dev/null +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt @@ -0,0 +1,298 @@ +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.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.DonationConfig +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.Resource +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.UriUtil + +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 + + 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()) + setResult(RESULT_OK) + finish() + } + } + } + } + } + } + + binding.errorView.backClickListener = View.OnClickListener { + onBackPressed() + } + + binding.checkBoxTransactionFee.setOnCheckedChangeListener { _, isChecked -> + val amountText = binding.donateAmountText.text.toString() + if (!validateInput(amountText)) { + return@setOnCheckedChangeListener + } + val amount = getAmountFloat(amountText) + setAmountText(if (isChecked) amount + viewModel.transactionFee else amount - viewModel.transactionFee) + } + + binding.payButton.setOnClickListener { + val amountText = binding.donateAmountText.text.toString() + if (!validateInput(amountText)) { + return@setOnClickListener + } + viewModel.finalAmount = getAmountFloat(amountText) + + 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))) + } + } + + private fun validateInput(text: String): Boolean { + val amount = getAmountFloat(text) + val min = viewModel.minimumAmount + val max = viewModel.maximumAmount + + 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 + + binding.checkBoxTransactionFee.text = getString(R.string.donate_gpay_check_transaction_fee, viewModel.currencyFormat.format(viewModel.transactionFee)) + + 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) + var selectedAmount = it.tag as Float + if (binding.checkBoxTransactionFee.isChecked) { + selectedAmount += viewModel.transactionFee + } + setAmountText(selectedAmount) + 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 getAmountFloat(text: String): Float { + var result: Float? = null + 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 { intent -> + PaymentData.getFromIntent(intent)?.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..acef58e1d23 --- /dev/null +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayComponent.kt @@ -0,0 +1,123 @@ +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.tasks.await +import org.json.JSONArray +import org.json.JSONObject +import org.wikipedia.settings.Prefs +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" + + 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 { + val readyToPayRequest = IsReadyToPayRequest.fromJson(googlePayBaseConfiguration.toString()) + val paymentsClient = createPaymentsClient(activity) + val readyToPayTask = paymentsClient.isReadyToPay(readyToPayRequest) + readyToPayTask.await() + return readyToPayTask.result + } + + 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..8b42b46471a --- /dev/null +++ b/app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt @@ -0,0 +1,171 @@ +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.WikipediaApp +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +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 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() } + + donationConfig = donationConfigCall.await() + + // 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, + 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() +} 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..0e7e57a4063 --- /dev/null +++ b/app/src/extra/res/layout/activity_donate.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b4fb7774caa..d7677e4ab57 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -37,8 +37,8 @@ - - + + + + + + android:name=".talk.TalkReplyActivity" + android:configChanges="orientation|screenSize"/> @@ -315,6 +319,7 @@ + + @@ -355,9 +365,7 @@ android:theme="@style/AppTheme" /> + android:name=".donate.GooglePayActivity"/> get() { val account = account() ?: return emptySet() @@ -114,19 +105,6 @@ object AccountUtil { } } - private var userIds: Map - get() { - val account = account() ?: return emptyMap() - val mapStr = accountManager().getUserData(account, WikipediaApp.instance.getString(R.string.preference_key_login_user_id_map)) - return if (mapStr.isNullOrEmpty()) emptyMap() else (JsonUtil.decodeFromString(mapStr) ?: emptyMap()) - } - private set(ids) { - val account = account() ?: return - accountManager().setUserData(account, - WikipediaApp.instance.getString(R.string.preference_key_login_user_id_map), - JsonUtil.encodeToString(ids)) - } - private fun accountManager(): AccountManager { return AccountManager.get(WikipediaApp.instance) } diff --git a/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt b/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt index b2e56062b58..4d25a643e64 100644 --- a/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt +++ b/app/src/main/java/org/wikipedia/bridge/JavaScriptActionHandler.kt @@ -160,10 +160,10 @@ object JavaScriptActionHandler { "})" } - fun mobileWebChromeShim(): String { + fun mobileWebChromeShim(marginTop: Int, marginBottom: Int): String { return "(function() {" + "let style = document.createElement('style');" + - "style.innerHTML = '.header-chrome { visibility: hidden; margin-top: 48px; height: 0px; } #page-secondary-actions { display: none; } .mw-footer { padding-bottom: 72px; } .page-actions-menu { display: none; } .minerva__tab-container { display: none; }';" + + "style.innerHTML = '.header-chrome { visibility: hidden; margin-top: ${marginTop}px; height: 0px; } #page-secondary-actions { display: none; } .mw-footer { padding-bottom: ${marginBottom}px; } .page-actions-menu { display: none; } .minerva__tab-container { display: none; }';" + "document.head.appendChild(style);" + "})();" } diff --git a/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt b/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt index eaa43aa88b4..47ce994bcf6 100644 --- a/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt +++ b/app/src/main/java/org/wikipedia/captcha/CaptchaHandler.kt @@ -1,11 +1,11 @@ package org.wikipedia.captcha -import android.app.Activity import android.view.View import androidx.appcompat.app.AppCompatActivity -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.databinding.GroupCaptchaBinding import org.wikipedia.dataclient.ServiceFactory @@ -17,12 +17,13 @@ import org.wikipedia.util.StringUtil import org.wikipedia.views.ViewAnimations import org.wikipedia.views.ViewUtil -class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, +class CaptchaHandler(private val activity: AppCompatActivity, private val wiki: WikiSite, captchaView: View, private val primaryView: View, private val prevTitle: String, submitButtonText: String?) { private val binding = GroupCaptchaBinding.bind(captchaView) - private val disposables = CompositeDisposable() private var captchaResult: CaptchaResult? = null + private var clientJob: Job? = null + var token: String? = null val isActive get() = captchaResult != null @@ -45,7 +46,7 @@ class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, } fun dispose() { - disposables.clear() + clientJob?.cancel() } fun handleCaptcha(token: String?, captchaResult: CaptchaResult) { @@ -56,17 +57,15 @@ class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, fun requestNewCaptcha() { binding.captchaImageProgress.visibility = View.VISIBLE - disposables.add(ServiceFactory.get(wiki).newCaptcha - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { binding.captchaImageProgress.visibility = View.GONE } - .subscribe({ response -> - captchaResult = CaptchaResult(response.captchaId()) - handleCaptcha(true) - }) { caught -> - cancelCaptcha() - FeedbackUtil.showError(activity, caught) - }) + 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) { @@ -83,7 +82,7 @@ class CaptchaHandler(private val activity: Activity, private val wiki: WikiSite, } fun hideCaptcha() { - (activity as AppCompatActivity).supportActionBar?.title = prevTitle + activity.supportActionBar?.title = prevTitle ViewAnimations.crossFade(binding.root, primaryView) } diff --git a/app/src/main/java/org/wikipedia/commons/FilePage.kt b/app/src/main/java/org/wikipedia/commons/FilePage.kt new file mode 100644 index 00000000000..cef9cd08353 --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/FilePage.kt @@ -0,0 +1,13 @@ +package org.wikipedia.commons + +import org.wikipedia.dataclient.mwapi.MwQueryPage + +class FilePage( + val thumbnailWidth: Int = 0, + val thumbnailHeight: Int = 0, + val imageFromCommons: Boolean = false, + val showEditButton: Boolean = false, + val showFilename: Boolean = false, + val page: MwQueryPage = MwQueryPage(), + val imageTags: Map> = emptyMap() +) diff --git a/app/src/main/java/org/wikipedia/commons/FilePageFragment.kt b/app/src/main/java/org/wikipedia/commons/FilePageFragment.kt index e26fa6bf180..561319c5a0e 100644 --- a/app/src/main/java/org/wikipedia/commons/FilePageFragment.kt +++ b/app/src/main/java/org/wikipedia/commons/FilePageFragment.kt @@ -8,60 +8,48 @@ import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts import androidx.core.os.bundleOf import androidx.fragment.app.Fragment -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent import org.wikipedia.databinding.FragmentFilePageBinding -import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.descriptions.DescriptionEditActivity import org.wikipedia.descriptions.DescriptionEditActivity.Action -import org.wikipedia.extensions.parcelable -import org.wikipedia.language.LanguageUtil import org.wikipedia.page.PageTitle import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.suggestededits.SuggestedEditsImageTagEditActivity import org.wikipedia.suggestededits.SuggestedEditsSnackbars +import org.wikipedia.util.DimenUtil import org.wikipedia.util.L10nUtil -import org.wikipedia.util.StringUtil -import org.wikipedia.util.log.L +import org.wikipedia.util.Resource class FilePageFragment : Fragment(), FilePageView.Callback { private var _binding: FragmentFilePageBinding? = null private val binding get() = _binding!! - private lateinit var pageTitle: PageTitle - private lateinit var pageSummaryForEdit: PageSummaryForEdit - private var allowEdit = true - private val disposables = CompositeDisposable() + private val viewModel: FilePageViewModel by viewModels { FilePageViewModel.Factory(requireArguments()) } private val addImageCaptionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { SuggestedEditsSnackbars.show(requireActivity(), Action.ADD_CAPTION, true) - loadImageInfo() + viewModel.loadImageInfo() } } private val addImageTagsLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (it.resultCode == RESULT_OK) { SuggestedEditsSnackbars.show(requireActivity(), Action.ADD_IMAGE_TAGS, true) - loadImageInfo() + viewModel.loadImageInfo() } } - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - pageTitle = requireArguments().parcelable(Constants.ARG_TITLE)!! - allowEdit = requireArguments().getBoolean(FilePageActivity.INTENT_EXTRA_ALLOW_EDIT) - retainInstance = true - } - override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) _binding = FragmentFilePageBinding.inflate(inflater, container, false) - L10nUtil.setConditionalLayoutDirection(container!!, pageTitle.wikiSite.languageCode) + L10nUtil.setConditionalLayoutDirection(binding.root, viewModel.pageTitle.wikiSite.languageCode) return binding.root } @@ -69,109 +57,70 @@ class FilePageFragment : Fragment(), FilePageView.Callback { super.onViewCreated(view, savedInstanceState) binding.swipeRefreshLayout.setOnRefreshListener { binding.swipeRefreshLayout.isRefreshing = false - loadImageInfo() + viewModel.loadImageInfo() } binding.errorView.backClickListener = View.OnClickListener { requireActivity().finish() } - loadImageInfo() - ImageRecommendationsEvent.logImpression("imagedetails_dialog", ImageRecommendationsEvent.getActionDataString(filename = pageTitle.prefixedText)) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> onLoading() + is Resource.Success -> onSuccess(it.data) + is Resource.Error -> onError(it.throwable) + } + } + } + } + } + ImageRecommendationsEvent.logImpression("imagedetails_dialog", ImageRecommendationsEvent.getActionDataString(filename = viewModel.pageTitle.prefixedText)) } override fun onDestroyView() { - disposables.clear() _binding = null super.onDestroyView() } - private fun showError(caught: Throwable?) { + private fun onError(caught: Throwable?) { binding.progressBar.visibility = View.GONE binding.filePageView.visibility = View.GONE binding.errorView.visibility = View.VISIBLE binding.errorView.setError(caught) } - private fun loadImageInfo() { - lateinit var imageTags: Map> - lateinit var page: MwQueryPage - var isFromCommons = false - var isEditProtected = false - var thumbnailWidth = 0 - var thumbnailHeight = 0 - + private fun onLoading() { binding.errorView.visibility = View.GONE binding.filePageView.visibility = View.GONE binding.progressBar.visibility = View.VISIBLE + } - disposables.add(ServiceFactory.get(Constants.commonsWikiSite).getImageInfoWithEntityTerms(pageTitle.prefixedText, pageTitle.wikiSite.languageCode, LanguageUtil.convertToUselangIfNeeded(pageTitle.wikiSite.languageCode)) - .subscribeOn(Schedulers.io()) - .flatMap { - // set image caption to pageTitle description - pageTitle.description = it.query?.firstPage()?.entityTerms?.label?.firstOrNull() - if (it.query?.firstPage()?.imageInfo() == null) { - // If file page originally comes from *.wikipedia.org (i.e. movie posters), it will not have imageInfo and pageId. - ServiceFactory.get(pageTitle.wikiSite).getImageInfo(pageTitle.prefixedText, pageTitle.wikiSite.languageCode) - } else { - // Fetch API from commons.wikimedia.org and check whether if it is not a "shared" image. - isFromCommons = !(it.query?.firstPage()?.isImageShared ?: false) - Observable.just(it) - } - } - .subscribeOn(Schedulers.io()) - .flatMap { - page = it.query?.firstPage()!! - val imageInfo = page.imageInfo()!! - pageSummaryForEdit = PageSummaryForEdit( - pageTitle.prefixedText, - pageTitle.wikiSite.languageCode, - pageTitle, - pageTitle.displayText, - StringUtil.fromHtml(imageInfo.metadata!!.imageDescription()).toString().ifBlank { null }, - imageInfo.thumbUrl, - null, - null, - imageInfo.timestamp, - imageInfo.user, - imageInfo.metadata - ) - thumbnailHeight = imageInfo.thumbHeight - thumbnailWidth = imageInfo.thumbWidth - ImageTagsProvider.getImageTagsObservable(page.pageId, pageSummaryForEdit.lang) - } - .flatMap { - imageTags = it - ServiceFactory.get(Constants.commonsWikiSite).getProtectionInfo(pageTitle.prefixedText) - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .doAfterTerminate { - binding.filePageView.visibility = View.VISIBLE - binding.progressBar.visibility = View.GONE - binding.filePageView.setup( - pageSummaryForEdit, - imageTags, - page, - binding.container.width, - thumbnailWidth, - thumbnailHeight, - imageFromCommons = isFromCommons, - showFilename = true, - showEditButton = allowEdit && isFromCommons && !isEditProtected, - callback = this - ) - } - .subscribe({ - isEditProtected = it.query?.isEditProtected ?: false - }, { caught -> - L.e(caught) - showError(caught) - })) + private fun onSuccess(filePage: FilePage) { + viewModel.pageSummaryForEdit?.let { + binding.filePageView.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + binding.filePageView.setup( + it, + filePage.imageTags, + filePage.page, + DimenUtil.displayWidthPx, + filePage.thumbnailWidth, + filePage.thumbnailHeight, + imageFromCommons = filePage.imageFromCommons, + showFilename = filePage.showFilename, + showEditButton = filePage.showEditButton, + callback = this + ) + } } override fun onImageCaptionClick(summaryForEdit: PageSummaryForEdit) { - addImageCaptionLauncher.launch( - DescriptionEditActivity.newIntent(requireContext(), - pageSummaryForEdit.pageTitle, null, summaryForEdit, null, - Action.ADD_CAPTION, Constants.InvokeSource.FILE_PAGE_ACTIVITY) - ) + viewModel.pageSummaryForEdit?.let { + addImageCaptionLauncher.launch( + DescriptionEditActivity.newIntent(requireContext(), + it.pageTitle, null, summaryForEdit, null, + Action.ADD_CAPTION, Constants.InvokeSource.FILE_PAGE_ACTIVITY) + ) + } } override fun onImageTagsClick(page: MwQueryPage) { diff --git a/app/src/main/java/org/wikipedia/commons/FilePageView.kt b/app/src/main/java/org/wikipedia/commons/FilePageView.kt index 803de60a5aa..e376b10a93c 100644 --- a/app/src/main/java/org/wikipedia/commons/FilePageView.kt +++ b/app/src/main/java/org/wikipedia/commons/FilePageView.kt @@ -31,7 +31,7 @@ import org.wikipedia.util.UriUtil import org.wikipedia.views.ImageDetailView import org.wikipedia.views.ImageZoomHelper import org.wikipedia.views.ViewUtil -import java.util.* +import java.util.Locale class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : LinearLayout(context, attrs) { @@ -66,7 +66,7 @@ class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : binding.filenameView.binding.contentText.setTextIsSelectable(false) binding.filenameView.binding.contentText.maxLines = 3 binding.filenameView.binding.contentText.ellipsize = TextUtils.TruncateAt.END - binding.filenameView.binding.contentText.text = StringUtil.removeNamespace(summaryForEdit.displayTitle.orEmpty()) + binding.filenameView.binding.contentText.text = StringUtil.removeNamespace(summaryForEdit.displayTitle) binding.filenameView.binding.divider.visibility = View.GONE } @@ -76,25 +76,30 @@ class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : addActionButton(context.getString(R.string.file_page_add_image_caption_button), imageCaptionOnClickListener(summaryForEdit, callback)) } else if ((action == DescriptionEditActivity.Action.ADD_CAPTION || action == null) && summaryForEdit.pageTitle.description.isNullOrEmpty()) { // Show the image description when a structured caption does not exist. - addDetail(context.getString(R.string.description_edit_add_caption_label), summaryForEdit.description, - if (showEditButton) imageCaptionOnClickListener(summaryForEdit, callback) else null) + addDetail( + titleString = context.getString(R.string.description_edit_add_caption_label), + detail = summaryForEdit.description, + listener = if (showEditButton) imageCaptionOnClickListener(summaryForEdit, callback) else null + ) } else { - addDetail(context.getString(R.string.suggested_edits_image_preview_dialog_caption_in_language_title, + addDetail( + titleString = context.getString(R.string.suggested_edits_image_preview_dialog_caption_in_language_title, WikipediaApp.instance.languageState.getAppLanguageLocalizedName(getProperLanguageCode(summaryForEdit, imageFromCommons))), - if (summaryForEdit.pageTitle.description.isNullOrEmpty()) summaryForEdit.description - else summaryForEdit.pageTitle.description, if (showEditButton) imageCaptionOnClickListener(summaryForEdit, callback) else null) + detail = if (summaryForEdit.pageTitle.description.isNullOrEmpty()) summaryForEdit.description else summaryForEdit.pageTitle.description, + listener = if (showEditButton) imageCaptionOnClickListener(summaryForEdit, callback) else null + ) } if ((imageTags.isEmpty() || !imageTags.containsKey(getProperLanguageCode(summaryForEdit, imageFromCommons))) && showEditButton) { addActionButton(context.getString(R.string.file_page_add_image_tags_button), imageTagsOnClickListener(page, callback)) } else { - addDetail(context.getString(R.string.suggested_edits_image_tags), getImageTags(imageTags, getProperLanguageCode(summaryForEdit, imageFromCommons))) + addDetail(titleString = context.getString(R.string.suggested_edits_image_tags), detail = getImageTags(imageTags, getProperLanguageCode(summaryForEdit, imageFromCommons))) } - addDetail(context.getString(R.string.suggested_edits_image_caption_summary_title_author), summaryForEdit.metadata!!.artist()) - addDetail(context.getString(R.string.suggested_edits_image_preview_dialog_date), summaryForEdit.metadata!!.dateTime()) - addDetail(context.getString(R.string.suggested_edits_image_caption_summary_title_source), summaryForEdit.metadata!!.credit()) - addDetail(true, context.getString(R.string.suggested_edits_image_preview_dialog_licensing), summaryForEdit.metadata!!.licenseShortName(), summaryForEdit.metadata!!.licenseUrl()) + addDetail(titleString = context.getString(R.string.suggested_edits_image_caption_summary_title_author), detail = summaryForEdit.metadata!!.artist()) + addDetail(titleString = context.getString(R.string.suggested_edits_image_preview_dialog_date), detail = summaryForEdit.metadata!!.dateTime()) + addDetail(titleString = context.getString(R.string.suggested_edits_image_caption_summary_title_source), detail = summaryForEdit.metadata!!.credit()) + addDetail(titleString = context.getString(R.string.suggested_edits_image_preview_dialog_licensing), detail = summaryForEdit.metadata!!.licenseShortName(), externalLink = summaryForEdit.metadata!!.licenseUrl()) if (imageFromCommons) { addDetail(false, context.getString(R.string.suggested_edits_image_preview_dialog_more_info), context.getString(R.string.suggested_edits_image_preview_dialog_file_page_link_text), context.getString(R.string.suggested_edits_image_file_page_commons_link, summaryForEdit.title)) } else { @@ -146,19 +151,10 @@ class FilePageView constructor(context: Context, attrs: AttributeSet? = null) : } } - private fun addDetail(titleString: String, detail: String?) { - addDetail(true, titleString, detail, null, null) - } - - private fun addDetail(titleString: String, detail: String?, listener: OnClickListener?) { - addDetail(true, titleString, detail, null, listener) - } - - private fun addDetail(showDivider: Boolean, titleString: String, detail: String?, externalLink: String?) { - addDetail(showDivider, titleString, detail, externalLink, null) - } - - private fun addDetail(showDivider: Boolean, titleString: String, detail: String?, externalLink: String?, listener: OnClickListener?) { + private fun addDetail( + showDivider: Boolean = true, titleString: String, detail: String? = null, + externalLink: String? = null, listener: OnClickListener? = null + ) { if (!detail.isNullOrEmpty()) { val view = ImageDetailView(context) view.binding.titleText.text = titleString diff --git a/app/src/main/java/org/wikipedia/commons/FilePageViewModel.kt b/app/src/main/java/org/wikipedia/commons/FilePageViewModel.kt new file mode 100644 index 00000000000..99a464d6a08 --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/FilePageViewModel.kt @@ -0,0 +1,112 @@ +package org.wikipedia.commons + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.extensions.parcelable +import org.wikipedia.language.LanguageUtil +import org.wikipedia.page.PageTitle +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil + +class FilePageViewModel(bundle: Bundle) : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + private val allowEdit = bundle.getBoolean(FilePageActivity.INTENT_EXTRA_ALLOW_EDIT, true) + val pageTitle = bundle.parcelable(Constants.ARG_TITLE)!! + var pageSummaryForEdit: PageSummaryForEdit? = null + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + loadImageInfo() + } + + fun loadImageInfo() { + _uiState.value = Resource.Loading() + viewModelScope.launch(handler) { + var isFromCommons = false + var firstPage = ServiceFactory.get(Constants.commonsWikiSite) + .getImageInfoWithEntityTerms( + pageTitle.prefixedText, pageTitle.wikiSite.languageCode, + LanguageUtil.convertToUselangIfNeeded(pageTitle.wikiSite.languageCode) + ).query?.firstPage() + + // set image caption to pageTitle description + pageTitle.description = firstPage?.entityTerms?.label?.firstOrNull() + if (firstPage?.imageInfo() == null) { + // If file page originally comes from *.wikipedia.org (i.e. movie posters), it will not have imageInfo and pageId. + firstPage = ServiceFactory.get(pageTitle.wikiSite) + .getImageInfoSuspend( + pageTitle.prefixedText, + pageTitle.wikiSite.languageCode + ).query?.firstPage() + } else { + // Fetch API from commons.wikimedia.org and check whether if it is not a "shared" image. + isFromCommons = !(firstPage.isImageShared) + } + + firstPage?.imageInfo()?.let { imageInfo -> + pageSummaryForEdit = PageSummaryForEdit( + pageTitle.prefixedText, + pageTitle.wikiSite.languageCode, + pageTitle, + pageTitle.displayText, + StringUtil.fromHtml(imageInfo.metadata!!.imageDescription()).toString() + .ifBlank { null }, + imageInfo.thumbUrl, + null, + null, + imageInfo.timestamp, + imageInfo.user, + imageInfo.metadata + ) + + val imageTagsResponse = async { + ImageTagsProvider.getImageTags( + firstPage.pageId, + pageSummaryForEdit!!.lang + ) + } + val isEditProtected = async { + ServiceFactory.get(Constants.commonsWikiSite) + .getProtectionInfoSuspend(pageTitle.prefixedText).query?.isEditProtected + ?: false + } + + val filePage = FilePage( + imageFromCommons = isFromCommons, + showEditButton = allowEdit && isFromCommons && !isEditProtected.await(), + showFilename = true, + page = firstPage, + imageTags = imageTagsResponse.await(), + thumbnailWidth = imageInfo.thumbWidth, + thumbnailHeight = imageInfo.thumbHeight + ) + + _uiState.value = Resource.Success(filePage) + } ?: run { + _uiState.value = Resource.Error(Throwable("No image info found.")) + } + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return FilePageViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/commons/ImagePreviewDialog.kt b/app/src/main/java/org/wikipedia/commons/ImagePreviewDialog.kt new file mode 100644 index 00000000000..1cd74f3c832 --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/ImagePreviewDialog.kt @@ -0,0 +1,109 @@ +package org.wikipedia.commons + +import android.content.DialogInterface +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.google.android.material.bottomsheet.BottomSheetBehavior +import kotlinx.coroutines.launch +import org.wikipedia.R +import org.wikipedia.databinding.DialogImagePreviewBinding +import org.wikipedia.descriptions.DescriptionEditActivity.Action +import org.wikipedia.page.ExtendedBottomSheetDialogFragment +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.L10nUtil.setConditionalLayoutDirection +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil + +class ImagePreviewDialog : ExtendedBottomSheetDialogFragment(), DialogInterface.OnDismissListener { + + private var _binding: DialogImagePreviewBinding? = null + private val binding get() = _binding!! + private val viewModel: ImagePreviewViewModel by viewModels { ImagePreviewViewModel.Factory(requireArguments()) } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = DialogImagePreviewBinding.inflate(inflater, container, false) + setConditionalLayoutDirection(binding.root, viewModel.pageSummaryForEdit.lang) + return binding.root + } + + override fun onStart() { + super.onStart() + BottomSheetBehavior.from(requireView().parent as View).peekHeight = DimenUtil.roundedDpToPx(DimenUtil.getDimension(R.dimen.imagePreviewSheetPeekHeight)) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + binding.toolbarView.setOnClickListener { dismiss() } + binding.titleText.text = StringUtil.removeHTMLTags(StringUtil.removeNamespace(viewModel.pageSummaryForEdit.displayTitle)) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> onLoading() + is Resource.Success -> onSuccess(it.data) + is Resource.Error -> onError(it.throwable) + } + } + } + } + } + } + + override fun onDestroyView() { + binding.toolbarView.setOnClickListener(null) + _binding = null + super.onDestroyView() + } + + private fun onLoading() { + binding.progressBar.visibility = View.VISIBLE + } + + private fun onError(caught: Throwable?) { + binding.dialogDetailContainer.layoutTransition = null + binding.dialogDetailContainer.minimumHeight = 0 + binding.progressBar.visibility = View.GONE + binding.filePageView.visibility = View.GONE + binding.errorView.visibility = View.VISIBLE + binding.errorView.setError(caught, viewModel.pageSummaryForEdit.pageTitle) + } + + private fun onSuccess(filePage: FilePage) { + binding.filePageView.visibility = View.VISIBLE + binding.progressBar.visibility = View.GONE + binding.filePageView.setup( + viewModel.pageSummaryForEdit, + filePage.imageTags, + filePage.page, + binding.dialogDetailContainer.width, + filePage.thumbnailWidth, + filePage.thumbnailHeight, + imageFromCommons = filePage.imageFromCommons, + showFilename = filePage.showFilename, + showEditButton = filePage.showEditButton, + action = viewModel.action + ) + } + + companion object { + const val ARG_SUMMARY = "summary" + const val ARG_ACTION = "action" + + fun newInstance(pageSummaryForEdit: PageSummaryForEdit, action: Action? = null): ImagePreviewDialog { + val dialog = ImagePreviewDialog().apply { + arguments = bundleOf(ARG_SUMMARY to pageSummaryForEdit, ARG_ACTION to action) + } + return dialog + } + } +} diff --git a/app/src/main/java/org/wikipedia/commons/ImagePreviewViewModel.kt b/app/src/main/java/org/wikipedia/commons/ImagePreviewViewModel.kt new file mode 100644 index 00000000000..9fe4a06e8bc --- /dev/null +++ b/app/src/main/java/org/wikipedia/commons/ImagePreviewViewModel.kt @@ -0,0 +1,78 @@ +package org.wikipedia.commons + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.descriptions.DescriptionEditActivity +import org.wikipedia.extensions.parcelable +import org.wikipedia.suggestededits.PageSummaryForEdit +import org.wikipedia.util.Resource + +class ImagePreviewViewModel(bundle: Bundle) : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + var pageSummaryForEdit = bundle.parcelable(ImagePreviewDialog.ARG_SUMMARY)!! + var action = bundle.getSerializable(ImagePreviewDialog.ARG_ACTION) as DescriptionEditActivity.Action? + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + loadImageInfo() + } + + private fun loadImageInfo() { + _uiState.value = Resource.Loading() + viewModelScope.launch(handler) { + var isFromCommons = false + var firstPage = ServiceFactory.get(Constants.commonsWikiSite) + .getImageInfoSuspend(pageSummaryForEdit.title, pageSummaryForEdit.lang).query?.firstPage() + + if (firstPage?.imageInfo() == null) { + // If file page originally comes from *.wikipedia.org (i.e. movie posters), it will not have imageInfo and pageId. + firstPage = ServiceFactory.get(pageSummaryForEdit.pageTitle.wikiSite) + .getImageInfoSuspend(pageSummaryForEdit.title, pageSummaryForEdit.lang).query?.firstPage() + } else { + // Fetch API from commons.wikimedia.org and check whether if it is not a "shared" image. + isFromCommons = !(firstPage.isImageShared) + } + + firstPage?.imageInfo()?.let { imageInfo -> + pageSummaryForEdit.timestamp = imageInfo.timestamp + pageSummaryForEdit.user = imageInfo.user + pageSummaryForEdit.metadata = imageInfo.metadata + + val imageTagsResponse = async { ImageTagsProvider.getImageTags(firstPage.pageId, pageSummaryForEdit.lang) } + + val filePage = FilePage( + imageFromCommons = isFromCommons, + page = firstPage, + imageTags = imageTagsResponse.await(), + thumbnailWidth = imageInfo.thumbWidth, + thumbnailHeight = imageInfo.thumbHeight, + ) + + _uiState.value = Resource.Success(filePage) + } ?: run { + _uiState.value = Resource.Error(Throwable("No image info found.")) + } + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return ImagePreviewViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt b/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt index 2dc101a3b84..c38c4d56c1e 100644 --- a/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt +++ b/app/src/main/java/org/wikipedia/commons/ImageTagsProvider.kt @@ -15,7 +15,7 @@ object ImageTagsProvider { .onErrorReturnItem(Claims()) .flatMap { claims -> val ids = getDepictsClaims(claims.claims) - if (ids.isNullOrEmpty()) { + if (ids.isEmpty()) { Observable.just(MwQueryResponse()) } else { ServiceFactory.get(Constants.wikidataWikiSite).getWikidataEntityTerms(ids.joinToString(separator = "|"), LanguageUtil.convertToUselangIfNeeded(langCode)) @@ -30,6 +30,25 @@ object ImageTagsProvider { } } + suspend fun getImageTags(pageId: Int, langCode: String): Map> { + try { + val claims = ServiceFactory.get(Constants.commonsWikiSite).getClaimsSuspend("M$pageId", "P180") + val ids = getDepictsClaims(claims.claims) + return if (ids.isEmpty()) { + emptyMap() + } else { + val response = ServiceFactory.get(Constants.wikidataWikiSite).getWikidataEntityTermsSuspend(ids.joinToString(separator = "|"), + LanguageUtil.convertToUselangIfNeeded(langCode)) + val labelList = response.query?.pages?.mapNotNull { + it.entityTerms?.label?.firstOrNull() + } + if (labelList.isNullOrEmpty()) emptyMap() else mapOf(langCode to labelList) + } + } catch (e: Exception) { + return emptyMap() + } + } + fun getDepictsClaims(claims: Map>): List { return claims["P180"]?.mapNotNull { it.mainSnak?.dataValue?.value() }.orEmpty() } diff --git a/app/src/main/java/org/wikipedia/csrf/CsrfTokenClient.kt b/app/src/main/java/org/wikipedia/csrf/CsrfTokenClient.kt index 47ebec8f774..3e7c61b4f1a 100644 --- a/app/src/main/java/org/wikipedia/csrf/CsrfTokenClient.kt +++ b/app/src/main/java/org/wikipedia/csrf/CsrfTokenClient.kt @@ -19,11 +19,7 @@ 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 { + fun getToken(site: WikiSite, type: String = "csrf", svc: Service? = null): Observable { return Observable.create { emitter -> var token = "" try { diff --git a/app/src/main/java/org/wikipedia/dataclient/RestService.kt b/app/src/main/java/org/wikipedia/dataclient/RestService.kt index b2127a541a8..3ddb73fa62d 100644 --- a/app/src/main/java/org/wikipedia/dataclient/RestService.kt +++ b/app/src/main/java/org/wikipedia/dataclient/RestService.kt @@ -12,11 +12,23 @@ import org.wikipedia.feed.configure.FeedAvailability import org.wikipedia.feed.onthisday.OnThisDay import org.wikipedia.gallery.MediaList import org.wikipedia.readinglist.sync.SyncedReadingLists -import org.wikipedia.readinglist.sync.SyncedReadingLists.* +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteIdResponse +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteIdResponseBatch +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteReadingList +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteReadingListEntry +import org.wikipedia.readinglist.sync.SyncedReadingLists.RemoteReadingListEntryBatch import org.wikipedia.suggestededits.provider.SuggestedEditItem import retrofit2.Call import retrofit2.Response -import retrofit2.http.* +import retrofit2.http.Body +import retrofit2.http.DELETE +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Path +import retrofit2.http.Query interface RestService { @@ -61,20 +73,13 @@ interface RestService { @Path("title") title: String ): PageSummary - // todo: this Content Service-only endpoint is under page/ but that implementation detail should - // probably not be reflected here. Move to WordDefinitionClient - /** - * Gets selected Wiktionary content for a given title derived from user-selected text - * - * @param title the Wiktionary page title derived from user-selected Wikipedia article text - */ @Headers("Accept: $ACCEPT_HEADER_DEFINITION") @GET("page/definition/{title}") - fun getDefinition(@Path("title") title: String): Observable>> + suspend fun getDefinition(@Path("title") title: String): Map> - @get:GET("page/random/summary") - @get:Headers("Accept: $ACCEPT_HEADER_SUMMARY") - val randomSummary: Observable + @GET("page/random/summary") + @Headers("Accept: $ACCEPT_HEADER_SUMMARY") + suspend fun getRandomSummary(): PageSummary @GET("page/media-list/{title}/{revision}") fun getMediaList( @@ -102,17 +107,9 @@ interface RestService { fun getOnThisDay(@Path("mm") month: Int, @Path("dd") day: Int): Observable // TODO: Remove this before next fundraising campaign in 2024 - @get:GET("feed/announcements") - @get:Headers("Accept: " + ACCEPT_HEADER_PREFIX + "announcements/0.1.0\"") - val announcements: Observable - - @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}") diff --git a/app/src/main/java/org/wikipedia/dataclient/Service.kt b/app/src/main/java/org/wikipedia/dataclient/Service.kt index ec7f3fe18d3..366b2fd6dac 100644 --- a/app/src/main/java/org/wikipedia/dataclient/Service.kt +++ b/app/src/main/java/org/wikipedia/dataclient/Service.kt @@ -6,7 +6,16 @@ import org.wikipedia.dataclient.discussiontools.DiscussionToolsEditResponse import org.wikipedia.dataclient.discussiontools.DiscussionToolsInfoResponse import org.wikipedia.dataclient.discussiontools.DiscussionToolsSubscribeResponse import org.wikipedia.dataclient.discussiontools.DiscussionToolsSubscriptionList -import org.wikipedia.dataclient.mwapi.* +import org.wikipedia.dataclient.donate.PaymentResponseContainer +import org.wikipedia.dataclient.mwapi.CreateAccountResponse +import org.wikipedia.dataclient.mwapi.MwParseResponse +import org.wikipedia.dataclient.mwapi.MwPostResponse +import org.wikipedia.dataclient.mwapi.MwQueryResponse +import org.wikipedia.dataclient.mwapi.MwStreamConfigsResponse +import org.wikipedia.dataclient.mwapi.ParamInfoResponse +import org.wikipedia.dataclient.mwapi.ShortenUrlResponse +import org.wikipedia.dataclient.mwapi.SiteMatrix +import org.wikipedia.dataclient.mwapi.TemplateDataResponse import org.wikipedia.dataclient.okhttp.OfflineCacheInterceptor import org.wikipedia.dataclient.rollback.RollbackPostResponse import org.wikipedia.dataclient.watch.WatchPostResponse @@ -16,7 +25,13 @@ import org.wikipedia.dataclient.wikidata.EntityPostResponse import org.wikipedia.dataclient.wikidata.Search import org.wikipedia.edit.Edit import org.wikipedia.login.LoginClient.LoginResponse -import retrofit2.http.* +import retrofit2.http.Field +import retrofit2.http.FormUrlEncoded +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.Headers +import retrofit2.http.POST +import retrofit2.http.Query /** * Retrofit service layer for all API interactions, including regular MediaWiki and RESTBase. @@ -69,21 +84,34 @@ interface Service { @Query("gsroffset") gsrOffset: Int?, ): MwQueryResponse + @GET( + MW_API_PREFIX + "action=query&converttitles=" + + "&prop=description|info" + + "&generator=search&gsrnamespace=0&gsrwhat=text" + + "&inprop=varianttitles|displaytitle" + + "&gsrinfo=&gsrprop=redirecttitle" + ) + suspend fun fullTextSearchTemplates( + @Query("gsrsearch") searchTerm: String, + @Query("gsrlimit") gsrLimit: Int, + @Query("gsroffset") gsrOffset: Int?, + ): MwQueryResponse + @GET( MW_API_PREFIX + "action=query&generator=search&gsrnamespace=0&gsrqiprofile=classic_noboostlinks" + "&origin=*&piprop=thumbnail&prop=pageimages|description|info|pageprops" + "&inprop=varianttitles&smaxage=86400&maxage=86400&pithumbsize=" + PREFERRED_THUMB_SIZE ) - fun searchMoreLike( + suspend fun searchMoreLike( @Query("gsrsearch") searchTerm: String?, @Query("gsrlimit") gsrLimit: Int, @Query("pilimit") piLimit: Int, - ): Observable + ): MwQueryResponse // ------- Miscellaneous ------- - @get:GET(MW_API_PREFIX + "action=fancycaptchareload") - val newCaptcha: Observable + @GET(MW_API_PREFIX + "action=fancycaptchareload") + suspend fun getNewCaptcha(): Captcha @GET(MW_API_PREFIX + "action=query&prop=langlinks&lllimit=500&redirects=&converttitles=") suspend fun getLangLinks(@Query("titles") title: String): MwQueryResponse @@ -116,15 +144,18 @@ interface Service { ): Observable @GET(MW_API_PREFIX + "action=query&prop=imageinfo|entityterms&iiprop=timestamp|user|url|mime|extmetadata&iiurlwidth=" + PREFERRED_THUMB_SIZE) - fun getImageInfoWithEntityTerms( + suspend fun getImageInfoWithEntityTerms( @Query("titles") titles: String, @Query("iiextmetadatalanguage") metadataLang: String, @Query("wbetlanguage") entityLang: String - ): Observable + ): MwQueryResponse @GET(MW_API_PREFIX + "action=query&meta=userinfo&prop=info&inprop=protection&uiprop=groups") fun getProtectionInfo(@Query("titles") titles: String): Observable + @GET(MW_API_PREFIX + "action=query&meta=userinfo&prop=info&inprop=protection&uiprop=groups") + suspend fun getProtectionInfoSuspend(@Query("titles") titles: String): MwQueryResponse + @get:GET(MW_API_PREFIX + "action=sitematrix&smtype=language&smlangprop=code|name|localname&maxage=" + SITE_INFO_MAXAGE + "&smaxage=" + SITE_INFO_MAXAGE) val siteMatrix: Observable @@ -210,6 +241,33 @@ interface Service { @Query("colimit") coLimit: Int, ): MwQueryResponse + @GET("api.php?format=json&action=getPaymentMethods") + suspend fun getPaymentMethods(@Query("country") country: String): PaymentResponseContainer + + @FormUrlEncoded + @POST("api.php?format=json&action=submitPayment") + suspend fun submitPayment( + @Field("amount") amount: String, + @Field("app_version") appVersion: String, + @Field("banner") banner: String, + @Field("city") city: String, + @Field("country") country: String, + @Field("currency") currency: String, + @Field("donor_country") donorCountry: String, + @Field("email") email: String, + @Field("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 ------- @Headers("Cache-Control: no-cache") @@ -419,6 +477,12 @@ interface Service { @Query("sites") sites: String ): Observable + @GET(MW_API_PREFIX + "action=wbgetentities") + suspend fun getEntitiesByTitleSuspend( + @Query("titles") titles: String, + @Query("sites") sites: String + ): Entities + @GET(MW_API_PREFIX + "action=wbsearchentities&type=item&limit=20") fun searchEntities( @Query("search") searchTerm: String, @@ -432,15 +496,32 @@ interface Service { @Query("wbetlanguage") lang: String ): Observable + @GET(MW_API_PREFIX + "action=query&prop=entityterms") + suspend fun getWikidataEntityTermsSuspend( + @Query("titles") titles: String, + @Query("wbetlanguage") lang: String + ): MwQueryResponse + @GET(MW_API_PREFIX + "action=wbgetclaims") fun getClaims( @Query("entity") entity: String, @Query("property") property: String? ): Observable + @GET(MW_API_PREFIX + "action=wbgetclaims") + suspend fun getClaimsSuspend( + @Query("entity") entity: String, + @Query("property") property: String? + ): Claims + @GET(MW_API_PREFIX + "action=wbgetentities&props=descriptions|labels|sitelinks") suspend fun getWikidataLabelsAndDescriptions(@Query("ids") idList: String): Entities + @GET(MW_API_PREFIX + "action=wbgetentities&props=descriptions") + suspend fun getWikidataDescription(@Query("titles") titles: String, + @Query("sites") sites: String, + @Query("languages") langCode: String): Entities + @POST(MW_API_PREFIX + "action=wbsetclaim&errorlang=uselang") @FormUrlEncoded fun postSetClaim( @@ -637,7 +718,7 @@ interface Service { @Query("ggtlimit") count: Int ): MwQueryResponse - @GET(MW_API_PREFIX + "action=query&generator=search&gsrsearch=hasrecommendation%3Aimage&gsrnamespace=0&gsrsort=random&prop=growthimagesuggestiondata|revisions&rvprop=ids|timestamp|flags|comment|user|content&rvslots=main&rvsection=0") + @GET(MW_API_PREFIX + "action=query&generator=search&gsrsearch=hasrecommendation%3Aimage&gsrnamespace=0&gsrsort=random&prop=growthimagesuggestiondata|revisions|pageimages&rvprop=ids|timestamp|flags|comment|user|content&rvslots=main&rvsection=0") suspend fun getPagesWithImageRecommendations( @Query("gsrlimit") count: Int ): MwQueryResponse @@ -656,6 +737,10 @@ interface Service { @Query("modules") modules: String ): ParamInfoResponse + @GET(MW_API_PREFIX + "action=templatedata&includeMissingTitles=&converttitles=") + suspend fun getTemplateData(@Query("lang") langCode: String, + @Query("titles") titles: String): TemplateDataResponse + companion object { const val WIKIPEDIA_URL = "https://wikipedia.org/" const val WIKIDATA_URL = "https://www.wikidata.org/" diff --git a/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt b/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt index 3a4829da7cf..e75ea6c0ebf 100644 --- a/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt +++ b/app/src/main/java/org/wikipedia/dataclient/SharedPreferenceCookieManager.kt @@ -25,8 +25,18 @@ class SharedPreferenceCookieManager( @Synchronized fun getCookieByName(name: String): String? { for (domainSpec in cookieJar.keys) { - for (cookie in cookieJar[domainSpec]!!) { - if (cookie.name == name) { + getCookieByName(name, domainSpec)?.let { + return it + } + } + return null + } + + @Synchronized + fun getCookieByName(name: String, domainSpec: String, matchExactName: Boolean = true): String? { + cookieJar[domainSpec]?.let { cookies -> + for (cookie in cookies) { + if (if (matchExactName) cookie.name == name else cookie.name.contains(name, ignoreCase = false)) { return cookie.value } } diff --git a/app/src/main/java/org/wikipedia/dataclient/WikiSite.kt b/app/src/main/java/org/wikipedia/dataclient/WikiSite.kt index 7c8bc198f95..d72da5abc23 100644 --- a/app/src/main/java/org/wikipedia/dataclient/WikiSite.kt +++ b/app/src/main/java/org/wikipedia/dataclient/WikiSite.kt @@ -132,7 +132,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/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..e73077283eb --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/donate/DonationConfigHelper.kt @@ -0,0 +1,41 @@ +package org.wikipedia.dataclient.donate + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.decodeFromJsonElement +import okhttp3.Request +import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory +import org.wikipedia.json.JsonUtil + +object DonationConfigHelper { + + private const val CONFIG_VERSION = 1 + + private const val CONFIG_URL = "https://donate.wikimedia.org/wiki/MediaWiki:AppsDonationConfig.json?action=raw" + + suspend fun getConfig(): DonationConfig? { + val campaignList = mutableListOf() + + withContext(Dispatchers.IO) { + val url = CONFIG_URL + val request = Request.Builder().url(url).build() + val response = OkHttpConnectionFactory.client.newCall(request).execute() + val configs = JsonUtil.decodeFromString>(response.body?.string()).orEmpty() + + campaignList.addAll(configs.filter { + val proto = JsonUtil.json.decodeFromJsonElement(it) + proto.version == CONFIG_VERSION + }.map { + JsonUtil.json.decodeFromJsonElement(it) + }) + } + return campaignList.firstOrNull() + } + + @Serializable + class ConfigProto( + val version: Int = 0 + ) +} diff --git a/app/src/main/java/org/wikipedia/dataclient/donate/PaymentResponseContainer.kt b/app/src/main/java/org/wikipedia/dataclient/donate/PaymentResponseContainer.kt new file mode 100644 index 00000000000..fc09cddeec1 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/donate/PaymentResponseContainer.kt @@ -0,0 +1,48 @@ +package org.wikipedia.dataclient.donate + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.wikipedia.dataclient.mwapi.MwException +import org.wikipedia.dataclient.mwapi.MwServiceError + +@Suppress("unused") +@Serializable +class PaymentResponseContainer( + val response: PaymentResponse? = null +) + +@Suppress("unused") +@Serializable +class PaymentResponse( + val status: String = "", + @SerialName("error_message") val errorMessage: String = "", + @SerialName("order_id") val orderId: String = "", + @SerialName("gateway_transaction_id") val gatewayTransactionId: String = "", + val paymentMethods: List = emptyList() +) { + init { + if (status == "error") { + throw MwException(MwServiceError("donate_error", errorMessage)) + } + } +} + +@Suppress("unused") +@Serializable +class PaymentMethod( + val name: String = "", + val type: String = "", + val brands: List = emptyList(), + val configuration: PaymentMethodConfiguration? = null +) + +@Suppress("unused") +@Serializable +class PaymentMethodConfiguration( + val merchantId: String = "", + val merchantName: String = "", + val gatewayMerchantId: String = "", + val storeId: String = "", + val region: String = "", + val publicKeyId: String = "" +) diff --git a/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/MwQueryResult.kt index 46324ed268e..36706c53e05 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 @@ -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/TemplateDataResponse.kt b/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt new file mode 100644 index 00000000000..f196c872058 --- /dev/null +++ b/app/src/main/java/org/wikipedia/dataclient/mwapi/TemplateDataResponse.kt @@ -0,0 +1,65 @@ +package org.wikipedia.dataclient.mwapi + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.decodeFromJsonElement +import kotlinx.serialization.json.jsonPrimitive +import org.wikipedia.json.JsonUtil +import org.wikipedia.util.log.L + +@Serializable +class TemplateDataResponse : MwResponse() { + + private val pages: Map? = null + + val getTemplateData get() = pages?.values?.toList() ?: emptyList() + + @Serializable + class TemplateData { + val title: String = "" + // When send lang=[langCode], the type of it will become String instead of a Map + val description: String? = null + private val params: JsonElement? = null + val format: String? = null + @SerialName("notemplatedata") val noTemplateData: Boolean = false + + val getParams: Map? get() { + try { + if (params != null && params !is JsonArray) { + return if (noTemplateData) { + JsonUtil.json.decodeFromJsonElement>(params).mapValues { + TemplateDataParam() + } + } else { + JsonUtil.json.decodeFromJsonElement>(params) + } + } + } catch (e: Exception) { + L.d("Error on parsing params $e") + } + return null + } + } + + @Serializable + class TemplateDataParam { + // [label, description, default and example]: The original format of them is in a Map style; + // When you send a target language in the request, it will become a String. + val label: String? = null + val description: String? = null + val default: String? = null + val example: String? = null + val type: String = "" + val required: Boolean = false + val suggested: Boolean = false + @SerialName("autovalue") val autoValue: String? = null + @SerialName("suggestedvalues") val suggestedValues: List = emptyList() + val aliases: List = emptyList() + private val deprecated: JsonElement? = null + + val isDeprecated get() = deprecated != null && !deprecated.jsonPrimitive.contentOrNull.equals("false", true) + } +} diff --git a/app/src/main/java/org/wikipedia/dataclient/okhttp/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/restbase/RbDefinition.kt b/app/src/main/java/org/wikipedia/dataclient/restbase/RbDefinition.kt index 65b4f07f541..31df15d60b0 100644 --- a/app/src/main/java/org/wikipedia/dataclient/restbase/RbDefinition.kt +++ b/app/src/main/java/org/wikipedia/dataclient/restbase/RbDefinition.kt @@ -3,7 +3,7 @@ package org.wikipedia.dataclient.restbase import kotlinx.serialization.Serializable @Serializable -class RbDefinition(val usagesByLang: Map>) { +class RbDefinition { @Serializable class Usage(val partOfSpeech: String = "", val definitions: List) diff --git a/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt b/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt index 12735d80c60..8a25a091888 100644 --- a/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt +++ b/app/src/main/java/org/wikipedia/dataclient/wikidata/Entities.kt @@ -37,6 +37,10 @@ class Entities : MwResponse() { emptyMap() } } + + fun getDescription(langCode: String): String? { + return descriptions[langCode]?.value + } } @Serializable diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt index be16f86d193..17c268e1006 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditActivity.kt @@ -10,6 +10,7 @@ import org.wikipedia.activity.SingleFragmentActivity import org.wikipedia.analytics.eventplatform.ABTest.Companion.GROUP_1 import org.wikipedia.analytics.eventplatform.MachineGeneratedArticleDescriptionsAnalyticsHelper import org.wikipedia.auth.AccountUtil +import org.wikipedia.commons.ImagePreviewDialog import org.wikipedia.extensions.parcelableExtra import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter @@ -19,7 +20,6 @@ import org.wikipedia.settings.Prefs import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.util.DeviceUtil import org.wikipedia.util.ReleaseUtil -import org.wikipedia.views.ImagePreviewDialog import org.wikipedia.views.SuggestedArticleDescriptionsDialog class DescriptionEditActivity : SingleFragmentActivity(), DescriptionEditFragment.Callback { diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditBottomBarView.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditBottomBarView.kt index 3c20fc141c2..5df3a3d0c9b 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditBottomBarView.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditBottomBarView.kt @@ -27,7 +27,7 @@ class DescriptionEditBottomBarView constructor(context: Context, attrs: Attribut fun setSummary(summaryForEdit: PageSummaryForEdit) { setConditionalLayoutDirection(this, summaryForEdit.lang) - binding.viewArticleTitle.text = StringUtil.fromHtml(StringUtil.removeNamespace(summaryForEdit.displayTitle!!)) + binding.viewArticleTitle.text = StringUtil.fromHtml(StringUtil.removeNamespace(summaryForEdit.displayTitle)) if (summaryForEdit.thumbnailUrl.isNullOrEmpty()) { binding.viewImageThumbnail.visibility = GONE } else { diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt index 933c2f574d9..7458a42fcf9 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditFragment.kt @@ -9,6 +9,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity import androidx.core.os.bundleOf import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope @@ -16,7 +17,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.schedulers.Schedulers -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R @@ -46,11 +48,13 @@ import org.wikipedia.settings.Prefs import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.suggestededits.SuggestedEditsSurvey import org.wikipedia.suggestededits.SuggestionsActivity -import org.wikipedia.util.* +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ReleaseUtil +import org.wikipedia.util.StringUtil import org.wikipedia.util.log.L import java.io.IOException -import java.lang.Runnable -import java.util.* +import java.util.Date import java.util.concurrent.TimeUnit class DescriptionEditFragment : Fragment() { @@ -140,7 +144,7 @@ class DescriptionEditFragment : Fragment() { val loginIntent = LoginActivity.newIntent(requireActivity(), LoginActivity.SOURCE_EDIT) loginLauncher.launch(loginIntent) } - captchaHandler = CaptchaHandler(requireActivity(), pageTitle.wikiSite, binding.fragmentDescriptionEditView.getCaptchaContainer().root, + captchaHandler = CaptchaHandler(requireActivity() as AppCompatActivity, pageTitle.wikiSite, binding.fragmentDescriptionEditView.getCaptchaContainer().root, binding.fragmentDescriptionEditView.getDescriptionEditTextView(), "", null) return binding.root } diff --git a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditView.kt b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditView.kt index f275dff87a3..7cf08ea31a0 100644 --- a/app/src/main/java/org/wikipedia/descriptions/DescriptionEditView.kt +++ b/app/src/main/java/org/wikipedia/descriptions/DescriptionEditView.kt @@ -67,7 +67,7 @@ class DescriptionEditView : LinearLayout, MlKitLanguageDetector.Callback { } init { - FeedbackUtil.setButtonLongPressToast(binding.viewDescriptionEditSaveButton, binding.viewDescriptionEditCancelButton) + FeedbackUtil.setButtonTooltip(binding.viewDescriptionEditSaveButton, binding.viewDescriptionEditCancelButton) orientation = VERTICAL mlKitLanguageDetector.callback = this diff --git a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt index 6d614b431b5..d8715fe55f4 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,6 +53,7 @@ import org.wikipedia.suggestededits.SuggestedEditsCardsFragment import org.wikipedia.talk.TalkReplyActivity import org.wikipedia.talk.TalkTopicsActivity import org.wikipedia.talk.UserTalkPopupHelper +import org.wikipedia.talk.template.TalkTemplatesActivity import org.wikipedia.util.ClipboardUtil import org.wikipedia.util.DateUtil import org.wikipedia.util.FeedbackUtil @@ -83,30 +86,25 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M binding.overlayRevisionDetailsView.isVisible = -verticalOffset > bounds.top } - private val requestWarn = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == TalkReplyActivity.RESULT_EDIT_SUCCESS || it.resultCode == TalkReplyActivity.RESULT_SAVE_TEMPLATE) { - viewModel.revisionTo?.let { revision -> - val pageTitle = PageTitle(UserAliasData.valueFor(viewModel.pageTitle.wikiSite.languageCode), revision.user, viewModel.pageTitle.wikiSite) - val message = if (it.resultCode == TalkReplyActivity.RESULT_EDIT_SUCCESS) { - sendPatrollerExperienceEvent("publish_message_toast", "pt_warning_messages") - R.string.talk_warn_submitted - } else { - sendPatrollerExperienceEvent("publish_message_saved_toast", "pt_warning_messages") - R.string.talk_warn_submitted_and_saved - } - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(message)) - snackbar.setAction(R.string.patroller_tasks_patrol_edit_snackbar_view) { - sendPatrollerExperienceEvent("publish_message_view_click", "pt_warning_messages") - startActivity(TalkTopicsActivity.newIntent(requireContext(), pageTitle, InvokeSource.DIFF_ACTIVITY)) - } - snackbar.show() + private val requestTalk = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode == TalkReplyActivity.RESULT_EDIT_SUCCESS || result.resultCode == TalkReplyActivity.RESULT_SAVE_TEMPLATE) { + val pageTitle = result.data?.parcelableExtra(Constants.ARG_TITLE) ?: viewModel.pageTitle + val message = if (result.resultCode == TalkReplyActivity.RESULT_EDIT_SUCCESS) { + PatrollerExperienceEvent.logAction("publish_message_toast", "pt_warning_messages") + R.string.talk_warn_submitted + } else { + PatrollerExperienceEvent.logAction("publish_message_saved_toast", "pt_warning_messages") + R.string.talk_warn_submitted_and_saved } - } - } - - private fun sendPatrollerExperienceEvent(action: String, activeInterface: String, actionData: String = "") { - if (viewModel.fromRecentEdits) { - PatrollerExperienceEvent.logAction(action, activeInterface, actionData) + FeedbackUtil.makeSnackbar(requireActivity(), getString(message)) + .setAction(R.string.patroller_tasks_patrol_edit_snackbar_view) { + if (isAdded) { + PatrollerExperienceEvent.logAction("publish_message_view_click", "pt_warning_messages") + startActivity(TalkTopicsActivity.newIntent(requireContext(), pageTitle, InvokeSource.DIFF_ACTIVITY)) + } + } + .setAnchorView(binding.navTabContainer) + .show() } } @@ -124,7 +122,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M super.onCreateView(inflater, container, savedInstanceState) _binding = FragmentArticleEditDetailsBinding.inflate(inflater, container, false) binding.diffRecyclerView.layoutManager = LinearLayoutManager(requireContext()) - FeedbackUtil.setButtonLongPressToast(binding.newerIdButton, binding.olderIdButton) + FeedbackUtil.setButtonTooltip(binding.newerIdButton, binding.olderIdButton) return binding.root } @@ -134,6 +132,10 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M setLoadingState() requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + if (savedInstanceState == null) { + EditAttemptStepEvent.logInit(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) + } + if (!viewModel.fromRecentEdits) { (requireActivity() as AppCompatActivity).supportActionBar?.title = getString(R.string.revision_diff_compare) binding.articleTitleView.text = StringUtil.fromHtml(viewModel.pageTitle.displayText) @@ -197,6 +199,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M viewModel.undoEditResponse.observe(viewLifecycleOwner) { binding.progressBar.isVisible = false if (it is Resource.Success) { + EditAttemptStepEvent.logSaveSuccess(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) setLoadingState() viewModel.getRevisionDetails(it.data.edit!!.newRevId) sendPatrollerExperienceEvent("undo_success", "pt_edit", @@ -205,6 +208,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M editHistoryInteractionEvent?.logUndoSuccess() callback()?.onUndoSuccess() } else if (it is Resource.Error) { + EditAttemptStepEvent.logSaveFailure(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) it.throwable.printStackTrace() FeedbackUtil.showError(requireActivity(), it.throwable) editHistoryInteractionEvent?.logUndoFail() @@ -224,6 +228,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M viewModel.rollbackResponse.observe(viewLifecycleOwner) { binding.progressBar.isVisible = false if (it is Resource.Success) { + EditAttemptStepEvent.logSaveSuccess(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) setLoadingState() viewModel.getRevisionDetails(it.data.rollback?.revision ?: 0) sendPatrollerExperienceEvent("rollback_success", "pt_edit", @@ -231,6 +236,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M showRollbackSnackbar() callback()?.onRollbackSuccess() } else if (it is Resource.Error) { + EditAttemptStepEvent.logSaveFailure(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) it.throwable.printStackTrace() FeedbackUtil.showError(requireActivity(), it.throwable) } @@ -309,7 +315,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M binding.undoButton.setOnClickListener { val canUndo = viewModel.revisionFrom != null && AccountUtil.isLoggedIn val canRollback = AccountUtil.isLoggedIn && viewModel.hasRollbackRights && !viewModel.canGoForward - + EditAttemptStepEvent.logSaveIntent(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) if (canUndo && canRollback) { PopupMenu(requireContext(), binding.undoLabel, Gravity.END).apply { menuInflater.inflate(R.menu.menu_context_undo, menu) @@ -350,7 +356,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M sendPatrollerExperienceEvent("warn_init", "pt_toolbar") viewModel.revisionTo?.let { revision -> val pageTitle = PageTitle(UserTalkAliasData.valueFor(viewModel.pageTitle.wikiSite.languageCode), revision.user, viewModel.pageTitle.wikiSite) - requestWarn.launch(TalkReplyActivity.newIntent(requireContext(), pageTitle, null, null, invokeSource = InvokeSource.DIFF_ACTIVITY, fromDiff = true)) + requestTalk.launch(TalkTemplatesActivity.newIntent(requireContext(), pageTitle, fromRevisionId = viewModel.revisionFromId, toRevisionId = viewModel.revisionToId)) } } @@ -366,6 +372,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M menu.findItem(R.id.menu_view_edit_history).isVisible = viewModel.fromRecentEdits menu.findItem(R.id.menu_report_feature).isVisible = viewModel.fromRecentEdits menu.findItem(R.id.menu_learn_more).isVisible = viewModel.fromRecentEdits + menu.findItem(R.id.menu_saved_messages).isVisible = viewModel.fromRecentEdits } override fun onMenuItemSelected(item: MenuItem): Boolean { @@ -396,6 +403,12 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M showFeedbackOptionsDialog(true) true } + R.id.menu_saved_messages -> { + sendPatrollerExperienceEvent("diff_saved_init", "pt_warning_messages") + val pageTitle = PageTitle(UserTalkAliasData.valueFor(viewModel.pageTitle.wikiSite.languageCode), viewModel.pageTitle.text, viewModel.pageTitle.wikiSite) + requireActivity().startActivity(TalkTemplatesActivity.newIntent(requireContext(), pageTitle, true)) + true + } else -> false } } @@ -542,52 +555,54 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M updateWatchButton(false) if (!viewModel.isWatched) { sendPatrollerExperienceEvent("unwatch_success_toast", "pt_watchlist") - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.watchlist_page_removed_from_watchlist_snackbar, viewModel.pageTitle.displayText)) - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.watchlist_page_removed_from_watchlist_snackbar, viewModel.pageTitle.displayText)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() } - showFeedbackOptionsDialog() - } - }) - snackbar.show() + }) + .setAnchorView(binding.navTabContainer) + .show() } else if (viewModel.isWatched) { sendPatrollerExperienceEvent("watch_success_toast", "pt_watchlist") - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.watchlist_page_add_to_watchlist_snackbar, viewModel.pageTitle.displayText, getString(WatchlistExpiry.NEVER.stringId))) - snackbar.setAction(R.string.watchlist_page_add_to_watchlist_snackbar_action) { - ExclusiveBottomSheetPresenter.show(childFragmentManager, WatchlistExpiryDialog.newInstance(viewModel.pageTitle, WatchlistExpiry.NEVER)) - } - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return - } - showFeedbackOptionsDialog() + .setAction(R.string.watchlist_page_add_to_watchlist_snackbar_action) { + ExclusiveBottomSheetPresenter.show(childFragmentManager, WatchlistExpiryDialog.newInstance(viewModel.pageTitle, WatchlistExpiry.NEVER)) } - }) - snackbar.show() + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() + } + }) + .setAnchorView(binding.navTabContainer) + .show() } } private fun showThankSnackbar() { - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.thank_success_message, - viewModel.revisionTo?.user)) binding.thankIcon.setImageResource(R.drawable.ic_heart_24) binding.thankButton.isEnabled = false - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.thank_success_message, viewModel.revisionTo?.user)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() } - showFeedbackOptionsDialog() - } - }) + }) + .setAnchorView(binding.navTabContainer) + .show() sendPatrollerExperienceEvent("thank_success", "pt_thank") - snackbar.show() } private fun showThankDialog() { @@ -612,6 +627,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M if (viewModel.fromRecentEdits) InvokeSource.SUGGESTED_EDITS_RECENT_EDITS else null) { text -> viewModel.revisionTo?.let { binding.progressBar.isVisible = true + EditAttemptStepEvent.logSaveAttempt(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) viewModel.undoEdit(viewModel.pageTitle, it.user, text.toString(), viewModel.revisionToId, 0) } } @@ -619,16 +635,17 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M } private fun showUndoSnackbar() { - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.patroller_tasks_patrol_edit_undo_success)) - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.patroller_tasks_patrol_edit_undo_success)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() } - showFeedbackOptionsDialog() - } - }) - snackbar.show() + }) + .setAnchorView(binding.navTabContainer) + .show() } private fun showRollbackDialog() { @@ -638,6 +655,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M sendPatrollerExperienceEvent("rollback_confirm", "pt_edit") binding.progressBar.isVisible = true viewModel.revisionTo?.let { + EditAttemptStepEvent.logSaveAttempt(viewModel.pageTitle, EditAttemptStepEvent.INTERFACE_OTHER) viewModel.postRollback(viewModel.pageTitle, it.user) } } @@ -648,16 +666,17 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M } private fun showRollbackSnackbar() { - val snackbar = FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.patroller_tasks_patrol_edit_rollback_success)) - snackbar.addCallback(object : Snackbar.Callback() { - override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { - if (!isAdded) { - return + FeedbackUtil.makeSnackbar(requireActivity(), getString(R.string.patroller_tasks_patrol_edit_rollback_success)) + .addCallback(object : Snackbar.Callback() { + override fun onDismissed(transientBottomBar: Snackbar, @DismissEvent event: Int) { + if (!isAdded) { + return + } + showFeedbackOptionsDialog() } - showFeedbackOptionsDialog() - } - }) - snackbar.show() + }) + .setAnchorView(binding.navTabContainer) + .show() } private fun showFeedbackOptionsDialog(skipPreference: Boolean = false) { @@ -693,14 +712,17 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M FeedbackUtil.showMessage(this, R.string.address_copied) } + private fun sendPatrollerExperienceEvent(action: String, activeInterface: String, actionData: String = "") { + if (viewModel.fromRecentEdits) { + PatrollerExperienceEvent.logAction(action, activeInterface, actionData) + } + } + private fun callback(): Callback? { return FragmentUtil.getCallback(this, Callback::class.java) } companion object { - const val DIFF_UNDO_COMMENT = "#diff-undo" - const val DIFF_ROLLBACK_COMMENT = "#diff-rollback" - fun newInstance(title: PageTitle, pageId: Int, revisionFrom: Long, revisionTo: Long, source: InvokeSource): ArticleEditDetailsFragment { return ArticleEditDetailsFragment().apply { arguments = bundleOf(ArticleEditDetailsActivity.EXTRA_ARTICLE_TITLE to title, diff --git a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt index 76652421d78..bc095b12162 100644 --- a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt +++ b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsViewModel.kt @@ -29,12 +29,10 @@ import org.wikipedia.suggestededits.provider.EditingSuggestionsProvider import org.wikipedia.util.Resource import org.wikipedia.util.SingleLiveData import org.wikipedia.watchlist.WatchlistExpiry -import org.wikipedia.watchlist.WatchlistFragment class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { private val invokeSource = bundle.getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as InvokeSource - private val fromWatchList = invokeSource == InvokeSource.WATCHLIST_ACTIVITY val watchedStatus = MutableLiveData>() val rollbackRights = MutableLiveData>() @@ -60,8 +58,6 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { var hasRollbackRights = false var isWatched = false - var feedbackInput = "" - val diffSize get() = if (revisionFrom != null) revisionTo!!.size - revisionFrom!!.size else revisionTo!!.size init { @@ -230,8 +226,9 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { 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 - summary += if (fromRecentEdits) DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_UNDO else if (fromWatchList) WatchlistFragment.WATCHLIST_UNDO_COMMENT - else ArticleEditDetailsFragment.DIFF_UNDO_COMMENT + if (fromRecentEdits) { + summary += ", " + DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_UNDO + } val token = ServiceFactory.get(title.wikiSite).getToken().query!!.csrfToken()!! val undoResponse = ServiceFactory.get(title.wikiSite).postUndoEdit(title.prefixedText, summary, null, token, revisionId, if (revisionIdAfter > 0) revisionIdAfter else null) @@ -248,8 +245,10 @@ class ArticleEditDetailsViewModel(bundle: Bundle) : ViewModel() { .query?.allmessages?.firstOrNull { it.name == "revertpage" }?.content val rollbackToken = ServiceFactory.get(title.wikiSite).getToken("rollback").query!!.rollbackToken()!! - val summary = "$rollbackSummaryMsg, " + if (fromRecentEdits) DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_ROLLBACK else if (fromWatchList) WatchlistFragment.WATCHLIST_ROLLBACK_COMMENT - else ArticleEditDetailsFragment.DIFF_ROLLBACK_COMMENT + var summary = rollbackSummaryMsg + if (fromRecentEdits) { + summary += ", " + DescriptionEditFragment.SUGGESTED_EDITS_PATROLLER_TASKS_ROLLBACK + } val rollbackPostResponse = ServiceFactory.get(title.wikiSite).postRollback(title.prefixedText, summary, user, rollbackToken) rollbackResponse.postValue(Resource.Success(rollbackPostResponse)) } diff --git a/app/src/main/java/org/wikipedia/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..cf31b337c2d --- /dev/null +++ b/app/src/main/java/org/wikipedia/donate/DonateViewModel.kt @@ -0,0 +1,34 @@ +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.GeoUtil +import org.wikipedia.util.ReleaseUtil +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() + + val isGooglePayAvailable = GooglePayComponent.isGooglePayAvailable(activity) && + (ReleaseUtil.isPreProdRelease || ALLOWED_COUNTRIES.contains(GeoUtil.geoIPCountry.orEmpty())) // TODO: remove when ready + + _uiState.value = Resource.Success(isGooglePayAvailable) + } + } + + companion object { + private val ALLOWED_COUNTRIES = listOf("JP") + } +} diff --git a/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt b/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt index e5265bbb1be..45df88692d4 100644 --- a/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt +++ b/app/src/main/java/org/wikipedia/edit/EditSectionActivity.kt @@ -716,6 +716,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) diff --git a/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt b/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt index e3d81417de3..a3838cc4bc3 100644 --- a/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt +++ b/app/src/main/java/org/wikipedia/edit/FindInEditorActionProvider.kt @@ -63,8 +63,8 @@ class FindInEditorActionProvider(private val scrollView: View, currentResultIndex = 0 resultPositions.clear() - searchQuery?.let { - resultPositions += it.toRegex(StringUtil.SEARCH_REGEX_OPTIONS).findAll(textView.text) + searchQuery?.let { query -> + resultPositions += query.toRegex(StringUtil.SEARCH_REGEX_OPTIONS).findAll(textView.text) .map { it.range.first } } scrollToCurrentResult() @@ -72,11 +72,15 @@ class FindInEditorActionProvider(private val scrollView: View, private fun scrollToCurrentResult() { setMatchesResults(currentResultIndex, resultPositions.size) - val textPosition = resultPositions.getOrElse(currentResultIndex) { 0 } - textView.setSelection(textPosition, textPosition + searchQuery.orEmpty().length) + var highlightLength = searchQuery.orEmpty().length + val textPosition = resultPositions.getOrElse(currentResultIndex) { + highlightLength = 0 + 0 + } + textView.setSelection(textPosition, textPosition + highlightLength) val r = Rect() textView.getFocusedRect(r) scrollView.scrollTo(0, r.top - DimenUtil.roundedDpToPx(32f)) - syntaxHighlighter.setSearchQueryInfo(resultPositions, searchQuery.orEmpty().length, currentResultIndex) + syntaxHighlighter.setSearchQueryInfo(resultPositions, highlightLength, currentResultIndex) } } diff --git a/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt b/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt index 675508a74a3..9616134b297 100644 --- a/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt +++ b/app/src/main/java/org/wikipedia/edit/SyntaxHighlightViewAdapter.kt @@ -7,7 +7,9 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import org.wikipedia.Constants +import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent import org.wikipedia.edit.insertmedia.InsertMediaActivity +import org.wikipedia.edit.templates.TemplatesSearchActivity import org.wikipedia.extensions.parcelableExtra import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter @@ -27,6 +29,7 @@ class SyntaxHighlightViewAdapter( private val wikiTextKeyboardHeadingsView: WikiTextKeyboardHeadingsView, private val invokeSource: Constants.InvokeSource, private val requestInsertMedia: ActivityResultLauncher, + private val isFromDiff: Boolean = false, showUserMention: Boolean = false ) : WikiTextKeyboardView.Callback { @@ -61,6 +64,15 @@ class SyntaxHighlightViewAdapter( } } + private val requestInsertTemplate = activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == TemplatesSearchActivity.RESULT_INSERT_TEMPLATE_SUCCESS) { + it.data?.let { data -> + val newWikiText = data.getStringExtra(TemplatesSearchActivity.RESULT_WIKI_TEXT) + editText.inputConnection?.commitText(newWikiText, 1) + } + } + } + override fun onPreviewLink(title: String) { val dialog = LinkPreviewDialog.newInstance(HistoryEntry(PageTitle(title, pageTitle.wikiSite), HistoryEntry.SOURCE_INTERNAL_LINK)) ExclusiveBottomSheetPresenter.show(activity.supportFragmentManager, dialog) @@ -81,6 +93,14 @@ class SyntaxHighlightViewAdapter( invokeSource)) } + override fun onRequestInsertTemplate() { + if (isFromDiff) { + val activeInterface = if (invokeSource == Constants.InvokeSource.TALK_REPLY_ACTIVITY) "pt_talk" else "pt_edit" + PatrollerExperienceEvent.logAction("template_init", activeInterface) + } + requestInsertTemplate.launch(TemplatesSearchActivity.newIntent(activity, pageTitle.wikiSite, isFromDiff, invokeSource)) + } + override fun onRequestInsertLink() { requestLinkFromSearch.launch(SearchActivity.newIntent(activity, invokeSource, null, true)) } diff --git a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardFormattingView.kt b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardFormattingView.kt index d3badf69a2f..302d0254ed2 100644 --- a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardFormattingView.kt +++ b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardFormattingView.kt @@ -20,7 +20,7 @@ class WikiTextKeyboardFormattingView : FrameLayout { binding.closeButton.setOnClickListener { callback?.onSyntaxOverlayCollapse() } - FeedbackUtil.setButtonLongPressToast(binding.closeButton) + FeedbackUtil.setButtonTooltip(binding.closeButton) binding.wikitextButtonBold.setOnClickListener { editText?.inputConnection?.let { WikiTextKeyboardView.toggleSyntaxAroundCurrentSelection(editText, it, "'''", "'''") diff --git a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardHeadingsView.kt b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardHeadingsView.kt index 6eaba4967be..42568fe51c9 100644 --- a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardHeadingsView.kt +++ b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardHeadingsView.kt @@ -47,7 +47,7 @@ class WikiTextKeyboardHeadingsView : FrameLayout { } } - FeedbackUtil.setButtonLongPressToast(binding.closeButton, binding.wikitextButtonH2, binding.wikitextButtonH3, + FeedbackUtil.setButtonTooltip(binding.closeButton, binding.wikitextButtonH2, binding.wikitextButtonH3, binding.wikitextButtonH4, binding.wikitextButtonH5) } } diff --git a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardView.kt b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardView.kt index eb813ba1151..c2f72ea7dfa 100644 --- a/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardView.kt +++ b/app/src/main/java/org/wikipedia/edit/WikiTextKeyboardView.kt @@ -19,6 +19,7 @@ class WikiTextKeyboardView constructor(context: Context, attrs: AttributeSet?) : fun onPreviewLink(title: String) fun onRequestInsertMedia() fun onRequestInsertLink() + fun onRequestInsertTemplate() fun onRequestHeading() fun onRequestFormatting() fun onSyntaxOverlayCollapse() @@ -64,9 +65,7 @@ class WikiTextKeyboardView constructor(context: Context, attrs: AttributeSet?) : } binding.wikitextButtonTemplate.setOnClickListener { - editText?.inputConnection?.let { - toggleSyntaxAroundCurrentSelection(editText, it, "{{", "}}") - } + callback?.onRequestInsertTemplate() } binding.wikitextButtonRef.setOnClickListener { diff --git a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaSettingsFragment.kt b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaSettingsFragment.kt index fed345548e3..373807a626f 100644 --- a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaSettingsFragment.kt +++ b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaSettingsFragment.kt @@ -17,6 +17,7 @@ import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.ImageRecommendationsEvent +import org.wikipedia.commons.ImagePreviewDialog import org.wikipedia.databinding.FragmentInsertMediaSettingsBinding import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.LinkMovementMethodExt @@ -27,7 +28,6 @@ import org.wikipedia.util.DeviceUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.StringUtil import org.wikipedia.views.AppTextViewWithImages -import org.wikipedia.views.ImagePreviewDialog import org.wikipedia.views.ViewUtil class InsertMediaSettingsFragment : Fragment() { diff --git a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaViewModel.kt b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaViewModel.kt index 11b534505d0..1c136e20920 100644 --- a/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaViewModel.kt +++ b/app/src/main/java/org/wikipedia/edit/insertmedia/InsertMediaViewModel.kt @@ -18,6 +18,7 @@ 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 @@ -30,7 +31,7 @@ class InsertMediaViewModel(bundle: Bundle) : ViewModel() { 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 imagePosition: String = bundle.getString(InsertMediaActivity.RESULT_IMAGE_POS, defaultImagePositionForLang(wikiSite.languageCode)) var imageType: String = bundle.getString(InsertMediaActivity.RESULT_IMAGE_TYPE, IMAGE_TYPE_THUMBNAIL) var imageSize: String = bundle.getString(InsertMediaActivity.RESULT_IMAGE_SIZE, IMAGE_SIZE_DEFAULT) @@ -164,6 +165,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 +182,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 +220,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..af5583aedb7 100644 --- a/app/src/main/java/org/wikipedia/edit/richtext/SyntaxHighlighter.kt +++ b/app/src/main/java/org/wikipedia/edit/richtext/SyntaxHighlighter.kt @@ -23,6 +23,7 @@ class SyntaxHighlighter( private val highlightDelayMillis: Long = HIGHLIGHT_DELAY_MILLIS) { private val syntaxRules = listOf( + SyntaxRule("{{{", "}}}", SyntaxRuleStyle.PRE_TEMPLATE), SyntaxRule("{{", "}}", SyntaxRuleStyle.TEMPLATE), SyntaxRule("[[", "]]", SyntaxRuleStyle.INTERNAL_LINK), SyntaxRule("[", "]", SyntaxRuleStyle.EXTERNAL_LINK), diff --git a/app/src/main/java/org/wikipedia/edit/richtext/SyntaxRuleStyle.kt b/app/src/main/java/org/wikipedia/edit/richtext/SyntaxRuleStyle.kt index d8e1e3c6bb8..334b9ac166d 100644 --- a/app/src/main/java/org/wikipedia/edit/richtext/SyntaxRuleStyle.kt +++ b/app/src/main/java/org/wikipedia/edit/richtext/SyntaxRuleStyle.kt @@ -9,6 +9,11 @@ import org.wikipedia.R import org.wikipedia.util.ResourceUtil enum class SyntaxRuleStyle { + PRE_TEMPLATE { + override fun createSpan(ctx: Context, spanStart: Int, syntaxItem: SyntaxRule): SpanExtents { + return ColorSpanEx(ResourceUtil.getThemedColor(ctx, R.attr.success_color), Color.TRANSPARENT, spanStart, syntaxItem) + } + }, TEMPLATE { override fun createSpan(ctx: Context, spanStart: Int, syntaxItem: SyntaxRule): SpanExtents { return ColorSpanEx(ResourceUtil.getThemedColor(ctx, R.attr.placeholder_color), Color.TRANSPARENT, spanStart, syntaxItem) diff --git a/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt b/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt new file mode 100644 index 00000000000..50c91ebee37 --- /dev/null +++ b/app/src/main/java/org/wikipedia/edit/templates/InsertTemplateFragment.kt @@ -0,0 +1,134 @@ +package org.wikipedia.edit.templates + +import android.net.Uri +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.widget.addTextChangedListener +import androidx.fragment.app.Fragment +import com.google.android.material.textfield.TextInputLayout +import org.wikipedia.R +import org.wikipedia.databinding.FragmentInsertTemplateBinding +import org.wikipedia.databinding.ItemInsertTemplateBinding +import org.wikipedia.dataclient.mwapi.TemplateDataResponse +import org.wikipedia.page.LinkMovementMethodExt +import org.wikipedia.page.PageTitle +import org.wikipedia.util.StringUtil +import org.wikipedia.util.UriUtil +import org.wikipedia.views.PlainPasteEditText + +class InsertTemplateFragment : Fragment() { + + private lateinit var activity: TemplatesSearchActivity + private var _binding: FragmentInsertTemplateBinding? = null + private val binding get() = _binding!! + val isActive get() = binding.root.visibility == View.VISIBLE + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + _binding = FragmentInsertTemplateBinding.inflate(layoutInflater, container, false) + activity = (requireActivity() as TemplatesSearchActivity) + activity.supportActionBar?.title = null + return binding.root + } + + private fun buildParamsInputFields(templateData: TemplateDataResponse.TemplateData) { + activity.updateInsertButton(true) + binding.templateDataParamsContainer.removeAllViews() + templateData.getParams?.filter { + !it.value.isDeprecated + }?.forEach { + val itemBinding = ItemInsertTemplateBinding.inflate(layoutInflater) + val labelText = it.value.label.orEmpty().ifEmpty { StringUtil.capitalize(it.key) } + itemBinding.root.tag = false + if (it.value.required) { + itemBinding.textInputLayout.hint = labelText + itemBinding.editText.addTextChangedListener { + if (!activity.isDestroyed) { + checkRequiredParams() + } + } + itemBinding.root.tag = true + // Make the insert button disable when require param shows up. + activity.updateInsertButton(false) + } else if (it.value.suggested) { + itemBinding.textInputLayout.hint = getString(R.string.templates_param_suggested_hint, labelText) + } else { + itemBinding.textInputLayout.hint = getString(R.string.templates_param_optional_hint, labelText) + } + itemBinding.textInputLayout.tag = it.key + val hintText = it.value.suggestedValues.firstOrNull() + if (!hintText.isNullOrEmpty()) { + itemBinding.textInputLayout.placeholderText = getString(R.string.templates_param_suggested_value, hintText) + } + itemBinding.textInputLayout.helperText = it.value.description + binding.templateDataParamsContainer.addView(itemBinding.root) + } + } + + private fun checkRequiredParams() { + val allRequiredParamsFilled = !binding.templateDataParamsContainer.children + .any { it.tag == true && it.findViewById(R.id.editText).text.toString().trim().isEmpty() } + activity.updateInsertButton(allRequiredParamsFilled) + } + + fun show(pageTitle: PageTitle, templateData: TemplateDataResponse.TemplateData) { + activity.sendPatrollerExperienceEvent("search_success", "pt_templates") + binding.root.isVisible = true + binding.templateDataTitle.text = StringUtil.removeNamespace(pageTitle.displayText) + binding.templateDataDescription.text = StringUtil.fromHtml(getTemplateDescription(templateData)) + binding.templateDataDescription.isVisible = !binding.templateDataDescription.text.isNullOrEmpty() + binding.templateDataMissing.isVisible = templateData.noTemplateData + binding.templateDataMissingText.text = StringUtil.fromHtml(getString(R.string.templates_description_missing_data, + getString(R.string.template_parameters_url), getString(R.string.autogenerated_parameters_url))) + binding.templateDataMissingText.movementMethod = LinkMovementMethodExt.getExternalLinkMovementMethod() + binding.templateDataLearnMoreButton.setOnClickListener { + activity.sendPatrollerExperienceEvent("learn_click", "pt_templates") + UriUtil.visitInExternalBrowser(requireContext(), Uri.parse(pageTitle.uri)) + } + buildParamsInputFields(templateData) + } + + private fun getTemplateDescription(templateData: TemplateDataResponse.TemplateData): String { + return if (templateData.description.isNullOrEmpty()) { + getString(R.string.templates_description_empty, templateData.title) + } else { + templateData.description + "
" + getString(R.string.templates_description_incomplete) + "" + } + } + + fun hide() { + binding.root.isVisible = false + activity.invalidateOptionsMenu() + } + + fun collectParamsInfoAndBuildWikiText(): String { + var wikiText = "{{" + wikiText += binding.templateDataTitle.text + binding.templateDataParamsContainer.children.iterator().forEach { + var label = it.findViewById(R.id.textInputLayout).tag as String + label = if (label.toIntOrNull() != null) "" else "$label=" + val editText = it.findViewById(R.id.editText).text.toString().trim() + if (editText.isNotEmpty()) { + wikiText += "|$label$editText" + } + } + wikiText += "}}" + return wikiText + } + + fun handleBackPressed(): Boolean { + if (isActive) { + hide() + return true + } + return false + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } +} diff --git a/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt new file mode 100644 index 00000000000..7962a467eb2 --- /dev/null +++ b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchActivity.kt @@ -0,0 +1,228 @@ +package org.wikipedia.edit.templates + +import android.content.Context +import android.content.Intent +import android.graphics.Color +import android.graphics.Typeface +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import androidx.activity.viewModels +import androidx.appcompat.widget.SearchView +import androidx.core.view.isVisible +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.paging.LoadState +import androidx.paging.PagingDataAdapter +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.R +import org.wikipedia.activity.BaseActivity +import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent +import org.wikipedia.databinding.ActivityTemplatesSearchBinding +import org.wikipedia.databinding.ItemTemplatesSearchBinding +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.mwapi.TemplateDataResponse +import org.wikipedia.page.PageTitle +import org.wikipedia.settings.Prefs +import org.wikipedia.util.DeviceUtil +import org.wikipedia.util.DimenUtil +import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ResourceUtil +import org.wikipedia.util.StringUtil + +class TemplatesSearchActivity : BaseActivity() { + private lateinit var binding: ActivityTemplatesSearchBinding + private lateinit var insertTemplateFragment: InsertTemplateFragment + + private var templatesSearchAdapter: TemplatesSearchAdapter? = null + + val viewModel: TemplatesSearchViewModel by viewModels { TemplatesSearchViewModel.Factory(intent.extras!!) } + + private val searchCloseListener = SearchView.OnCloseListener { + closeSearch() + false + } + + private val searchQueryListener = object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(queryText: String): Boolean { + DeviceUtil.hideSoftKeyboard(this@TemplatesSearchActivity) + return true + } + + override fun onQueryTextChange(queryText: String): Boolean { + binding.searchCabView.setCloseButtonVisibility(queryText) + startSearch(queryText.trim()) + return true + } + } + + public override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityTemplatesSearchBinding.inflate(layoutInflater) + setContentView(binding.root) + setSupportActionBar(binding.toolbar) + sendPatrollerExperienceEvent("search_init", "pt_templates") + initSearchView() + + templatesSearchAdapter = TemplatesSearchAdapter() + binding.templateRecyclerView.layoutManager = LinearLayoutManager(this) + binding.templateRecyclerView.adapter = templatesSearchAdapter + + insertTemplateFragment = supportFragmentManager.findFragmentById(R.id.insertTemplateFragment) as InsertTemplateFragment + + binding.insertTemplateButton.setOnClickListener { + viewModel.selectedPageTitle?.let { + sendPatrollerExperienceEvent("template_insert_click", "pt_templates") + Prefs.addRecentUsedTemplates(setOf(it)) + val wikiText = insertTemplateFragment.collectParamsInfoAndBuildWikiText() + val intent = Intent().putExtra(RESULT_WIKI_TEXT, wikiText) + setResult(RESULT_INSERT_TEMPLATE_SUCCESS, intent) + finish() + } + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.searchTemplatesFlow.collectLatest { + templatesSearchAdapter?.submitData(it) + } + } + launch { + templatesSearchAdapter?.loadStateFlow?.collectLatest { + binding.searchProgressBar.isVisible = it.append is LoadState.Loading || it.refresh is LoadState.Loading + val showEmpty = (it.append is LoadState.NotLoading && it.append.endOfPaginationReached && templatesSearchAdapter?.itemCount == 0) + binding.emptyMessage.isVisible = showEmpty + } + } + launch { + viewModel.uiState.collect { + when (it) { + is TemplatesSearchViewModel.UiState.LoadTemplateData -> { + showInsertTemplateFragment(it.pageTitle, it.templateData) + } + is TemplatesSearchViewModel.UiState.LoadError -> { + FeedbackUtil.showError(this@TemplatesSearchActivity, it.throwable) + } + } + } + } + } + } + } + + private fun initSearchView() { + binding.searchCabView.setOnQueryTextListener(searchQueryListener) + binding.searchCabView.setOnCloseListener(searchCloseListener) + binding.searchCabView.setSearchHintTextColor(ResourceUtil.getThemedColor(this, R.attr.secondary_color)) + binding.searchCabView.queryHint = getString(R.string.templates_search_hint) + val searchEditPlate = binding.searchCabView.findViewById(androidx.appcompat.R.id.search_plate) + searchEditPlate.setBackgroundColor(Color.TRANSPARENT) + } + + private fun startSearch(term: String?) { + viewModel.searchQuery = term + templatesSearchAdapter?.refresh() + } + + private fun closeSearch() { + DeviceUtil.hideSoftKeyboard(this) + } + + private fun showInsertTemplateFragment(pageTitle: PageTitle, templateData: TemplateDataResponse.TemplateData) { + binding.searchCabView.isVisible = false + binding.insertTemplateButton.isVisible = true + updateToolbarElevation(false) + insertTemplateFragment.show(pageTitle, templateData) + } + + fun updateInsertButton(enabled: Boolean) { + binding.insertTemplateButton.isEnabled = enabled + binding.insertTemplateButton.setTextColor(ResourceUtil.getThemedColor(this, if (enabled) R.attr.progressive_color else R.attr.inactive_color)) + } + + private fun updateToolbarElevation(enabled: Boolean) { + binding.toolbarContainer.elevation = if (enabled) DimenUtil.dpToPx(1f) else 0f + } + + fun sendPatrollerExperienceEvent(action: String, activeInterface: String, actionData: String = "") { + if (viewModel.isFromDiff) { + PatrollerExperienceEvent.logAction(action, activeInterface, actionData) + } + } + + override fun onBackPressed() { + if (insertTemplateFragment.handleBackPressed()) { + if (templatesSearchAdapter != null) { + binding.searchCabView.isVisible = true + binding.insertTemplateButton.isVisible = false + supportActionBar?.title = null + updateToolbarElevation(true) + } else { + finish() + } + return + } + super.onBackPressed() + } + + private inner class TemplatesSearchDiffCallback : DiffUtil.ItemCallback() { + override fun areItemsTheSame(oldItem: PageTitle, newItem: PageTitle): Boolean { + return oldItem == newItem + } + + override fun areContentsTheSame(oldItem: PageTitle, newItem: PageTitle): Boolean { + return oldItem.prefixedText == newItem.prefixedText && oldItem.namespace == newItem.namespace + } + } + + private inner class TemplatesSearchAdapter : PagingDataAdapter(TemplatesSearchDiffCallback()) { + override fun onCreateViewHolder(parent: ViewGroup, pos: Int): TemplatesSearchItemHolder { + return TemplatesSearchItemHolder(ItemTemplatesSearchBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { + getItem(position)?.let { + (holder as TemplatesSearchItemHolder).bindItem(it) + } + } + } + + private inner class TemplatesSearchItemHolder(val binding: ItemTemplatesSearchBinding) : RecyclerView.ViewHolder(binding.root) { + fun bindItem(pageTitle: PageTitle) { + binding.itemTitle.text = StringUtil.removeNamespace(pageTitle.displayText) + binding.itemDescription.isVisible = !pageTitle.description.isNullOrEmpty() + binding.itemDescription.text = pageTitle.description + StringUtil.boldenKeywordText(binding.itemTitle, binding.itemTitle.text.toString(), viewModel.searchQuery) + + if (viewModel.searchQuery.isNullOrEmpty()) { + binding.itemTitle.typeface = Typeface.DEFAULT_BOLD + } else { + binding.itemTitle.typeface = Typeface.DEFAULT + } + + itemView.setOnClickListener { + viewModel.loadTemplateData(pageTitle) + viewModel.selectedPageTitle = pageTitle + } + } + } + + companion object { + const val EXTRA_FROM_DIFF = "isFromDiff" + const val RESULT_INSERT_TEMPLATE_SUCCESS = 100 + const val RESULT_WIKI_TEXT = "resultWikiText" + fun newIntent(context: Context, wikiSite: WikiSite, isFromDiff: Boolean, invokeSource: Constants.InvokeSource): Intent { + return Intent(context, TemplatesSearchActivity::class.java) + .putExtra(Constants.ARG_WIKISITE, wikiSite) + .putExtra(EXTRA_FROM_DIFF, isFromDiff) + .putExtra(Constants.INTENT_EXTRA_INVOKE_SOURCE, invokeSource) + } + } +} diff --git a/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchViewModel.kt b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchViewModel.kt new file mode 100644 index 00000000000..c977c6fa8e3 --- /dev/null +++ b/app/src/main/java/org/wikipedia/edit/templates/TemplatesSearchViewModel.kt @@ -0,0 +1,91 @@ +package org.wikipedia.edit.templates + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingSource +import androidx.paging.PagingState +import androidx.paging.cachedIn +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.mwapi.TemplateDataResponse +import org.wikipedia.extensions.parcelable +import org.wikipedia.page.Namespace +import org.wikipedia.page.PageTitle +import org.wikipedia.settings.Prefs + +class TemplatesSearchViewModel(bundle: Bundle) : ViewModel() { + + val invokeSource = bundle.getSerializable(Constants.INTENT_EXTRA_INVOKE_SOURCE) as Constants.InvokeSource + val wikiSite = bundle.parcelable(Constants.ARG_WIKISITE)!! + val isFromDiff = bundle.getBoolean(TemplatesSearchActivity.EXTRA_FROM_DIFF, false) + var searchQuery: String? = null + var selectedPageTitle: PageTitle? = null + val searchTemplatesFlow = Pager(PagingConfig(pageSize = 10)) { + SearchTemplatesFlowSource(searchQuery, wikiSite) + }.flow.cachedIn(viewModelScope) + + val uiState = MutableStateFlow(UiState()) + + fun loadTemplateData(pageTitle: PageTitle) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + uiState.value = UiState.LoadError(throwable) + }) { + val response = ServiceFactory.get(pageTitle.wikiSite).getTemplateData(pageTitle.wikiSite.languageCode, pageTitle.prefixedText) + uiState.value = UiState.LoadTemplateData(pageTitle, response.getTemplateData.first()) + } + } + + class SearchTemplatesFlowSource(val searchQuery: String?, val wikiSite: WikiSite) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + return try { + if (searchQuery.isNullOrEmpty()) { + val recentUsedTemplates = Prefs.recentUsedTemplates.filter { it.wikiSite == wikiSite } + return LoadResult.Page(recentUsedTemplates, null, null) + } + val query = Namespace.TEMPLATE.name + ":" + searchQuery + val response = ServiceFactory.get(wikiSite) + .fullTextSearchTemplates("$query*", params.loadSize, params.key) + + return response.query?.pages?.let { list -> + val partition = list.partition { it.title.equals(query, true) }.apply { + second.sortedBy { it.index } + } + val results = partition.toList().flatten().map { + val pageTitle = PageTitle(wikiSite = wikiSite, _text = it.title, description = it.description) + pageTitle.displayText = it.displayTitle(wikiSite.languageCode) + pageTitle + } + LoadResult.Page(results, null, response.continuation?.gsroffset) + } ?: run { + LoadResult.Page(emptyList(), null, null) + } + } catch (e: Exception) { + LoadResult.Error(e) + } + } + + override fun getRefreshKey(state: PagingState): Int? { + return null + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return TemplatesSearchViewModel(bundle) as T + } + } + + open class UiState { + data class LoadTemplateData(val pageTitle: PageTitle, val templateData: TemplateDataResponse.TemplateData) : UiState() + data class LoadError(val throwable: Throwable) : UiState() + } +} diff --git a/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt b/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt index 07cab03f492..827aabb0595 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedCoordinator.kt @@ -1,9 +1,11 @@ package org.wikipedia.feed import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Completable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.wikipedia.WikipediaApp import org.wikipedia.feed.aggregated.AggregatedFeedContentClient import org.wikipedia.feed.announcement.AnnouncementClient @@ -40,12 +42,12 @@ class FeedCoordinator internal constructor(context: Context) : FeedCoordinatorBa companion object { fun postCardsToCallback(cb: FeedClient.Callback, cards: List) { - Completable.fromAction { - val delayMillis = 150L - Thread.sleep(delayMillis) - }.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { cb.success(cards) } + CoroutineScope(Dispatchers.Default).launch { + delay(150L) + withContext(Dispatchers.Main) { + cb.success(cards) + } + } } } } diff --git a/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt b/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt index 994ab113278..4dd9cc609e8 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedCoordinatorBase.kt @@ -21,7 +21,7 @@ import org.wikipedia.settings.Prefs import org.wikipedia.util.DeviceUtil import org.wikipedia.util.ThrowableUtil import org.wikipedia.util.log.L -import java.util.* +import java.util.Collections abstract class FeedCoordinatorBase(private val context: Context) { @@ -149,7 +149,7 @@ abstract class FeedCoordinatorBase(private val context: Context) { if (pendingClients.isNotEmpty()) { pendingClients.removeAt(0) } - if (lastCard !is ProgressCard && shouldShowProgressCard(pendingClients[0])) { + if (lastCard !is ProgressCard && shouldShowProgressCard(pendingClients.getOrNull(0))) { requestProgressCard() } requestCard(wiki) @@ -262,10 +262,11 @@ abstract class FeedCoordinatorBase(private val context: Context) { card is FeaturedImageCard } - private fun shouldShowProgressCard(pendingClient: FeedClient): Boolean { + private fun shouldShowProgressCard(pendingClient: FeedClient?): Boolean { return pendingClient is SuggestedEditsFeedClient || pendingClient is AnnouncementClient || - pendingClient is BecauseYouReadClient + pendingClient is BecauseYouReadClient || + pendingClient == null } companion object { diff --git a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt index ef6a6ba2708..ae966b87f0c 100644 --- a/app/src/main/java/org/wikipedia/feed/FeedFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/FeedFragment.kt @@ -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) } diff --git a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt index 1b684911180..f1d478133d2 100644 --- a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt +++ b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContent.kt @@ -9,10 +9,10 @@ import org.wikipedia.feed.onthisday.OnThisDay import org.wikipedia.feed.topread.TopRead @Serializable -class AggregatedFeedContent { - val tfa: PageSummary? = null - val news: List? = null - @SerialName("mostread") val topRead: TopRead? = null - @SerialName("image") val potd: FeaturedImage? = null +class AggregatedFeedContent( + val tfa: PageSummary? = null, + val news: List? = null, + @SerialName("mostread") val topRead: TopRead? = null, + @SerialName("image") val potd: FeaturedImage? = null, val onthisday: List? = null -} +) diff --git a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt index 93ec1aaaa42..481ff8e72a3 100644 --- a/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt +++ b/app/src/main/java/org/wikipedia/feed/aggregated/AggregatedFeedContentClient.kt @@ -1,13 +1,18 @@ package org.wikipedia.feed.aggregated import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.wikipedia.Constants import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.feed.FeedContentType import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient @@ -23,7 +28,7 @@ import org.wikipedia.util.log.L class AggregatedFeedContentClient { private val aggregatedResponses = mutableMapOf() private var aggregatedResponseAge = -1 - private val disposables = CompositeDisposable() + var clientJob: Job? = null class OnThisDayFeed(aggregatedClient: AggregatedFeedContentClient) : BaseClient(aggregatedClient) { @@ -110,10 +115,6 @@ class AggregatedFeedContentClient { aggregatedResponseAge = -1 } - fun cancel() { - disposables.clear() - } - abstract class BaseClient internal constructor(private val aggregatedClient: AggregatedFeedContentClient) : FeedClient { private lateinit var cb: FeedClient.Callback private lateinit var wiki: WikiSite @@ -137,31 +138,64 @@ class AggregatedFeedContentClient { override fun cancel() {} private fun requestAggregated() { - aggregatedClient.cancel() + aggregatedClient.clientJob?.cancel() val date = DateUtil.getUtcRequestDateFor(age) - aggregatedClient.disposables.add(Observable.fromIterable(FeedContentType.aggregatedLanguages) - .flatMap({ lang -> - ServiceFactory.getRest(WikiSite.forLanguageCode(lang)) - .getAggregatedFeed(date.year, date.month, date.day) - .subscribeOn(Schedulers.io()) - }, { first, second -> Pair(first, second) }) - .toList() - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ pairList -> - val cards = mutableListOf() - for (pair in pairList) { - val content = pair.second ?: continue - aggregatedClient.aggregatedResponses[WikiSite.forLanguageCode(pair.first).languageCode] = content - aggregatedClient.aggregatedResponseAge = age - } - if (aggregatedClient.aggregatedResponses.containsKey(wiki.languageCode)) { - getCardFromResponse(aggregatedClient.aggregatedResponses, wiki, age, cards) - } - FeedCoordinator.postCardsToCallback(cb, cards) - }) { caught -> + aggregatedClient.clientJob = CoroutineScope(Dispatchers.Main).launch( + CoroutineExceptionHandler { _, caught -> L.v(caught) cb.error(caught) - }) + } + ) { + val cards = mutableListOf() + FeedContentType.aggregatedLanguages.forEach { langCode -> + val wikiSite = WikiSite.forLanguageCode(langCode) + val hasParentLanguageCode = !WikipediaApp.instance.languageState.getDefaultLanguageCode(langCode).isNullOrEmpty() + var feedContentResponse = ServiceFactory.getRest(wikiSite).getFeedFeatured(date.year, date.month, date.day) + + // TODO: This is a temporary fix for T355192 + if (hasParentLanguageCode) { + // TODO: Needs to update tfa and most read + feedContentResponse.tfa?.let { + val tfaResponse = getPageSummaryForLanguageVariant(it, wikiSite) + feedContentResponse = AggregatedFeedContent( + tfa = tfaResponse, + news = feedContentResponse.news, + topRead = feedContentResponse.topRead, + potd = feedContentResponse.potd, + onthisday = feedContentResponse.onthisday + ) + } + } + + aggregatedClient.aggregatedResponses[langCode] = feedContentResponse + aggregatedClient.aggregatedResponseAge = age + } + if (aggregatedClient.aggregatedResponses.containsKey(wiki.languageCode)) { + getCardFromResponse(aggregatedClient.aggregatedResponses, wiki, age, cards) + } + FeedCoordinator.postCardsToCallback(cb, cards) + } + } + + // TODO: This is a temporary fix for T355192 + private suspend fun getPageSummaryForLanguageVariant(pageSummary: PageSummary, wikiSite: WikiSite): PageSummary { + var newPageSummary = pageSummary + withContext(Dispatchers.IO) { + // First, get the correct description from Wikidata directly. + val wikiDataResponse = async { + ServiceFactory.get(Constants.wikidataWikiSite) + .getWikidataDescription(titles = pageSummary.apiTitle, sites = wikiSite.dbName(), langCode = wikiSite.languageCode) + } + // Second, fetch PageSummary endpoint instead of using the one with incorrect language variant (mostly from the feed endpoint). + val pageSummaryResponse = async { + ServiceFactory.getRest(wikiSite).getPageSummary(null, pageSummary.apiTitle) + } + + newPageSummary = pageSummaryResponse.await().apply { + description = wikiDataResponse.await().first?.getDescription(wikiSite.languageCode) ?: description + } + } + return newPageSummary } } } diff --git a/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt b/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt index 28bfef857de..a30d841fd1c 100644 --- a/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt +++ b/app/src/main/java/org/wikipedia/feed/announcement/AnnouncementClient.kt @@ -2,9 +2,11 @@ package org.wikipedia.feed.announcement import android.content.Context import androidx.annotation.VisibleForTesting -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wikipedia.WikipediaApp import org.wikipedia.auth.AccountUtil import org.wikipedia.dataclient.ServiceFactory @@ -20,23 +22,23 @@ import java.util.Date class AnnouncementClient : FeedClient { - private val disposables = CompositeDisposable() + private var clientJob: Job? = null override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { cancel() - disposables.add(ServiceFactory.getRest(wiki).announcements - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ list -> - FeedCoordinator.postCardsToCallback(cb, buildCards(list.items)) - }) { throwable -> - L.v(throwable) - cb.error(throwable) - }) + clientJob = CoroutineScope(Dispatchers.Main).launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.error(caught) + } + ) { + val announcementsResponse = ServiceFactory.getRest(wiki).getAnnouncements() + FeedCoordinator.postCardsToCallback(cb, buildCards(announcementsResponse.items)) + } } override fun cancel() { - disposables.clear() + clientJob?.cancel() } companion object { diff --git a/app/src/main/java/org/wikipedia/feed/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/becauseyouread/BecauseYouReadClient.kt b/app/src/main/java/org/wikipedia/feed/becauseyouread/BecauseYouReadClient.kt index 1b2b8d75b59..7117ac598bd 100644 --- a/app/src/main/java/org/wikipedia/feed/becauseyouread/BecauseYouReadClient.kt +++ b/app/src/main/java/org/wikipedia/feed/becauseyouread/BecauseYouReadClient.kt @@ -1,10 +1,11 @@ package org.wikipedia.feed.becauseyouread import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.WikipediaApp @@ -14,68 +15,59 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient -import org.wikipedia.history.HistoryEntry import org.wikipedia.util.StringUtil +import org.wikipedia.util.log.L class BecauseYouReadClient : FeedClient { - - private val disposables = CompositeDisposable() - + private var clientJob: Job? = null override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { cancel() - disposables.add( - Observable.fromCallable { - AppDatabase.instance.historyEntryWithImageDao().findEntryForReadMore(age, - context.resources.getInteger(R.integer.article_engagement_threshold_sec)) + clientJob = CoroutineScope(Dispatchers.Main).launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.success(emptyList()) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ entries -> - if (entries.size <= age) cb.success(emptyList()) else getCardForHistoryEntry(entries[age], cb) - }) { cb.success(emptyList()) }) - } + ) { + val entries = AppDatabase.instance.historyEntryWithImageDao().findEntryForReadMore(age, context.resources.getInteger(R.integer.article_engagement_threshold_sec)) + if (entries.size <= age) { + cb.success(emptyList()) + } else { + val entry = entries[age] + val langCode = entry.title.wikiSite.languageCode + // If the language code has a parent language code, it means set "Accept-Language" will slow down the loading time of /page/related + // TODO: remove when https://phabricator.wikimedia.org/T271145 is resolved. + val hasParentLanguageCode = !WikipediaApp.instance.languageState.getDefaultLanguageCode(langCode).isNullOrEmpty() + val searchTerm = StringUtil.removeUnderscores(entry.title.prefixedText) + val relatedPages = mutableListOf() - override fun cancel() { - disposables.clear() - } + val moreLikeResponse = ServiceFactory.get(entry.title.wikiSite).searchMoreLike("morelike:$searchTerm", + Constants.SUGGESTION_REQUEST_ITEMS, Constants.SUGGESTION_REQUEST_ITEMS) - private fun getCardForHistoryEntry(entry: HistoryEntry, cb: FeedClient.Callback) { + val headerPage = PageSummary(entry.title.displayText, entry.title.prefixedText, entry.title.description, + entry.title.extract, entry.title.thumbUrl, langCode) - // If the language code has a parent language code, it means set "Accept-Language" will slow down the loading time of /page/related - // TODO: remove when https://phabricator.wikimedia.org/T271145 is resolved. - val hasParentLanguageCode = !WikipediaApp.instance.languageState.getDefaultLanguageCode(entry.title.wikiSite.languageCode).isNullOrEmpty() - val searchTerm = StringUtil.removeUnderscores(entry.title.prefixedText) - disposables.add(ServiceFactory.get(entry.title.wikiSite) - .searchMoreLike("morelike:$searchTerm", - Constants.SUGGESTION_REQUEST_ITEMS, Constants.SUGGESTION_REQUEST_ITEMS) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .flatMap { response -> - val relatedPages = mutableListOf() - val langCode = entry.title.wikiSite.languageCode - relatedPages.add(PageSummary(entry.title.displayText, entry.title.prefixedText, entry.title.description, - entry.title.extract, entry.title.thumbUrl, langCode)) - response.query?.pages?.forEach { + moreLikeResponse.query?.pages?.forEach { if (it.title != searchTerm) { - relatedPages.add(PageSummary(it.displayTitle(langCode), it.title, it.description, - it.extract, it.thumbUrl(), langCode)) + if (hasParentLanguageCode) { + val pageSummary = ServiceFactory.getRest(entry.title.wikiSite).getPageSummary(entry.referrer, it.title) + relatedPages.add(pageSummary) + } else { + relatedPages.add(PageSummary(it.displayTitle(langCode), it.title, it.description, + it.extract, it.thumbUrl(), langCode)) + } } } - Observable.fromIterable(relatedPages) - } - .concatMap { pageSummary -> - if (hasParentLanguageCode) ServiceFactory.getRest(entry.title.wikiSite).getSummary(entry.referrer, pageSummary.apiTitle) - else Observable.just(pageSummary) - } - .observeOn(AndroidSchedulers.mainThread()) - .toList() - .subscribe({ list -> - val headerPage = list.removeAt(0) + FeedCoordinator.postCardsToCallback(cb, - if (list.isEmpty()) emptyList() - else listOf(toBecauseYouReadCard(list, headerPage, entry.title.wikiSite)) + if (relatedPages.isEmpty()) emptyList() + else listOf(toBecauseYouReadCard(relatedPages, headerPage, entry.title.wikiSite)) ) - }) { caught -> cb.error(caught) }) + } + } + } + + override fun cancel() { + clientJob?.cancel() } private fun toBecauseYouReadCard(results: List, pageSummary: PageSummary, wikiSite: WikiSite): BecauseYouReadCard { diff --git a/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleCardView.kt b/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleCardView.kt index c4e1d9c1326..d4cf7d15b9c 100644 --- a/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleCardView.kt +++ b/app/src/main/java/org/wikipedia/feed/featured/FeaturedArticleCardView.kt @@ -11,7 +11,7 @@ import org.wikipedia.history.HistoryEntry import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.LongPressMenu import org.wikipedia.readinglist.database.ReadingListPage -import org.wikipedia.settings.SiteInfoClient +import org.wikipedia.staticdata.MainPageNameData import org.wikipedia.views.ImageZoomHelper @Suppress("LeakingThis") @@ -111,7 +111,7 @@ open class FeaturedArticleCardView(context: Context) : DefaultFeedCardView(context) { @@ -38,7 +38,7 @@ class MainPageCardView(context: Context) : DefaultFeedCardView(con private fun goToMainPage() { card?.let { - callback?.onSelectPage(it, HistoryEntry(PageTitle(getMainPageForLang(it.wikiSite().languageCode), it.wikiSite()), + callback?.onSelectPage(it, HistoryEntry(PageTitle(MainPageNameData.valueFor(it.wikiSite().languageCode), it.wikiSite()), HistoryEntry.SOURCE_FEED_MAIN_PAGE), false) } } diff --git a/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt b/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt index 7f28c05195c..3409a759c1d 100644 --- a/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt +++ b/app/src/main/java/org/wikipedia/feed/random/RandomClient.kt @@ -1,14 +1,14 @@ package org.wikipedia.feed.random import android.content.Context -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite -import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.feed.FeedContentType import org.wikipedia.feed.FeedCoordinator import org.wikipedia.feed.dataclient.FeedClient @@ -17,37 +17,35 @@ import org.wikipedia.util.log.L class RandomClient : FeedClient { - private val disposables = CompositeDisposable() + private var clientJob: Job? = null override fun request(context: Context, wiki: WikiSite, age: Int, cb: FeedClient.Callback) { cancel() - disposables.add( - Observable.fromIterable(FeedContentType.aggregatedLanguages) - .flatMap({ lang -> getRandomSummaryObservable(lang) }, { first, second -> Pair(first, second) }) - .observeOn(AndroidSchedulers.mainThread()) - .toList() - .subscribe({ pairs -> - val list = pairs.map { RandomCard(it.second, age, WikiSite.forLanguageCode(it.first)) } - FeedCoordinator.postCardsToCallback(cb, list) - }) { t -> - L.v(t) - cb.error(t) - }) - } - - private fun getRandomSummaryObservable(lang: String): Observable { - return ServiceFactory.getRest(WikiSite.forLanguageCode(lang)) - .randomSummary - .subscribeOn(Schedulers.io()) - .onErrorResumeNext { throwable -> - Observable.fromCallable { - val page = AppDatabase.instance.readingListPageDao().getRandomPage() ?: throw throwable as Exception - ReadingListPage.toPageSummary(page) + clientJob = CoroutineScope(Dispatchers.Main).launch( + CoroutineExceptionHandler { _, caught -> + L.v(caught) + cb.error(caught) + } + ) { + val list = mutableListOf() + FeedContentType.aggregatedLanguages.forEach { lang -> + val wikiSite = WikiSite.forLanguageCode(lang) + val randomSummary = try { + ServiceFactory.getRest(wikiSite).getRandomSummary() + } catch (e: Exception) { + AppDatabase.instance.readingListPageDao().getRandomPage()?.let { + ReadingListPage.toPageSummary(it) + } ?: run { + throw e + } } + list.add(RandomCard(randomSummary, age, wikiSite)) } + FeedCoordinator.postCardsToCallback(cb, list) + } } override fun cancel() { - disposables.clear() + clientJob?.cancel() } } diff --git a/app/src/main/java/org/wikipedia/feed/searchbar/SearchCardView.kt b/app/src/main/java/org/wikipedia/feed/searchbar/SearchCardView.kt index ad56366419d..a15a6d7c3e5 100644 --- a/app/src/main/java/org/wikipedia/feed/searchbar/SearchCardView.kt +++ b/app/src/main/java/org/wikipedia/feed/searchbar/SearchCardView.kt @@ -20,7 +20,7 @@ class SearchCardView(context: Context) : DefaultFeedCardView(context init { val binding = ViewSearchBarBinding.inflate(LayoutInflater.from(context), this, true) binding.searchContainer.setCardBackgroundColor(ResourceUtil.getThemedColor(context, R.attr.background_color)) - FeedbackUtil.setButtonLongPressToast(binding.voiceSearchButton) + FeedbackUtil.setButtonTooltip(binding.voiceSearchButton) binding.searchContainer.setOnClickListener { callback?.onSearchRequested(it) } binding.voiceSearchButton.setOnClickListener { callback?.onVoiceSearchRequested() } diff --git a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt index a877917bac3..10955b7bd08 100644 --- a/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt +++ b/app/src/main/java/org/wikipedia/feed/suggestededits/SuggestedEditsCardItemFragment.kt @@ -21,7 +21,10 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryPage import org.wikipedia.descriptions.DescriptionEditActivity import org.wikipedia.descriptions.DescriptionEditActivity.Action -import org.wikipedia.descriptions.DescriptionEditActivity.Action.* +import org.wikipedia.descriptions.DescriptionEditActivity.Action.ADD_CAPTION +import org.wikipedia.descriptions.DescriptionEditActivity.Action.ADD_IMAGE_TAGS +import org.wikipedia.descriptions.DescriptionEditActivity.Action.TRANSLATE_CAPTION +import org.wikipedia.descriptions.DescriptionEditActivity.Action.TRANSLATE_DESCRIPTION import org.wikipedia.descriptions.DescriptionEditReviewView.Companion.ARTICLE_EXTRACT_MAX_LINE_WITHOUT_IMAGE import org.wikipedia.descriptions.DescriptionEditReviewView.Companion.ARTICLE_EXTRACT_MAX_LINE_WITH_IMAGE import org.wikipedia.extensions.parcelable @@ -178,8 +181,8 @@ class SuggestedEditsCardItemFragment : Fragment() { binding.articleDescriptionPlaceHolder2.visibility = VISIBLE binding.viewArticleTitle.visibility = VISIBLE binding.divider.visibility = VISIBLE - binding.viewArticleTitle.text = StringUtil.fromHtml(sourceSummaryForEdit!!.displayTitle!!) - binding.viewArticleExtract.text = StringUtil.fromHtml(sourceSummaryForEdit!!.extract) + binding.viewArticleTitle.text = StringUtil.fromHtml(sourceSummaryForEdit?.displayTitle) + binding.viewArticleExtract.text = StringUtil.fromHtml(sourceSummaryForEdit?.extract) binding.viewArticleExtract.maxLines = ARTICLE_EXTRACT_MAX_LINE_WITHOUT_IMAGE showItemImage() } diff --git a/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt b/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt index e9c51dfaba0..fbfba2688fb 100644 --- a/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt +++ b/app/src/main/java/org/wikipedia/gallery/GalleryItemFragment.kt @@ -184,7 +184,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener } .subscribe({ response -> mediaPage = response.query?.firstPage() - if (FileUtil.isVideo(mediaListItem.type)) { + if (FileUtil.isVideo(mediaPage?.imageInfo()?.mime.orEmpty())) { loadVideo() } else { loadImage(ImageUrlUtil.getUrlForPreferredSize(mediaInfo!!.thumbUrl, @@ -197,7 +197,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener } private fun getMediaInfoDisposable(title: String, lang: String): Observable { - return if (FileUtil.isVideo(mediaListItem.type)) { + return if (mediaListItem.isVideo) { ServiceFactory.get(if (mediaListItem.isInCommons) Constants.commonsWikiSite else pageTitle!!.wikiSite).getVideoInfo(title, lang) } else { @@ -209,13 +209,12 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener private val videoThumbnailClickListener: View.OnClickListener = object : View.OnClickListener { private var loading = false override fun onClick(v: View) { - val derivative = mediaInfo?.getBestDerivativeForSize(Constants.PREFERRED_GALLERY_IMAGE_SIZE) - if (loading || derivative == null) { + if (loading) { return } - val bestDerivative = derivative.src + val bestUrl = mediaInfo?.getBestDerivativeForSize(Constants.PREFERRED_GALLERY_IMAGE_SIZE)?.src ?: mediaInfo?.originalUrl ?: return loading = true - L.d("Loading video from url: $bestDerivative") + L.d("Loading video from url: $bestUrl") binding.videoView.visibility = View.VISIBLE mediaController = MediaController(requireActivity()) if (!DeviceUtil.isNavigationBarShowing) { @@ -244,7 +243,7 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener loading = false true } - binding.videoView.setVideoURI(Uri.parse(bestDerivative)) + binding.videoView.setVideoURI(Uri.parse(bestUrl)) } } @@ -282,17 +281,15 @@ class GalleryItemFragment : Fragment(), MenuProvider, RequestListener private fun shareImage() { mediaInfo?.let { - object : ImagePipelineBitmapGetter(ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl, - Constants.PREFERRED_GALLERY_IMAGE_SIZE)) { - override fun onSuccess(bitmap: Bitmap?) { - if (!isAdded) { - return - } - imageTitle?.let { title -> - callback()?.onShare(this@GalleryItemFragment, bitmap, shareSubject, title) - } + val imageUrl = ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl, Constants.PREFERRED_GALLERY_IMAGE_SIZE) + ImagePipelineBitmapGetter(requireContext(), imageUrl) { bitmap -> + if (!isAdded) { + return@ImagePipelineBitmapGetter } - }[requireContext()] + imageTitle?.let { title -> + callback()?.onShare(this@GalleryItemFragment, bitmap, shareSubject, title) + } + } } } diff --git a/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt b/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt index 88ce9749d09..61628f9fba9 100644 --- a/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt +++ b/app/src/main/java/org/wikipedia/gallery/ImagePipelineBitmapGetter.kt @@ -7,19 +7,19 @@ import com.bumptech.glide.Glide import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition -abstract class ImagePipelineBitmapGetter(private val imageUrl: String?) { - - abstract fun onSuccess(bitmap: Bitmap?) +class ImagePipelineBitmapGetter(context: Context, imageUrl: String?, callback: Callback) { + fun interface Callback { + fun onSuccess(bitmap: Bitmap) + } - operator fun get(context: Context) { + init { Glide.with(context) .asBitmap() .load(imageUrl) .into(object : CustomTarget() { override fun onResourceReady(resource: Bitmap, transition: Transition?) { - onSuccess(resource) + callback.onSuccess(resource) } - override fun onLoadCleared(placeholder: Drawable?) {} }) } diff --git a/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt b/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt index 977b8d2cba0..67020882895 100644 --- a/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt +++ b/app/src/main/java/org/wikipedia/gallery/MediaListItem.kt @@ -19,6 +19,8 @@ class MediaListItem constructor(val title: String = "", val isInCommons get() = srcSets.firstOrNull()?.src?.contains(Service.URL_FRAGMENT_FROM_COMMONS) == true + val isVideo get() = type == "video" + fun getImageUrl(deviceScale: Float): String { var imageUrl = srcSets[0].src var lastScale = 1.0f diff --git a/app/src/main/java/org/wikipedia/history/HistoryFragment.kt b/app/src/main/java/org/wikipedia/history/HistoryFragment.kt index 971c9ecdd32..fd881df0346 100644 --- a/app/src/main/java/org/wikipedia/history/HistoryFragment.kt +++ b/app/src/main/java/org/wikipedia/history/HistoryFragment.kt @@ -300,7 +300,7 @@ class HistoryFragment : Fragment(), BackPressedHandler { deleteSelectedPages() } } - FeedbackUtil.setButtonLongPressToast(historyFilterButton, clearHistoryButton) + FeedbackUtil.setButtonTooltip(historyFilterButton, clearHistoryButton) adjustSearchCardView(searchCardView) } } diff --git a/app/src/main/java/org/wikipedia/language/AppLanguageState.kt b/app/src/main/java/org/wikipedia/language/AppLanguageState.kt index 072d1dbce4f..6889408d8e5 100644 --- a/app/src/main/java/org/wikipedia/language/AppLanguageState.kt +++ b/app/src/main/java/org/wikipedia/language/AppLanguageState.kt @@ -36,8 +36,8 @@ class AppLanguageState(context: Context) { val appLanguageCode: String get() = appLanguageCodes.first() - val remainingAvailableLanguageCodes: List - get() = LanguageUtil.availableLanguages.filter { !_appLanguageCodes.contains(it) && appLanguageLookUpTable.isSupportedCode(it) } + val remainingSuggestedLanguageCodes: List + get() = LanguageUtil.suggestedLanguagesFromSystem.filter { !_appLanguageCodes.contains(it) && appLanguageLookUpTable.isSupportedCode(it) } val systemLanguageCode: String get() { @@ -143,7 +143,7 @@ class AppLanguageState(context: Context) { private fun initAppLanguageCodes() { if (_appLanguageCodes.isEmpty()) { if (Prefs.isInitialOnboardingEnabled) { - setAppLanguageCodes(remainingAvailableLanguageCodes) + setAppLanguageCodes(remainingSuggestedLanguageCodes) } else { // If user has never changed app language before addAppLanguageCode(systemLanguageCode) diff --git a/app/src/main/java/org/wikipedia/language/LangLinksActivity.kt b/app/src/main/java/org/wikipedia/language/LangLinksActivity.kt index fe78a2b4450..ee435d174f2 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 @@ -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..aede093c035 100644 --- a/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt +++ b/app/src/main/java/org/wikipedia/language/LangLinksViewModel.kt @@ -5,7 +5,8 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.WikipediaApp import org.wikipedia.dataclient.ServiceFactory @@ -13,10 +14,9 @@ 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() { @@ -84,7 +84,7 @@ class LangLinksViewModel(bundle: Bundle) : ViewModel() { // remove the language code and replace it with its variants it.remove() for (variant in languageVariants) { - it.add(PageTitle(if (pageTitle.isMainPage) SiteInfoClient.getMainPageForLang(variant) else link.prefixedText, + it.add(PageTitle(if (pageTitle.isMainPage) MainPageNameData.valueFor(variant) else link.prefixedText, WikiSite.forLanguageCode(variant))) } } @@ -122,7 +122,6 @@ class LangLinksViewModel(bundle: Bundle) : ViewModel() { } companion object { - @JvmStatic fun addVariantEntriesIfNeeded(language: AppLanguageState, title: PageTitle, languageEntries: MutableList) { val parentLanguageCode = language.getDefaultLanguageCode(title.wikiSite.languageCode) if (parentLanguageCode != null) { @@ -130,8 +129,8 @@ class LangLinksViewModel(bundle: Bundle) : ViewModel() { if (languageVariants != null) { for (languageCode in languageVariants) { if (!title.wikiSite.languageCode.contains(languageCode)) { - val pageTitle = PageTitle(if (title.isMainPage) SiteInfoClient.getMainPageForLang(languageCode) else title.displayText, WikiSite.forLanguageCode(languageCode)) - 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 c7df631eaca..354b9b36956 100644 --- a/app/src/main/java/org/wikipedia/language/LanguageUtil.kt +++ b/app/src/main/java/org/wikipedia/language/LanguageUtil.kt @@ -7,9 +7,11 @@ import androidx.core.content.getSystemService import androidx.core.os.LocaleListCompat import org.apache.commons.lang3.StringUtils import org.wikipedia.WikipediaApp +import org.wikipedia.util.StringUtil import java.util.Locale object LanguageUtil { + private const val MAX_SUGGESTED_LANGUAGES = 8 private const val HONG_KONG_COUNTRY_CODE = "HK" private const val MACAU_COUNTRY_CODE = "MO" private val TRADITIONAL_CHINESE_COUNTRY_CODES = listOf(Locale.TAIWAN.country, HONG_KONG_COUNTRY_CODE, MACAU_COUNTRY_CODE) @@ -26,7 +28,7 @@ object LanguageUtil { return if (actualTag.isNotEmpty()) Locale.forLanguageTag(actualTag) else null } - val availableLanguages: Set + val suggestedLanguagesFromSystem: List get() { val languages = mutableSetOf() @@ -57,7 +59,7 @@ object LanguageUtil { .map { localeToWikiLanguageCode(it) } .filter { it.isNotEmpty() && it != "und" } - return languages + return languages.take(MAX_SUGGESTED_LANGUAGES) } fun localeToWikiLanguageCode(locale: Locale): String { diff --git a/app/src/main/java/org/wikipedia/language/LanguagesListViewModel.kt b/app/src/main/java/org/wikipedia/language/LanguagesListViewModel.kt index 1e508732392..832c27ffabd 100644 --- a/app/src/main/java/org/wikipedia/language/LanguagesListViewModel.kt +++ b/app/src/main/java/org/wikipedia/language/LanguagesListViewModel.kt @@ -16,7 +16,7 @@ import org.wikipedia.util.log.L class LanguagesListViewModel : ViewModel() { - private val suggestedLanguageCodes = WikipediaApp.instance.languageState.remainingAvailableLanguageCodes + private val suggestedLanguageCodes = WikipediaApp.instance.languageState.remainingSuggestedLanguageCodes private val nonSuggestedLanguageCodes = WikipediaApp.instance.languageState.appMruLanguageCodes.filterNot { suggestedLanguageCodes.contains(it) || WikipediaApp.instance.languageState.appLanguageCodes.contains(it) } diff --git a/app/src/main/java/org/wikipedia/main/MainFragment.kt b/app/src/main/java/org/wikipedia/main/MainFragment.kt index 9ad9bb1baba..63cbf1a40d9 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 @@ -34,6 +33,7 @@ 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 @@ -70,7 +70,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 @@ -132,7 +132,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. it.maxLines = 2 } - FeedbackUtil.setButtonLongPressToast(binding.navMoreContainer) + FeedbackUtil.setButtonTooltip(binding.navMoreContainer) binding.navMoreContainer.setOnClickListener { ExclusiveBottomSheetPresenter.show(childFragmentManager, MenuNavTabDialog.newInstance()) } @@ -202,7 +202,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) { @@ -272,7 +273,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. tabCountsView!!.contentDescription = getString(R.string.menu_page_show_tabs) tabsItem.actionView = tabCountsView tabsItem.expandActionView() - FeedbackUtil.setButtonLongPressToast(tabCountsView!!) + FeedbackUtil.setButtonTooltip(tabCountsView!!) showTabCountsAnimation = false } val notificationMenuItem = menu.findItem(R.id.menu_notifications) @@ -287,7 +288,7 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. notificationButtonView.contentDescription = getString(R.string.notifications_activity_title) notificationMenuItem.actionView = notificationButtonView notificationMenuItem.expandActionView() - FeedbackUtil.setButtonLongPressToast(notificationButtonView) + FeedbackUtil.setButtonTooltip(notificationButtonView) } else { notificationMenuItem.isVisible = false } @@ -371,16 +372,13 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. override fun onFeedShareImage(card: FeaturedImageCard) { val thumbUrl = card.baseImage().thumbnailUrl val fullSizeUrl = card.baseImage().original.source - object : ImagePipelineBitmapGetter(thumbUrl) { - override fun onSuccess(bitmap: Bitmap?) { - if (bitmap != null) { - ShareUtil.shareImage(requireContext(), bitmap, File(thumbUrl).name, - ShareUtil.getFeaturedImageShareSubject(requireContext(), card.age()), fullSizeUrl) - } else { - FeedbackUtil.showMessage(this@MainFragment, getString(R.string.gallery_share_error, card.baseImage().title)) - } + ImagePipelineBitmapGetter(requireContext(), thumbUrl) { bitmap -> + if (!isAdded) { + return@ImagePipelineBitmapGetter } - }[requireContext()] + ShareUtil.shareImage(requireContext(), bitmap, File(thumbUrl).name, + ShareUtil.getFeaturedImageShareSubject(requireContext(), card.age()), fullSizeUrl) + } } override fun onFeedDownloadImage(image: FeaturedImage) { @@ -456,6 +454,10 @@ class MainFragment : Fragment(), BackPressedHandler, MenuProvider, FeedFragment. } } + override fun donateClick() { + (requireActivity() as? BaseActivity)?.launchDonateDialog() + } + fun setBottomNavVisible(visible: Boolean) { binding.mainNavTabBorder.isVisible = visible binding.mainNavTabContainer.isVisible = visible diff --git a/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt b/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt index 9cf8f48cce5..85b46c75f59 100644 --- a/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt +++ b/app/src/main/java/org/wikipedia/navtab/MenuNavTabDialog.kt @@ -6,9 +6,7 @@ import android.view.View import android.view.ViewGroup 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.DonorExperienceEvent @@ -17,7 +15,6 @@ import org.wikipedia.auth.AccountUtil import org.wikipedia.databinding.ViewMainDrawerBinding import org.wikipedia.page.ExtendedBottomSheetDialogFragment import org.wikipedia.places.PlacesActivity -import org.wikipedia.util.CustomTabsUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.ResourceUtil.getThemedColorStateList @@ -29,6 +26,7 @@ class MenuNavTabDialog : ExtendedBottomSheetDialogFragment() { fun settingsClick() fun watchlistClick() fun contribsClick() + fun donateClick() } private var _binding: ViewMainDrawerBinding? = null @@ -80,8 +78,7 @@ 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)) + callback()?.donateClick() dismiss() } diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt b/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt index 631085c774b..be57fd24a8a 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationActivity.kt @@ -54,6 +54,7 @@ import org.wikipedia.util.DeviceUtil import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil import org.wikipedia.util.L10nUtil +import org.wikipedia.util.Resource import org.wikipedia.util.ResourceUtil import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil @@ -138,8 +139,8 @@ class NotificationActivity : BaseActivity() { repeatOnLifecycle(Lifecycle.State.CREATED) { viewModel.uiState.collect { when (it) { - is NotificationViewModel.UiState.Success -> onNotificationsComplete(it.notifications, it.fromContinuation) - is NotificationViewModel.UiState.Error -> setErrorState(it.throwable) + is Resource.Success -> onNotificationsComplete(it.data.first, it.data.second) + is Resource.Error -> setErrorState(it.throwable) } } } @@ -534,7 +535,7 @@ class NotificationActivity : BaseActivity() { resultLauncher.launch(NotificationFilterActivity.newIntent(it.context)) } - FeedbackUtil.setButtonLongPressToast(notificationFilterButton) + FeedbackUtil.setButtonTooltip(notificationFilterButton) } fun updateFilterIconAndCount() { diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt b/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt index 76f06c09310..483939ed994 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationRepository.kt @@ -7,11 +7,11 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.notifications.db.Notification import org.wikipedia.notifications.db.NotificationDao -class NotificationRepository constructor(private val notificationDao: NotificationDao) { +class NotificationRepository(private val notificationDao: NotificationDao) { fun getAllNotifications() = notificationDao.getAllNotifications() - fun insertNotifications(notifications: List) { + private fun insertNotifications(notifications: List) { notificationDao.insertNotifications(notifications) } @@ -19,10 +19,6 @@ class NotificationRepository constructor(private val notificationDao: Notificati notificationDao.updateNotification(notification) } - suspend fun deleteNotification(notification: Notification) { - notificationDao.deleteNotification(notification) - } - suspend fun fetchUnreadWikiDbNames(): Map { val response = ServiceFactory.get(Constants.commonsWikiSite).unreadNotificationWikis() return response.query?.unreadNotificationWikis!! diff --git a/app/src/main/java/org/wikipedia/notifications/NotificationViewModel.kt b/app/src/main/java/org/wikipedia/notifications/NotificationViewModel.kt index 6c2a6a30678..befbdb80a02 100644 --- a/app/src/main/java/org/wikipedia/notifications/NotificationViewModel.kt +++ b/app/src/main/java/org/wikipedia/notifications/NotificationViewModel.kt @@ -13,14 +13,16 @@ import org.wikipedia.database.AppDatabase import org.wikipedia.dataclient.WikiSite import org.wikipedia.notifications.db.Notification import org.wikipedia.settings.Prefs +import org.wikipedia.util.Resource import org.wikipedia.util.StringUtil -import java.util.* +import java.util.Date +import java.util.Random class NotificationViewModel : ViewModel() { private val notificationRepository = NotificationRepository(AppDatabase.instance.notificationDao()) private val handler = CoroutineExceptionHandler { _, throwable -> - _uiState.value = UiState.Error(throwable) + _uiState.value = Resource.Error(throwable) } private val notificationList = mutableListOf() private var dbNameMap = mapOf() @@ -31,7 +33,7 @@ class NotificationViewModel : ViewModel() { var mentionsUnreadCount: Int = 0 var allUnreadCount: Int = 0 - private val _uiState = MutableStateFlow(UiState()) + private val _uiState = MutableStateFlow(Resource, Boolean>>()) val uiState = _uiState.asStateFlow() init { @@ -42,8 +44,8 @@ class NotificationViewModel : ViewModel() { } private fun filterAndPostNotifications() { - _uiState.value = UiState.Success(processList(notificationRepository.getAllNotifications()), - !currentContinueStr.isNullOrEmpty()) + val pair = Pair(processList(notificationRepository.getAllNotifications()), !currentContinueStr.isNullOrEmpty()) + _uiState.value = Resource.Success(pair) } private fun processList(list: List): List { @@ -191,10 +193,4 @@ class NotificationViewModel : ViewModel() { filterAndPostNotifications() } } - - open class UiState { - class Success(val notifications: List, - val fromContinuation: Boolean) : UiState() - class Error(val throwable: Throwable) : UiState() - } } diff --git a/app/src/main/java/org/wikipedia/page/LinkHandler.kt b/app/src/main/java/org/wikipedia/page/LinkHandler.kt index 66f1b61aff6..66dd66766f8 100644 --- a/app/src/main/java/org/wikipedia/page/LinkHandler.kt +++ b/app/src/main/java/org/wikipedia/page/LinkHandler.kt @@ -1,5 +1,6 @@ package org.wikipedia.page +import android.app.Activity import android.content.Context import android.net.Uri import kotlinx.serialization.json.JsonObject @@ -7,6 +8,8 @@ import kotlinx.serialization.json.jsonPrimitive import org.wikipedia.bridge.CommunicationBridge.JSEventListener import org.wikipedia.dataclient.WikiSite import org.wikipedia.page.LinkMovementMethodExt.UrlHandlerWithText +import org.wikipedia.places.PlacesActivity +import org.wikipedia.util.StringUtil import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L @@ -99,6 +102,12 @@ abstract class LinkHandler(protected val context: Context) : JSEventListener, Ur } open fun onExternalLinkClicked(uri: Uri) { + if (uri.authority.orEmpty().contains("geohack") && context is Activity) { + StringUtil.geoHackToLocation(uri.getQueryParameter("params"))?.let { + context.startActivity(PlacesActivity.newIntent(context, null, it)) + return + } + } UriUtil.handleExternalLink(context, uri) } diff --git a/app/src/main/java/org/wikipedia/page/PageActionTabLayout.kt b/app/src/main/java/org/wikipedia/page/PageActionTabLayout.kt index aa3fce8025c..93667a4b33c 100644 --- a/app/src/main/java/org/wikipedia/page/PageActionTabLayout.kt +++ b/app/src/main/java/org/wikipedia/page/PageActionTabLayout.kt @@ -45,7 +45,7 @@ class PageActionTabLayout constructor(context: Context, attrs: AttributeSet? = n view.id = item.viewId view.text = context.getString(item.titleResId) view.contentDescription = view.text - FeedbackUtil.setButtonLongPressToast(view) + FeedbackUtil.setButtonTooltip(view) TextViewCompat.setCompoundDrawableTintList(view, tintColor) view.setCompoundDrawablesWithIntrinsicBounds(0, item.iconResId, 0, 0) view.compoundDrawablePadding = -DimenUtil.roundedDpToPx(4f) diff --git a/app/src/main/java/org/wikipedia/page/PageActivity.kt b/app/src/main/java/org/wikipedia/page/PageActivity.kt index a5f5058b9b3..8e6a031008a 100644 --- a/app/src/main/java/org/wikipedia/page/PageActivity.kt +++ b/app/src/main/java/org/wikipedia/page/PageActivity.kt @@ -58,7 +58,7 @@ import org.wikipedia.page.tabs.TabActivity import org.wikipedia.readinglist.ReadingListActivity import org.wikipedia.search.SearchActivity import org.wikipedia.settings.Prefs -import org.wikipedia.settings.SiteInfoClient +import org.wikipedia.staticdata.MainPageNameData import org.wikipedia.staticdata.UserTalkAliasData import org.wikipedia.suggestededits.PageSummaryForEdit import org.wikipedia.suggestededits.SuggestedEditsImageTagEditActivity @@ -190,7 +190,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo requestBrowseTabLauncher.launch(TabActivity.newIntentFromPageActivity(this)) } toolbarHideHandler = ViewHideHandler(binding.pageToolbarContainer, null, Gravity.TOP) { isTooltipShowing } - FeedbackUtil.setButtonLongPressToast(binding.pageToolbarButtonNotifications, binding.pageToolbarButtonTabs, binding.pageToolbarButtonShowOverflowMenu) + FeedbackUtil.setButtonTooltip(binding.pageToolbarButtonNotifications, binding.pageToolbarButtonTabs, binding.pageToolbarButtonShowOverflowMenu) binding.pageToolbarButtonShowOverflowMenu.setOnClickListener { pageFragment.showOverflowMenu(it) pageFragment.articleInteractionEvent?.logMoreClick() @@ -430,11 +430,7 @@ 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?) { @@ -494,7 +490,7 @@ class PageActivity : BaseActivity(), PageFragment.Callback, LinkPreviewDialog.Lo 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") + DonorExperienceEvent.logAction("impression", "webpay_processed", wiki.languageCode) startActivity(SingleWebViewActivity.newIntent(this@PageActivity, uri.toString(), true, pageFragment.title, SingleWebViewActivity.PAGE_CONTENT_SOURCE_DONOR_EXPERIENCE)) finish() @@ -589,7 +585,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) } diff --git a/app/src/main/java/org/wikipedia/page/PageFragment.kt b/app/src/main/java/org/wikipedia/page/PageFragment.kt index e9e1b766728..65a3a2abb48 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragment.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragment.kt @@ -19,6 +19,7 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.widget.LinearLayout +import androidx.appcompat.app.AppCompatActivity import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.animation.doOnEnd import androidx.core.app.ActivityCompat @@ -399,7 +400,8 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi onPageSetupEvent() bridge.onMetadataReady() bridge.onPcsReady() - bridge.execute(JavaScriptActionHandler.mobileWebChromeShim()) + bridge.execute(JavaScriptActionHandler.mobileWebChromeShim(DimenUtil.roundedPxToDp(((requireActivity() as AppCompatActivity).supportActionBar?.height ?: 0).toFloat()), + DimenUtil.roundedPxToDp(binding.pageActionsTabLayout.height.toFloat()))) } } } @@ -672,8 +674,7 @@ 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() 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/campaign/CampaignDialog.kt b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialog.kt index 0080e09ff48..ac869b05017 100644 --- a/app/src/main/java/org/wikipedia/page/campaign/CampaignDialog.kt +++ b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialog.kt @@ -5,6 +5,7 @@ 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 @@ -45,8 +46,13 @@ 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() { diff --git a/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt index f8837149f87..ab951e035fa 100644 --- a/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt +++ b/app/src/main/java/org/wikipedia/page/campaign/CampaignDialogView.kt @@ -46,7 +46,7 @@ class CampaignDialogView(context: Context) : FrameLayout(context) { binding.closeButton.setOnClickListener { callback?.onClose() } - FeedbackUtil.setButtonLongPressToast(binding.closeButton) + FeedbackUtil.setButtonTooltip(binding.closeButton) // TODO: think about optimizing the usage of actions array try { diff --git a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt index ca2b3764994..296e7132654 100644 --- a/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt +++ b/app/src/main/java/org/wikipedia/page/edithistory/EditHistoryListActivity.kt @@ -389,7 +389,7 @@ class EditHistoryListActivity : BaseActivity() { showFilterOverflowMenu() } - FeedbackUtil.setButtonLongPressToast(binding.filterByButton) + FeedbackUtil.setButtonTooltip(binding.filterByButton) binding.root.isVisible = true } 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..bee0a8cf5ab 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) + .getProtectionInfoSuspend(imageTitle).query?.isEditProtected ?: false + if (!isProtected) { + val firstEntity = async { + ServiceFactory.get(Constants.commonsWikiSite).getEntitiesByTitleSuspend(imageTitle, Constants.COMMONS_DB_NAME).first + } + val firstImageInfo = async { + ServiceFactory.get(Constants.commonsWikiSite).getImageInfoSuspend(imageTitle, Constants.COMMONS_DB_NAME).query?.firstPage() + } + val labelMap = firstEntity.await()?.labels?.values?.associate { v -> v.language to v.value }.orEmpty() + val depicts = ImageTagsProvider.getDepictsClaims(firstEntity.await()?.getStatements().orEmpty()) + imagePage = firstImageInfo.await() captionSourcePageTitle = PageTitle(imageTitle, WikiSite(Service.COMMONS_URL, it.wikiSite.languageCode)) captionSourcePageTitle!!.description = labelMap[it.wikiSite.languageCode] - 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() + } } } @@ -239,7 +236,7 @@ class LeadImagesHandler(private val parentFragment: PageFragment, } fun dispose() { - disposables.clear() + handlerJob?.cancel() callToActionSourceSummary = null callToActionTargetSummary = null callToActionIsTranslation = false diff --git a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewContents.kt b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewContents.kt index 54398481a92..a4e0b438745 100644 --- a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewContents.kt +++ b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewContents.kt @@ -5,8 +5,9 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.page.PageSummary import org.wikipedia.util.L10nUtil -class LinkPreviewContents constructor(pageSummary: PageSummary, wiki: WikiSite) { +class LinkPreviewContents(pageSummary: PageSummary, wiki: WikiSite) { val title = pageSummary.getPageTitle(wiki) + val ns = pageSummary.namespace val isDisambiguation = pageSummary.type == PageSummary.TYPE_DISAMBIGUATION val extract = if (isDisambiguation) "

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

" + pageSummary.extractHtml diff --git a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt index 0fd12ec2f11..c218b38a07c 100644 --- a/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt +++ b/app/src/main/java/org/wikipedia/page/linkpreview/LinkPreviewDialog.kt @@ -28,6 +28,8 @@ import org.wikipedia.analytics.metricsplatform.ArticleLinkPreviewInteraction import org.wikipedia.bridge.JavaScriptActionHandler import org.wikipedia.databinding.DialogLinkPreviewBinding import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.edit.EditHandler +import org.wikipedia.edit.EditSectionActivity import org.wikipedia.gallery.GalleryActivity import org.wikipedia.gallery.GalleryThumbnailScrollView.GalleryViewListener import org.wikipedia.history.HistoryEntry @@ -145,12 +147,26 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV } } + private val requestStubArticleEditLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == EditHandler.RESULT_REFRESH_PAGE) { + overlayView?.let { overlay -> + FeedbackUtil.makeSnackbar(overlay.rootView, getString(R.string.stub_article_edit_saved_successfully)) + .setAnchorView(overlay.secondaryButtonView).show() + } + } + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { _binding = DialogLinkPreviewBinding.inflate(inflater, container, false) binding.linkPreviewToolbar.setOnClickListener { goToLinkedPage(false) } binding.linkPreviewOverflowButton.setOnClickListener { setupOverflowMenu() } + binding.linkPreviewEditButton.setOnClickListener { + viewModel.pageTitle.run { + requestStubArticleEditLauncher.launch(EditSectionActivity.newIntent(requireContext(), -1, null, this, Constants.InvokeSource.LINK_PREVIEW_MENU, null)) + } + } L10nUtil.setConditionalLayoutDirection(binding.root, viewModel.pageTitle.wikiSite.languageCode) lifecycleScope.launch { @@ -374,23 +390,25 @@ class LinkPreviewDialog : ExtendedBottomSheetDialogFragment(), LinkPreviewErrorV } private fun setPreviewContents(contents: LinkPreviewContents) { - if (!contents.extract.isNullOrEmpty()) { - binding.linkPreviewExtractWebview.setBackgroundColor(Color.TRANSPARENT) - val colorHex = ResourceUtil.colorToCssString( - ResourceUtil.getThemedColor( - requireContext(), - android.R.attr.textColorPrimary - ) - ) - val dir = if (L10nUtil.isLangRTL(viewModel.pageTitle.wikiSite.languageCode)) "rtl" else "ltr" - binding.linkPreviewExtractWebview.loadDataWithBaseURL( - null, - "${JavaScriptActionHandler.getCssStyles(viewModel.pageTitle.wikiSite)}
${contents.extract}
", - "text/html", - "UTF-8", - null + binding.linkPreviewExtractWebview.setBackgroundColor(Color.TRANSPARENT) + val colorHex = ResourceUtil.colorToCssString( + ResourceUtil.getThemedColor( + requireContext(), + android.R.attr.textColorPrimary ) - } + ) + val dir = if (L10nUtil.isLangRTL(viewModel.pageTitle.wikiSite.languageCode)) "rtl" else "ltr" + val editVisibility = contents.extract.isNullOrBlank() && contents.ns?.id == Namespace.MAIN.code() + binding.linkPreviewEditButton.isVisible = editVisibility + binding.linkPreviewThumbnailGallery.isVisible = !editVisibility + val extract = if (editVisibility) "" + getString(R.string.link_preview_stub_placeholder_text) + "" else contents.extract + binding.linkPreviewExtractWebview.loadDataWithBaseURL( + null, + "${JavaScriptActionHandler.getCssStyles(viewModel.pageTitle.wikiSite)}
$extract
", + "text/html", + "UTF-8", + null + ) contents.title.thumbUrl?.let { binding.linkPreviewThumbnail.visibility = View.VISIBLE ViewUtil.loadImage(binding.linkPreviewThumbnail, it) 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 0cba2933e87..a28b3aea6ae 100644 --- a/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt +++ b/app/src/main/java/org/wikipedia/page/tabs/TabActivity.kt @@ -48,7 +48,7 @@ class TabActivity : BaseActivity() { setContentView(binding.root) binding.tabCountsView.updateTabCount(false) binding.tabCountsView.setOnClickListener { onBackPressed() } - FeedbackUtil.setButtonLongPressToast(binding.tabCountsView, binding.tabButtonNotifications) + FeedbackUtil.setButtonTooltip(binding.tabCountsView, binding.tabButtonNotifications) binding.tabSwitcher.setPreserveState(false) binding.tabSwitcher.decorator = object : TabSwitcherDecorator() { override fun onInflateView(inflater: LayoutInflater, parent: ViewGroup?, viewType: Int): View { diff --git a/app/src/main/java/org/wikipedia/places/PlacesFragment.kt b/app/src/main/java/org/wikipedia/places/PlacesFragment.kt index 9d7c1eea463..466ddca5e87 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,8 +35,6 @@ 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 @@ -70,6 +67,7 @@ import org.wikipedia.databinding.ItemPlacesListBinding import org.wikipedia.dataclient.okhttp.OkHttpConnectionFactory import org.wikipedia.extensions.parcelable import org.wikipedia.extensions.parcelableExtra +import org.wikipedia.gallery.ImagePipelineBitmapGetter import org.wikipedia.history.HistoryEntry import org.wikipedia.page.ExclusiveBottomSheetPresenter import org.wikipedia.page.LinkMovementMethodExt @@ -92,7 +90,6 @@ import org.wikipedia.util.StringUtil import org.wikipedia.util.TabUtil import org.wikipedia.util.log.L import org.wikipedia.views.DrawableItemDecoration -import org.wikipedia.views.SurveyDialog import org.wikipedia.views.ViewUtil import java.util.Locale import kotlin.math.abs @@ -397,8 +394,6 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi FeedbackUtil.showError(requireActivity(), it.throwable) } } - - maybeShowSurvey() } private fun updateToggleViews(isMapVisible: Boolean) { @@ -469,7 +464,7 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi } binding.langCodeButton.setLangCode(Prefs.placesWikiCode) - FeedbackUtil.setButtonLongPressToast(binding.tabsButton, binding.langCodeButton) + FeedbackUtil.setButtonTooltip(binding.tabsButton, binding.langCodeButton) } private fun setUpSymbolManagerWithClustering(mapboxMap: MapboxMap, style: Style) { @@ -675,29 +670,22 @@ class PlacesFragment : Fragment(), LinkPreviewDialog.LoadPageCallback, LinkPrevi 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) { 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,15 +747,6 @@ 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() { override fun getItemCount(): Int { return nearbyPages.size @@ -846,7 +825,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..5bcc1cd3bbf 100644 --- a/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt +++ b/app/src/main/java/org/wikipedia/places/PlacesFragmentViewModel.kt @@ -28,10 +28,6 @@ class PlacesFragmentViewModel(bundle: Bundle) : ViewModel() { 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)) diff --git a/app/src/main/java/org/wikipedia/random/RandomFragment.kt b/app/src/main/java/org/wikipedia/random/RandomFragment.kt index 69170497272..e2f0abe1511 100644 --- a/app/src/main/java/org/wikipedia/random/RandomFragment.kt +++ b/app/src/main/java/org/wikipedia/random/RandomFragment.kt @@ -9,21 +9,20 @@ import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.functions.Consumer -import io.reactivex.rxjava3.schedulers.Schedulers +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp -import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentRandomBinding import org.wikipedia.dataclient.WikiSite import org.wikipedia.events.ArticleSavedOrDeletedEvent -import org.wikipedia.extensions.parcelable import org.wikipedia.history.HistoryEntry import org.wikipedia.page.PageActivity import org.wikipedia.page.PageTitle @@ -33,47 +32,25 @@ import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.util.AnimationUtil.PagerTransformer import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.Resource import org.wikipedia.util.log.L import org.wikipedia.views.PositionAwareFragmentStateAdapter class RandomFragment : Fragment() { - companion object { - const val DEFAULT_PAGER_TAB = 0 - const val PAGER_OFFSCREEN_PAGE_LIMIT = 2 - const val ENABLED_BACK_BUTTON_ALPHA = 1f - const val DISABLED_BACK_BUTTON_ALPHA = 0.5f - - fun newInstance(wikiSite: WikiSite, invokeSource: InvokeSource) = RandomFragment().apply { - arguments = bundleOf( - Constants.ARG_WIKISITE to wikiSite, - Constants.INTENT_EXTRA_INVOKE_SOURCE to invokeSource - ) - } - } - private var _binding: FragmentRandomBinding? = null private val binding get() = _binding!! - private val disposables = CompositeDisposable() - private val viewPagerListener: ViewPagerListener = ViewPagerListener() - - private lateinit var wikiSite: WikiSite + private val viewModel: RandomViewModel by viewModels { RandomViewModel.Factory(requireArguments()) } + private val viewPagerListener = ViewPagerListener() private val topTitle get() = getTopChild()?.title - private var saveButtonState = false - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) _binding = FragmentRandomBinding.inflate(inflater, container, false) val view = binding.root - FeedbackUtil.setButtonLongPressToast(binding.randomNextButton, binding.randomSaveButton) - - wikiSite = requireArguments().parcelable(Constants.ARG_WIKISITE)!! + FeedbackUtil.setButtonTooltip(binding.randomNextButton, binding.randomSaveButton) binding.randomItemPager.offscreenPageLimit = 2 binding.randomItemPager.adapter = RandomItemAdapter(this) @@ -84,13 +61,23 @@ class RandomFragment : Fragment() { binding.randomBackButton.setOnClickListener { onBackClick() } binding.randomSaveButton.setOnClickListener { onSaveShareClick() } - disposables.add(WikipediaApp.instance.bus.subscribe(EventBusConsumer())) + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + val eventBus = WikipediaApp.instance.bus.subscribe(EventBusConsumer()) + viewModel.uiState.collect { + when (it) { + is Resource.Success -> setSaveButton() + is Resource.Error -> L.w(it.throwable) + } + } + } + } - updateSaveShareButton() + updateSaveButton() updateBackButton(DEFAULT_PAGER_TAB) if (savedInstanceState != null && binding.randomItemPager.currentItem == DEFAULT_PAGER_TAB && topTitle != null) { - updateSaveShareButton(topTitle) + updateSaveButton(topTitle) } return view @@ -98,11 +85,10 @@ class RandomFragment : Fragment() { override fun onResume() { super.onResume() - updateSaveShareButton(topTitle) + updateSaveButton(topTitle) } override fun onDestroyView() { - disposables.clear() binding.randomItemPager.unregisterOnPageChangeCallback(viewPagerListener) _binding = null super.onDestroyView() @@ -128,25 +114,25 @@ class RandomFragment : Fragment() { private fun onSaveShareClick() { val title = topTitle ?: return - if (saveButtonState) { + if (viewModel.saveButtonState) { LongPressMenu(binding.randomSaveButton, existsInAnyList = false, callback = object : LongPressMenu.Callback { override fun 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 +149,7 @@ class RandomFragment : Fragment() { intent.putExtra(Constants.INTENT_EXTRA_HAS_TRANSITION_ANIM, true) } - startActivity( - intent, - if (DimenUtil.isLandscape(requireContext()) || sharedElements.isEmpty()) null else options.toBundle() - ) + startActivity(intent, if (DimenUtil.isLandscape(requireContext()) || sharedElements.isEmpty()) null else options.toBundle()) } private fun updateBackButton(pagerPosition: Int) { @@ -175,38 +158,24 @@ class RandomFragment : Fragment() { if (pagerPosition == DEFAULT_PAGER_TAB) DISABLED_BACK_BUTTON_ALPHA else ENABLED_BACK_BUTTON_ALPHA } - private fun updateSaveShareButton(title: PageTitle?) { - if (title == null) { - return + private fun updateSaveButton(title: PageTitle? = null) { + title?.let { + viewModel.findPageInAnyList(title) + } ?: run { + val enable = getTopChild()?.isLoadComplete ?: false + binding.randomSaveButton.isClickable = enable + binding.randomSaveButton.alpha = + if (enable) ENABLED_BACK_BUTTON_ALPHA else DISABLED_BACK_BUTTON_ALPHA } - - val d = Observable.fromCallable { - AppDatabase.instance.readingListPageDao().findPageInAnyList(title) != null - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ exists: Boolean -> - saveButtonState = exists - val img = - if (saveButtonState) R.drawable.ic_bookmark_white_24dp else R.drawable.ic_bookmark_border_white_24dp - binding.randomSaveButton.setImageResource(img) - }, { t -> - L.w(t) - }) - - disposables.add(d) } - private fun updateSaveShareButton() { - val enable = getTopChild()?.isLoadComplete ?: false - - binding.randomSaveButton.isClickable = enable - binding.randomSaveButton.alpha = - if (enable) ENABLED_BACK_BUTTON_ALPHA else DISABLED_BACK_BUTTON_ALPHA + private fun setSaveButton() { + val imageSource = if (viewModel.saveButtonState) R.drawable.ic_bookmark_white_24dp else R.drawable.ic_bookmark_border_white_24dp + binding.randomSaveButton.setImageResource(imageSource) } fun onChildLoaded() { - updateSaveShareButton() + updateSaveButton() } private fun getTopChild(): RandomItemFragment? { @@ -221,7 +190,7 @@ class RandomFragment : Fragment() { } override fun createFragment(position: Int): Fragment { - return RandomItemFragment.newInstance(wikiSite) + return RandomItemFragment.newInstance(viewModel.wikiSite) } } @@ -235,12 +204,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) { @@ -257,10 +226,23 @@ class RandomFragment : Fragment() { } for (page in event.pages) { if (page.apiTitle == topTitle?.prefixedText && page.wiki.languageCode == topTitle?.wikiSite?.languageCode) { - updateSaveShareButton(topTitle) + updateSaveButton(topTitle) } } } } } + + companion object { + const val DEFAULT_PAGER_TAB = 0 + const val ENABLED_BACK_BUTTON_ALPHA = 1f + const val DISABLED_BACK_BUTTON_ALPHA = 0.5f + + fun newInstance(wikiSite: WikiSite, invokeSource: InvokeSource) = RandomFragment().apply { + arguments = bundleOf( + Constants.ARG_WIKISITE to wikiSite, + Constants.INTENT_EXTRA_INVOKE_SOURCE to invokeSource + ) + } + } } diff --git a/app/src/main/java/org/wikipedia/random/RandomItemFragment.kt b/app/src/main/java/org/wikipedia/random/RandomItemFragment.kt index 60ecfd4d533..e97c0b3d1db 100644 --- a/app/src/main/java/org/wikipedia/random/RandomItemFragment.kt +++ b/app/src/main/java/org/wikipedia/random/RandomItemFragment.kt @@ -6,55 +6,36 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.disposables.CompositeDisposable -import io.reactivex.rxjava3.schedulers.Schedulers +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch import org.wikipedia.Constants import org.wikipedia.databinding.FragmentRandomItemBinding -import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.page.PageSummary -import org.wikipedia.extensions.parcelable import org.wikipedia.page.PageTitle import org.wikipedia.util.ImageUrlUtil.getUrlForPreferredSize import org.wikipedia.util.L10nUtil +import org.wikipedia.util.Resource import org.wikipedia.util.log.L class RandomItemFragment : Fragment() { - companion object { - private const val EXTRACT_MAX_LINES = 4 - - fun newInstance(wikiSite: WikiSite) = RandomItemFragment().apply { - arguments = bundleOf(Constants.ARG_WIKISITE to wikiSite) - } - } - private var _binding: FragmentRandomItemBinding? = null private val binding get() = _binding!! + private val viewModel: RandomItemViewModel by viewModels { RandomItemViewModel.Factory(requireArguments()) } - private val disposables = CompositeDisposable() - - private lateinit var wikiSite: WikiSite - private var summary: PageSummary? = null - - val isLoadComplete: Boolean get() = summary != null - val title: PageTitle? get() = summary?.getPageTitle(wikiSite) - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - wikiSite = requireArguments().parcelable(Constants.ARG_WIKISITE)!! - - retainInstance = true - } + val isLoadComplete: Boolean get() = viewModel.summary != null + val title: PageTitle? get() = viewModel.summary?.getPageTitle(viewModel.wikiSite) override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { super.onCreateView(inflater, container, savedInstanceState) _binding = FragmentRandomItemBinding.inflate(inflater, container, false) - val view = binding.root binding.randomItemWikiArticleCardView.setOnClickListener { title?.let { title -> @@ -68,74 +49,69 @@ class RandomItemFragment : Fragment() { binding.randomItemErrorView.retryClickListener = View.OnClickListener { binding.randomItemProgress.visibility = View.VISIBLE - getRandomPage() + viewModel.getRandomPage() } - updateContents() - - if (summary == null) { - getRandomPage() + L10nUtil.setConditionalLayoutDirection(binding.root, viewModel.wikiSite.languageCode) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + viewModel.uiState.collect { + when (it) { + is Resource.Loading -> { + binding.randomItemProgress.isVisible = true + } + is Resource.Success -> updateContents(it.data) + is Resource.Error -> setErrorState(it.throwable) + } + } + } } - L10nUtil.setConditionalLayoutDirection(view, wikiSite.languageCode) - return view + return binding.root } override fun onDestroyView() { - disposables.clear() _binding = null - super.onDestroyView() } - private fun getRandomPage() { - val d = ServiceFactory.getRest(wikiSite).randomSummary - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe({ pageSummary -> - summary = pageSummary - updateContents() - parent().onChildLoaded() - }, { t -> - setErrorState(t) - }) - - disposables.add(d) - } - private fun setErrorState(t: Throwable) { L.e(t) - binding.randomItemErrorView.setError(t) - binding.randomItemErrorView.visibility = View.VISIBLE - binding.randomItemProgress.visibility = View.GONE - binding.randomItemWikiArticleCardView.visibility = View.GONE + binding.randomItemErrorView.isVisible = true + binding.randomItemProgress.isVisible = false + binding.randomItemWikiArticleCardView.isVisible = false } - private fun updateContents() { - binding.randomItemErrorView.visibility = View.GONE - - binding.randomItemWikiArticleCardView.visibility = - if (summary == null) View.GONE else View.VISIBLE + private fun updateContents(summary: PageSummary?) { + binding.randomItemErrorView.isVisible = false + binding.randomItemProgress.isVisible = false + binding.randomItemWikiArticleCardView.isVisible = summary != null + summary?.run { + binding.randomItemWikiArticleCardView.setTitle(displayTitle) + binding.randomItemWikiArticleCardView.setDescription(description) + binding.randomItemWikiArticleCardView.setExtract(extract, EXTRACT_MAX_LINES) - binding.randomItemProgress.visibility = - if (summary == null) View.VISIBLE else View.GONE + var imageUri: Uri? = null - val summary = summary ?: return - - binding.randomItemWikiArticleCardView.setTitle(summary.displayTitle) - binding.randomItemWikiArticleCardView.setDescription(summary.description) - binding.randomItemWikiArticleCardView.setExtract(summary.extract, EXTRACT_MAX_LINES) - - var imageUri: Uri? = null - - summary.thumbnailUrl.takeUnless { it.isNullOrBlank() }?.let { thumbnailUrl -> - imageUri = Uri.parse(getUrlForPreferredSize(thumbnailUrl, Constants.PREFERRED_CARD_THUMBNAIL_SIZE)) + thumbnailUrl.takeUnless { it.isNullOrBlank() }?.let { thumbnailUrl -> + imageUri = Uri.parse(getUrlForPreferredSize(thumbnailUrl, Constants.PREFERRED_CARD_THUMBNAIL_SIZE)) + } + binding.randomItemWikiArticleCardView.setImageUri(imageUri, false) } - binding.randomItemWikiArticleCardView.setImageUri(imageUri, false) + parent().onChildLoaded() } private fun parent(): RandomFragment { return requireActivity().supportFragmentManager.fragments[0] as RandomFragment } + + companion object { + private const val EXTRACT_MAX_LINES = 4 + + fun newInstance(wikiSite: WikiSite) = RandomItemFragment().apply { + arguments = bundleOf(Constants.ARG_WIKISITE to wikiSite) + } + } } diff --git a/app/src/main/java/org/wikipedia/random/RandomItemViewModel.kt b/app/src/main/java/org/wikipedia/random/RandomItemViewModel.kt new file mode 100644 index 00000000000..be1ab2caf55 --- /dev/null +++ b/app/src/main/java/org/wikipedia/random/RandomItemViewModel.kt @@ -0,0 +1,47 @@ +package org.wikipedia.random + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.extensions.parcelable +import org.wikipedia.util.Resource + +class RandomItemViewModel(bundle: Bundle) : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + val wikiSite: WikiSite = bundle.parcelable(Constants.ARG_WIKISITE)!! + var summary: PageSummary? = null + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + init { + getRandomPage() + } + + fun getRandomPage() { + _uiState.value = Resource.Loading() + viewModelScope.launch(handler) { + summary = ServiceFactory.getRest(wikiSite).getRandomSummary() + _uiState.value = Resource.Success(summary) + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return RandomItemViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/random/RandomViewModel.kt b/app/src/main/java/org/wikipedia/random/RandomViewModel.kt new file mode 100644 index 00000000000..8e43d5b6cf3 --- /dev/null +++ b/app/src/main/java/org/wikipedia/random/RandomViewModel.kt @@ -0,0 +1,43 @@ +package org.wikipedia.random + +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.database.AppDatabase +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.extensions.parcelable +import org.wikipedia.page.PageTitle +import org.wikipedia.util.Resource + +class RandomViewModel(bundle: Bundle) : ViewModel() { + + private val handler = CoroutineExceptionHandler { _, throwable -> + _uiState.value = Resource.Error(throwable) + } + val wikiSite: WikiSite = bundle.parcelable(Constants.ARG_WIKISITE)!! + var saveButtonState = false + + private val _uiState = MutableStateFlow(Resource()) + val uiState = _uiState.asStateFlow() + + fun findPageInAnyList(title: PageTitle) { + viewModelScope.launch(handler) { + val inAnyList = AppDatabase.instance.readingListPageDao().findPageInAnyList(title) != null + saveButtonState = inAnyList + _uiState.value = Resource.Success(inAnyList) + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return RandomViewModel(bundle) as T + } + } +} diff --git a/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt b/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt index dc3d4e751fc..7d064fec246 100644 --- a/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt +++ b/app/src/main/java/org/wikipedia/readinglist/AddToReadingListDialog.kt @@ -27,7 +27,6 @@ import org.wikipedia.page.PageTitle import org.wikipedia.readinglist.ReadingListTitleDialog.readingListTitleDialog import org.wikipedia.readinglist.database.ReadingList import org.wikipedia.settings.Prefs -import org.wikipedia.settings.SiteInfoClient import org.wikipedia.util.DimenUtil.getDimension import org.wikipedia.util.DimenUtil.roundedDpToPx import org.wikipedia.util.FeedbackUtil.makeSnackbar @@ -123,8 +122,8 @@ open class AddToReadingListDialog : ExtendedBottomSheetDialogFragment() { } private fun addAndDismiss(readingList: ReadingList, titles: List?) { - if (readingList.pages.size + titles!!.size > SiteInfoClient.maxPagesPerReadingList) { - val message = getString(R.string.reading_list_article_limit_message, readingList.title, SiteInfoClient.maxPagesPerReadingList) + if (readingList.pages.size + titles!!.size > Constants.MAX_READING_LIST_ARTICLE_LIMIT) { + val message = getString(R.string.reading_list_article_limit_message, readingList.title, Constants.MAX_READING_LIST_ARTICLE_LIMIT) makeSnackbar(requireActivity(), message).show() dismiss() return diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt index eb53efd046d..818ae126578 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListFragment.kt @@ -51,7 +51,6 @@ import org.wikipedia.readinglist.database.ReadingListPage import org.wikipedia.readinglist.sync.ReadingListSyncEvent import org.wikipedia.settings.Prefs import org.wikipedia.settings.RemoteConfig -import org.wikipedia.settings.SiteInfoClient.maxPagesPerReadingList import org.wikipedia.util.* import org.wikipedia.util.log.L import org.wikipedia.views.* @@ -288,8 +287,8 @@ class ReadingListFragment : Fragment(), MenuProvider, ReadingListItemActionsDial if (!toolbarExpanded) { binding.readingListToolbarContainer.title = it.title } - if (!articleLimitMessageShown && it.pages.size >= maxPagesPerReadingList) { - val message = getString(R.string.reading_list_article_limit_message, readingList.title, maxPagesPerReadingList) + if (!articleLimitMessageShown && it.pages.size >= Constants.MAX_READING_LIST_ARTICLE_LIMIT) { + val message = getString(R.string.reading_list_article_limit_message, readingList.title, Constants.MAX_READING_LIST_ARTICLE_LIMIT) FeedbackUtil.makeSnackbar(requireActivity(), message).show() articleLimitMessageShown = true } diff --git a/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt b/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt index 471766a2421..dc7877a09a9 100644 --- a/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt +++ b/app/src/main/java/org/wikipedia/readinglist/ReadingListItemView.kt @@ -103,7 +103,7 @@ class ReadingListItemView : ConstraintLayout { } } - FeedbackUtil.setButtonLongPressToast(binding.itemShareButton, binding.itemOverflowMenu) + FeedbackUtil.setButtonTooltip(binding.itemShareButton, binding.itemOverflowMenu) } fun setReadingList(readingList: ReadingList, description: Description, selectMode: Boolean = false, newImport: Boolean = false) { diff --git a/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt b/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt index 7db11d37458..fcda69e823f 100644 --- a/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt +++ b/app/src/main/java/org/wikipedia/richtext/CustomHtmlParser.kt @@ -21,11 +21,9 @@ import androidx.core.text.HtmlCompat import androidx.core.text.getSpans import androidx.core.text.parseAsHtml import androidx.core.text.toSpanned -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition import org.wikipedia.dataclient.Service import org.wikipedia.dataclient.WikiSite +import org.wikipedia.gallery.ImagePipelineBitmapGetter import org.wikipedia.util.DimenUtil import org.wikipedia.util.ResourceUtil import org.wikipedia.util.WhiteBackgroundTransformation @@ -148,21 +146,15 @@ class CustomHtmlParser(private val handler: TagHandler) : TagHandler, ContentHan uri = Service.COMMONS_URL + uri.replace("./", "") } - Glide.with(view) - .asBitmap() - .load(uri) - .into(object : CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - if (!drawable.bitmap.isRecycled) { - drawable.bitmap.applyCanvas { - drawBitmap(resource, Rect(0, 0, resource.width, resource.height), drawable.bounds, null) - } - WhiteBackgroundTransformation.maybeDimImage(drawable.bitmap) - view.postInvalidate() - } + ImagePipelineBitmapGetter(view.context, uri) { bitmap -> + if (!drawable.bitmap.isRecycled) { + drawable.bitmap.applyCanvas { + drawBitmap(bitmap, Rect(0, 0, bitmap.width, bitmap.height), drawable.bounds, null) } - override fun onLoadCleared(placeholder: Drawable?) { } - }) + WhiteBackgroundTransformation.maybeDimImage(drawable.bitmap) + view.postInvalidate() + } + } } } } else if (tag == "a") { diff --git a/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt b/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt index ce93b0f154f..099bcc73316 100644 --- a/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt +++ b/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt @@ -22,7 +22,7 @@ import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryResult import org.wikipedia.page.Namespace import org.wikipedia.search.db.RecentSearch -import org.wikipedia.util.FeedbackUtil.setButtonLongPressToast +import org.wikipedia.util.FeedbackUtil.setButtonTooltip import org.wikipedia.util.ResourceUtil import org.wikipedia.util.log.L import org.wikipedia.views.SwipeableItemTouchHelperCallback @@ -36,7 +36,7 @@ class RecentSearchesFragment : Fragment() { } private var _binding: FragmentSearchRecentBinding? = null - private val binding get() = _binding!! + val binding get() = _binding!! private val namespaceHints = listOf(Namespace.USER, Namespace.PORTAL, Namespace.HELP) private val namespaceMap = ConcurrentHashMap>() private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> L.e(throwable) } @@ -66,7 +66,7 @@ class RecentSearchesFragment : Fragment() { touchCallback.swipeableEnabled = true val itemTouchHelper = ItemTouchHelper(touchCallback) itemTouchHelper.attachToRecyclerView(binding.recentSearchesRecycler) - setButtonLongPressToast(binding.recentSearchesDeleteButton) + setButtonTooltip(binding.recentSearchesDeleteButton) return binding.root } diff --git a/app/src/main/java/org/wikipedia/search/SearchFragment.kt b/app/src/main/java/org/wikipedia/search/SearchFragment.kt index b7b1ea80a89..0b18ee199a6 100644 --- a/app/src/main/java/org/wikipedia/search/SearchFragment.kt +++ b/app/src/main/java/org/wikipedia/search/SearchFragment.kt @@ -13,6 +13,7 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.core.os.bundleOf +import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.CoroutineExceptionHandler @@ -128,6 +129,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche setUpLanguageScroll(Prefs.selectedLanguagePositionInSearch) startSearch(query, langBtnClicked) binding.searchCabView.setCloseButtonVisibility(query) + recentSearchesFragment.binding.namespacesContainer.isVisible = invokeSource != InvokeSource.PLACES if (!query.isNullOrEmpty()) { showPanel(PANEL_SEARCH_RESULTS) } @@ -321,7 +323,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche private fun initLangButton() { binding.searchLangButton.setLangCode(app.languageState.appLanguageCode.uppercase(Locale.ENGLISH)) - FeedbackUtil.setButtonLongPressToast(binding.searchLangButton) + FeedbackUtil.setButtonTooltip(binding.searchLangButton) } private fun addRecentSearch(title: String?) { diff --git a/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt b/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt index 10dccdd32ed..c47a2bccd4f 100644 --- a/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt +++ b/app/src/main/java/org/wikipedia/search/SearchResultsViewModel.kt @@ -72,7 +72,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) @@ -147,12 +147,10 @@ class SearchResultsViewModel : ViewModel() { } 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/LogoutPreference.kt b/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt index 3ca1f854505..e87c0651a6f 100644 --- a/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt +++ b/app/src/main/java/org/wikipedia/settings/LogoutPreference.kt @@ -3,6 +3,7 @@ package org.wikipedia.settings import android.app.Activity import android.content.Context import android.util.AttributeSet +import android.view.View import android.widget.Button import android.widget.TextView import androidx.preference.Preference @@ -10,7 +11,9 @@ import androidx.preference.PreferenceViewHolder import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.activity.SingleWebViewActivity import org.wikipedia.auth.AccountUtil +import org.wikipedia.util.StringUtil @Suppress("unused") class LogoutPreference : Preference { @@ -28,7 +31,7 @@ class LogoutPreference : Preference { super.onBindViewHolder(holder) holder.itemView.isClickable = false holder.itemView.findViewById(R.id.accountName).text = AccountUtil.userName - holder.itemView.findViewById