From 89a6bd6f7b43b5f488edaf45634a43dac587abb8 Mon Sep 17 00:00:00 2001 From: Serg Date: Thu, 28 May 2026 17:48:12 -0400 Subject: [PATCH] [Android] Surface Play in-app messages for subscription events (#36819) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calls BillingClient.showInAppMessages on app foreground so the Play Store can render its recovery dialog (declined payment, opt-in price change) on top of Brave. Gated on the user having an active VPN or Leo subscription, throttled to one call per real foreground session via an ApplicationStatus listener that resets only on HAS_STOPPED_ACTIVITIES / HAS_DESTROYED_ACTIVITIES — in-app navigation does not re-trigger. Resolves: https://github.com/brave/brave-browser/issues/55735 --- .../chrome/browser/app/BraveActivity.java | 6 ++ .../browser/billing/InAppPurchaseWrapper.java | 76 +++++++++++++++++++ 2 files changed, 82 insertions(+) diff --git a/android/java/org/chromium/chrome/browser/app/BraveActivity.java b/android/java/org/chromium/chrome/browser/app/BraveActivity.java index 53d9547dd95..42b3310370b 100644 --- a/android/java/org/chromium/chrome/browser/app/BraveActivity.java +++ b/android/java/org/chromium/chrome/browser/app/BraveActivity.java @@ -446,6 +446,12 @@ public abstract class BraveActivity extends ChromeActivity mMiscAndroidMetrics.recordIntentUrl(resumeIntent.getData().toString()); } } + + Profile profile = mTabModelProfileSupplier.get(); + if (profile != null) { + InAppPurchaseWrapper.getInstance() + .maybeShowSubscriptionInAppMessages(BraveActivity.this, profile); + } } @Override diff --git a/android/java/org/chromium/chrome/browser/billing/InAppPurchaseWrapper.java b/android/java/org/chromium/chrome/browser/billing/InAppPurchaseWrapper.java index 31f175089b2..4a63ca0197a 100644 --- a/android/java/org/chromium/chrome/browser/billing/InAppPurchaseWrapper.java +++ b/android/java/org/chromium/chrome/browser/billing/InAppPurchaseWrapper.java @@ -22,12 +22,15 @@ import com.android.billingclient.api.BillingClientStateListener; import com.android.billingclient.api.BillingFlowParams; import com.android.billingclient.api.BillingResult; import com.android.billingclient.api.ConsumeParams; +import com.android.billingclient.api.InAppMessageParams; import com.android.billingclient.api.ProductDetails; import com.android.billingclient.api.Purchase; import com.android.billingclient.api.PurchasesUpdatedListener; import com.android.billingclient.api.QueryProductDetailsParams; import com.android.billingclient.api.QueryPurchasesParams; +import org.chromium.base.ApplicationState; +import org.chromium.base.ApplicationStatus; import org.chromium.base.ContextUtils; import org.chromium.base.Log; import org.chromium.base.ThreadUtils; @@ -181,6 +184,8 @@ public class InAppPurchaseWrapper { } private boolean mSuppressToasts; + private boolean mInAppMessagesShownThisForeground; + private boolean mAppStateListenerRegistered; private InAppPurchaseWrapper() {} @@ -718,6 +723,77 @@ public class InAppPurchaseWrapper { || productIds.contains(ORIGIN_BETA_PERPETUAL_PURCHASE); } + /** + * Asks Play to surface any pending in-app messages for the user's subscriptions (failed + * renewals, opt-in price-rise consent). Gated on the user having an active Play subscription to + * VPN or Leo, and throttled to one call per foreground session. The throttle is reset + * automatically when the app is fully backgrounded; callers do not need to manage it. + */ + public void maybeShowSubscriptionInAppMessages(Activity activity, Profile profile) { + if (mInAppMessagesShownThisForeground) { + return; + } + boolean hasVpnSubscription = BraveVpnPrefUtils.isSubscriptionPurchase(); + boolean hasLeoSubscription = BraveLeoPrefUtils.getIsSubscriptionActive(profile); + if (!hasVpnSubscription && !hasLeoSubscription) { + return; + } + // Register the throttle-reset listener lazily, only after confirming + // this user actually has a subscription. Non-subscribers never pay any + // listener cost. + ensureAppStateListenerRegistered(); + mInAppMessagesShownThisForeground = true; + showInAppMessages(activity); + } + + private void ensureAppStateListenerRegistered() { + if (mAppStateListenerRegistered) { + return; + } + mAppStateListenerRegistered = true; + // Clear the per-foreground throttle only when *all* activities are + // stopped, i.e. the app is truly backgrounded. In-app navigation + // (BraveActivity -> Settings -> back) stays in HAS_RUNNING_ACTIVITIES + // and must not re-trigger a Play call. + ApplicationStatus.registerApplicationStateListener( + state -> { + if ((state == ApplicationState.HAS_STOPPED_ACTIVITIES + || state == ApplicationState.HAS_DESTROYED_ACTIVITIES) + && mInAppMessagesShownThisForeground) { + mInAppMessagesShownThisForeground = false; + } + }); + } + + /** + * Surfaces Play in-app messages for subscription-related events: declined payment methods + * (account hold / grace period) and opt-in price-rise consent. Play renders any pending message + * as a dialog above {@code activity}; if there is nothing to show the callback returns + * NO_ACTION_NEEDED and the user sees nothing. Only SUBS products are eligible — Origin (INAPP) + * is excluded by Play. + */ + private void showInAppMessages(Activity activity) { + InAppMessageParams params = + InAppMessageParams.newBuilder() + .addInAppMessageCategoryToShow( + InAppMessageParams.InAppMessageCategoryId.TRANSACTIONAL) + .build(); + + MutableLiveData _billingConnectionState = new MutableLiveData<>(); + LiveData billingConnectionState = _billingConnectionState; + startBillingServiceConnection(_billingConnectionState); + LiveDataUtil.observeOnce( + billingConnectionState, + isConnected -> { + if (isConnected && mBillingClient != null) { + mBillingClient.showInAppMessages( + activity, params, result -> endConnection()); + } else { + endConnection(); + } + }); + } + private PurchasesUpdatedListener getPurchasesUpdatedListener(Context context) { return (billingResult, purchases) -> { endConnection();