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:
[libraries]
adapty-android-sdk = { module = "io.adapty:android-sdk" }
adapty-bom = { module = "io.adapty:adapty-bom", version.ref = "adaptyBom" }
And finally, add the Adapty Android SDK to your app-level build.gradle.kts
file:
dependencies {
// ...Other dependencies
// Adapty SDK
implementation(platform(libs.adapty.bom))
implementation(libs.adapty.android.sdk)
}
Sync your project to install the dependencies.
Initialize Adapty in your app
First, add these constants to your utils/AppConstants.kt
file:
package io.adapty.FocusJournal.utils
object AppConstants {
object Adapty {
const val API_KEY = "API key goes here"
const val ACCESS_LEVEL_ID = "premium"
const val PLACEMENT_ID = "on_tap_history"
}
}
🔐 Find your public SDK key: Adapty Dashboard > Settings > API Keys
Next, initialize Adapty in your FocusJournalApplication.kt
file:
package io.adapty.FocusJournal
// ...Existing imports
import com.adapty.Adapty
import com.adapty.models.AdaptyConfig.Builder
import io.adapty.focusjournal.utils.AppConstants
class FocusJournalApplication : Application() {
// ...
override fun onCreate() {
super.onCreate()
// Initialize Adapty SDK
Adapty.activate(this, Builder(AppConstants.Adapty.API_KEY).build())
}
}
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 statements
import com.adapty.Adapty
import com.adapty.models.AdaptyProfile
import com.adapty.utils.AdaptyResult
import io.adapty.focusjournal.utils.AppConstants
import javax.inject.Inject
import javax.inject.Singleton
// Update class to act as a singleton
// and add @Inject constructor after class name
@Singleton
class 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
}
}
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.screen
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.platform.LocalContext
import android.app.Activity
import com.adapty.Adapty
import com.adapty.models.AdaptyPaywallProduct
import com.adapty.models.AdaptyPurchaseParameters
import com.adapty.models.AdaptyPurchaseResult
import com.adapty.utils.AdaptyResult
import io.adapty.focusjournal.utils.AppConstants
import io.adapty.focusjournal.viewmodel.ProfileManager
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun 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
)
}
}
}
@Composable
fun 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
)
}
}
}
@Composable
fun 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)
)
}
}
}
}
}
}
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)
@Composable
fun 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) {
// ...
}
}
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 screen
composable("paywall") {
PaywallScreen(
profileManager = profileManager,
onDismiss = { navController.popBackStack() },
onPurchaseSuccess = {
navController.popBackStack()
// Navigate to history if purchase was successful
navController.navigate("history")
}
)
}
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
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
Learn more about Android best practices with Adapty in our documentation.