Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

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:

  1. BillingClient - Connects to Google Play
  2. ProductDetails - Info about purchasable items
  3. Purchase - Completed transaction
  4. 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?

AspectAPKAAB (Android App Bundle)
FormatSingle installable fileBundle for Play Store
SizeFull app sizeSplit by device config
DistributionDirect installPlay Store only
SigningDeveloper signsGoogle 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

  1. Version: Increment versionCode and versionName
  2. ProGuard: Test release build thoroughly
  3. Signing: Use release keystore
  4. Testing: Run on multiple devices
  5. Screenshots: Update if UI changed
  6. Release notes: Write user-friendly notes
  7. Staged rollout: Start with 5-10%
  8. Monitor: Watch Crashlytics for 24-48 hours

Summary Table

ConceptPurposeKey Points
AABPlay Store formatSmaller downloads, required
Play App SigningSecure key managementGoogle holds signing key
ProGuardCode shrinking/obfuscationTest release builds!
BillingClientIn-app purchasesHandle disconnections
AcknowledgementConfirm purchaseRequired within 3 days
Staged rolloutGradual releaseCatch issues early
CrashlyticsCrash monitoringEnable 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
← Previous

Building OwlPlot: A Weather-Aware Companion for Photographers

Next →

Android Testing Essentials