diff --git a/ALTERNATIVE_BILLING.md b/ALTERNATIVE_BILLING.md new file mode 100644 index 0000000..d7c68b7 --- /dev/null +++ b/ALTERNATIVE_BILLING.md @@ -0,0 +1,325 @@ +# Alternative Billing Implementation Guide + +This document explains how to implement Alternative Billing Only mode in your Android app using OpenIAP. + +## Overview + +Alternative Billing Only allows you to use your own payment system instead of Google Play billing, while still distributing your app through Google Play Store. + +## Requirements + +- ✅ Google Play Console enrollment in Alternative Billing program +- ✅ Google approval (can take several weeks) +- ✅ Billing Library 6.2+ (this library uses 8.0.0) +- ✅ Country/region eligibility +- ✅ Backend server for reporting transactions to Google Play + +## Quick Start + +### 1. Initialize OpenIapStore with Alternative Billing Mode + +```kotlin +val iapStore = OpenIapStore( + context = applicationContext, + alternativeBillingMode = AlternativeBillingMode.ALTERNATIVE_ONLY +) +``` + +### 2. Implementation (Step-by-Step Only) + +⚠️ **CRITICAL**: You **MUST** use the step-by-step approach below. + +**DO NOT use `requestPurchase()` for production** - it creates the token BEFORE payment, which means: +- User hasn't paid yet +- Reporting to Google would be fraud +- Your app will be banned + +The step-by-step approach is the **ONLY correct way**: + +#### Using OpenIapStore (Recommended) + +```kotlin +val iapStore = OpenIapStore(context, AlternativeBillingMode.ALTERNATIVE_ONLY) + +// Step 1: Check availability +val isAvailable = iapStore.checkAlternativeBillingAvailability() +if (!isAvailable) { + // Handle unavailable case + return +} + +// Step 2: Show information dialog +val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(activity) +if (!dialogAccepted) { + // User canceled + return +} + +// Step 3: Process payment in YOUR payment system +// ⚠️ Note: onPurchaseUpdated will NOT be called - handle success/failure here +val paymentResult = YourPaymentSystem.processPayment( + productId = productId, + amount = product.price, + userId = currentUserId, + onSuccess = { transactionId -> + // Step 4: Create token AFTER successful payment + lifecycleScope.launch { + val token = iapStore.createAlternativeBillingReportingToken() + if (token != null) { + // Step 5: Send token to your backend + YourBackendApi.reportTransaction( + externalTransactionToken = token, + productId = productId, + userId = currentUserId, + transactionId = transactionId + ) + + // Update your UI - purchase complete! + showSuccessMessage("Purchase successful") + } else { + showErrorMessage("Failed to create reporting token") + } + } + }, + onFailure = { error -> + // Handle payment failure in your UI + showErrorMessage("Payment failed: ${error.message}") + } +) +``` + +#### Using OpenIapModule Directly (Advanced) + +If you're not using OpenIapStore wrapper, you can call OpenIapModule methods directly: + +```kotlin +val openIapModule = OpenIapModule( + context = context, + alternativeBillingMode = AlternativeBillingMode.ALTERNATIVE_ONLY +) + +// Initialize connection first +val connected = openIapModule.initConnection() +if (!connected) { + // Handle connection failure + return +} + +// Step 1: Check availability +val isAvailable = openIapModule.checkAlternativeBillingAvailability() +if (!isAvailable) { + // Handle unavailable case + return +} + +// Step 2: Show information dialog +openIapModule.setActivity(activity) +val dialogAccepted = openIapModule.showAlternativeBillingInformationDialog(activity) +if (!dialogAccepted) { + // User canceled + return +} + +// Step 3: Process payment in YOUR payment system +// ⚠️ Note: onPurchaseUpdated will NOT be called - handle success/failure here +val paymentResult = YourPaymentSystem.processPayment( + productId = productId, + amount = product.price, + userId = currentUserId, + onSuccess = { transactionId -> + // Step 4: Create token AFTER successful payment + lifecycleScope.launch { + val token = openIapModule.createAlternativeBillingReportingToken() + if (token != null) { + // Step 5: Send token to your backend + YourBackendApi.reportTransaction( + externalTransactionToken = token, + productId = productId, + userId = currentUserId, + transactionId = transactionId + ) + + // Update your UI - purchase complete! + showSuccessMessage("Purchase successful") + } else { + showErrorMessage("Failed to create reporting token") + } + } + }, + onFailure = { error -> + // Handle payment failure in your UI + showErrorMessage("Payment failed: ${error.message}") + } +) +``` + +### Why This Order Matters + +```kotlin +// ❌ WRONG (what requestPurchase does - DO NOT USE) +1. Check availability ✓ +2. Show dialog ✓ +3. Create token ✓ ← Token created WITHOUT payment! +4. [No payment] ← User never paid anything +5. Report to Google ← This is FRAUD - claiming user paid when they didn't + +// ✅ CORRECT (step-by-step - MUST USE) +1. checkAvailability() +2. showDialog() +3. YOUR_PAYMENT.charge($9.99) ← User ACTUALLY pays here +4. createToken() ← Token created AFTER successful payment +5. backend.reportToGoogle() ← Report REAL transaction to Google +``` + +The token is **proof of payment**. Creating it before payment is like writing a receipt before the customer pays - it's fraud. + +## Backend Implementation + +### Important: No `onPurchaseUpdated` Callback + +⚠️ **Alternative Billing does NOT trigger `onPurchaseUpdated` or `onPurchaseError` callbacks.** + +Why? Because you're **not using Google Play billing system** - you're using your own payment system (Stripe, PayPal, Toss, etc.). The callbacks only fire for Google Play transactions. + +```kotlin +// ❌ This will NOT work with Alternative Billing +iapStore.addPurchaseUpdateListener { purchase -> + // This is NEVER called in Alternative Billing mode +} + +// ✅ Instead, handle payment completion in YOUR payment system +YourPaymentSystem.processPayment( + onSuccess = { transactionId -> + // Your payment succeeded - NOW create token + val token = iapStore.createAlternativeBillingReportingToken() + sendToBackend(token, transactionId) + }, + onFailure = { error -> + // Handle payment failure in your UI + } +) +``` + +### Backend Reporting API + +Your backend must report the transaction to Google Play Developer API within **24 hours**: + +```http +POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/externalTransactions + +Authorization: Bearer {oauth_token} +Content-Type: application/json + +{ + "externalTransactionToken": "token_from_step_4", + "productId": "your_product_id", + "externalTransactionId": "your_transaction_id", + "transactionTime": "2025-10-02T12:00:00Z", + "currentTaxAmount": { + "currencyCode": "USD", + "amountMicros": "1000000" // $1.00 + }, + "currentPreTaxAmount": { + "currencyCode": "USD", + "amountMicros": "9000000" // $9.00 + } +} +``` + +## API Reference + +### OpenIapStore Methods + +Available in both `OpenIapStore` and `OpenIapModule`: + +#### `suspend fun checkAlternativeBillingAvailability(): Boolean` +- **Purpose**: Check if alternative billing is available for current user/device +- **Returns**: `true` if available, `false` otherwise +- **When to call**: Before starting purchase flow (Step 1) +- **Throws**: `OpenIapError.NotPrepared` if billing client not ready + +#### `suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean` +- **Purpose**: Show required information dialog to user +- **Parameters**: `activity` - Current activity context +- **Returns**: `true` if user accepted, `false` if canceled +- **When to call**: BEFORE processing payment (Step 2) +- **Note**: Google requires this dialog to be shown every purchase +- **Throws**: `OpenIapError.NotPrepared` if billing client not ready + +#### `suspend fun createAlternativeBillingReportingToken(): String?` +- **Purpose**: Create external transaction token for reporting to Google +- **Returns**: Token string or `null` if failed +- **When to call**: AFTER successful payment in your system (Step 4) +- **Note**: Token must be reported to Google within 24 hours +- **Throws**: `OpenIapError.NotPrepared` if billing client not ready + +### OpenIapModule Only Methods + +If using `OpenIapModule` directly, you also need: + +#### `suspend fun initConnection(): Boolean` +- **Purpose**: Initialize billing client connection +- **Returns**: `true` if connection successful +- **When to call**: Before any billing operations +- **Note**: OpenIapStore handles this automatically + +#### `fun setActivity(activity: Activity?)` +- **Purpose**: Set current activity for billing flows +- **Parameters**: `activity` - Current activity or null +- **When to call**: Before showing dialogs or launching billing flows +- **Note**: OpenIapStore handles this automatically + +## Example App + +See [AlternativeBillingScreen.kt](Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt) for a complete example. + +⚠️ **Important**: The example app skips Step 3 (actual payment) because it's a demo. You **MUST** implement Step 3 with your real payment system (Stripe, PayPal, Toss, etc.) before calling `createAlternativeBillingReportingToken()`. + +## Testing + +1. **Enroll in Play Console**: + - Go to Play Console → Your App → Monetization setup + - Enable "Alternative billing" + - Select eligible countries + +2. **Add Test Accounts**: + - Go to Settings → License testing + - Add test account emails + +3. **Test Flow**: + - Build signed APK/Bundle + - Upload to Internal Testing track + - Install on device with test account + - Check logs for initialization status + +4. **Expected Logs**: +``` +✓ Alternative billing only enabled successfully +✓ Alternative billing is available +✓ Dialog shown to user +✓ External transaction token created: eyJhbG... +``` + +## Common Issues + +### "Alternative billing not available" +- **Cause**: App not enrolled, user not in eligible country, or console setup incomplete +- **Fix**: Check Play Console enrollment status and test account country + +### "enableAlternativeBillingOnly() method not found" +- **Cause**: Billing Library version < 6.2 +- **Fix**: Update to Billing Library 6.2+ (this library uses 8.0.0) + +### "Google Play dialog appears instead of alternative billing" +- **Cause**: `enableAlternativeBillingOnly()` not called on BillingClient +- **Fix**: Check initialization logs for "✓ Alternative billing only enabled successfully" + +### "`onPurchaseUpdated` is not called after payment" +- **Cause**: This is EXPECTED behavior - Alternative Billing bypasses Google Play callbacks +- **Fix**: Handle payment completion in your payment system's callback (Step 3), not in `onPurchaseUpdated` + +## Resources + +- [Official Google Documentation](https://developer.android.com/google/play/billing/alternative) +- [Alternative Billing Reporting API](https://developer.android.com/google/play/billing/alternative/reporting) +- [Play Console Help](https://support.google.com/googleplay/android-developer/answer/12419624) diff --git a/CLAUDE.md b/CLAUDE.md index c6ff266..bccf6d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,4 +16,10 @@ Welcome! This repository hosts the Android implementation of OpenIAP. 3. Put all reusable Kotlin helpers (e.g., safe map accessors) into the `utils` package so they can be used without modifying generated output. 4. After code generation or dependency changes, compile with `./gradlew :openiap:compileDebugKotlin` (or the appropriate target) to verify the build stays green. +## Updating openiap-gql Version + +1. Edit `openiap-versions.json` and update the `gql` field to the desired version +2. Run `./scripts/generate-types.sh` to download and regenerate Types.kt +3. Compile to verify: `./gradlew :openiap:compileDebugKotlin` + Refer back to this document and `CONVENTION.md` whenever you are unsure about workflow expectations. diff --git a/Example/src/main/java/dev/hyo/martie/MainActivity.kt b/Example/src/main/java/dev/hyo/martie/MainActivity.kt index 1d04e41..c311567 100644 --- a/Example/src/main/java/dev/hyo/martie/MainActivity.kt +++ b/Example/src/main/java/dev/hyo/martie/MainActivity.kt @@ -38,7 +38,7 @@ fun AppNavigation() { val context = androidx.compose.ui.platform.LocalContext.current val startRoute = remember { val route = (context as? android.app.Activity)?.intent?.getStringExtra("openiap_route") - if (route in setOf("home", "purchase_flow", "subscription_flow", "available_purchases", "offer_code")) route!! else "home" + if (route in setOf("home", "purchase_flow", "subscription_flow", "available_purchases", "offer_code", "alternative_billing")) route!! else "home" } NavHost( @@ -48,26 +48,30 @@ fun AppNavigation() { composable("home") { HomeScreen(navController) } - + composable("all_products") { AllProductsScreen(navController) } - + composable("purchase_flow") { PurchaseFlowScreen(navController) } - + composable("subscription_flow") { SubscriptionFlowScreen(navController) } - + composable("available_purchases") { AvailablePurchasesScreen(navController) } - + composable("offer_code") { OfferCodeScreen(navController) } + + composable("alternative_billing") { + AlternativeBillingScreen(navController) + } } } diff --git a/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt new file mode 100644 index 0000000..fb58795 --- /dev/null +++ b/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -0,0 +1,637 @@ +package dev.hyo.martie.screens + +import android.app.Activity +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.navigation.NavController +import dev.hyo.martie.IapConstants +import dev.hyo.martie.models.AppColors +import dev.hyo.martie.screens.uis.* +import dev.hyo.openiap.store.OpenIapStore +import dev.hyo.openiap.store.PurchaseResultStatus +import kotlinx.coroutines.launch +import dev.hyo.openiap.ProductAndroid +import dev.hyo.openiap.ProductQueryType +import dev.hyo.openiap.ProductRequest +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.RequestPurchaseProps +import dev.hyo.openiap.RequestPurchaseAndroidProps +import dev.hyo.openiap.RequestPurchasePropsByPlatforms +import dev.hyo.openiap.PurchaseInput +import dev.hyo.openiap.AlternativeBillingMode +import dev.hyo.openiap.AlternativeBillingModeAndroid +import dev.hyo.openiap.InitConnectionConfig +import dev.hyo.martie.util.findActivity + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AlternativeBillingScreen(navController: NavController) { + val context = LocalContext.current + val activity = remember(context) { context.findActivity() } + val appContext = remember(context) { context.applicationContext } + + var selectedMode by remember { mutableStateOf(AlternativeBillingMode.ALTERNATIVE_ONLY) } + var isModeDropdownExpanded by remember { mutableStateOf(false) } + + // Initialize store - recreate when mode changes + val iapStore = remember(selectedMode) { + android.util.Log.d("AlternativeBillingScreen", "Creating new OpenIapStore with mode: $selectedMode") + dev.hyo.openiap.OpenIapLog.isEnabled = true + + val store = OpenIapStore(appContext, alternativeBillingMode = selectedMode) + + // Set user choice listener if in USER_CHOICE mode + if (selectedMode == AlternativeBillingMode.USER_CHOICE) { + store.setUserChoiceBillingListener { details -> + android.util.Log.d("UserChoice", "User selected alternative billing") + android.util.Log.d("UserChoice", "Token: ${details.externalTransactionToken}") + android.util.Log.d("UserChoice", "Products: ${details.products}") + + // TODO: Process payment with your payment system + // Then create token and report to backend + } + } + + store + } + + val products by iapStore.products.collectAsState() + val androidProducts = remember(products) { products.filterIsInstance() } + val status by iapStore.status.collectAsState() + val lastPurchase by iapStore.currentPurchase.collectAsState(initial = null) + val connectionStatus by iapStore.connectionStatus.collectAsState() + val statusMessage = status.lastPurchaseResult + + var selectedProduct by remember { mutableStateOf(null) } + + // AUTO-FINISH TRANSACTION FOR TESTING + // PRODUCTION: Validate purchase on your backend server first! + LaunchedEffect(lastPurchase) { + lastPurchase?.let { purchase -> + try { + val purchaseAndroid = purchase as? PurchaseAndroid + if (purchaseAndroid != null) { + android.util.Log.d("AlternativeBilling", "Auto-finishing transaction for testing") + val purchaseInput = PurchaseInput( + id = purchaseAndroid.id, + ids = purchaseAndroid.ids, + isAutoRenewing = purchaseAndroid.isAutoRenewing ?: false, + platform = purchaseAndroid.platform, + productId = purchaseAndroid.productId, + purchaseState = purchaseAndroid.purchaseState, + purchaseToken = purchaseAndroid.purchaseToken, + quantity = purchaseAndroid.quantity ?: 1, + transactionDate = purchaseAndroid.transactionDate ?: 0.0 + ) + iapStore.finishTransaction(purchaseInput, true) + } + } catch (e: Exception) { + android.util.Log.e("AlternativeBilling", "Auto-finish failed: ${e.message}") + } + } + } + + // Initialize connection when mode changes + LaunchedEffect(selectedMode) { + try { + val config = when (selectedMode) { + AlternativeBillingMode.USER_CHOICE -> InitConnectionConfig( + alternativeBillingModeAndroid = AlternativeBillingModeAndroid.UserChoice + ) + AlternativeBillingMode.ALTERNATIVE_ONLY -> InitConnectionConfig( + alternativeBillingModeAndroid = AlternativeBillingModeAndroid.AlternativeOnly + ) + else -> null + } + + val connected = iapStore.initConnection(config) + if (connected) { + iapStore.setActivity(activity) + val request = ProductRequest( + skus = IapConstants.INAPP_SKUS, + type = ProductQueryType.InApp + ) + iapStore.fetchProducts(request) + } + } catch (_: Exception) { } + } + + DisposableEffect(Unit) { + onDispose { + kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.Main).launch { + runCatching { iapStore.endConnection() } + runCatching { iapStore.clear() } + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Alternative Billing") }, + navigationIcon = { + IconButton(onClick = { navController.navigateUp() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = AppColors.background + ) + ) + } + ) { paddingValues -> + val scope = rememberCoroutineScope() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(AppColors.background), + contentPadding = PaddingValues(vertical = 20.dp), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + // Mode Selection Dropdown + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = AppColors.cardBackground) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Billing Mode", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + ExposedDropdownMenuBox( + expanded = isModeDropdownExpanded, + onExpandedChange = { isModeDropdownExpanded = it } + ) { + OutlinedTextField( + value = when (selectedMode) { + AlternativeBillingMode.ALTERNATIVE_ONLY -> "Alternative Billing Only" + AlternativeBillingMode.USER_CHOICE -> "User Choice Billing" + else -> "None" + }, + onValueChange = {}, + readOnly = true, + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = isModeDropdownExpanded) + }, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + colors = OutlinedTextFieldDefaults.colors() + ) + + ExposedDropdownMenu( + expanded = isModeDropdownExpanded, + onDismissRequest = { isModeDropdownExpanded = false } + ) { + DropdownMenuItem( + text = { Text("Alternative Billing Only") }, + onClick = { + selectedProduct = null + selectedMode = AlternativeBillingMode.ALTERNATIVE_ONLY + isModeDropdownExpanded = false + }, + leadingIcon = { + Icon(Icons.Default.ShoppingCart, contentDescription = null) + } + ) + DropdownMenuItem( + text = { Text("User Choice Billing") }, + onClick = { + selectedProduct = null + selectedMode = AlternativeBillingMode.USER_CHOICE + isModeDropdownExpanded = false + }, + leadingIcon = { + Icon(Icons.Default.Person, contentDescription = null) + } + ) + } + } + } + } + } + + // Info Card + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = AppColors.warning.copy(alpha = 0.1f)) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + Icons.Default.Info, + contentDescription = null, + tint = AppColors.warning + ) + Text( + if (selectedMode == AlternativeBillingMode.ALTERNATIVE_ONLY) + "Alternative Billing Only" + else + "User Choice Billing", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + } + + Text( + if (selectedMode == AlternativeBillingMode.ALTERNATIVE_ONLY) { + "Alternative Billing Only Mode:\n\n" + + "• Users CANNOT use Google Play billing\n" + + "• Only your payment system is available\n" + + "• Requires manual 3-step flow:\n" + + " 1. Check availability\n" + + " 2. Show info dialog\n" + + " 3. Process payment → Create token\n\n" + + "• No onPurchaseUpdated callback\n" + + "• Must report to Google within 24h" + } else { + "User Choice Billing Mode:\n\n" + + "• Users CAN choose between:\n" + + " - Google Play (30% fee)\n" + + " - Your payment system (lower fee)\n" + + "• Google shows selection dialog automatically\n" + + "• If user selects Google Play:\n" + + " → onPurchaseUpdated callback\n" + + "• If user selects alternative:\n" + + " → UserChoiceBillingListener callback\n" + + " → Process payment → Report to Google" + }, + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + } + } + } + + // Connection Status + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = if (connectionStatus) AppColors.success.copy(alpha = 0.1f) + else AppColors.danger.copy(alpha = 0.1f) + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + if (connectionStatus) Icons.Default.CheckCircle else Icons.Default.Error, + contentDescription = null, + tint = if (connectionStatus) AppColors.success else AppColors.danger + ) + Column { + Text( + "Connection Status", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + if (connectionStatus) { + "Connected (${if (selectedMode == AlternativeBillingMode.ALTERNATIVE_ONLY) "Alternative Only" else "User Choice"})" + } else "Disconnected", + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + } + } + } + } + + // Purchase Result + if (statusMessage != null) { + item { + PurchaseResultCard( + message = statusMessage.message, + status = statusMessage.status, + code = statusMessage.code?.toString(), + onDismiss = { iapStore.clearStatusMessage() } + ) + } + } + + // Products Section + item { + SectionHeaderView(title = "Select Product") + } + + if (status.isLoading && androidProducts.isEmpty()) { + item { + LoadingCard() + } + } else if (androidProducts.isEmpty()) { + item { + EmptyStateCard( + icon = Icons.Default.ShoppingCart, + message = "No products available" + ) + } + } else { + items(androidProducts) { product -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .clickable { selectedProduct = product }, + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors( + containerColor = if (selectedProduct?.id == product.id) + AppColors.primary.copy(alpha = 0.1f) + else + AppColors.cardBackground + ), + border = if (selectedProduct?.id == product.id) + androidx.compose.foundation.BorderStroke(2.dp, AppColors.primary) + else null + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + product.title ?: product.id, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + Text( + product.description ?: "", + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + } + Text( + product.displayPrice ?: product.price?.toString() ?: "", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = AppColors.primary + ) + } + } + } + } + + // Product Details & Action Button (right after product selection) + if (selectedProduct != null) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + // Product Details Card + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = AppColors.cardBackground) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Product Details", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + DetailRow("ID", selectedProduct!!.id) + DetailRow("Title", selectedProduct!!.title ?: "N/A") + DetailRow("Description", selectedProduct!!.description ?: "N/A") + DetailRow("Price", selectedProduct!!.displayPrice ?: "N/A") + DetailRow("Currency", selectedProduct!!.currency ?: "N/A") + DetailRow("Type", selectedProduct!!.type.toString()) + } + } + + // Show button based on selected mode + if (selectedMode == AlternativeBillingMode.ALTERNATIVE_ONLY) { + // Alternative Billing Only Button + Button( + onClick = { + scope.launch { + try { + iapStore.setActivity(activity) + + // Step 1: Check availability + val isAvailable = iapStore.checkAlternativeBillingAvailability() + if (!isAvailable) { + iapStore.postStatusMessage( + "Alternative billing not available", + PurchaseResultStatus.Error + ) + return@launch + } + + // Step 2: Show information dialog + val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(activity!!) + if (!dialogAccepted) { + iapStore.postStatusMessage( + "User canceled", + PurchaseResultStatus.Info + ) + return@launch + } + + // Step 2.5: Process payment (DEMO - not implemented) + android.util.Log.d("AlternativeBilling", "⚠️ Payment processing not implemented") + + // Step 3: Create token + val token = iapStore.createAlternativeBillingReportingToken() + if (token != null) { + iapStore.postStatusMessage( + "Alternative billing completed (DEMO)\nToken: ${token.take(20)}...\n⚠️ Backend reporting required", + PurchaseResultStatus.Info, + selectedProduct!!.id + ) + } else { + iapStore.postStatusMessage( + "Failed to create reporting token", + PurchaseResultStatus.Error + ) + } + } catch (e: Exception) { + // Error handled by store + } + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !status.isLoading && connectionStatus, + colors = ButtonDefaults.buttonColors( + containerColor = AppColors.primary + ) + ) { + Icon( + Icons.Default.ShoppingCart, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Buy (Alternative Billing Only)") + } + } else { + // User Choice Button + Button( + onClick = { + scope.launch { + try { + iapStore.setActivity(activity) + + // User Choice: Just call requestPurchase + // Google will show selection dialog automatically + val props = RequestPurchaseProps( + request = RequestPurchaseProps.Request.Purchase( + RequestPurchasePropsByPlatforms( + android = RequestPurchaseAndroidProps( + skus = listOf(selectedProduct!!.id) + ) + ) + ), + type = ProductQueryType.InApp + ) + + iapStore.requestPurchase(props) + + // If user selects Google Play → onPurchaseUpdated callback + // If user selects alternative → UserChoiceBillingListener callback + } catch (e: Exception) { + // Error handled by store + } + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = !status.isLoading && connectionStatus, + colors = ButtonDefaults.buttonColors( + containerColor = AppColors.secondary + ) + ) { + Icon( + Icons.Default.Person, + contentDescription = null, + modifier = Modifier.size(20.dp) + ) + Spacer(Modifier.width(8.dp)) + Text("Buy (User Choice)") + } + } + } + } + } + + // Last Purchase Info + if (lastPurchase != null) { + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = AppColors.cardBackground) + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Last Purchase", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold + ) + + val purchase = lastPurchase as? PurchaseAndroid + if (purchase != null) { + Text( + "Product: ${purchase.productId}", + style = MaterialTheme.typography.bodySmall + ) + Text( + "State: ${purchase.purchaseState}", + style = MaterialTheme.typography.bodySmall + ) + Text( + "Token: ${purchase.purchaseToken?.take(20)}...", + style = MaterialTheme.typography.bodySmall + ) + + Text( + "ℹ️ Transaction auto-finished for testing.\n" + + "PRODUCTION: Validate on backend first!", + style = MaterialTheme.typography.bodySmall, + color = AppColors.warning, + modifier = Modifier.padding(top = 8.dp) + ) + } + } + } + } + } + + item { + Spacer(modifier = Modifier.height(20.dp)) + } + } + } +} + +@Composable +private fun DetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + Text( + value, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium + ) + } +} diff --git a/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt b/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt index 42cee98..780869a 100644 --- a/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt +++ b/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt @@ -87,10 +87,11 @@ fun HomeScreen(navController: NavController) { columns = GridCells.Fixed(2), modifier = Modifier .fillMaxWidth() - .height(400.dp) + .height(600.dp) .padding(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp) + verticalArrangement = Arrangement.spacedBy(16.dp), + userScrollEnabled = false ) { item { FeatureCard( @@ -103,7 +104,7 @@ fun HomeScreen(navController: NavController) { } ) } - + item { FeatureCard( title = "Subscription\nFlow", @@ -115,7 +116,7 @@ fun HomeScreen(navController: NavController) { } ) } - + item { FeatureCard( title = "Available\nPurchases", @@ -127,7 +128,7 @@ fun HomeScreen(navController: NavController) { } ) } - + item { FeatureCard( title = "Offer\nCode", @@ -139,6 +140,18 @@ fun HomeScreen(navController: NavController) { } ) } + + item { + FeatureCard( + title = "Alternative\nBilling", + subtitle = "Test alternative payment", + icon = Icons.Default.Payment, + color = AppColors.info, + onClick = { + navController.navigate("alternative_billing") + } + ) + } } // Testing Notes Card diff --git a/openiap-versions.json b/openiap-versions.json index 1da81bb..78d7c27 100644 --- a/openiap-versions.json +++ b/openiap-versions.json @@ -1,4 +1,4 @@ { "google": "1.2.10", - "gql": "1.0.9" + "gql": "1.0.10" } diff --git a/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt b/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt index 299b65a..3646237 100644 --- a/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt +++ b/openiap/src/main/java/dev/hyo/openiap/OpenIapError.kt @@ -247,6 +247,16 @@ sealed class OpenIapError : Exception() { const val MESSAGE = "The request has reached the maximum timeout before Google Play responds" } + class AlternativeBillingUnavailable(val details: String) : OpenIapError() { + val CODE = ErrorCode.BillingUnavailable.rawValue + override val code = CODE + override val message = details + + companion object { + val CODE = ErrorCode.BillingUnavailable.rawValue + } + } + companion object { private val defaultMessages: Map by lazy { mapOf( diff --git a/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt b/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt index 407dffb..66f2fb9 100644 --- a/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt +++ b/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt @@ -65,15 +65,42 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.resume import java.lang.ref.WeakReference +/** + * Alternative billing mode + */ +enum class AlternativeBillingMode { + /** Standard Google Play billing (default) */ + NONE, + /** Alternative billing with user choice (user selects between Google Play or alternative) */ + USER_CHOICE, + /** Alternative billing only (no Google Play option) */ + ALTERNATIVE_ONLY +} + /** * Main OpenIapModule implementation for Android + * + * @param context Android context + * @param alternativeBillingMode Alternative billing mode (default: NONE) + * @param userChoiceBillingListener Listener for user choice billing selection (optional) */ -class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { +class OpenIapModule( + private val context: Context, + private var alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE, + private var userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null +) : PurchasesUpdatedListener { companion object { private const val TAG = "OpenIapModule" } + // For backward compatibility + constructor(context: Context, enableAlternativeBilling: Boolean) : this( + context, + if (enableAlternativeBilling) AlternativeBillingMode.ALTERNATIVE_ONLY else AlternativeBillingMode.NONE, + null + ) + private var billingClient: BillingClient? = null private var currentActivityRef: WeakReference? = null private val productManager = ProductManager() @@ -84,7 +111,18 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { private val purchaseErrorListeners = mutableSetOf() private var currentPurchaseCallback: ((Result>) -> Unit)? = null - val initConnection: MutationInitConnectionHandler = { + val initConnection: MutationInitConnectionHandler = { config -> + // Update alternativeBillingMode if provided in config + config?.alternativeBillingModeAndroid?.let { modeAndroid -> + OpenIapLog.d("Setting alternative billing mode from config: $modeAndroid", TAG) + // Map AlternativeBillingModeAndroid to AlternativeBillingMode + alternativeBillingMode = when (modeAndroid) { + AlternativeBillingModeAndroid.None -> AlternativeBillingMode.NONE + AlternativeBillingModeAndroid.UserChoice -> AlternativeBillingMode.USER_CHOICE + AlternativeBillingModeAndroid.AlternativeOnly -> AlternativeBillingMode.ALTERNATIVE_ONLY + } + } + withContext(Dispatchers.IO) { suspendCancellableCoroutine { continuation -> initBillingClient( @@ -191,8 +229,254 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { getActiveSubscriptions(subscriptionIds).isNotEmpty() } + /** + * Check if alternative billing is available for this user/device + * Step 1 of alternative billing flow + */ + suspend fun checkAlternativeBillingAvailability(): Boolean = withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + OpenIapLog.d("Checking alternative billing availability...", TAG) + val checkAvailabilityMethod = client.javaClass.getMethod( + "isAlternativeBillingOnlyAvailableAsync", + com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener::class.java + ) + + suspendCancellableCoroutine { continuation -> + val listenerClass = Class.forName("com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener") + val availabilityListener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onAlternativeBillingOnlyAvailabilityResponse") { + val result = args?.get(0) as? BillingResult + OpenIapLog.d("Availability check result: ${result?.responseCode} - ${result?.debugMessage}", TAG) + + if (result?.responseCode == BillingClient.BillingResponseCode.OK) { + OpenIapLog.d("✓ Alternative billing is available", TAG) + if (continuation.isActive) continuation.resume(true) + } else { + OpenIapLog.e("✗ Alternative billing not available: ${result?.debugMessage}", tag = TAG) + if (continuation.isActive) continuation.resume(false) + } + } + null + } + checkAvailabilityMethod.invoke(client, availabilityListener) + } + } + + /** + * Show alternative billing information dialog to user + * Step 2 of alternative billing flow + * Must be called BEFORE processing payment + */ + suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + OpenIapLog.d("Showing alternative billing information dialog...", TAG) + val showDialogMethod = client.javaClass.getMethod( + "showAlternativeBillingOnlyInformationDialog", + android.app.Activity::class.java, + com.android.billingclient.api.AlternativeBillingOnlyInformationDialogListener::class.java + ) + + val dialogResult = suspendCancellableCoroutine { continuation -> + val listenerClass = Class.forName("com.android.billingclient.api.AlternativeBillingOnlyInformationDialogListener") + val dialogListener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onAlternativeBillingOnlyInformationDialogResponse") { + val result = args?.get(0) as? BillingResult + OpenIapLog.d("Dialog result: ${result?.responseCode} - ${result?.debugMessage}", TAG) + if (continuation.isActive && result != null) { + continuation.resume(result) + } + } + null + } + showDialogMethod.invoke(client, activity, dialogListener) + } + + when (dialogResult.responseCode) { + BillingClient.BillingResponseCode.OK -> true + BillingClient.BillingResponseCode.USER_CANCELED -> { + OpenIapLog.d("User canceled information dialog", TAG) + false + } + else -> { + OpenIapLog.e("Information dialog failed: ${dialogResult.debugMessage}", tag = TAG) + false + } + } + } + + /** + * Create external transaction token for alternative billing + * Step 3 of alternative billing flow + * Must be called AFTER successful payment in your payment system + * Token must be reported to Google Play backend within 24 hours + */ + suspend fun createAlternativeBillingReportingToken(): String? = withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (!client.isReady) throw OpenIapError.NotPrepared + + OpenIapLog.d("Creating alternative billing reporting token...", TAG) + val createTokenMethod = client.javaClass.getMethod( + "createAlternativeBillingOnlyReportingDetailsAsync", + com.android.billingclient.api.AlternativeBillingOnlyReportingDetailsListener::class.java + ) + + suspendCancellableCoroutine { continuation -> + val listenerClass = Class.forName("com.android.billingclient.api.AlternativeBillingOnlyReportingDetailsListener") + val tokenListener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "onAlternativeBillingOnlyTokenResponse") { + val result = args?.get(0) as? BillingResult + val details = args?.getOrNull(1) + + if (result?.responseCode == BillingClient.BillingResponseCode.OK && details != null) { + try { + val tokenMethod = details.javaClass.getMethod("getExternalTransactionToken") + val token = tokenMethod.invoke(details) as? String + OpenIapLog.d("✓ External transaction token created: $token", TAG) + if (continuation.isActive) continuation.resume(token) + } catch (e: Exception) { + OpenIapLog.e("Failed to extract token: ${e.message}", e, TAG) + if (continuation.isActive) continuation.resume(null) + } + } else { + OpenIapLog.e("Token creation failed: ${result?.debugMessage}", tag = TAG) + if (continuation.isActive) continuation.resume(null) + } + } + null + } + createTokenMethod.invoke(client, tokenListener) + } + } + val requestPurchase: MutationRequestPurchaseHandler = { props -> val purchases = withContext(Dispatchers.IO) { + // ALTERNATIVE_ONLY mode: Show information dialog and create token + if (alternativeBillingMode == AlternativeBillingMode.ALTERNATIVE_ONLY) { + OpenIapLog.d("=== ALTERNATIVE BILLING ONLY MODE ===", TAG) + + val client = billingClient + if (client == null || !client.isReady) { + val err = OpenIapError.NotPrepared + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + val activity = currentActivityRef?.get() ?: fallbackActivity + if (activity == null) { + val err = OpenIapError.MissingCurrentActivity + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + try { + // Step 1: Check if alternative billing is available + val isAvailable = checkAlternativeBillingAvailability() + if (!isAvailable) { + OpenIapLog.e("Alternative billing is not available for this user/app", tag = TAG) + + // Create detailed error for UI + val err = OpenIapError.AlternativeBillingUnavailable( + "Alternative Billing Unavailable\n\n" + + "Possible causes:\n" + + "1. User is not in an eligible country\n" + + "2. App not enrolled in Alternative Billing program\n" + + "3. Play Console setup incomplete\n\n" + + "To enable Alternative Billing:\n" + + "• Enroll app in Google Play Console\n" + + "• Wait for Google approval\n" + + "• Test with license tester accounts\n\n" + + "Current mode: ALTERNATIVE_ONLY\n" + + "Library: Billing 8.0.0" + ) + + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + // Step 2: Show alternative billing information dialog + val dialogSuccess = showAlternativeBillingInformationDialog(activity) + if (!dialogSuccess) { + val err = OpenIapError.UserCancelled + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + // Step 3: Create external transaction token + // ============================================================ + // ⚠️ PRODUCTION IMPLEMENTATION REQUIRED + // ============================================================ + // In production, this step should happen AFTER successful payment: + // 1. Dialog shown (✓ done above) + // 2. Process payment through YOUR payment system + // 3. After payment success, call: createAlternativeBillingReportingToken() + // 4. Send token to backend → report to Play within 24h + // + // For manual control, use the separate functions: + // - checkAlternativeBillingAvailability() + // - showAlternativeBillingInformationDialog(activity) + // - YOUR_PAYMENT_SYSTEM.processPayment() + // - createAlternativeBillingReportingToken() + // ============================================================ + val tokenResult = createAlternativeBillingReportingToken() + + if (tokenResult != null) { + OpenIapLog.d("✓ Alternative billing token created: $tokenResult", TAG) + OpenIapLog.d("", TAG) + OpenIapLog.d("============================================================", TAG) + OpenIapLog.d("NEXT STEPS (PRODUCTION IMPLEMENTATION REQUIRED)", TAG) + OpenIapLog.d("============================================================", TAG) + OpenIapLog.d("This token must be used to report the transaction to Google Play.", TAG) + OpenIapLog.d("", TAG) + OpenIapLog.d("Required implementation:", TAG) + OpenIapLog.d("1. Process payment through YOUR alternative payment system", TAG) + OpenIapLog.d("2. After successful payment, send this token to your backend:", TAG) + OpenIapLog.d(" Token: $tokenResult", TAG) + OpenIapLog.d("3. Backend reports to Google Play Developer API within 24 hours:", TAG) + OpenIapLog.d(" POST https://androidpublisher.googleapis.com/androidpublisher/v3/", TAG) + OpenIapLog.d(" applications/{packageName}/externalTransactions", TAG) + OpenIapLog.d(" Body: { externalTransactionToken: \"$tokenResult\", ... }", TAG) + OpenIapLog.d("", TAG) + OpenIapLog.d("See: https://developer.android.com/google/play/billing/alternative/reporting", TAG) + OpenIapLog.d("============================================================", TAG) + OpenIapLog.d("=== END ALTERNATIVE BILLING ONLY MODE ===", TAG) + + // TODO: In production, emit this token via callback for payment processing + // alternativeBillingCallback?.onTokenCreated( + // token = tokenResult, + // productId = props.skus.first(), + // onPaymentComplete = { transactionId -> + // // App reports to backend after payment success + // } + // ) + + // Return empty list - app should handle purchase via alternative billing + return@withContext emptyList() + } else { + val err = OpenIapError.PurchaseFailed + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + } catch (e: Exception) { + OpenIapLog.e("Alternative billing only flow failed: ${e.message}", e, TAG) + val err = OpenIapError.FeatureNotSupported + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + } + val androidArgs = props.toAndroidPurchaseArgs() val activity = currentActivityRef?.get() ?: fallbackActivity @@ -282,6 +566,22 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { androidArgs.obfuscatedAccountId?.let { flowBuilder.setObfuscatedAccountId(it) } + // Note: Alternative billing must be configured at BillingClient initialization + // via BillingClient.newBuilder(context).enableAlternativeBillingOnly() or + // enableUserChoiceBilling(). The useAlternativeBilling flag is currently + // informational only and requires proper BillingClient setup. + if (androidArgs.useAlternativeBilling == true) { + OpenIapLog.d("=== PURCHASE WITH ALTERNATIVE BILLING ===", TAG) + OpenIapLog.d("useAlternativeBilling flag: true", TAG) + OpenIapLog.d("Products: ${androidArgs.skus}", TAG) + OpenIapLog.d("Note: Alternative billing was configured during BillingClient initialization", TAG) + OpenIapLog.d("If alternative billing is not working, check:", TAG) + OpenIapLog.d("1. Google Play Console alternative billing setup", TAG) + OpenIapLog.d("2. App enrollment in alternative billing program", TAG) + OpenIapLog.d("3. Billing Library version (6.2+ required)", TAG) + OpenIapLog.d("==========================================", TAG) + } + // For subscription upgrades/downgrades, purchaseToken and obfuscatedProfileId are mutually exclusive if (androidArgs.type == ProductQueryType.Subs && !androidArgs.purchaseTokenAndroid.isNullOrBlank()) { // This is a subscription upgrade/downgrade - do not set obfuscatedProfileId @@ -583,7 +883,10 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { } private fun buildBillingClient() { - billingClient = BillingClient.newBuilder(context) + OpenIapLog.d("=== buildBillingClient START ===", TAG) + OpenIapLog.d("alternativeBillingMode: $alternativeBillingMode", TAG) + + val builder = BillingClient.newBuilder(context) .setListener(this) .enablePendingPurchases( PendingPurchasesParams.newBuilder() @@ -591,7 +894,105 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { .build() ) .enableAutoServiceReconnection() - .build() + + // Enable alternative billing if requested + // This requires proper Google Play Console configuration + when (alternativeBillingMode) { + AlternativeBillingMode.NONE -> { + OpenIapLog.d("Standard Google Play billing mode", TAG) + } + AlternativeBillingMode.USER_CHOICE -> { + OpenIapLog.d("=== USER CHOICE BILLING INITIALIZATION ===", TAG) + try { + // Try to use UserChoiceBillingListener via reflection for compatibility + val listenerClass = Class.forName("com.android.billingclient.api.UserChoiceBillingListener") + val userChoiceListener = java.lang.reflect.Proxy.newProxyInstance( + listenerClass.classLoader, + arrayOf(listenerClass) + ) { _, method, args -> + if (method.name == "userSelectedAlternativeBilling") { + OpenIapLog.d("=== USER SELECTED ALTERNATIVE BILLING ===", TAG) + val userChoiceDetails = args?.get(0) + OpenIapLog.d("UserChoiceDetails: $userChoiceDetails", TAG) + + // Extract external transaction token and products + try { + val detailsClass = userChoiceDetails?.javaClass + val tokenMethod = detailsClass?.getMethod("getExternalTransactionToken") + val productsMethod = detailsClass?.getMethod("getProducts") + + val externalToken = tokenMethod?.invoke(userChoiceDetails) as? String + val products = productsMethod?.invoke(userChoiceDetails) as? List<*> + + if (externalToken != null && products != null) { + val productIds = products.mapNotNull { it?.toString() } + OpenIapLog.d("External transaction token: $externalToken", TAG) + OpenIapLog.d("Products: $productIds", TAG) + + // Call user's listener + val details = dev.hyo.openiap.listener.UserChoiceDetails( + externalTransactionToken = externalToken, + products = productIds + ) + userChoiceBillingListener?.onUserSelectedAlternativeBilling(details) + } else { + OpenIapLog.w("Failed to extract user choice details", TAG) + } + } catch (e: Exception) { + OpenIapLog.w("Error processing user choice details: ${e.message}", TAG) + e.printStackTrace() + } + OpenIapLog.d("==========================================", TAG) + } + null + } + + val enableMethod = builder.javaClass.getMethod("enableUserChoiceBilling", listenerClass) + enableMethod.invoke(builder, userChoiceListener) + OpenIapLog.d("✓ User choice billing enabled successfully", TAG) + if (userChoiceBillingListener != null) { + OpenIapLog.d("✓ UserChoiceBillingListener registered", TAG) + } else { + OpenIapLog.w("⚠ No UserChoiceBillingListener provided", TAG) + } + } catch (e: Exception) { + OpenIapLog.w("✗ Failed to enable user choice billing: ${e.javaClass.simpleName}: ${e.message}", TAG) + OpenIapLog.w("User choice billing requires Billing Library 7.0+ and Google Play Console setup", TAG) + } + OpenIapLog.d("=== END USER CHOICE BILLING INITIALIZATION ===", TAG) + } + AlternativeBillingMode.ALTERNATIVE_ONLY -> { + OpenIapLog.d("=== ALTERNATIVE BILLING ONLY INITIALIZATION ===", TAG) + + // List all available methods on BillingClient.Builder + try { + val allMethods = builder.javaClass.methods.map { it.name }.sorted() + OpenIapLog.d("All BillingClient.Builder methods: $allMethods", TAG) + } catch (e: Exception) { + OpenIapLog.w("Could not list methods: ${e.message}", TAG) + } + + try { + // For Billing Library 6.2+, try enableAlternativeBillingOnly() + OpenIapLog.d("Attempting to call enableAlternativeBillingOnly()...", TAG) + val method = builder.javaClass.getMethod("enableAlternativeBillingOnly") + OpenIapLog.d("Method found: $method", TAG) + method.invoke(builder) // Returns void, mutates builder + OpenIapLog.d("✓ Alternative billing only enabled successfully", TAG) + } catch (e: NoSuchMethodException) { + OpenIapLog.e("✗ enableAlternativeBillingOnly() method not found", e, TAG) + OpenIapLog.e("This method requires Billing Library 6.2+", tag = TAG) + OpenIapLog.e("Current library version: 8.0.0", tag = TAG) + OpenIapLog.e("Alternative billing will NOT work - standard Google Play billing will be used", tag = TAG) + } catch (e: Exception) { + OpenIapLog.e("✗ Failed to enable alternative billing only: ${e.javaClass.simpleName}: ${e.message}", e, TAG) + } + OpenIapLog.d("=== END ALTERNATIVE BILLING ONLY INITIALIZATION ===", TAG) + } + } + + billingClient = builder.build() + OpenIapLog.d("=== buildBillingClient END ===", TAG) } private fun initBillingClient( @@ -629,4 +1030,13 @@ class OpenIapModule(private val context: Context) : PurchasesUpdatedListener { fun setActivity(activity: Activity?) { currentActivityRef = activity?.let { WeakReference(it) } } + + /** + * Set user choice billing listener + * + * @param listener User choice billing listener + */ + fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { + userChoiceBillingListener = listener + } } diff --git a/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt b/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt index db8acad..aa9e9f7 100644 --- a/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt +++ b/openiap/src/main/java/dev/hyo/openiap/OpenIapViewModel.kt @@ -18,7 +18,9 @@ class OpenIapViewModel(app: Application) : AndroidViewModel(app) { val availablePurchases = store.availablePurchases val status = store.status - fun initConnection() { viewModelScope.launch { runCatching { store.initConnection() } } } + fun initConnection(config: InitConnectionConfig? = null) { + viewModelScope.launch { runCatching { store.initConnection(config) } } + } fun endConnection() { viewModelScope.launch { runCatching { store.endConnection() } } } fun fetchProducts(skus: List, type: ProductQueryType = ProductQueryType.All) { diff --git a/openiap/src/main/java/dev/hyo/openiap/Types.kt b/openiap/src/main/java/dev/hyo/openiap/Types.kt index fced2a7..0b54a16 100644 --- a/openiap/src/main/java/dev/hyo/openiap/Types.kt +++ b/openiap/src/main/java/dev/hyo/openiap/Types.kt @@ -8,6 +8,41 @@ package dev.hyo.openiap // MARK: - Enums +/** + * Alternative billing mode for Android + * Controls which billing system is used + */ +public enum class AlternativeBillingModeAndroid(val rawValue: String) { + /** + * Standard Google Play billing (default) + */ + None("none"), + /** + * User choice billing - user can select between Google Play or alternative + * Requires Google Play Billing Library 7.0+ + */ + UserChoice("user-choice"), + /** + * Alternative billing only - no Google Play billing option + * Requires Google Play Billing Library 6.2+ + */ + AlternativeOnly("alternative-only"); + + companion object { + fun fromJson(value: String): AlternativeBillingModeAndroid = when (value) { + "none" -> AlternativeBillingModeAndroid.None + "None" -> AlternativeBillingModeAndroid.None + "user-choice" -> AlternativeBillingModeAndroid.UserChoice + "UserChoice" -> AlternativeBillingModeAndroid.UserChoice + "alternative-only" -> AlternativeBillingModeAndroid.AlternativeOnly + "AlternativeOnly" -> AlternativeBillingModeAndroid.AlternativeOnly + else -> throw IllegalArgumentException("Unknown AlternativeBillingModeAndroid value: $value") + } + } + + fun toJson(): String = rawValue +} + public enum class ErrorCode(val rawValue: String) { Unknown("unknown"), UserCancelled("user-cancelled"), @@ -1505,6 +1540,29 @@ public data class DiscountOfferInputIOS( ) } +/** + * Connection initialization configuration + */ +public data class InitConnectionConfig( + /** + * Alternative billing mode for Android + * If not specified, defaults to NONE (standard Google Play billing) + */ + val alternativeBillingModeAndroid: AlternativeBillingModeAndroid? = null +) { + companion object { + fun fromJson(json: Map): InitConnectionConfig { + return InitConnectionConfig( + alternativeBillingModeAndroid = (json["alternativeBillingModeAndroid"] as String?)?.let { AlternativeBillingModeAndroid.fromJson(it) }, + ) + } + } + + fun toJson(): Map = mapOf( + "alternativeBillingModeAndroid" to alternativeBillingModeAndroid?.toJson(), + ) +} + public data class ProductRequest( val skus: List, val type: ProductQueryType? = null @@ -1685,6 +1743,10 @@ public data class RequestPurchaseIosProps( * App account token for user tracking */ val appAccountToken: String? = null, + /** + * External purchase URL for alternative billing (iOS) + */ + val externalPurchaseUrl: String? = null, /** * Purchase quantity */ @@ -1703,6 +1765,7 @@ public data class RequestPurchaseIosProps( return RequestPurchaseIosProps( andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as Boolean?, appAccountToken = json["appAccountToken"] as String?, + externalPurchaseUrl = json["externalPurchaseUrl"] as String?, quantity = (json["quantity"] as Number?)?.toInt(), sku = json["sku"] as String, withOffer = (json["withOffer"] as Map?)?.let { DiscountOfferInputIOS.fromJson(it) }, @@ -1713,6 +1776,7 @@ public data class RequestPurchaseIosProps( fun toJson(): Map = mapOf( "andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically, "appAccountToken" to appAccountToken, + "externalPurchaseUrl" to externalPurchaseUrl, "quantity" to quantity, "sku" to sku, "withOffer" to withOffer?.toJson(), @@ -1721,7 +1785,8 @@ public data class RequestPurchaseIosProps( public data class RequestPurchaseProps( val request: Request, - val type: ProductQueryType + val type: ProductQueryType, + val useAlternativeBilling: Boolean? = null ) { init { when (request) { @@ -1733,19 +1798,20 @@ public data class RequestPurchaseProps( companion object { fun fromJson(json: Map): RequestPurchaseProps { val rawType = (json["type"] as String?)?.let { ProductQueryType.fromJson(it) } + val useAlternativeBilling = json["useAlternativeBilling"] as Boolean? val purchaseJson = json["requestPurchase"] as Map? if (purchaseJson != null) { val request = Request.Purchase(RequestPurchasePropsByPlatforms.fromJson(purchaseJson)) val finalType = rawType ?: ProductQueryType.InApp require(finalType == ProductQueryType.InApp) { "type must be IN_APP when requestPurchase is provided" } - return RequestPurchaseProps(request = request, type = finalType) + return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling) } val subscriptionJson = json["requestSubscription"] as Map? if (subscriptionJson != null) { val request = Request.Subscription(RequestSubscriptionPropsByPlatforms.fromJson(subscriptionJson)) val finalType = rawType ?: ProductQueryType.Subs require(finalType == ProductQueryType.Subs) { "type must be SUBS when requestSubscription is provided" } - return RequestPurchaseProps(request = request, type = finalType) + return RequestPurchaseProps(request = request, type = finalType, useAlternativeBilling = useAlternativeBilling) } throw IllegalArgumentException("RequestPurchaseProps requires requestPurchase or requestSubscription") } @@ -1755,10 +1821,12 @@ public data class RequestPurchaseProps( is Request.Purchase -> mapOf( "requestPurchase" to request.value.toJson(), "type" to type.toJson(), + "useAlternativeBilling" to useAlternativeBilling, ) is Request.Subscription -> mapOf( "requestSubscription" to request.value.toJson(), "type" to type.toJson(), + "useAlternativeBilling" to useAlternativeBilling, ) } @@ -1851,6 +1919,10 @@ public data class RequestSubscriptionAndroidProps( public data class RequestSubscriptionIosProps( val andDangerouslyFinishTransactionAutomatically: Boolean? = null, val appAccountToken: String? = null, + /** + * External purchase URL for alternative billing (iOS) + */ + val externalPurchaseUrl: String? = null, val quantity: Int? = null, val sku: String, val withOffer: DiscountOfferInputIOS? = null @@ -1860,6 +1932,7 @@ public data class RequestSubscriptionIosProps( return RequestSubscriptionIosProps( andDangerouslyFinishTransactionAutomatically = json["andDangerouslyFinishTransactionAutomatically"] as Boolean?, appAccountToken = json["appAccountToken"] as String?, + externalPurchaseUrl = json["externalPurchaseUrl"] as String?, quantity = (json["quantity"] as Number?)?.toInt(), sku = json["sku"] as String, withOffer = (json["withOffer"] as Map?)?.let { DiscountOfferInputIOS.fromJson(it) }, @@ -1870,6 +1943,7 @@ public data class RequestSubscriptionIosProps( fun toJson(): Map = mapOf( "andDangerouslyFinishTransactionAutomatically" to andDangerouslyFinishTransactionAutomatically, "appAccountToken" to appAccountToken, + "externalPurchaseUrl" to externalPurchaseUrl, "quantity" to quantity, "sku" to sku, "withOffer" to withOffer?.toJson(), @@ -1973,6 +2047,14 @@ public interface MutationResolver { * Initiate a refund request for a product (iOS 15+) */ suspend fun beginRefundRequestIOS(sku: String): String? + /** + * Check if alternative billing is available for this user/device + * Step 1 of alternative billing flow + * + * Returns true if available, false otherwise + * Throws OpenIapError.NotPrepared if billing client not ready + */ + suspend fun checkAlternativeBillingAvailabilityAndroid(): Boolean /** * Clear pending transactions from the StoreKit payment queue */ @@ -1981,6 +2063,16 @@ public interface MutationResolver { * Consume a purchase token so it can be repurchased */ suspend fun consumePurchaseAndroid(purchaseToken: String): Boolean + /** + * Create external transaction token for Google Play reporting + * Step 3 of alternative billing flow + * Must be called AFTER successful payment in your payment system + * Token must be reported to Google Play backend within 24 hours + * + * Returns token string, or null if creation failed + * Throws OpenIapError.NotPrepared if billing client not ready + */ + suspend fun createAlternativeBillingTokenAndroid(): String? /** * Open the native subscription management surface */ @@ -1996,7 +2088,7 @@ public interface MutationResolver { /** * Establish the platform billing connection */ - suspend fun initConnection(): Boolean + suspend fun initConnection(config: InitConnectionConfig? = null): Boolean /** * Present the App Store code redemption sheet */ @@ -2013,6 +2105,15 @@ public interface MutationResolver { * Restore completed purchases across platforms */ suspend fun restorePurchases(): Unit + /** + * Show alternative billing information dialog to user + * Step 2 of alternative billing flow + * Must be called BEFORE processing payment in your payment system + * + * Returns true if user accepted, false if user canceled + * Throws OpenIapError.NotPrepared if billing client not ready + */ + suspend fun showAlternativeBillingDialogAndroid(): Boolean /** * Open subscription management UI and return changed purchases (iOS 15+) */ @@ -2125,16 +2226,19 @@ public interface SubscriptionResolver { public typealias MutationAcknowledgePurchaseAndroidHandler = suspend (purchaseToken: String) -> Boolean public typealias MutationBeginRefundRequestIOSHandler = suspend (sku: String) -> String? +public typealias MutationCheckAlternativeBillingAvailabilityAndroidHandler = suspend () -> Boolean public typealias MutationClearTransactionIOSHandler = suspend () -> Boolean public typealias MutationConsumePurchaseAndroidHandler = suspend (purchaseToken: String) -> Boolean +public typealias MutationCreateAlternativeBillingTokenAndroidHandler = suspend () -> String? public typealias MutationDeepLinkToSubscriptionsHandler = suspend (options: DeepLinkOptions?) -> Unit public typealias MutationEndConnectionHandler = suspend () -> Boolean public typealias MutationFinishTransactionHandler = suspend (purchase: PurchaseInput, isConsumable: Boolean?) -> Unit -public typealias MutationInitConnectionHandler = suspend () -> Boolean +public typealias MutationInitConnectionHandler = suspend (config: InitConnectionConfig?) -> Boolean public typealias MutationPresentCodeRedemptionSheetIOSHandler = suspend () -> Boolean public typealias MutationRequestPurchaseHandler = suspend (params: RequestPurchaseProps) -> RequestPurchaseResult? public typealias MutationRequestPurchaseOnPromotedProductIOSHandler = suspend () -> Boolean public typealias MutationRestorePurchasesHandler = suspend () -> Unit +public typealias MutationShowAlternativeBillingDialogAndroidHandler = suspend () -> Boolean public typealias MutationShowManageSubscriptionsIOSHandler = suspend () -> List public typealias MutationSyncIOSHandler = suspend () -> Boolean public typealias MutationValidateReceiptHandler = suspend (options: ReceiptValidationProps) -> ReceiptValidationResult @@ -2142,8 +2246,10 @@ public typealias MutationValidateReceiptHandler = suspend (options: ReceiptValid public data class MutationHandlers( val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler? = null, val beginRefundRequestIOS: MutationBeginRefundRequestIOSHandler? = null, + val checkAlternativeBillingAvailabilityAndroid: MutationCheckAlternativeBillingAvailabilityAndroidHandler? = null, val clearTransactionIOS: MutationClearTransactionIOSHandler? = null, val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler? = null, + val createAlternativeBillingTokenAndroid: MutationCreateAlternativeBillingTokenAndroidHandler? = null, val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler? = null, val endConnection: MutationEndConnectionHandler? = null, val finishTransaction: MutationFinishTransactionHandler? = null, @@ -2152,6 +2258,7 @@ public data class MutationHandlers( val requestPurchase: MutationRequestPurchaseHandler? = null, val requestPurchaseOnPromotedProductIOS: MutationRequestPurchaseOnPromotedProductIOSHandler? = null, val restorePurchases: MutationRestorePurchasesHandler? = null, + val showAlternativeBillingDialogAndroid: MutationShowAlternativeBillingDialogAndroidHandler? = null, val showManageSubscriptionsIOS: MutationShowManageSubscriptionsIOSHandler? = null, val syncIOS: MutationSyncIOSHandler? = null, val validateReceipt: MutationValidateReceiptHandler? = null diff --git a/openiap/src/main/java/dev/hyo/openiap/helpers/Helpers.kt b/openiap/src/main/java/dev/hyo/openiap/helpers/Helpers.kt index f13c87c..919c303 100644 --- a/openiap/src/main/java/dev/hyo/openiap/helpers/Helpers.kt +++ b/openiap/src/main/java/dev/hyo/openiap/helpers/Helpers.kt @@ -90,7 +90,8 @@ internal data class AndroidPurchaseArgs( val purchaseTokenAndroid: String?, val replacementModeAndroid: Int?, val subscriptionOffers: List?, - val type: ProductQueryType + val type: ProductQueryType, + val useAlternativeBilling: Boolean? ) internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { @@ -106,7 +107,8 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { purchaseTokenAndroid = null, replacementModeAndroid = null, subscriptionOffers = null, - type = type + type = type, + useAlternativeBilling = useAlternativeBilling ) } is RequestPurchaseProps.Request.Subscription -> { @@ -127,7 +129,8 @@ internal fun RequestPurchaseProps.toAndroidPurchaseArgs(): AndroidPurchaseArgs { purchaseTokenAndroid = android.purchaseTokenAndroid, replacementModeAndroid = android.replacementModeAndroid, subscriptionOffers = android.subscriptionOffers, - type = type + type = type, + useAlternativeBilling = useAlternativeBilling ) } } diff --git a/openiap/src/main/java/dev/hyo/openiap/listener/UserChoiceBillingListener.kt b/openiap/src/main/java/dev/hyo/openiap/listener/UserChoiceBillingListener.kt new file mode 100644 index 0000000..963beb1 --- /dev/null +++ b/openiap/src/main/java/dev/hyo/openiap/listener/UserChoiceBillingListener.kt @@ -0,0 +1,28 @@ +package dev.hyo.openiap.listener + +/** + * User choice billing details when user selects alternative billing + */ +data class UserChoiceDetails( + /** + * External transaction token to be sent to backend server + */ + val externalTransactionToken: String, + /** + * Products being purchased + */ + val products: List +) + +/** + * Listener for user choice billing selection + * Called when user selects alternative billing in the user choice flow + */ +fun interface UserChoiceBillingListener { + /** + * Called when user selects alternative billing + * + * @param details User choice details including external transaction token and products + */ + fun onUserSelectedAlternativeBilling(details: UserChoiceDetails) +} diff --git a/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index 71c9769..cb0d521 100644 --- a/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -6,6 +6,7 @@ import dev.hyo.openiap.DeepLinkOptions import dev.hyo.openiap.FetchProductsResult import dev.hyo.openiap.FetchProductsResultProducts import dev.hyo.openiap.FetchProductsResultSubscriptions +import dev.hyo.openiap.InitConnectionConfig import dev.hyo.openiap.Product import dev.hyo.openiap.ProductAndroid import dev.hyo.openiap.ProductQueryType @@ -38,17 +39,45 @@ import dev.hyo.openiap.OpenIapModule import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.utils.toProduct +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch /** * OpenIapStore (Android) * Convenience store that wraps OpenIapModule and provides spec-aligned, suspend APIs * with observable StateFlows for UI layers (Compose/XML) to consume. + * + * @param module OpenIapModule instance */ class OpenIapStore(private val module: OpenIapModule) { - constructor(context: Context) : this(OpenIapModule(context)) + /** + * Convenience constructor that creates OpenIapModule + * + * @param context Android context + * @param alternativeBillingMode Alternative billing mode (default: NONE) + * @param userChoiceBillingListener Listener for user choice billing selection (optional) + */ + constructor( + context: Context, + alternativeBillingMode: dev.hyo.openiap.AlternativeBillingMode = dev.hyo.openiap.AlternativeBillingMode.NONE, + userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null + ) : this(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener)) + + /** + * Convenience constructor for backward compatibility + * + * @param context Android context + * @param enableAlternativeBilling Enable alternative billing mode (uses ALTERNATIVE_ONLY mode) + */ + @Deprecated("Use constructor with AlternativeBillingMode instead", ReplaceWith("OpenIapStore(context, if (enableAlternativeBilling) AlternativeBillingMode.ALTERNATIVE_ONLY else AlternativeBillingMode.NONE)")) + constructor( + context: Context, + enableAlternativeBilling: Boolean + ) : this(OpenIapModule(context, enableAlternativeBilling)) // Public state private val _isConnected = MutableStateFlow(false) @@ -119,6 +148,16 @@ class OpenIapStore(private val module: OpenIapModule) { pendingRequestProductId = null } + /** + * Set user choice billing listener + * This listener will be called when user selects alternative billing in user choice mode + * + * @param listener User choice billing listener + */ + fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { + module.setUserChoiceBillingListener(listener) + } + // Expose a way to set the current Activity for purchase flows fun setActivity(activity: Activity?) { module.setActivity(activity) @@ -142,18 +181,12 @@ class OpenIapStore(private val module: OpenIapModule) { // ------------------------------------------------------------------------- // Connection Management - Using GraphQL handler pattern // ------------------------------------------------------------------------- - val initConnection: MutationInitConnectionHandler = { + + val initConnection: MutationInitConnectionHandler = { config -> setLoading { it.initConnection = true } try { - val ok = module.initConnection() + val ok = module.initConnection(config) _isConnected.value = ok - - // Add listeners when connected - if (ok) { - addPurchaseUpdateListener(purchaseUpdateListener) - addPurchaseErrorListener(purchaseErrorListener) - } - ok } catch (e: Exception) { setError(e.message) @@ -163,6 +196,11 @@ class OpenIapStore(private val module: OpenIapModule) { } } + /** + * Convenience overload that calls initConnection with null config + */ + suspend fun initConnection(): Boolean = initConnection(null) + val endConnection: MutationEndConnectionHandler = { removePurchaseUpdateListener(purchaseUpdateListener) removePurchaseErrorListener(purchaseErrorListener) @@ -287,6 +325,33 @@ class OpenIapStore(private val module: OpenIapModule) { suspend fun deepLinkToSubscriptions(options: DeepLinkOptions) = module.deepLinkToSubscriptions(options) + // ------------------------------------------------------------------------- + // Alternative Billing (Step-by-Step API) + // ------------------------------------------------------------------------- + /** + * Step 1: Check if alternative billing is available for this user/device + * @return true if available, false otherwise + */ + suspend fun checkAlternativeBillingAvailability(): Boolean = module.checkAlternativeBillingAvailability() + + /** + * Step 2: Show alternative billing information dialog to user + * Must be called BEFORE processing payment + * @param activity Current activity context + * @return true if user accepted, false if canceled + */ + suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = + module.showAlternativeBillingInformationDialog(activity) + + /** + * Step 3: Create external transaction token for alternative billing + * Must be called AFTER successful payment in your payment system + * Token must be reported to Google Play backend within 24 hours + * @return External transaction token, or null if failed + */ + suspend fun createAlternativeBillingReportingToken(): String? = + module.createAlternativeBillingReportingToken() + // ------------------------------------------------------------------------- // Event listeners passthrough // -------------------------------------------------------------------------