diff --git a/CLAUDE.md b/CLAUDE.md index af0b34c..82a58b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,6 +188,12 @@ and `billing/`; keep new rules there. RevenueCat/Play ids live only in `subscrip USD prices are `BillingCatalog.WEB_PLANS`; change them together with the dashboard's. - The trial is promised only to customers who never bought anything (`awaitNeverPurchased`), which is the web products' eligibility rule. + - The card route is decided from the storefront **before** offerings load, and both the paywall + (`PremiumViewModel.load`) and Home's onboarding check skip `loadOfferings()` for it: Play is + slowest where it can't sell, and waiting lost ~half of Bhutan's paywall opens (2.3.2). + - Continue while signed out → login → back on the paywall, which opens the checkout by itself + (`PremiumEvent.OpenWebCheckout`). Funnel: `web_checkout_continue` (`signed_in`) → + `web_checkout_opened`; `paywall_dismissed` carries `paywall_type`, `loading` = closed on the spinner. - Its A/B test is ours, not a RevenueCat experiment: `WebPaywallExperiment` pre-selects monthly (control) or annual from a number stored per install. The variant is on the GA4 events (`variant`, user property `web_paywall_variant`) and on the RevenueCat customer (attribute diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/AppAnalytics.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/AppAnalytics.kt index 937c6d8..585a15b 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/AppAnalytics.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/analytics/AppAnalytics.kt @@ -152,6 +152,19 @@ object AppAnalytics { /** Where Play can't sell, the user was sent to card checkout instead. [reason] says why. */ fun logBillingUnavailable(reason: String) = log(EVENT_BILLING_UNAVAILABLE, PARAM_REASON to reason) + /** + * The card checkout's Continue was tapped. [signedIn] false means a sign-in comes first, so + * the gap to [logWebCheckoutOpened] is the sign-in step's drop. + */ + fun logWebCheckoutContinue(source: String, plan: String, variant: String?, signedIn: Boolean) = + log( + EVENT_WEB_CHECKOUT_CONTINUE, + PARAM_SOURCE to source, + PARAM_PLAN to plan, + PARAM_VARIANT to variant, + PARAM_SIGNED_IN to signedIn, + ) + /** [variant] is the side of the card checkout paywall test, a `WebPaywallVariant` label. */ fun logWebCheckoutOpened(source: String, plan: String, variant: String?) = log(EVENT_WEB_CHECKOUT_OPENED, PARAM_SOURCE to source, PARAM_PLAN to plan, PARAM_VARIANT to variant) @@ -216,7 +229,9 @@ object AppAnalytics { /** The user backed out of cancelling; [step] is where they stopped. */ fun logCancelAbandoned(step: String) = log(EVENT_CANCEL_ABANDONED, PARAM_STEP to step) - fun logPaywallDismissed(source: String) = log(EVENT_PAYWALL_DISMISSED, PARAM_SOURCE to source) + /** [paywallType] is one of [PaywallType], including [PaywallType.LOADING] for a close on the spinner. */ + fun logPaywallDismissed(source: String, paywallType: String? = null) = + log(EVENT_PAYWALL_DISMISSED, PARAM_SOURCE to source, PARAM_PAYWALL_TYPE to paywallType) fun logPaywallPlanSelected(source: String, plan: String) = log(EVENT_PAYWALL_PLAN_SELECTED, PARAM_SOURCE to source, PARAM_PLAN to plan) @@ -361,6 +376,7 @@ object AppAnalytics { private const val EVENT_RESUBSCRIBE_OPENED = "subscription_resubscribe_opened" private const val EVENT_ONBOARDING_PAYWALL_SHOWN = "onboarding_paywall_shown" private const val EVENT_BILLING_UNAVAILABLE = "billing_unavailable" + private const val EVENT_WEB_CHECKOUT_CONTINUE = "web_checkout_continue" private const val EVENT_WEB_CHECKOUT_OPENED = "web_checkout_opened" private const val EVENT_WEB_CHECKOUT_COMPLETED = "web_checkout_completed" @@ -408,6 +424,7 @@ object AppAnalytics { private const val PARAM_OFFERING = "offering" private const val PARAM_PAYWALL_TYPE = "paywall_type" private const val PARAM_VARIANT = "variant" + private const val PARAM_SIGNED_IN = "signed_in" // User properties private const val USER_PROP_KB_ENABLED = "kb_enabled" @@ -496,7 +513,7 @@ object AppAnalytics { ) } - /** Which paywall rendered, for [logPaywallViewed]. */ + /** Which paywall rendered, for [logPaywallViewed] and [logPaywallDismissed]. */ object PaywallType { /** Designed in the RevenueCat dashboard. */ const val DASHBOARD = "dashboard" @@ -504,5 +521,9 @@ object AppAnalytics { const val CUSTOM = "custom" /** Card checkout, only where Google Play can't sell (Bhutan). */ const val WEB = "web" + /** Only on [logPaywallDismissed]: closed before any paywall showed. */ + const val LOADING = "loading" + /** Only on [logPaywallDismissed]: closed on the "try again" screen. */ + const val ERROR = "error" } } diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt index 60fdb75..bb81a9b 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/data/repository/RevenueCatManager.kt @@ -34,6 +34,7 @@ import com.kharagedition.tibetankeyboard.subscription.WebPlans import com.kharagedition.tibetankeyboard.subscription.ManageSubscriptionPolicy import com.kharagedition.tibetankeyboard.subscription.SubscriptionSnapshot import com.kharagedition.tibetankeyboard.billing.BillingAvailability +import com.kharagedition.tibetankeyboard.billing.BillingRoute import com.kharagedition.tibetankeyboard.billing.BillingSignals import com.kharagedition.tibetankeyboard.data.local.MonetizationStore import kotlinx.coroutines.CancellationException @@ -469,6 +470,13 @@ class RevenueCatManager private constructor() { ) } + /** + * Whether this user pays by card rather than through Play, from what is already known (no + * network). Card checkout needs none of Play's products, so callers can skip [loadOfferings]. + */ + fun usesCardCheckout(storefront: String?): Boolean = + BillingAvailability.route(billingSignals(storefront)) == BillingRoute.WEB_CHECKOUT + /** * Whether this user can buy PRO at all, from what is already known (no network). False only * where Play can't sell and card checkout isn't live yet — then upsells must stay quiet. diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt index 2b39735..3e98a80 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/home/HomeViewModel.kt @@ -11,6 +11,7 @@ import com.kharagedition.tibetankeyboard.data.local.MonetizationStore import com.kharagedition.tibetankeyboard.data.local.TypingStatsStore import com.kharagedition.tibetankeyboard.data.repository.RevenueCatManager import com.kharagedition.tibetankeyboard.util.localEpochDay +import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -117,11 +118,15 @@ class HomeViewModel(app: Application) : AndroidViewModel(app) { try { // Past this, the "keyboard ready" moment is gone; the next resume tries again. val decision = withTimeoutOrNull(ONBOARDING_CHECK_TIMEOUT_MS) { - val isPremium = revenueCat.awaitIsPremium() - revenueCat.loadOfferings() // refreshes the dashboard kill switch + val isPremium = async { revenueCat.awaitIsPremium() } + val storefront = revenueCat.storefrontCountry() + // Refreshes the dashboard kill switch. Skipped for card checkout: where Play + // can't sell, its products load slowest or never arrive, and that wait sits + // inside this timeout (Bhutan got none of these paywalls in 2.3.2). + if (!revenueCat.usesCardCheckout(storefront)) revenueCat.loadOfferings() // Never pop a paywall at someone who can't pay (Bhutan before card checkout is live). - val canSell = revenueCat.canSellHere(revenueCat.storefrontCountry()) - decide(isPremium, revenueCat.remoteConfig.onboardingPaywallEnabled && canSell) + val canSell = revenueCat.canSellHere(storefront) + decide(isPremium.await(), revenueCat.remoteConfig.onboardingPaywallEnabled && canSell) } ?: return@launch val source = when (decision) { OnboardingPaywall.FIRST -> AppAnalytics.UpgradeSource.ONBOARDING diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt index 1fb4cee..2923fb5 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumActivity.kt @@ -69,6 +69,7 @@ open class PremiumActivity : AppCompatActivity() { closedAfterPurchase = true closePaywall() } + PremiumEvent.OpenWebCheckout -> openWebCheckout() } } } @@ -157,7 +158,7 @@ open class PremiumActivity : AppCompatActivity() { private fun closePaywall() { if (closing || isFinishing) return closing = true - if (!closedAfterPurchase) AppAnalytics.logPaywallDismissed(viewModel.source) + if (!closedAfterPurchase) AppAnalytics.logPaywallDismissed(viewModel.source, viewModel.dismissedPaywallType()) if (isTaskRoot && returnsHomeWhenRoot) { startActivity(Intent(this, HomeActivity::class.java)) } @@ -190,15 +191,26 @@ open class PremiumActivity : AppCompatActivity() { /** * Card checkout (Bhutan only). The purchase must land on a real account, so a signed-out user - * signs in first and comes straight back here; then RevenueCat's hosted checkout opens in a - * browser tab on the picked plan, and [onResume] checks for the purchase when the tab closes. + * signs in first and comes straight back here, where the checkout opens by itself + * ([PremiumEvent.OpenWebCheckout]). */ private fun continueToWebCheckout() { if (webCheckoutOpened) return // double tap - if (!viewModel.uiState.value.isSignedIn) { + val signedIn = viewModel.uiState.value.isSignedIn + viewModel.onWebCheckoutContinue(signedIn) + if (!signedIn) { AuthManager(this).redirectToLogin(returnAfterLogin = true, finishCaller = false) return } + openWebCheckout() + } + + /** + * RevenueCat's hosted checkout, in a browser tab on the picked plan; [onResume] checks for the + * purchase when the tab closes. + */ + private fun openWebCheckout() { + if (webCheckoutOpened) return val url = viewModel.webCheckoutUrl() ?: run { showToast(getString(R.string.web_checkout_unavailable)) return diff --git a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt index e7e2c04..76a3c86 100644 --- a/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt +++ b/app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt @@ -101,6 +101,9 @@ sealed interface PremiumEvent { * purchase or restore in this paywall; not for someone who already had PRO. */ data class ProActive(val announce: Boolean) : PremiumEvent + + /** Back from the sign-in that Continue asked for: open the card checkout. */ + data object OpenWebCheckout : PremiumEvent } /** @@ -145,6 +148,9 @@ class PremiumViewModel( /** This install's side of the card checkout paywall test, once that paywall shows. */ private var webVariant: WebPaywallVariant? = null + /** Back from signing in to pay: open the checkout once the account is checked. */ + private var autoContinuePending = false + private val premiumObserver = Observer { isPremium -> _uiState.update { it.copy(isPremium = isPremium) } if (isPremium) _events.trySend(PremiumEvent.ProActive(announce = false)) @@ -162,9 +168,20 @@ class PremiumViewModel( val premium = async { revenueCat.awaitIsPremium() } val country = currentStorefront() storefront = country - val resolved = revenueCat.resolvePaywall(_uiState.value.placementId, country) + // Card checkout needs none of Play's products, and Play is slowest to answer exactly + // where it can't sell (in Bhutan nearly half the paywalls closed before loading ended). + val known = billingDecision() + val webFirst = known.route == BillingRoute.WEB_CHECKOUT + val resolved = if (webFirst) { + RevenueCatManager.ResolvedPaywall(PaywallChoice.Unavailable, null, emptyList()) + } else { + revenueCat.resolvePaywall(_uiState.value.placementId, country) + } + // A failed offerings load can itself prove Play can't sell (Bhutanese SIM, no storefront). + val decision = if (webFirst) known else billingDecision() + val web = decision.route == BillingRoute.WEB_CHECKOUT + val trialLookup = if (web) async { enterWebCheckout() } else null val isPremium = premium.await() - val decision = billingDecision() val content = when { decision.route == BillingRoute.WEB_CHECKOUT -> PaywallContent.WebCheckout @@ -173,9 +190,8 @@ class PremiumViewModel( resolved.choice is PaywallChoice.Custom && resolved.plans.isNotEmpty() -> PaywallContent.Custom else -> PaywallContent.Error } - val web = decision.route == BillingRoute.WEB_CHECKOUT if (web) logBillingUnavailableOnce(decision) - val trialEligible = web && enterWebCheckout() + val trialEligible = trialLookup?.await() == true _uiState.update { state -> state.withWebPlans().copy( @@ -347,16 +363,35 @@ class PremiumViewModel( AppAnalytics.logPaywallPlanSelected(source, plan.plan) } + /** + * The card checkout's Continue was tapped. Signed out, the Activity sends the user to sign in, + * and [refreshSignIn] carries on to the checkout when they come back signed in. + */ + fun onWebCheckoutContinue(signedIn: Boolean) { + autoContinuePending = false // tapped while the return from sign-in was still checking + savedStateHandle[KEY_CONTINUE_AFTER_SIGN_IN] = !signedIn + AppAnalytics.logWebCheckoutContinue(source, webPlanLabel(), webVariantLabel(), signedIn) + } + /** Re-read the sign-in state after returning from the login screen. */ fun refreshSignIn() { val signedIn = isSignedIn() val justSignedIn = signedIn && !_uiState.value.isSignedIn _uiState.update { it.copy(isSignedIn = signedIn) } + // Only the return from that sign-in continues; backing out of it cancels. + val continueToCheckout = savedStateHandle.remove(KEY_CONTINUE_AFTER_SIGN_IN) == true && signedIn // The account may have bought before, on another phone; the checkout then gives no trial. - if (justSignedIn && _uiState.value.content == PaywallContent.WebCheckout) { + if ((justSignedIn || continueToCheckout) && _uiState.value.content == PaywallContent.WebCheckout) { + autoContinuePending = continueToCheckout viewModelScope.launch { val trialEligible = enterWebCheckout() _uiState.update { it.copy(webTrialEligible = trialEligible) } + // They signed in to pay, so don't make them tap Continue again, unless the + // account already has PRO (the premium observer then closes the paywall). + if (continueToCheckout && revenueCat.awaitIsPremium() != true && autoContinuePending) { + autoContinuePending = false + _events.trySend(PremiumEvent.OpenWebCheckout) + } } } } @@ -413,6 +448,13 @@ class PremiumViewModel( else -> AppAnalytics.PaywallType.CUSTOM } + /** What was on screen when the paywall closed, so closing on the spinner can be counted. */ + fun dismissedPaywallType(): String = when (val content = _uiState.value.content) { + PaywallContent.Loading -> AppAnalytics.PaywallType.LOADING + PaywallContent.Error -> AppAnalytics.PaywallType.ERROR + else -> paywallType(content) + } + override fun onCleared() { revenueCat.isPremiumUser.removeObserver(premiumObserver) super.onCleared() @@ -421,5 +463,6 @@ class PremiumViewModel( private companion object { const val KEY_VIEW_LOGGED = "paywall_view_logged" const val KEY_WEB_PLAN = "web_plan" + const val KEY_CONTINUE_AFTER_SIGN_IN = "continue_after_sign_in" } }