Subscription Store Example
A complete subscription store implementation with monthly and yearly plans using kmp-iap.
Features
- Multiple subscription tiers
- Subscription status display
- Automatic renewal handling
- Restore purchases
- Grace period support
Complete Implementation
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.github.hyochan.kmpiap.KmpIAP
import io.github.hyochan.kmpiap.data.*
import io.github.hyochan.kmpiap.openiap.IapPlatform
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.datetime.*
class SubscriptionStoreViewModel : ViewModel() {
// State management
data class SubscriptionState(
val isConnected: Boolean = false,
val isLoading: Boolean = false,
val subscriptions: List<Product> = emptyList(),
val activeSubscriptions: List<ActiveSubscription> = emptyList(),
val hasActiveSubscription: Boolean = false,
val error: String? = null
)
private val _state = MutableStateFlow(SubscriptionState())
val state: StateFlow<SubscriptionState> = _state.asStateFlow()
// Your subscription IDs
private val subscriptionIds = listOf(
"premium_monthly",
"premium_yearly",
"pro_monthly",
"pro_yearly"
)
init {
initializeStore()
observeStates()
}
private fun initializeStore() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
KmpIAP.initConnection()
loadSubscriptions()
} catch (e: PurchaseError) {
showError("Failed to initialize store: ${e.message}")
}
}
}
private fun observeStates() {
// Observe connection
viewModelScope.launch {
KmpIAP.isConnected.collectLatest { connected ->
_state.update { it.copy(isConnected = connected) }
}
}
// Observe subscriptions
viewModelScope.launch {
KmpIAP.subscriptions.collectLatest { subs ->
_state.update { it.copy(subscriptions = subs) }
}
}
// Observe active subscriptions using new API
viewModelScope.launch {
// Check for active subscriptions periodically or when state changes
loadActiveSubscriptions()
}
// Observe purchase updates
viewModelScope.launch {
KmpIAP.currentPurchase.collectLatest { purchase ->
purchase?.let { handlePurchaseUpdate(it) }
}
}
// Observe errors
viewModelScope.launch {
KmpIAP.currentError.collectLatest { error ->
error?.let {
handlePurchaseError(it)
KmpIAP.clearError()
}
}
}
}
private suspend fun loadSubscriptions() {
_state.update { it.copy(isLoading = true, error = null) }
try {
val subscriptions = KmpIAP.getSubscriptions(subscriptionIds)
println("Loaded ${subscriptions.size} subscriptions")
// Also load active subscriptions
loadActiveSubscriptions()
} catch (e: PurchaseError) {
showError("Failed to load subscriptions: ${e.message}")
} finally {
_state.update { it.copy(isLoading = false) }
}
}
private suspend fun loadActiveSubscriptions() {
try {
// Use new APIs to get active subscription information
val activeSubscriptions = kmpIapInstance.getActiveSubscriptions(subscriptionIds)
val hasActiveSubscription = kmpIapInstance.hasActiveSubscriptions(subscriptionIds)
_state.update {
it.copy(
activeSubscriptions = activeSubscriptions,
hasActiveSubscription = hasActiveSubscription
)
}
println("Found ${activeSubscriptions.size} active subscriptions")
activeSubscriptions.forEach { subscription ->
println("Active subscription: ${subscription.productId}")
// iOS-specific information
subscription.expirationDateIOS?.let { expDate ->
val expirationDate = Instant.fromEpochMilliseconds(expDate)
println(" Expires: $expirationDate")
}
subscription.environmentIOS?.let { env ->
println(" Environment: $env")
}
subscription.daysUntilExpirationIOS?.let { days ->
println(" Days until expiration: $days")
}
// Android-specific information
subscription.autoRenewingAndroid?.let { autoRenew ->
println(" Auto-renewing: $autoRenew")
}
// Cross-platform warnings
if (subscription.willExpireSoon == true) {
println(" ⚠️ This subscription will expire soon!")
}
}
} catch (e: Exception) {
showError("Failed to load active subscriptions: ${e.message}")
}
}
private suspend fun handlePurchaseUpdate(purchase: Purchase) {
println("Purchase update: ${purchase.productId}")
try {
// Verify purchase on your server
val isValid = verifyPurchase(purchase)
if (isValid) {
// Deliver subscription access
deliverSubscription(purchase)
// Complete transaction
completeTransaction(purchase)
// Reload active subscriptions to reflect changes
loadActiveSubscriptions()
// Clear current purchase
KmpIAP.clearPurchase()
showMessage("Subscription activated!")
} else {
showError("Purchase verification failed")
}
} catch (e: Exception) {
showError("Failed to process purchase: ${e.message}")
}
}
private fun handlePurchaseError(error: PurchaseError) {
when (error.code) {
ErrorCode.USER_CANCELLED -> {
// Silent - user cancelled
}
ErrorCode.PRODUCT_ALREADY_OWNED -> {
showMessage("You already have an active subscription")
}
else -> {
showError("Purchase failed: ${error.message}")
}
}
}
fun requestSubscription(productId: String) {
viewModelScope.launch {
try {
// For Android, you might want to handle subscription offers
if (KmpIAP.getCurrentPlatform() == IapPlatform.ANDROID) {
// Get available offers for the subscription
val product = _state.value.subscriptions.find { it.productId == productId }
val offers = product?.subscriptionOffers
if (!offers.isNullOrEmpty()) {
// Use the first offer (you might want to let user choose)
KmpIAP.requestSubscription(
sku = productId,
subscriptionOffers = listOf(
SubscriptionOfferAndroid(
sku = productId,
offerToken = offers.first().offerToken
)
)
)
} else {
KmpIAP.requestSubscription(sku = productId)
}
} else {
// iOS doesn't need offer tokens
KmpIAP.requestSubscription(sku = productId)
}
} catch (e: PurchaseError) {
showError("Failed to request subscription: ${e.message}")
}
}
}
fun restorePurchases() {
viewModelScope.launch {
_state.update { it.copy(isLoading = true, error = null) }
try {
// Get available purchases - this automatically refreshes
val purchases = KmpIAP.availablePurchases.value
val activeCount = purchases.count {
subscriptionIds.contains(it.productId) && isSubscriptionActive(it)
}
showMessage("Restored $activeCount active subscriptions")
} catch (e: PurchaseError) {
showError("Failed to restore purchases: ${e.message}")
} finally {
_state.update { it.copy(isLoading = false) }
}
}
}
private suspend fun verifyPurchase(purchase: Purchase): Boolean {
// TODO: Implement server-side verification
// This should verify the receipt with your backend
return true
}
private fun deliverSubscription(purchase: Purchase) {
// TODO: Grant subscription access to user
println("Delivering subscription: ${purchase.productId}")
}
private suspend fun completeTransaction(purchase: Purchase) {
val success = KmpIAP.finishTransaction(
purchase = purchase,
isConsumable = false // Subscriptions are non-consumable
)
if (success) {
println("Transaction completed successfully")
}
}
private fun isSubscriptionActive(purchase: Purchase): Boolean {
// For a real implementation, check expiration date
// This is a simplified version
return when (purchase.purchaseState) {
PurchaseState.PURCHASED -> true
PurchaseState.PENDING -> true // Show as active during pending
else -> false
}
}
fun getSubscriptionTier(productId: String): String {
return when {
productId.contains("premium") -> "Premium"
productId.contains("pro") -> "Pro"
else -> "Basic"
}
}
fun getSubscriptionPeriod(productId: String): String {
return when {
productId.contains("monthly") -> "Monthly"
productId.contains("yearly") -> "Yearly"
else -> ""
}
}
fun getTierColor(tier: String): Color {
return when (tier.lowercase()) {
"premium" -> Color(0xFFFFA726) // Orange
"pro" -> Color(0xFF9C27B0) // Purple
else -> Color(0xFF2196F3) // Blue
}
}
private fun showError(message: String) {
_state.update { it.copy(error = message) }
}
private fun showMessage(message: String) {
// In a real app, show a snackbar
println("ℹ️ $message")
}
fun clearError() {
_state.update { it.copy(error = null) }
}
override fun onCleared() {
super.onCleared()
KmpIAP.dispose()
}
}
@Composable
fun SubscriptionStoreScreen(
viewModel: SubscriptionStoreViewModel = viewModel()
) {
val state by viewModel.state.collectAsState()
Scaffold(
topBar = {
TopAppBar(
title = { Text("Premium Subscriptions") },
actions = {
IconButton(
onClick = viewModel::restorePurchases,
enabled = !state.isLoading
) {
Icon(
Icons.Default.Restore,
contentDescription = "Restore Purchases"
)
}
}
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when {
state.isLoading && state.subscriptions.isEmpty() -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
else -> {
SubscriptionPlans(
state = state,
viewModel = viewModel
)
}
}
// Error banner
state.error?.let { error ->
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.errorContainer
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = error,
modifier = Modifier.weight(1f),
color = MaterialTheme.colorScheme.onErrorContainer
)
IconButton(onClick = viewModel::clearError) {
Icon(
Icons.Default.Close,
contentDescription = "Dismiss",
tint = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
}
}
}
}
@Composable
fun SubscriptionPlans(
state: SubscriptionStoreViewModel.SubscriptionState,
viewModel: SubscriptionStoreViewModel
) {
// Group subscriptions by tier
val groupedSubs = state.subscriptions.groupBy {
viewModel.getSubscriptionTier(it.productId)
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Current Status
item {
CurrentSubscriptionStatus(
activeSubscriptions = state.activeSubscriptions,
hasActiveSubscription = state.hasActiveSubscription,
viewModel = viewModel
)
}
// Subscription Plans Header
item {
Text(
"Choose Your Plan",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.padding(vertical = 8.dp)
)
}
// Subscription Tiers
groupedSubs.forEach { (tier, subscriptions) ->
item {
SubscriptionTierSection(
tier = tier,
subscriptions = subscriptions,
activeSubscriptions = state.activeSubscriptions,
hasActiveSubscription = state.hasActiveSubscription,
viewModel = viewModel
)
}
}
}
}
@Composable
fun CurrentSubscriptionStatus(
activeSubscriptions: List<ActiveSubscription>,
hasActiveSubscription: Boolean,
viewModel: SubscriptionStoreViewModel
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = if (!hasActiveSubscription)
MaterialTheme.colorScheme.surfaceVariant
else
Color(0xFF4CAF50).copy(alpha = 0.1f)
)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (!hasActiveSubscription)
Icons.Default.Info
else
Icons.Default.CheckCircle,
contentDescription = null,
tint = if (!hasActiveSubscription)
MaterialTheme.colorScheme.onSurfaceVariant
else
Color(0xFF4CAF50)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = if (!hasActiveSubscription)
"No active subscriptions"
else
"Active Subscriptions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
activeSubscriptions.forEach { sub ->
Column {
Text(
text = "${viewModel.getSubscriptionTier(sub.productId)} ${viewModel.getSubscriptionPeriod(sub.productId)}",
style = MaterialTheme.typography.bodyMedium,
color = Color(0xFF4CAF50)
)
// Show expiration information
sub.expirationDateIOS?.let { expDate ->
val expirationDate = Instant.fromEpochMilliseconds(expDate)
Text(
text = "Expires: ${expirationDate.toLocalDateTime(TimeZone.currentSystemDefault()).date}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Show environment (iOS)
sub.environmentIOS?.let { env ->
if (env == "Sandbox") {
Text(
text = "🧪 Sandbox",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFF9800)
)
}
}
// Show expiration warning
if (sub.willExpireSoon == true) {
Text(
text = "⚠️ Expires soon",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFF5722)
)
}
// Show auto-renewal status (Android)
sub.autoRenewingAndroid?.let { autoRenew ->
Text(
text = if (autoRenew) "🔄 Auto-renewing" else "⏸️ Will not renew",
style = MaterialTheme.typography.bodySmall,
color = if (autoRenew) Color(0xFF4CAF50) else Color(0xFFFF5722)
)
}
}
}
}
}
}
}
@Composable
fun SubscriptionTierSection(
tier: String,
subscriptions: List<Product>,
activeSubscriptions: List<ActiveSubscription>,
hasActiveSubscription: Boolean,
viewModel: SubscriptionStoreViewModel
) {
val tierColor = viewModel.getTierColor(tier)
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors()
) {
Column {
// Tier Header
Surface(
modifier = Modifier.fillMaxWidth(),
color = tierColor.copy(alpha = 0.1f)
) {
Text(
text = tier,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = tierColor
)
}
// Subscription Options
subscriptions.forEach { subscription ->
SubscriptionTile(
subscription = subscription,
isActive = activeSubscriptions.any {
it.productId == subscription.productId
},
tierColor = tierColor,
onSubscribe = { viewModel.requestSubscription(subscription.productId) }
)
if (subscription != subscriptions.last()) {
Divider()
}
}
}
}
}
@Composable
fun SubscriptionTile(
subscription: Product,
isActive: Boolean,
tierColor: Color,
onSubscribe: () -> Unit
) {
ListItem(
headlineContent = {
Text(subscription.title)
},
supportingContent = {
subscription.description?.let { Text(it) }
},
trailingContent = {
if (isActive) {
Surface(
shape = MaterialTheme.shapes.small,
color = Color(0xFF4CAF50),
contentColor = Color.White
) {
Text(
text = "ACTIVE",
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold
)
}
} else {
Button(
onClick = onSubscribe,
colors = ButtonDefaults.buttonColors(
containerColor = tierColor
)
) {
Text(subscription.price)
}
}
}
)
}
Key Features Explained
1. Subscription Grouping
The store groups subscriptions by tier (Premium, Pro) for better organization:
val groupedSubs = state.subscriptions.groupBy {
viewModel.getSubscriptionTier(it.productId)
}
2. Status Display
Shows current subscription status prominently:
@Composable
fun CurrentSubscriptionStatus(
activeSubscriptions: List<Purchase>,
viewModel: SubscriptionStoreViewModel
) {
// Visual indication of active subscriptions
// Different colors and icons for active vs inactive
}
3. Visual Hierarchy
Different colors and styling for different subscription tiers:
fun getTierColor(tier: String): Color {
return when (tier.lowercase()) {
"premium" -> Color(0xFFFFA726) // Orange
"pro" -> Color(0xFF9C27B0) // Purple
else -> Color(0xFF2196F3) // Blue
}
}
4. Android Subscription Offers
Handles Android subscription offers properly:
if (KmpIAP.getCurrentPlatform() == IapPlatform.ANDROID) {
val offers = product?.subscriptionOffers
if (!offers.isNullOrEmpty()) {
KmpIAP.requestSubscription(
sku = productId,
subscriptionOffers = listOf(
SubscriptionOfferAndroid(
sku = productId,
offerToken = offers.first().offerToken
)
)
)
}
}
Best Practices Implemented
- Error Handling: Comprehensive error handling with user-friendly messages
- Loading States: Shows loading indicators during async operations
- Purchase Verification: Placeholder for server-side verification
- Transaction Completion: Proper handling of iOS and Android differences
- Restore Functionality: Easy way for users to restore purchases
- Status Display: Clear indication of active subscriptions with detailed information
- StateFlow Usage: Reactive state management with Kotlin StateFlow
- Enhanced Subscription Management: Uses new
getActiveSubscriptions()
andhasActiveSubscriptions()
APIs - Platform-Specific Details: Shows expiration dates (iOS), auto-renewal status (Android), and environment info
Enhanced Subscription Management Features
Using New ActiveSubscription APIs
The example now uses the enhanced subscription APIs for more detailed information:
// Check if user has any active subscriptions (quick check)
val hasActiveSubscription = kmpIapInstance.hasActiveSubscriptions(subscriptionIds)
// Get detailed subscription information
val activeSubscriptions = kmpIapInstance.getActiveSubscriptions(subscriptionIds)
activeSubscriptions.forEach { subscription ->
println("Active subscription: ${subscription.productId}")
// iOS-specific information
subscription.expirationDateIOS?.let { expDate ->
val expirationDate = Instant.fromEpochMilliseconds(expDate)
println(" Expires: $expirationDate")
}
subscription.environmentIOS?.let { env ->
println(" Environment: $env") // "Sandbox" or "Production"
}
subscription.daysUntilExpirationIOS?.let { days ->
println(" Days until expiration: $days")
}
// Android-specific information
subscription.autoRenewingAndroid?.let { autoRenew ->
println(" Auto-renewing: $autoRenew")
}
// Cross-platform warnings
if (subscription.willExpireSoon == true) {
println(" ⚠️ This subscription will expire soon!")
}
}
Platform-Specific Subscription Details
iOS Features
- Expiration Date: Exact timestamp when subscription expires
- Environment Detection: Automatically detects Sandbox vs Production
- Days Until Expiration: Calculated remaining days
- Expiration Warnings:
willExpireSoon
flag for subscriptions expiring within 7 days
Android Features
- Auto-Renewal Status: Shows if subscription will automatically renew
- Grace Period Support: Can be extended with server-side validation
Visual Subscription Status
The UI now shows rich subscription information:
// Show expiration information
sub.expirationDateIOS?.let { expDate ->
val expirationDate = Instant.fromEpochMilliseconds(expDate)
Text(
text = "Expires: ${expirationDate.toLocalDateTime(TimeZone.currentSystemDefault()).date}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// Show environment (iOS)
sub.environmentIOS?.let { env ->
if (env == "Sandbox") {
Text(
text = "🧪 Sandbox",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFF9800)
)
}
}
// Show expiration warning
if (sub.willExpireSoon == true) {
Text(
text = "⚠️ Expires soon",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFFFF5722)
)
}
// Show auto-renewal status (Android)
sub.autoRenewingAndroid?.let { autoRenew ->
Text(
text = if (autoRenew) "🔄 Auto-renewing" else "⏸️ Will not renew",
style = MaterialTheme.typography.bodySmall,
color = if (autoRenew) Color(0xFF4CAF50) else Color(0xFFFF5722)
)
}
Legacy Subscription Status Check
For backward compatibility, you can still use the basic check:
private fun isSubscriptionActive(purchase: Purchase): Boolean {
// In production, check actual expiration date
return when (purchase.purchaseState) {
PurchaseState.PURCHASED -> true
PurchaseState.PENDING -> true // Show as active during pending
else -> false
}
}
Handling Grace Periods
For production apps, implement grace period handling:
private fun checkSubscriptionWithGracePeriod(purchase: Purchase): SubscriptionStatus {
val expirationDate = purchase.expirationDate ?: return SubscriptionStatus.EXPIRED
val now = Clock.System.now()
return when {
now < expirationDate -> SubscriptionStatus.ACTIVE
now < expirationDate.plus(3.days) -> SubscriptionStatus.IN_GRACE_PERIOD
else -> SubscriptionStatus.EXPIRED
}
}
Testing Considerations
- Test with different subscription tiers
- Test restoration on device reinstall
- Test subscription expiration handling
- Test grace period scenarios
- Test with different payment methods
- Test subscription upgrades/downgrades
Platform-Specific Features
iOS Promotional Offers
// iOS specific - check for promotional offers
if (KmpIAP.getCurrentPlatform() == IapPlatform.IOS) {
viewModelScope.launch {
KmpIAP.promotedProductsIOS.collectLatest { promotedProducts ->
// Handle App Store promoted products
}
}
}
Android Subscription Management
// Android specific - deep link to subscription management
suspend fun openSubscriptionManagement(productId: String) {
if (KmpIAP.getCurrentPlatform() == IapPlatform.ANDROID) {
KmpIAP.deepLinkToSubscriptionsAndroid(productId)
}
}
This example provides a solid foundation for a subscription-based app with multiple tiers and billing periods using Kotlin Multiplatform.