Skip to content
Merged
Show file tree
Hide file tree
Changes from 44 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
74f9a14
Add donation reminder helper class for T399605
cooltey Jul 21, 2025
2bf39dd
Merge branch 'main' into donation-reminder-design
Williamrai Jul 23, 2025
1d96aba
Merge branch 'main' into donation-reminder-design
cooltey Jul 24, 2025
d870bf8
Update comment
cooltey Jul 25, 2025
7ba6283
Update helper config
cooltey Jul 25, 2025
b298a35
Merge branch 'main' into donation-reminder-design
cooltey Jul 30, 2025
3c57229
Create a Donation reminder data class for the configuration (#5802)
cooltey Jul 30, 2025
f06e94f
Move donation reminder helper to the sub package
cooltey Jul 30, 2025
ac9e54f
Fix lint
cooltey Jul 30, 2025
b3f42b1
Merge branch 'main' into donation-reminder-design
Williamrai Jul 31, 2025
022bbd7
Merge branch 'main' into donation-reminder-design
Williamrai Jul 31, 2025
8e48b39
Merge branch 'main' into donation-reminder-design
cooltey Jul 31, 2025
e3a8ccc
Merge branch 'main' into donation-reminder-design
cooltey Jul 31, 2025
5f8117a
Strings for donation reminders (#5812)
Williamrai Aug 1, 2025
2049b14
Merge branch 'main' into donation-reminder-design
cooltey Aug 1, 2025
058eb47
Merge branch 'main' into donation-reminder-design
Williamrai Aug 4, 2025
149a502
Donation reminder set-up form (#5786)
Williamrai Aug 4, 2025
2f6b93c
- updates logic which shows toggle or confirm button based on where i…
Williamrai Aug 5, 2025
1c30aef
Merge branch 'main' into donation-reminder-design
cooltey Aug 5, 2025
38a1e70
Use strings item instead of hard-coded text
cooltey Aug 5, 2025
8178ec8
Merge branch 'main' into donation-reminder-design
Williamrai Aug 6, 2025
a2757fd
- code fix (#5819)
Williamrai Aug 6, 2025
13d5665
Donation reminder: In-article prompt (#5787)
cooltey Aug 6, 2025
e4d7a47
Use proper title for Settings and add two buttons for QA purposes. (#…
cooltey Aug 7, 2025
35e8726
Merge branch 'main' into donation-reminder-design
Williamrai Aug 7, 2025
c9e8898
Donation Reminders Survey (#5794)
Williamrai Aug 7, 2025
2b72d9b
- updates userGroup "B" logic to show survey dialog on next time user…
Williamrai Aug 7, 2025
d7a98e3
Merge branch 'main' into donation-reminder-design
cooltey Aug 11, 2025
65ae04f
Merge branch 'main' into donation-reminder-design
Williamrai Aug 11, 2025
1449494
Merge branch 'main' into donation-reminder-design
cooltey Aug 12, 2025
85e8d4c
Donation Reminders Instrumentation (#5822)
Williamrai Aug 12, 2025
ee988e4
Survey Instrumentation Donation Reminders (#5835)
Williamrai Aug 13, 2025
858af55
Merge branch 'main' into donation-reminder-design
cooltey Aug 13, 2025
b9e76ac
Design review fix
cooltey Aug 13, 2025
2abe4ed
Update margins
cooltey Aug 14, 2025
7416c83
Merge branch 'main' into donation-reminder-design
cooltey Aug 14, 2025
85c3460
Border to use 1dp
cooltey Aug 14, 2025
a7456a0
Remove unused thank you message
cooltey Aug 14, 2025
b40ca24
Donation reminder UI fixes (#5843)
Williamrai Aug 15, 2025
7d20d77
Merge branch 'main' into donation-reminder-design
Williamrai Aug 15, 2025
7a1a3ce
Update experiment end date
cooltey Aug 15, 2025
5ba9ae6
Merge branch 'donation-reminder-design' of github.com:wikimedia/apps-…
cooltey Aug 15, 2025
e971d50
Merge branch 'main' into donation-reminder-design
cooltey Aug 15, 2025
4079922
Design fix: add horizontal paddings to the about button
cooltey Aug 15, 2025
f82b649
Code review comments and update experiment date
cooltey Aug 15, 2025
fff2ce0
Lint
cooltey Aug 15, 2025
42cfb10
Merge branch 'main' into donation-reminder-design
dbrant Aug 15, 2025
f8b8a05
String.
dbrant Aug 15, 2025
ee388b6
Use translated strings in preferences category
cooltey Aug 15, 2025
e49b806
Merge branch 'donation-reminder-design' of github.com:wikimedia/apps-…
cooltey Aug 15, 2025
e61027b
Unrelated change
cooltey Aug 15, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 33 additions & 24 deletions app/src/extra/java/org/wikipedia/donate/GooglePayActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ class GooglePayActivity : BaseActivity() {
private var shouldWatchText = true
private var typedManually = false

private val transactionFee get() = max(getAmountFloat(binding.donateAmountText.text.toString()) * GooglePayComponent.TRANSACTION_FEE_PERCENTAGE, viewModel.transactionFee)
private val transactionFee get() = max(DonateUtil.getAmountFloat(binding.donateAmountText.text.toString()) * GooglePayComponent.TRANSACTION_FEE_PERCENTAGE, viewModel.transactionFee)

public override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand All @@ -56,7 +56,7 @@ class GooglePayActivity : BaseActivity() {
supportActionBar?.setDisplayHomeAsUpEnabled(true)
title = ""

binding.donateAmountInput.prefixText = viewModel.currencySymbol
binding.donateAmountInput.prefixText = DonateUtil.currencySymbol

paymentsClient = GooglePayComponent.createPaymentsClient(this)

Expand Down Expand Up @@ -87,7 +87,7 @@ class GooglePayActivity : BaseActivity() {
)
CampaignCollection.addDonationResult(
amount = viewModel.finalAmount,
currency = viewModel.currencyCode,
currency = DonateUtil.currencyCode,
recurring = binding.checkBoxRecurring.isChecked
)
setResult(RESULT_OK)
Expand All @@ -109,7 +109,7 @@ class GooglePayActivity : BaseActivity() {
return@setOnClickListener
}

var totalAmount = getAmountFloat(amountText)
var totalAmount = DonateUtil.getAmountFloat(amountText)
if (binding.checkBoxTransactionFee.isChecked) {
totalAmount += transactionFee
}
Expand All @@ -135,7 +135,7 @@ class GooglePayActivity : BaseActivity() {
}
val buttonToHighlight = binding.amountPresetsContainer.children.firstOrNull { child ->
if (child is MaterialButton) {
val amount = getAmountFloat(text.toString())
val amount = DonateUtil.getAmountFloat(text.toString())
child.tag == amount
} else {
false
Expand Down Expand Up @@ -166,18 +166,20 @@ class GooglePayActivity : BaseActivity() {
}

private fun validateInput(text: String): Boolean {
val amount = getAmountFloat(text)
val amount = DonateUtil.getAmountFloat(text)
val min = viewModel.minimumAmount
val max = viewModel.maximumAmount

updateTransactionFee()

if (amount <= 0f || amount < min) {
binding.donateAmountInput.error = getString(R.string.donate_gpay_minimum_amount, viewModel.currencyFormat.format(min))
binding.donateAmountInput.error = getString(R.string.donate_gpay_minimum_amount,
DonateUtil.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))
binding.donateAmountInput.error = getString(R.string.donate_gpay_maximum_amount,
DonateUtil.currencyFormat.format(max))
DonorExperienceEvent.submit("submission_error", "gpay", "error_reason: max_amount")
return false
} else {
Expand Down Expand Up @@ -219,23 +221,38 @@ class GooglePayActivity : BaseActivity() {
.build())

val viewIds = mutableListOf<Int>()
val presets = donationConfig.currencyAmountPresets[viewModel.currencyCode]
presets?.forEach { amount ->
val presets = donationConfig.currencyAmountPresets[DonateUtil.currencyCode]?.toMutableSet()
if (viewModel.filledAmount > 0f) {
presets?.add(viewModel.filledAmount)
}
var filledAmountButton: MaterialButton? = null
presets?.sorted()?.forEach { amount ->
val viewId = View.generateViewId()
viewIds.add(viewId)
val button = MaterialButton(this)
button.text = viewModel.currencyFormat.format(amount)
button.text = DonateUtil.currencyFormat.format(amount)
button.id = viewId
button.tag = amount
if (amount == viewModel.filledAmount) {
filledAmountButton = button
}
binding.amountPresetsContainer.addView(button)

button.setOnClickListener {
setButtonHighlighted(it)
setAmountText(it.tag as Float)
DonorExperienceEvent.logAction("amount_selected", "gpay")
}
}
binding.amountPresetsFlow.referencedIds = viewIds.toIntArray()
setButtonHighlighted()
setFilledAmountToText()
setButtonHighlighted(filledAmountButton)
}

private fun setFilledAmountToText() {
if (viewModel.filledAmount > 0f) {
setAmountText(viewModel.filledAmount)
}
}

private fun setButtonHighlighted(button: View? = null) {
Expand All @@ -254,17 +271,7 @@ class GooglePayActivity : BaseActivity() {

private fun updateTransactionFee() {
binding.checkBoxTransactionFee.text = getString(R.string.donate_gpay_check_transaction_fee,
viewModel.currencyFormat.format(transactionFee))
}

private fun getAmountFloat(text: String): Float {
var result: Float?
result = text.toFloatOrNull()
if (result == null) {
val text2 = if (text.contains(".")) text.replace(".", ",") else text.replace(",", ".")
result = text2.toFloatOrNull()
}
return result ?: 0f
DonateUtil.currencyFormat.format(transactionFee))
}

private fun setAmountText(amount: Float) {
Expand Down Expand Up @@ -306,11 +313,13 @@ class GooglePayActivity : BaseActivity() {
companion object {
private const val LOAD_PAYMENT_DATA_REQUEST_CODE = 42
private const val CAMPAIGN_ID_APP_MENU = "appmenu"
const val FILLED_AMOUNT = "filledAmount"

fun newIntent(context: Context, campaignId: String? = null, donateUrl: String? = null): Intent {
fun newIntent(context: Context, campaignId: String? = null, donateUrl: String? = null, filledAmount: Float = 0f): Intent {
return Intent(context, GooglePayActivity::class.java)
.putExtra(DonateDialog.ARG_CAMPAIGN_ID, campaignId)
.putExtra(DonateDialog.ARG_DONATE_URL, donateUrl)
.putExtra(FILLED_AMOUNT, filledAmount)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ internal object GooglePayComponent {
return available
}

fun getDonateActivityIntent(activity: Activity, campaignId: String? = null, donateUrl: String? = null): Intent {
return GooglePayActivity.newIntent(activity, campaignId, donateUrl)
fun getDonateActivityIntent(activity: Activity, campaignId: String? = null, donateUrl: String? = null, filledAmount: Float = 0f): Intent {
return GooglePayActivity.newIntent(activity, campaignId, donateUrl, filledAmount)
}

fun getPaymentDataRequestJson(
Expand Down
45 changes: 19 additions & 26 deletions app/src/extra/java/org/wikipedia/donate/GooglePayViewModel.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.wikipedia.donate

import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.android.gms.wallet.PaymentData
Expand All @@ -19,52 +20,45 @@ import org.wikipedia.dataclient.donate.CampaignCollection
import org.wikipedia.dataclient.donate.DonationConfig
import org.wikipedia.dataclient.donate.DonationConfigHelper
import org.wikipedia.settings.Prefs
import org.wikipedia.util.GeoUtil
import org.wikipedia.util.Resource
import org.wikipedia.util.log.L
import java.text.NumberFormat
import java.time.Instant
import java.util.Locale
import java.util.concurrent.TimeUnit
import kotlin.math.abs

class GooglePayViewModel : ViewModel() {
class GooglePayViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
val filledAmount = savedStateHandle.get<Float>(GooglePayActivity.FILLED_AMOUNT) ?: 0f
val uiState = MutableStateFlow(Resource<DonationConfig>())
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 decimalFormat = GooglePayComponent.getDecimalFormat(DonateUtil.currencyCode)

val transactionFee get() = donationConfig?.currencyTransactionFees?.get(currencyCode)
val transactionFee get() = donationConfig?.currencyTransactionFees?.get(DonateUtil.currencyCode)
?: donationConfig?.currencyTransactionFees?.get("default") ?: 0f

val minimumAmount get() = donationConfig?.currencyMinimumDonation?.get(currencyCode) ?: 0f
val minimumAmount get() = donationConfig?.currencyMinimumDonation?.get(DonateUtil.currencyCode) ?: 0f

val maximumAmount: Float get() {
var max = donationConfig?.currencyMaximumDonation?.get(currencyCode) ?: 0f
var max = donationConfig?.currencyMaximumDonation?.get(DonateUtil.currencyCode) ?: 0f
if (max == 0f) {
val defaultMin = donationConfig?.currencyMinimumDonation?.get(GooglePayComponent.CURRENCY_FALLBACK) ?: 0f
if (defaultMin > 0f) {
max = (donationConfig?.currencyMinimumDonation?.get(currencyCode) ?: 0f) / defaultMin *
max = (donationConfig?.currencyMinimumDonation?.get(DonateUtil.currencyCode) ?: 0f) / defaultMin *
(donationConfig?.currencyMaximumDonation?.get(GooglePayComponent.CURRENCY_FALLBACK) ?: 0f)
}
}
return max
}

val emailOptInRequired get() = donationConfig?.countryCodeEmailOptInRequired.orEmpty().contains(currentCountryCode)
val emailOptInRequired get() = donationConfig?.countryCodeEmailOptInRequired.orEmpty().contains(DonateUtil.currentCountryCode)

var disclaimerInformationSharing: String? = null
var disclaimerMonthlyCancel: String? = null

var finalAmount = 0f

init {
currencyFormat.minimumFractionDigits = 0
DonateUtil.currencyFormat.minimumFractionDigits = 0
load()
}

Expand All @@ -75,8 +69,7 @@ class GooglePayViewModel : ViewModel() {
uiState.value = Resource.Loading()

val donationConfigCall = async { DonationConfigHelper.getConfig() }
val donationMessagesCall = async { ServiceFactory.get(WikipediaApp.instance.wikiSite,
DonationConfigHelper.DONATE_WIKI_URL, Service::class.java).getMessages(
val donationMessagesCall = async { ServiceFactory[WikipediaApp.instance.wikiSite, DonationConfigHelper.DONATE_WIKI_URL, Service::class.java].getMessages(
listOf(MSG_DISCLAIMER_INFORMATION_SHARING, MSG_DISCLAIMER_MONTHLY_CANCEL).joinToString("|"),
null, WikipediaApp.instance.appOrSystemLanguageCode) }

Expand All @@ -94,7 +87,7 @@ class GooglePayViewModel : ViewModel() {

val paymentMethodsCall = async {
ServiceFactory.get(WikiSite(GooglePayComponent.PAYMENTS_API_URL))
.getPaymentMethods(currentCountryCode)
.getPaymentMethods(DonateUtil.currentCountryCode)
}
paymentMethodsCall.await().response?.let { response ->
Prefs.paymentMethodsLastQueryTime = now
Expand All @@ -107,8 +100,8 @@ class GooglePayViewModel : ViewModel() {

if (Prefs.paymentMethodsMerchantId.isEmpty() ||
Prefs.paymentMethodsGatewayId.isEmpty() ||
!donationConfig!!.countryCodeGooglePayEnabled.contains(currentCountryCode) ||
!donationConfig!!.currencyAmountPresets.containsKey(currencyCode)) {
!donationConfig!!.countryCodeGooglePayEnabled.contains(DonateUtil.currentCountryCode) ||
!donationConfig!!.currencyAmountPresets.containsKey(DonateUtil.currencyCode)) {
uiState.value = NoPaymentMethod()
} else {
uiState.value = Resource.Success(donationConfig!!)
Expand All @@ -118,7 +111,7 @@ class GooglePayViewModel : ViewModel() {

fun getPaymentDataRequest(): PaymentDataRequest {
return PaymentDataRequest.fromJson(GooglePayComponent.getPaymentDataRequestJson(finalAmount,
currencyCode,
DonateUtil.currencyCode,
Prefs.paymentMethodsMerchantId,
Prefs.paymentMethodsGatewayId
).toString())
Expand Down Expand Up @@ -149,17 +142,17 @@ class GooglePayViewModel : ViewModel() {

// 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 decimalFormatCanonical = GooglePayComponent.getDecimalFormat(DonateUtil.currencyCode, true)

val response = ServiceFactory.get(WikiSite(GooglePayComponent.PAYMENTS_API_URL))
.submitPayment(
decimalFormatCanonical.format(finalAmount),
BuildConfig.VERSION_NAME,
CampaignCollection.getFormattedCampaignId(campaignId),
billingObj.optString("locality", ""),
currentCountryCode,
currencyCode,
billingObj.optString("countryCode", currentCountryCode),
DonateUtil.currentCountryCode,
DonateUtil.currencyCode,
billingObj.optString("countryCode", DonateUtil.currentCountryCode),
paymentDataObj.optString("email", ""),
billingObj.optString("name", ""),
WikipediaApp.instance.appOrSystemLanguageCode,
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,10 @@
android:name=".readinglist.recommended.RecommendedReadingListSettingsActivity"
android:windowSoftInputMode="adjustResize" />

<activity
android:name=".donate.donationreminder.DonationReminderActivity"
android:windowSoftInputMode="adjustResize"/>

<provider
android:name=".WikipediaFileProvider"
android:authorities="${applicationId}.fileprovider"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package org.wikipedia.analytics.eventplatform

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import org.wikipedia.WikipediaApp
import org.wikipedia.dataclient.donate.CampaignCollection
import org.wikipedia.json.JsonUtil
import org.wikipedia.settings.Prefs

open class DonorExperienceEvent {
Expand All @@ -22,6 +25,37 @@ open class DonorExperienceEvent {
)
}

fun logDonationReminderAction(
action: String,
activeInterface: String,
wikiId: String = WikipediaApp.instance.appOrSystemLanguageCode,
defaultMilestone: Boolean? = null,
campaignId: String? = null,
articleFrequency: Int? = null,
donateAmount: Float? = null,
settingSelect: Boolean? = null,
feedbackSelect: Int? = null,
feedbackText: String? = null,
userGroup: String? = null
) {
val actionData = DonationRemindersActionData(
defaultMilestone = defaultMilestone,
campaignId = campaignId?.let { CampaignCollection.getFormattedCampaignId(campaignId) },
articleFrequency = articleFrequency,
donateAmount = donateAmount,
settingSelect = settingSelect,
feedbackSelect = feedbackSelect,
feedbackText = feedbackText,
userGroup = userGroup
)
submit(
action,
activeInterface,
JsonUtil.encodeToString(actionData).orEmpty(),
wikiId
)
}

fun submit(
action: String,
activeInterface: String,
Expand All @@ -40,4 +74,16 @@ open class DonorExperienceEvent {
)
}
}

@Serializable
class DonationRemindersActionData(
@SerialName("milestone_default") val defaultMilestone: Boolean? = null,
@SerialName("campaign_id") val campaignId: String? = null,
@SerialName("read_freq") val articleFrequency: Int? = null,
@SerialName("donate_amount") val donateAmount: Float? = null,
@SerialName("setting_select") val settingSelect: Boolean? = null,
@SerialName("feedback_select") val feedbackSelect: Int? = null,
@SerialName("feedback_text") val feedbackText: String? = null,
@SerialName("user_group") val userGroup: String? = null
)
}
10 changes: 8 additions & 2 deletions app/src/main/java/org/wikipedia/bridge/CommunicationBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@ import android.annotation.SuppressLint
import android.os.Handler
import android.os.Looper
import android.os.Message
import android.webkit.*
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import org.wikipedia.bridge.JavaScriptActionHandler.setUp
Expand Down Expand Up @@ -40,6 +45,7 @@ class CommunicationBridge constructor(private val communicationBridgeListener: C
val model: PageViewModel
val isPreview: Boolean
val toolbarMargin: Int
val messageCardHeight: Int
}

init {
Expand Down Expand Up @@ -175,7 +181,7 @@ class CommunicationBridge constructor(private val communicationBridgeListener: C
val setupSettings: String
get() = setUp(communicationBridgeListener.webView.context,
communicationBridgeListener.model.title!!, communicationBridgeListener.isPreview,
communicationBridgeListener.toolbarMargin)
communicationBridgeListener.toolbarMargin, communicationBridgeListener.messageCardHeight)
}

@Serializable
Expand Down
Loading
Loading