Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -496,13 +513,17 @@ 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"
/** Our own Compose screen — the fallback when no dashboard paywall is attached. */
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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ open class PremiumActivity : AppCompatActivity() {
closedAfterPurchase = true
closePaywall()
}
PremiumEvent.OpenWebCheckout -> openWebCheckout()
}
}
}
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,9 @@
* 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
}

/**
Expand Down Expand Up @@ -145,6 +148,9 @@
/** 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<Boolean> { isPremium ->
_uiState.update { it.copy(isPremium = isPremium) }
if (isPremium) _events.trySend(PremiumEvent.ProActive(announce = false))
Expand All @@ -162,9 +168,20 @@
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
Expand All @@ -173,9 +190,8 @@
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(
Expand Down Expand Up @@ -333,7 +349,7 @@
val plans = revenueCat.webPlans()
val picked = selectedWebPlanId?.takeIf { id -> plans.any { it.packageId == id } }
?: webVariant?.let { WebPlans.preselected(plans, it) }?.packageId
if (picked != null) savedStateHandle[KEY_WEB_PLAN] = picked

Check warning on line 352 in app/src/main/java/com/kharagedition/tibetankeyboard/ui/subscription/PremiumViewModel.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless non-null check, it always succeeds.

See more on https://sonarcloud.io/project/issues?id=CodingWithTashi_Tibetan-Keyboard&issues=AaD_uxav46-QiBaKs1Qg&open=AaD_uxav46-QiBaKs1Qg&pullRequest=23
return copy(
webPlans = plans,
selectedWebPlanId = picked,
Expand All @@ -347,16 +363,35 @@
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<Boolean>(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)
}
}
}
}
Expand Down Expand Up @@ -413,6 +448,13 @@
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()
Expand All @@ -421,5 +463,6 @@
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"
}
}
Loading