fix(billing): link RevenueCat identity to Firebase auth + purchase UX
Critical fix: Purchases was never told the Firebase uid, so RevenueCat assigned its
own anonymous app_user_id. The revenueCatWebhook Cloud Function writes premium status
to users/{app_user_id}/entitlements/premium using that id — meaning a real purchase
would silently never unlock premium for the signed-in account, and CouplePremiumChecker's
partner-side read (same path, different uid) was broken by the same root cause.
RevenueCatBillingRepository now collects AuthRepository.authState and calls
Purchases.awaitLogIn(uid) on sign-in / awaitLogOut() on sign-out, guarded against
redundant calls and wrapped best-effort so a failed sync retries on the next auth event
instead of crashing the singleton.
Also:
- Bump com.revenuecat.purchases 8.20.0 -> 10.12.0 (verified: real published version,
stable API surface across 8->10 per RevenueCat's own migration notes for the calls
this app uses; confirmed resolved + full Hilt/KSP graph compiles clean).
- Purchase cancellation (user backs out of the Play billing sheet) is now distinguished
from a real failure via PurchasesTransactionException.userCancelled, using a shared
PURCHASE_CANCELLED_SENTINEL (same marker-constant idiom PaywallViewModel already uses
for offering-load failures) so PaywallViewModel resets silently instead of surfacing
the SDK's internal error text.
- PaywallScreen: genuine purchase errors (billing unavailable, network, etc.) now show
a snackbar. Previously there was zero user-facing feedback on a real purchase failure
beyond the loading spinner disappearing.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
661f391582
commit
b99a83388e
|
|
@ -2,9 +2,13 @@ package app.closer.data.repository
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.util.Log
|
||||||
import app.closer.BuildConfig
|
import app.closer.BuildConfig
|
||||||
import app.closer.core.billing.EntitlementChecker
|
import app.closer.core.billing.EntitlementChecker
|
||||||
|
import app.closer.domain.model.AuthState
|
||||||
|
import app.closer.domain.repository.AuthRepository
|
||||||
import app.closer.domain.repository.BillingRepository
|
import app.closer.domain.repository.BillingRepository
|
||||||
|
import app.closer.domain.repository.BillingRepository.Companion.PURCHASE_CANCELLED_SENTINEL
|
||||||
import app.closer.domain.repository.BillingState
|
import app.closer.domain.repository.BillingState
|
||||||
import com.revenuecat.purchases.CustomerInfo
|
import com.revenuecat.purchases.CustomerInfo
|
||||||
import com.revenuecat.purchases.LogLevel
|
import com.revenuecat.purchases.LogLevel
|
||||||
|
|
@ -14,19 +18,35 @@ import com.revenuecat.purchases.Purchases
|
||||||
import com.revenuecat.purchases.PurchasesConfiguration
|
import com.revenuecat.purchases.PurchasesConfiguration
|
||||||
import com.revenuecat.purchases.PurchaseResult
|
import com.revenuecat.purchases.PurchaseResult
|
||||||
import com.revenuecat.purchases.PurchaseParams
|
import com.revenuecat.purchases.PurchaseParams
|
||||||
|
import com.revenuecat.purchases.PurchasesTransactionException
|
||||||
|
import com.revenuecat.purchases.awaitLogIn
|
||||||
|
import com.revenuecat.purchases.awaitLogOut
|
||||||
import com.revenuecat.purchases.awaitOfferings
|
import com.revenuecat.purchases.awaitOfferings
|
||||||
import com.revenuecat.purchases.awaitPurchase
|
import com.revenuecat.purchases.awaitPurchase
|
||||||
import com.revenuecat.purchases.awaitRestore
|
import com.revenuecat.purchases.awaitRestore
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.awaitClose
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.callbackFlow
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RevenueCat-backed implementation of [BillingRepository].
|
* RevenueCat-backed implementation of [BillingRepository].
|
||||||
*
|
*
|
||||||
* - Initializes Purchases once per process with the API key from BuildConfig.
|
* - Initializes Purchases once per process with the API key from BuildConfig.
|
||||||
|
* - Identifies the RevenueCat user with the Firebase Auth uid on every sign-in (and logs out to a
|
||||||
|
* fresh anonymous identity on sign-out) — REQUIRED for the server-authoritative entitlement
|
||||||
|
* architecture: the `revenueCatWebhook` Cloud Function writes premium status to
|
||||||
|
* `users/{app_user_id}/entitlements/premium` using whatever app_user_id RevenueCat reports on the
|
||||||
|
* purchase event. Without this link, purchases attach to RevenueCat's auto-generated anonymous ID
|
||||||
|
* instead of the Firebase uid, so [FirestoreEntitlementChecker] (which reads by Firebase uid) and
|
||||||
|
* the couple-shared premium check ([CouplePremiumChecker], which reads the PARTNER's doc the same
|
||||||
|
* way) would never see the entitlement flip.
|
||||||
* - Uses RevenueCat Kotlin coroutine extensions where available.
|
* - Uses RevenueCat Kotlin coroutine extensions where available.
|
||||||
* - Maps RevenueCat results to [BillingState] for stable UI contracts.
|
* - Maps RevenueCat results to [BillingState] for stable UI contracts.
|
||||||
*
|
*
|
||||||
|
|
@ -36,9 +56,15 @@ import kotlinx.coroutines.flow.callbackFlow
|
||||||
@Singleton
|
@Singleton
|
||||||
class RevenueCatBillingRepository @Inject constructor(
|
class RevenueCatBillingRepository @Inject constructor(
|
||||||
private val application: Application,
|
private val application: Application,
|
||||||
private val entitlementChecker: EntitlementChecker
|
private val entitlementChecker: EntitlementChecker,
|
||||||
|
private val authRepository: AuthRepository,
|
||||||
) : BillingRepository {
|
) : BillingRepository {
|
||||||
|
|
||||||
|
// Mirrors FirestoreEntitlementChecker's own process-lifetime scope for the same reason: this
|
||||||
|
// singleton has no natural coroutine scope of its own, and the identity link must survive for
|
||||||
|
// the life of the process, not any single caller's collection.
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
if (!Purchases.isConfigured) {
|
if (!Purchases.isConfigured) {
|
||||||
Purchases.logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
Purchases.logLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||||
|
|
@ -46,6 +72,30 @@ class RevenueCatBillingRepository @Inject constructor(
|
||||||
PurchasesConfiguration.Builder(application, BuildConfig.RC_API_KEY).build()
|
PurchasesConfiguration.Builder(application, BuildConfig.RC_API_KEY).build()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
scope.launch {
|
||||||
|
authRepository.authState.distinctUntilChanged().collect { state ->
|
||||||
|
syncRevenueCatIdentity(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort — a failed identity sync must never crash the app; it just retries on the next auth event. */
|
||||||
|
private suspend fun syncRevenueCatIdentity(state: AuthState) {
|
||||||
|
runCatching {
|
||||||
|
when (state) {
|
||||||
|
is AuthState.Authenticated -> {
|
||||||
|
if (Purchases.sharedInstance.appUserID != state.userId) {
|
||||||
|
Purchases.sharedInstance.awaitLogIn(state.userId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthState.Unauthenticated -> {
|
||||||
|
if (!Purchases.sharedInstance.isAnonymous) {
|
||||||
|
Purchases.sharedInstance.awaitLogOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AuthState.Loading -> Unit
|
||||||
|
}
|
||||||
|
}.onFailure { Log.w(TAG, "RevenueCat identity sync failed (will retry on next auth event)", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getOfferings(): BillingState<Offerings> = runCatching {
|
override suspend fun getOfferings(): BillingState<Offerings> = runCatching {
|
||||||
|
|
@ -58,7 +108,15 @@ class RevenueCatBillingRepository @Inject constructor(
|
||||||
PurchaseParams.Builder(getActivity(), pkg).build()
|
PurchaseParams.Builder(getActivity(), pkg).build()
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}.getOrElse { BillingState.Error(it.localizedMessage ?: "Purchase failed") }
|
}.getOrElse { error ->
|
||||||
|
// The user backing out of the Play billing sheet is not a failure — it's a distinct, expected
|
||||||
|
// path callers must not render as an error (see PaywallViewModel.purchase()).
|
||||||
|
if (error is PurchasesTransactionException && error.userCancelled) {
|
||||||
|
BillingState.Error(PURCHASE_CANCELLED_SENTINEL)
|
||||||
|
} else {
|
||||||
|
BillingState.Error(error.localizedMessage ?: "Purchase failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun restorePurchases(): BillingState<CustomerInfo> = runCatching {
|
override suspend fun restorePurchases(): BillingState<CustomerInfo> = runCatching {
|
||||||
BillingState.Success(Purchases.sharedInstance.awaitRestore())
|
BillingState.Success(Purchases.sharedInstance.awaitRestore())
|
||||||
|
|
@ -88,4 +146,8 @@ class RevenueCatBillingRepository @Inject constructor(
|
||||||
return ActivityProvider.currentActivity
|
return ActivityProvider.currentActivity
|
||||||
?: throw IllegalStateException("No active Activity available for purchase")
|
?: throw IllegalStateException("No active Activity available for purchase")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "RevenueCatBilling"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,16 @@ interface BillingRepository {
|
||||||
|
|
||||||
/** Continuous stream of customer info updates. */
|
/** Continuous stream of customer info updates. */
|
||||||
fun getCustomerInfo(): Flow<BillingState<CustomerInfo>>
|
fun getCustomerInfo(): Flow<BillingState<CustomerInfo>>
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Marker-only [BillingState.Error] message for a purchase the user cancelled (backed out of
|
||||||
|
* the Play billing sheet). Callers (see PaywallViewModel.purchase()) must treat this as a
|
||||||
|
* silent reset, not a user-facing error — mirrors PaywallViewModel's own LOAD_FAILED marker
|
||||||
|
* pattern for offering-load failures.
|
||||||
|
*/
|
||||||
|
const val PURCHASE_CANCELLED_SENTINEL = "purchase_cancelled"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.RadioButton
|
import androidx.compose.material3.RadioButton
|
||||||
import androidx.compose.material3.RadioButtonDefaults
|
import androidx.compose.material3.RadioButtonDefaults
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -86,12 +88,19 @@ fun PaywallScreen(
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showThankYou by remember { mutableStateOf(false) }
|
var showThankYou by remember { mutableStateOf(false) }
|
||||||
|
val snackbar = remember { SnackbarHostState() }
|
||||||
|
|
||||||
LaunchedEffect(uiState.purchaseState) {
|
LaunchedEffect(uiState.purchaseState) {
|
||||||
val state = uiState.purchaseState
|
val state = uiState.purchaseState
|
||||||
if (state is BillingState.Success<*>) {
|
if (state is BillingState.Success<*>) {
|
||||||
showThankYou = true
|
showThankYou = true
|
||||||
}
|
}
|
||||||
|
// Cancellation is reset to null upstream (PaywallViewModel), so any Error reaching here is a
|
||||||
|
// genuine failure (billing unavailable, network, etc.) — previously this had NO user-facing
|
||||||
|
// surface at all; the spinner just vanished with no explanation.
|
||||||
|
if (state is BillingState.Error) {
|
||||||
|
snackbar.showSnackbar("We couldn't complete that purchase. Please try again.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -167,6 +176,14 @@ fun PaywallScreen(
|
||||||
onNavigate("back")
|
onNavigate("back")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SnackbarHost(
|
||||||
|
hostState = snackbar,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(16.dp)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -110,7 +110,13 @@ class PaywallViewModel @Inject constructor(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_uiState.update { it.copy(purchaseState = BillingState.Loading) }
|
_uiState.update { it.copy(purchaseState = BillingState.Loading) }
|
||||||
val result = billingRepository.purchasePackage(pkg)
|
val result = billingRepository.purchasePackage(pkg)
|
||||||
_uiState.update { it.copy(purchaseState = result) }
|
// The user backing out of the Play billing sheet is expected, not an error — reset to
|
||||||
|
// null so the screen just returns to plan selection with no error toast (BillingRepository
|
||||||
|
// tags this path with PURCHASE_CANCELLED_SENTINEL; see purchasePackage's cancellation
|
||||||
|
// handling). A real failure keeps its BillingState.Error and PaywallScreen surfaces it.
|
||||||
|
val isCancelled = result is BillingState.Error &&
|
||||||
|
result.message == BillingRepository.PURCHASE_CANCELLED_SENTINEL
|
||||||
|
_uiState.update { it.copy(purchaseState = if (isCancelled) null else result) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ playIntegrity = "1.4.0"
|
||||||
googleid = "1.1.1"
|
googleid = "1.1.1"
|
||||||
|
|
||||||
# Third-party
|
# Third-party
|
||||||
revenuecat = "8.20.0"
|
revenuecat = "10.12.0"
|
||||||
coil = "2.7.0"
|
coil = "2.7.0"
|
||||||
coroutines = "1.9.0"
|
coroutines = "1.9.0"
|
||||||
tink = "1.13.0"
|
tink = "1.13.0"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue