📖 Przegląd

Moduł InApp zapewnia prostą obsługę Google Play Billing Library v8. Wspiera zakupy jednorazowe (consumable i non-consumable) oraz subskrypcje z automatycznym acknowledge/consume.

💡 Billing v8: Moduł jest zgodny z najnowszą wersją Billing Library, obsługując PendingPurchasesParams, QueryProductDetailsResult i inne nowe API.
💰 Integracja z Paywall: Moduł InApp jest automatycznie używany przez moduł Paywall. Jeśli korzystasz z Paywall, wystarczy skonfigurować produkty przez setKnownProducts() - połączenie z Billing jest zarządzane automatycznie.

⚙️ Konfiguracja

setKnownProducts(subs, inappConsumable, inappNonConsumable)

Zdefiniuj znane produkty do automatycznego ACK/consume.

setListener(listener: Listener)

Ustaw listener dla zdarzeń billingowych.

Konfiguracja produktów
// Zdefiniuj produkty
ADict.InApp.setKnownProducts(
    subs = setOf("premium_monthly", "premium_yearly"),
    inappConsumable = setOf("coins_100", "coins_500", "gems_pack"),
    inappNonConsumable = setOf("remove_ads", "unlock_all")
)

// Listener zdarzeń
ADict.InApp.setListener(object : InApp.Listener {
    override fun onBillingReady() {
        Log.d("Billing", "Gotowy do płatności")
        loadProducts()
    }

    override fun onBillingDisconnected() {
        Log.d("Billing", "Rozłączono")
    }

    override fun onPurchaseAcknowledged(productId: String) {
        Log.d("Billing", "Zakup potwierdzony: $productId")
        when (productId) {
            "premium_monthly", "premium_yearly" -> enablePremium()
            "remove_ads" -> disableAds()
            "unlock_all" -> unlockAllContent()
        }
    }

    override fun onPurchaseConsumed(productId: String, token: String) {
        Log.d("Billing", "Zakup skonsumowany: $productId")
        when (productId) {
            "coins_100" -> addCoins(100)
            "coins_500" -> addCoins(500)
            "gems_pack" -> addGems(50)
        }
    }

    override fun onBillingError(msg: String?, code: Int) {
        Log.e("Billing", "Błąd: $code - $msg")
        showError("Błąd płatności")
    }
})

🔌 Połączenie

startConnection()

Połącz z Google Play Billing.

endConnection()

Rozłącz.

Zarządzanie połączeniem
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Połącz przy starcie
        ADict.InApp.startConnection()
    }

    override fun onDestroy() {
        // Rozłącz przy zamknięciu
        ADict.InApp.endConnection()
        super.onDestroy()
    }
}

📦 Produkty

queryProductDetails(productType, productIds, callback)

Pobierz szczegóły produktów z Google Play.

Parametry:

  • productType: String - BillingClient.ProductType.SUBS lub INAPP
  • productIds: List<String> - Lista ID produktów
  • callback: (Map<String, ProductDetails>) -> Unit
Pobieranie produktów
// Subskrypcje
ADict.InApp.queryProductDetails(
    BillingClient.ProductType.SUBS,
    listOf("premium_monthly", "premium_yearly")
) { products ->
    products.forEach { (id, details) ->
        val price = details.subscriptionOfferDetails
            ?.firstOrNull()
            ?.pricingPhases
            ?.pricingPhaseList
            ?.lastOrNull()
            ?.formattedPrice

        Log.d("Product", "$id: ${details.name} - $price")
    }
}

// Produkty jednorazowe
ADict.InApp.queryProductDetails(
    BillingClient.ProductType.INAPP,
    listOf("coins_100", "remove_ads")
) { products ->
    products.forEach { (id, details) ->
        val price = details.oneTimePurchaseOfferDetails?.formattedPrice
        Log.d("Product", "$id: ${details.name} - $price")
    }
}

🛒 Zakupy

launchInappPurchase(activity, productId)

Uruchom zakup produktu jednorazowego.

launchSubscriptionPurchase(activity, productId, offerToken?)

Uruchom zakup subskrypcji.

consume(purchase, callback)

Ręczne skonsumowanie zakupu.

Dokonywanie zakupów
// Zakup jednorazowy
fun buyCoins() {
    ADict.InApp.launchInappPurchase(activity, "coins_100")
}

fun removeAds() {
    ADict.InApp.launchInappPurchase(activity, "remove_ads")
}

// Zakup subskrypcji
fun buyPremiumMonthly() {
    ADict.InApp.launchSubscriptionPurchase(activity, "premium_monthly")
}

// Subskrypcja z konkretną ofertą
fun buyPremiumYearlyWithTrial() {
    ADict.InApp.fetchSubscriptionOfferToken(
        productId = "premium_yearly",
        preferTrialOrIntro = true
    ) { offerToken ->
        ADict.InApp.launchSubscriptionPurchase(
            activity,
            "premium_yearly",
            offerToken
        )
    }
}

📅 Subskrypcje

hasActiveSubscription(callback: (Boolean) -> Unit)

Sprawdź czy użytkownik ma aktywną subskrypcję.

hasActiveSubscriptionFor(productId, callback: (Boolean) -> Unit)

Sprawdź konkretną subskrypcję.

fetchSubscriptionOfferToken(productId, basePlanId, offerId, callback)

Pobierz offerToken dla konkretnego planu.

fetchSubscriptionOfferToken(productId, requiredOfferTags?, preferTrialOrIntro, callback)

Pobierz offerToken z preferencjami.

Zarządzanie subskrypcjami
// Sprawdź status premium
fun checkPremiumStatus() {
    ADict.InApp.hasActiveSubscription { hasSubscription ->
        if (hasSubscription) {
            enablePremiumFeatures()
        } else {
            showUpgradePrompt()
        }
    }
}

// Sprawdź konkretną subskrypcję
ADict.InApp.hasActiveSubscriptionFor("premium_yearly") { isYearly ->
    if (isYearly) {
        showYearlyBadge()
    }
}

// Pobierz offerToken dla triala
ADict.InApp.fetchSubscriptionOfferToken(
    productId = "premium_monthly",
    preferTrialOrIntro = true
) { offerToken ->
    if (offerToken != null) {
        showTrialOffer()
    }
}

// Pobierz offerToken po tagu
ADict.InApp.fetchSubscriptionOfferToken(
    productId = "premium_yearly",
    requiredOfferTags = setOf("special-offer"),
    preferTrialOrIntro = false
) { offerToken ->
    offerToken?.let {
        launchSubscriptionWithOffer(it)
    }
}

🔍 Zapytania

queryActivePurchases(productType, callback)

Pobierz aktywne zakupy użytkownika.

Pobieranie zakupów
// Pobierz subskrypcje
ADict.InApp.queryActivePurchases(BillingClient.ProductType.SUBS) { purchases ->
    purchases.forEach { purchase ->
        Log.d("Purchase", "Subskrypcja: ${purchase.products}")
        Log.d("Purchase", "Stan: ${purchase.purchaseState}")
        Log.d("Purchase", "Token: ${purchase.purchaseToken}")
    }
}

// Pobierz produkty jednorazowe
ADict.InApp.queryActivePurchases(BillingClient.ProductType.INAPP) { purchases ->
    purchases.filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
        .forEach { purchase ->
            purchase.products.forEach { productId ->
                when (productId) {
                    "remove_ads" -> disableAds()
                    "unlock_all" -> unlockContent()
                }
            }
        }
}

💡 Przykłady praktyczne

Kompletna implementacja

BillingManager
object BillingManager : InApp.Listener {
    private val _isPremium = MutableStateFlow(false)
    val isPremium: StateFlow<Boolean> = _isPremium

    private val _coins = MutableStateFlow(0)
    val coins: StateFlow<Int> = _coins

    fun init() {
        ADict.InApp.setKnownProducts(
            subs = setOf("premium_monthly", "premium_yearly"),
            inappConsumable = setOf("coins_100", "coins_500"),
            inappNonConsumable = setOf("remove_ads")
        )
        ADict.InApp.setListener(this)
        ADict.InApp.startConnection()
    }

    override fun onBillingReady() {
        // Sprawdź status przy starcie
        checkPurchaseStatus()
    }

    override fun onBillingDisconnected() {
        // Auto-reconnect po 3 sekundach
        Handler(Looper.getMainLooper()).postDelayed({
            ADict.InApp.startConnection()
        }, 3000)
    }

    override fun onPurchaseAcknowledged(productId: String) {
        when (productId) {
            "premium_monthly", "premium_yearly" -> {
                _isPremium.value = true
                ADict.EventBus.postSticky(EventBus.Events.PremiumStatusChanged(true))
                ADict.Analytics.logSubscriptionStart(productId, 0.0, "USD")
            }
            "remove_ads" -> {
                // Zapisz stan
                ADict.SecureStorage.putBoolean("ads_removed", true)
            }
        }
    }

    override fun onPurchaseConsumed(productId: String, token: String) {
        val coinsToAdd = when (productId) {
            "coins_100" -> 100
            "coins_500" -> 500
            else -> 0
        }

        if (coinsToAdd > 0) {
            _coins.value += coinsToAdd
            ADict.SecureStorage.putInt("coins", _coins.value)
            ADict.Analytics.logPurchase(productId, coinsToAdd / 100.0, "USD")
        }
    }

    override fun onBillingError(msg: String?, code: Int) {
        ADict.Analytics.logError("billing_error", msg, mapOf("code" to code))
    }

    private fun checkPurchaseStatus() {
        // Sprawdź subskrypcje
        ADict.InApp.hasActiveSubscription { hasActive ->
            _isPremium.value = hasActive
        }

        // Sprawdź jednorazowe zakupy
        ADict.InApp.queryActivePurchases(BillingClient.ProductType.INAPP) { purchases ->
            purchases.forEach { purchase ->
                if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
                    purchase.products.forEach { productId ->
                        onPurchaseAcknowledged(productId)
                    }
                }
            }
        }

