Get from zero Adapty setup to displaying a paywall and processing purchases in under 60 minutes.
Pre-requisites
Before you begin, make sure you have:
A working Android app with Kotlin and Jetpack Compose
Android Studio with minimum SDK 24 or later
Google Play Console setup for in-app purchases
At least one in-app purchase product created in Google Play Console
An Adapty account with your app added to the dashboard, including at least one product, paywall, and placement
Project files
To follow along with this tutorial, download the project files from GitHub. The starter branch contains the app without Adapty integration and a mocked purchase experience, while the main branch contains the finished project.
Install the Adapty SDK in your project
Add the following to the versions section of the libs.versions.toml file:
[versions]adaptyBom = "3.10.0"
Then, in the same file, add the following definitions to the Libraries section:
This call configures and activates the Adapty SDK when your app launches.
Set up profile handling
Next, edit the ProfileManager.kt file to add Adapty integration.
The primary changes are the addition of the Adapty profile property, the call to refresh the profile, and updating premium status based on the profile's access levels.
package io.adapty.FocusJournal.viewmodel// Add these import statementsimport com.adapty.Adaptyimport com.adapty.models.AdaptyProfileimport com.adapty.utils.AdaptyResultimport io.adapty.focusjournal.utils.AppConstantsimport javax.inject.Injectimport javax.inject.Singleton// Update class to act as a singleton// and add @Inject constructor after class name@Singletonclass ProfileManager @Inject constructor( private val journalRepository: JournalRepository) : ViewModel() { // Add the customer profile and isLoading values private val _customerProfile = MutableStateFlow<AdaptyProfile?>(null) val customerProfile: StateFlow<AdaptyProfile?> = _customerProfile.asStateFlow() private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow() // Update the existing isPremium declaration to pull data from the profile val isPremium: StateFlow<Boolean> = customerProfile.map { profile -> profile?.accessLevels?.get(AppConstants.Adapty.ACCESS_LEVEL_ID)?.let { accessLevel -> accessLevel.isActive || accessLevel.isInGracePeriod || accessLevel.isLifetime } ?: false }.stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), initialValue = false ) // Add an init function to refresh the profile immediately init { refreshProfile() } // Delete this function as it's no longer needed fun mockPurchasePremium() { // ... } // Add this function to update the profile from Adapty's servers fun refreshProfile() { viewModelScope.launch { _isLoading.value = true Adapty.getProfile { result -> when (result) { is AdaptyResult.Success -> { val profile = result.value _customerProfile.value = profile } is AdaptyResult.Error -> { _customerProfile.value = null } } } _isLoading.value = false } } // Add this function to update the profile after the user performs a purchase fun subscriptionPurchased(profile: AdaptyProfile) { _customerProfile.value = profile }}
Kotlin
Create the paywall screen
Update your PaywallScreen.kt file to integrate with Adapty's purchase flow. This will replace the mocked purchase experience from the starter branch for a proper paywall with elements loaded from Adapty's SDK:
Note: In the starter branch, you'll find an updateDialog composable view function. Simply delete the entire function and replace it with the PaywallScreen, FeatureItem, and ProductButton composable view functions.
package io.adapty.focusjournal.ui.screenimport androidx.compose.foundation.layout.*import androidx.compose.foundation.rememberScrollStateimport androidx.compose.foundation.verticalScrollimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Closeimport androidx.compose.material.icons.filled.Listimport androidx.compose.material.icons.filled.Starimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.text.font.FontWeightimport androidx.compose.ui.text.style.TextAlignimport androidx.compose.ui.unit.dpimport androidx.compose.ui.unit.spimport androidx.compose.ui.platform.LocalContextimport android.app.Activityimport com.adapty.Adaptyimport com.adapty.models.AdaptyPaywallProductimport com.adapty.models.AdaptyPurchaseParametersimport com.adapty.models.AdaptyPurchaseResultimport com.adapty.utils.AdaptyResultimport io.adapty.focusjournal.utils.AppConstantsimport io.adapty.focusjournal.viewmodel.ProfileManagerimport kotlinx.coroutines.launch@OptIn(ExperimentalMaterial3Api::class)@Composablefun PaywallScreen( profileManager: ProfileManager, onDismiss: () -> Unit, onPurchaseSuccess: () -> Unit) { val context = LocalContext.current val scope = rememberCoroutineScope() var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf<String?>(null) } var products by remember { mutableStateOf<List<AdaptyPaywallProduct>>(emptyList()) } var isLoadingPaywall by remember { mutableStateOf(true) } // Fetch paywall and products on component load LaunchedEffect(Unit) { Adapty.getPaywall(AppConstants.Adapty.PLACEMENT_ID, locale = "en") { result -> when (result) { is AdaptyResult.Success -> { val fetchedPaywall = result.value // Fetch products for this paywall Adapty.getPaywallProducts(fetchedPaywall) { productsResult -> when (productsResult) { is AdaptyResult.Success -> { products = productsResult.value isLoadingPaywall = false } is AdaptyResult.Error -> { errorMessage = "Failed to load products: ${productsResult.error.message}" isLoadingPaywall = false } } } } is AdaptyResult.Error -> { errorMessage = "Failed to load paywall: ${result.error.message}" isLoadingPaywall = false } } } } Column( modifier = Modifier.fillMaxSize() ) { // Top Bar TopAppBar( title = { Text("Premium Features") }, navigationIcon = { IconButton(onClick = onDismiss) { Icon(Icons.Default.Close, contentDescription = "Close") } } ) Column( modifier = Modifier .fillMaxSize() .padding(24.dp) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(24.dp) ) { // Premium Icon Icon( Icons.Default.Star, contentDescription = null, modifier = Modifier.size(64.dp), tint = MaterialTheme.colorScheme.primary ) // Title Text( text = "Unlock Premium Features", fontSize = 28.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center ) // Features List Column( verticalArrangement = Arrangement.spacedBy(16.dp) ) { FeatureItem( icon = Icons.Default.List, title = "Journal History", description = "Access all your past journal entries anytime" ) FeatureItem( icon = Icons.Default.Star, title = "Premium Support", description = "Get priority customer support" ) } Spacer(modifier = Modifier.height(16.dp)) // Products Section if (isLoadingPaywall) { Box( modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center ) { CircularProgressIndicator() } } else if (products.isNotEmpty()) { Column( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(12.dp) ) { products.forEach { product -> ProductButton( product = product, isLoading = isLoading, onClick = { scope.launch { isLoading = true errorMessage = null try { val activity = context as Activity val params = AdaptyPurchaseParameters.Builder().build() Adapty.makePurchase(activity, product, params) { result -> scope.launch { when (result) { is AdaptyResult.Success -> { when (val purchaseResult = result.value) { is AdaptyPurchaseResult.Success -> { val profile = purchaseResult.profile // Notify ProfileManager of successful subscription profileManager.subscriptionPurchased(profile) // Navigate away from paywall onPurchaseSuccess() } is AdaptyPurchaseResult.UserCanceled -> { errorMessage = "Purchase was canceled" } is AdaptyPurchaseResult.Pending -> { errorMessage = "Purchase is pending" } } } is AdaptyResult.Error -> { errorMessage = "Purchase failed: ${result.error.message}" } } isLoading = false } } } catch (e: Exception) { errorMessage = "Purchase failed: ${e.message}" isLoading = false } } } ) } } } else { Text( text = "No products available", modifier = Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant ) } // Restore Button (Placeholder) TextButton( onClick = { scope.launch { isLoading = true errorMessage = null // TODO: Implement actual restore logic with correct API kotlinx.coroutines.delay(1000) // For demo purposes errorMessage = "No purchases to restore" isLoading = false } }, enabled = !isLoading ) { Text("Restore Purchases") } // Error Message errorMessage?.let { message -> Card( colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.errorContainer ), modifier = Modifier.fillMaxWidth() ) { Text( text = message, modifier = Modifier.padding(16.dp), color = MaterialTheme.colorScheme.onErrorContainer ) } } // Terms and Privacy (placeholder) Text( text = "By purchasing, you agree to our Terms of Service and Privacy Policy", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center ) } }}@Composablefun FeatureItem( icon: androidx.compose.ui.graphics.vector.ImageVector, title: String, description: String) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp) ) { Icon( icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(24.dp) ) Column( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( text = title, fontWeight = FontWeight.Medium, fontSize = 16.sp ) Text( text = description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } }}@Composablefun ProductButton( product: AdaptyPaywallProduct, isLoading: Boolean, onClick: () -> Unit) { Card( modifier = Modifier.fillMaxWidth(), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) ) { Button( onClick = onClick, enabled = !isLoading, modifier = Modifier .fillMaxWidth() .padding(16.dp) .height(56.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.primary ) ) { if (isLoading) { CircularProgressIndicator( modifier = Modifier.size(24.dp), strokeWidth = 2.dp, color = MaterialTheme.colorScheme.onPrimary ) } else { Column( horizontalAlignment = Alignment.CenterHorizontally ) { Text( text = product.localizedTitle ?: product.vendorProductId, fontSize = 16.sp, fontWeight = FontWeight.Bold ) product.price?.localizedString?.let { price -> Text( text = price, fontSize = 14.sp, color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.9f) ) } } } } }}
Kotlin
The above sets up a fully functional paywall screen with Adapty integration. It loads products from your Adapty configuration and handles the complete purchase flow. You also have the option of using our Paywall Builder feature to design and deploy paywalls from the Adapty dashboard without requiring a new app submission.
Update the home screen to show a paywall
Edit your HomeScreen.kt to replace the mock upgrade dialog with a call to the paywall:
package io.adapty.focusjournal.ui.screen// No import changes// Add the onShowPaywall parameter@OptIn(ExperimentalMaterial3Api::class)@Composablefun HomeScreen( profileManager: ProfileManager, onNavigateToHistory: () -> Unit, onShowPaywall: () -> Unit) { // ...existing values // Delete this value as it's not needed var showUpgradeDialog by remember { mutableStateOf(false) } Column( modifier = Modifier .fillMaxSize() .padding(16.dp) .verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(16.dp) ) { // ...existing UI elements // History Button with Premium Gate Button( onClick = { if (isPremium) { onNavigateToHistory() } else { // Remove the boolean flip of showUpgradeDialog and replace with: onShowPaywall() } }, // ...other modifiers ) { // ...button contents } // ... } // Remove the entire if-block as its not needed if (showUpgradeDialog) { // ... }}
Kotlin
And in the FocusJournalNavigation.kt file, update the arguments to the HomeScreen by adding the route for onShowPaywall. Also
sealed class Screen(val route: String) { // ...other screens // Add a route to the paywall screen object Paywall : Screen("paywall")}composable(Screen.Home.route) { HomeScreen( profileManager = profileManager, onNavigateToHistory = { navController.navigate(Screen.History.route) }, // Add the following argument with the routing to the Paywall screen onShowPaywall = { navController.navigate(Screen.Paywall.route) } )}// Add this composable for displayig the paywall screencomposable("paywall") { PaywallScreen( profileManager = profileManager, onDismiss = { navController.popBackStack() }, onPurchaseSuccess = { navController.popBackStack() // Navigate to history if purchase was successful navController.navigate("history") } )}
Kotlin
Check for purchases on app launch
The initialization code we added to FocusJournalApplication.kt and ProfileManager.kt handles checking subscription status when the app launches. The ProfileManager automatically updates the premium status based on the user's access levels.
Here's how the purchase flow works:
App Launch: refreshProfile() gets the latest subscription status from Adapty
Profile Updates: Purchases automatically update the profile state
Access Control: The isPremium StateFlow controls which features are available
Real-time Updates: Profile changes trigger UI updates throughout the app
Test your integration
Build and run your app:
./gradlew assembleDebug
Bash
The expected flow is as follows:
Load subscription status from Adapty on launch
Show premium features to subscribers
Display paywalls to non-subscribers
Process purchases through Google Play Billing and unlock content immediately
Handle subscription status changes in real-time
Congrats!
At this point, your Android app:
Loads paywalls from Adapty
Processes subscriptions through Google Play Billing with native Android interfaces
Unlocks premium features based on access levels from Adapty
Handles subscription status automatically across app launches
Most apps send every install to the same onboarding and the same paywall, no matter the intentBut matching the flow to the ad source (TikTok, Apple ,Meta) showed a significant uplift. Here's how you can do it in the Flow & paywall builder.
A developer's framework for choosing between native and WebView paywall rendering: latency, accessibility, platform fidelity, and what each trade-off actually costs.