Android Play Store Deployment and Billing
October 19, 2025 · 10 min read
Android, Kotlin, Play Store, In-App Billing, Deployment, Interview Prep
Part 10 of the Android Deep Dive series
Publishing to the Google Play Store requires proper app signing, release configuration, and compliance with Play policies. This final article in the series covers the release process, implementing subscriptions with Google Play Billing, and managing apps post-launch - essential knowledge for production Android development.
2025-2026 Trend
Google now requires AAB (Android App Bundle) format for new apps. Play App Signing is the recommended approach, where Google manages your signing key securely.
Release Configuration
// build.gradle.kts (app)
android {
namespace = "com.nutrition.app"
compileSdk = 34
defaultConfig {
applicationId = "com.nutrition.app"
minSdk = 26
targetSdk = 34
versionCode = 16
versionName = "1.0.16"
}
signingConfigs {
create("release") {
storeFile = file(System.getenv("KEYSTORE_FILE") ?: "release.keystore")
storePassword = System.getenv("KEYSTORE_PASSWORD") ?: ""
keyAlias = System.getenv("KEY_ALIAS") ?: ""
keyPassword = System.getenv("KEY_PASSWORD") ?: ""
}
}
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
// Release-specific config
buildConfigField("String", "API_BASE_URL", "\"https://api.myapp.com\"")
}
debug {
isMinifyEnabled = false
applicationIdSuffix = ".debug"
versionNameSuffix = "-debug"
buildConfigField("String", "API_BASE_URL", "\"http://10.0.2.2:5040\"")
}
}
bundle {
language {
enableSplit = true
}
density {
enableSplit = true
}
abi {
enableSplit = true
}
}
}
ProGuard Rules
# proguard-rules.pro
# Keep Kotlin metadata
-keepattributes *Annotation*, Signature, Exception, RuntimeVisibleAnnotations
# Kotlinx Serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
kotlinx.serialization.KSerializer serializer(...);
}
# Room Database
-keep class com.nutrition.app.data.local.entity.** { *; }
-keep class com.nutrition.app.data.local.dao.** { *; }
# Hilt
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
# Retrofit
-keepattributes Signature
-keepattributes Exceptions
-keep class retrofit2.** { *; }
# OkHttp
-dontwarn okhttp3.**
-dontwarn okio.**
# Google Play Billing
-keep class com.android.vending.billing.** { *; }
# Crashlytics
-keepattributes SourceFile,LineNumberTable
-keep public class * extends java.lang.Exception
Common Mistake
Missing ProGuard rules cause release crashes that work fine in debug. Always test release builds thoroughly before publishing.
Google Play Billing
Billing Manager
class BillingManager @Inject constructor(
@ApplicationContext private val context: Context
) {
private var billingClient: BillingClient? = null
private val _purchaseState = MutableStateFlow<PurchaseState>(PurchaseState.NotConnected)
val purchaseState = _purchaseState.asStateFlow()
private val _products = MutableStateFlow<List<ProductDetails>>(emptyList())
val products = _products.asStateFlow()
private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases ->
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
purchases?.forEach { purchase ->
handlePurchase(purchase)
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
_purchaseState.value = PurchaseState.Cancelled
}
else -> {
_purchaseState.value = PurchaseState.Error(
"Purchase failed: ${billingResult.debugMessage}"
)
}
}
}
fun connect() {
billingClient = BillingClient.newBuilder(context)
.setListener(purchasesUpdatedListener)
.enablePendingPurchases()
.build()
billingClient?.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
_purchaseState.value = PurchaseState.Connected
queryProducts()
queryExistingPurchases()
}
}
override fun onBillingServiceDisconnected() {
_purchaseState.value = PurchaseState.NotConnected
// Retry connection
connect()
}
})
}
private fun queryProducts() {
val params = QueryProductDetailsParams.newBuilder()
.setProductList(
SubscriptionProducts.allProducts.map { productId ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(BillingClient.ProductType.SUBS)
.build()
}
)
.build()
billingClient?.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
_products.value = productDetailsList
}
}
}
private fun queryExistingPurchases() {
billingClient?.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
) { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
val activePurchase = purchases.find {
it.purchaseState == Purchase.PurchaseState.PURCHASED
}
if (activePurchase != null) {
_purchaseState.value = PurchaseState.Subscribed(
productId = activePurchase.products.first()
)
}
}
}
}
fun launchPurchaseFlow(activity: Activity, productDetails: ProductDetails) {
val offerToken = productDetails.subscriptionOfferDetails
?.firstOrNull()?.offerToken ?: return
val productDetailsParams = BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.setOfferToken(offerToken)
.build()
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(listOf(productDetailsParams))
.build()
billingClient?.launchBillingFlow(activity, billingFlowParams)
}
private fun handlePurchase(purchase: Purchase) {
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged) {
acknowledgePurchase(purchase)
}
_purchaseState.value = PurchaseState.Subscribed(
productId = purchase.products.first()
)
}
}
private fun acknowledgePurchase(purchase: Purchase) {
val params = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient?.acknowledgePurchase(params) { billingResult ->
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e("BillingManager", "Failed to acknowledge: ${billingResult.debugMessage}")
}
}
}
fun disconnect() {
billingClient?.endConnection()
billingClient = null
}
}
sealed class PurchaseState {
data object NotConnected : PurchaseState()
data object Connected : PurchaseState()
data object Cancelled : PurchaseState()
data class Subscribed(val productId: String) : PurchaseState()
data class Error(val message: String) : PurchaseState()
}
Interview Tip
Purchases MUST be acknowledged within 3 days or Google automatically refunds them. This is a critical requirement that interviewers often ask about.
Subscription UI
@Composable
fun SubscriptionScreen(
viewModel: SubscriptionViewModel = hiltViewModel(),
onClose: () -> Unit
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val activity = context as Activity
LaunchedEffect(Unit) {
viewModel.connect()
}
DisposableEffect(Unit) {
onDispose { viewModel.disconnect() }
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Unlock Premium",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(32.dp))
// Features list
PremiumFeaturesList()
Spacer(Modifier.height(32.dp))
// Products
uiState.products.forEach { product ->
ProductCard(
product = product,
isSelected = uiState.selectedProduct?.productId == product.productId,
onClick = { viewModel.selectProduct(product) }
)
Spacer(Modifier.height(12.dp))
}
Spacer(Modifier.weight(1f))
// Purchase button
Button(
onClick = {
uiState.selectedProduct?.let { product ->
viewModel.purchase(activity, product)
}
},
enabled = uiState.selectedProduct != null && !uiState.isLoading,
modifier = Modifier.fillMaxWidth()
) {
if (uiState.isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text("Continue")
}
}
Spacer(Modifier.height(16.dp))
// Restore purchases
TextButton(onClick = { viewModel.restorePurchases() }) {
Text("Restore Purchases")
}
// Legal text
Text(
text = "Payment will be charged to your Google Play account. " +
"Subscriptions auto-renew unless cancelled at least " +
"24 hours before the end of the current period.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 16.dp)
)
}
// Handle purchase state
LaunchedEffect(uiState.purchaseState) {
when (uiState.purchaseState) {
is PurchaseState.Subscribed -> onClose()
is PurchaseState.Error -> {
// Show error snackbar
}
else -> {}
}
}
}
Server-Side Verification
class SubscriptionRepository @Inject constructor(
private val api: NutritionApi,
private val billingManager: BillingManager,
private val preferencesStore: PreferencesStore
) {
suspend fun verifyPurchase(purchase: Purchase): Result<SubscriptionStatus> {
return try {
val response = api.verifyPurchase(
VerifyPurchaseRequest(
productId = purchase.products.first(),
purchaseToken = purchase.purchaseToken,
orderId = purchase.orderId
)
)
preferencesStore.setSubscriptionStatus(response.status)
Result.Success(response.status)
} catch (e: Exception) {
Result.Error(AppException.NetworkError(e.message ?: "Verification failed"))
}
}
fun observeSubscriptionStatus(): Flow<SubscriptionStatus> {
return combine(
billingManager.purchaseState,
preferencesStore.subscriptionStatus
) { purchaseState, cachedStatus ->
when (purchaseState) {
is PurchaseState.Subscribed -> SubscriptionStatus.ACTIVE
else -> cachedStatus
}
}
}
}
Crash Reporting
class CrashReporter @Inject constructor() {
fun initialize() {
FirebaseCrashlytics.getInstance().apply {
setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
}
}
fun setUser(userId: String) {
FirebaseCrashlytics.getInstance().setUserId(userId)
}
fun log(message: String) {
FirebaseCrashlytics.getInstance().log(message)
}
fun recordException(throwable: Throwable) {
FirebaseCrashlytics.getInstance().recordException(throwable)
}
}
// Custom Timber tree for production
class CrashlyticsTree : Timber.Tree() {
override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
if (priority >= Log.WARN) {
FirebaseCrashlytics.getInstance().log("${tag ?: "App"}: $message")
t?.let { FirebaseCrashlytics.getInstance().recordException(it) }
}
}
}
Interview Questions
Q: How does Google Play Billing work?
Key components:
- BillingClient - Connects to Google Play
- ProductDetails - Info about purchasable items
- Purchase - Completed transaction
- Acknowledgement - Required within 3 days or refunded
// 1. Connect
billingClient.startConnection(listener)
// 2. Query products
billingClient.queryProductDetailsAsync(params)
// 3. Launch purchase flow
billingClient.launchBillingFlow(activity, flowParams)
// 4. Handle purchase in listener
purchasesUpdatedListener = { result, purchases ->
purchases?.forEach { purchase ->
if (!purchase.isAcknowledged) {
acknowledgePurchase(purchase)
}
}
}
Q: What's the difference between AAB and APK?
| Aspect | APK | AAB (Android App Bundle) |
|---|---|---|
| Format | Single installable file | Bundle for Play Store |
| Size | Full app size | Split by device config |
| Distribution | Direct install | Play Store only |
| Signing | Developer signs | Google signs + developer |
# Build AAB for Play Store
./gradlew bundleRelease
# Build APK for testing
./gradlew assembleRelease
AAB reduces download size by 15-20% via language, density, and ABI splits.
Q: How do you handle subscription status client-side?
Use multiple sources:
fun observeSubscriptionStatus(): Flow<SubscriptionStatus> {
return combine(
// 1. Google Play Billing (source of truth)
billingManager.purchaseState,
// 2. Cached server status (offline support)
preferencesStore.subscriptionStatus,
// 3. Backend verification (security)
backendSubscriptionFlow
) { playState, cached, server ->
when {
playState is PurchaseState.Subscribed -> SubscriptionStatus.ACTIVE
server == SubscriptionStatus.ACTIVE -> SubscriptionStatus.ACTIVE
cached == SubscriptionStatus.ACTIVE -> SubscriptionStatus.ACTIVE
else -> SubscriptionStatus.NONE
}
}
}
Always verify on server for security; client can be tampered.
Q: What's Play App Signing and why use it?
Play App Signing means Google holds your signing key:
- Security: If your upload key is compromised, you can reset it
- Key rotation: Google can help with key changes
- Smaller bundles: Google re-signs with optimized keys
You upload with your upload key, Google re-signs with the app signing key.
Common Mistakes
1. Not Acknowledging Purchases
// BAD - Purchase refunded after 3 days!
purchasesUpdatedListener = { result, purchases ->
purchases?.forEach { purchase ->
savePurchase(purchase) // Forgot to acknowledge
}
}
// GOOD - Always acknowledge
purchases?.forEach { purchase ->
if (!purchase.isAcknowledged) {
billingClient.acknowledgePurchase(
AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
) { }
}
}
2. Not Handling Billing Disconnects
// BAD - App crashes if Play Store disconnects
val products = billingClient.queryProductDetails(params)
// GOOD - Handle disconnection
override fun onBillingServiceDisconnected() {
// Retry with exponential backoff
handler.postDelayed({ connect() }, retryDelay)
retryDelay *= 2
}
3. Missing ProGuard Rules
// BAD - Release crashes due to stripped classes
// App works in debug, crashes in release
// GOOD - Add ProGuard keep rules for:
// - Room entities and DAOs
// - Retrofit interfaces
// - Kotlinx Serialization
// - Hilt modules
4. Hardcoding API Keys
// BAD - Keys in code (can be extracted from APK)
const val API_KEY = "sk_live_xxxxx"
// GOOD - Use BuildConfig
buildTypes {
release {
buildConfigField("String", "API_KEY", "\"${System.getenv("API_KEY")}\"")
}
}
Release Checklist
- Version: Increment versionCode and versionName
- ProGuard: Test release build thoroughly
- Signing: Use release keystore
- Testing: Run on multiple devices
- Screenshots: Update if UI changed
- Release notes: Write user-friendly notes
- Staged rollout: Start with 5-10%
- Monitor: Watch Crashlytics for 24-48 hours
Summary Table
| Concept | Purpose | Key Points |
|---|---|---|
| AAB | Play Store format | Smaller downloads, required |
| Play App Signing | Secure key management | Google holds signing key |
| ProGuard | Code shrinking/obfuscation | Test release builds! |
| BillingClient | In-app purchases | Handle disconnections |
| Acknowledgement | Confirm purchase | Required within 3 days |
| Staged rollout | Gradual release | Catch issues early |
| Crashlytics | Crash monitoring | Enable for production |
This concludes the Android Interview Prep series. For more content, explore the blog archive or connect with me on LinkedIn.
Additional Resources
Based on 2025-2026 interview trends, consider exploring these additional topics:
- Kotlin Multiplatform (KMP) - Sharing code between Android and iOS
- Compose Multiplatform - Cross-platform UI with Jetpack Compose
- On-device AI/ML - TensorFlow Lite and ML Kit integration
- Android 15 features - Predictive back, per-app language preferences
- Baseline Profiles - Startup and runtime performance optimization