        // Załaduj zapisane coiny
        _coins.value = ADict.SecureStorage.getInt("coins", 0)
    }

    // === Public API ===

    fun buyPremium(activity: Activity, yearly: Boolean) {
        val productId = if (yearly) "premium_yearly" else "premium_monthly"
        ADict.InApp.launchSubscriptionPurchase(activity, productId)
    }

    fun buyCoins(activity: Activity, amount: Int) {
        val productId = when (amount) {
            100 -> "coins_100"
            500 -> "coins_500"
            else -> return
        }
        ADict.InApp.launchInappPurchase(activity, productId)
    }

    fun removeAds(activity: Activity) {
        ADict.InApp.launchInappPurchase(activity, "remove_ads")
    }

    fun spendCoins(amount: Int): Boolean {
        return if (_coins.value >= amount) {
            _coins.value -= amount
            ADict.SecureStorage.putInt("coins", _coins.value)
            true
        } else {
            false
        }
    }
}

UI płatności

Premium Screen
@Composable
fun PremiumScreen(
    onClose: () -> Unit
) {
    val isPremium by BillingManager.isPremium.collectAsState()
    val context = LocalContext.current
    val activity = context as Activity

    var monthlyPrice by remember { mutableStateOf("") }
    var yearlyPrice by remember { mutableStateOf("") }

    LaunchedEffect(Unit) {
        ADict.InApp.queryProductDetails(
            BillingClient.ProductType.SUBS,
            listOf("premium_monthly", "premium_yearly")
        ) { products ->
            products["premium_monthly"]?.let { details ->
                monthlyPrice = details.subscriptionOfferDetails
                    ?.firstOrNull()
                    ?.pricingPhases
                    ?.pricingPhaseList
                    ?.lastOrNull()
                    ?.formattedPrice ?: ""
            }
            products["premium_yearly"]?.let { details ->
                yearlyPrice = details.subscriptionOfferDetails
                    ?.firstOrNull()
                    ?.pricingPhases
                    ?.pricingPhaseList
                    ?.lastOrNull()
                    ?.formattedPrice ?: ""
            }
        }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("🌟 Premium", style = MaterialTheme.typography.h4)

        Spacer(modifier = Modifier.height(16.dp))

        // Features
        FeatureItem("✓ Bez reklam")
        FeatureItem("✓ Wszystkie funkcje")
        FeatureItem("✓ Priorytetowe wsparcie")

        Spacer(modifier = Modifier.height(24.dp))

        // Monthly
        Button(
            onClick = { BillingManager.buyPremium(activity, yearly = false) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Miesięczna - $monthlyPrice/mies")
        }

        Spacer(modifier = Modifier.height(8.dp))

        // Yearly
        Button(
            onClick = { BillingManager.buyPremium(activity, yearly = true) },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("Roczna - $yearlyPrice/rok (oszczędź 40%)")
        }
    }
}

💰 Integracja z Paywall

Moduł Paywall automatycznie korzysta z InApp do obsługi płatności. Jeśli używasz Paywall, nie musisz ręcznie zarządzać połączeniem z Billing - Paywall robi to za Ciebie.

Konfiguracja dla Paywall
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()

        // 1. Inicjalizacja ADict
        ADict.init(this)

        // 2. Konfiguracja produktów - wymagane dla Paywall
        ADict.InApp.setKnownProducts(
            subs = setOf("pro_monthly", "pro_yearly"),
            inappNonConsumable = setOf("pro_lifetime", "remove_ads")
        )

        // 3. Listener (opcjonalny, ale zalecany)
        ADict.InApp.setListener(object : InApp.Listener {
            override fun onBillingReady() { /* gotowy */ }
            override fun onBillingDisconnected() { /* rozłączony */ }
            override fun onPurchaseAcknowledged(productId: String) {
                // Zakup potwierdzony - włącz funkcje premium
                enableFeature(productId)
            }
            override fun onPurchaseConsumed(productId: String, token: String) { }
            override fun onBillingError(msg: String?, code: Int) { }
        })
    }
}

// W Activity - Paywall zarządza połączeniem automatycznie
ADict.Paywall.show("premium", activity) { result ->
    when (result) {
        is Paywall.Result.Purchased -> { /* sukces */ }
        is Paywall.Result.Dismissed -> { /* zamknięty */ }
        is Paywall.Result.Error -> { /* błąd */ }
        else -> { }
    }
}
⚠️ Uwaga: Gdy korzystasz z Paywall, nie wywołuj startConnection() ani endConnection() ręcznie - Paywall zarządza tym automatycznie.

📚 Listener Interface

interface InApp.Listener

  • onBillingReady()Billing gotowy
  • onBillingDisconnected()Rozłączono
  • onPurchaseAcknowledged(productId)Zakup potwierdzony
  • onPurchaseConsumed(productId, token)Zakup skonsumowany
  • onBillingError(msg, code)Błąd