diff --git a/CLAUDE.md b/CLAUDE.md index 071f36e5..7d568904 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,8 +17,14 @@ openiap/ ## Required Pre-Work -- Before writing or editing anything, load and review the relevant `CONVENTION.md` file in the specific package directory -- For cross-package changes, review conventions for all affected packages +**CRITICAL**: Before writing or editing anything in a package, **ALWAYS** load and review the relevant `CONVENTION.md` file: + +- **GraphQL Types** (`packages/gql`): See [`packages/gql/CONVENTION.md`](packages/gql/CONVENTION.md) +- **Android Library** (`packages/google`): See [`packages/google/CONVENTION.md`](packages/google/CONVENTION.md) +- **Apple Library** (`packages/apple`): See [`packages/apple/CONVENTION.md`](packages/apple/CONVENTION.md) +- **Documentation** (`packages/docs`): Follow conventions in this file (below) + +For cross-package changes, review conventions for all affected packages. --- @@ -289,20 +295,20 @@ Before committing any changes: ### Android Function Naming Conventions -- **Android-specific functions MUST have `Android` suffix** -- **Cross-platform functions have NO suffix** +**IMPORTANT**: See [`packages/google/CONVENTION.md`](packages/google/CONVENTION.md) for detailed Android naming conventions. -#### Android Examples +**Key Rule**: Since `packages/google` is Android-only, **DO NOT add `Android` suffix** to function names, even for Android-specific APIs. **✅ Correct**: ```kotlin -// Android-specific -fun acknowledgePurchaseAndroid() -fun consumePurchaseAndroid() -fun getPackageNameAndroid() +// Android-specific functions (no suffix needed) +fun acknowledgePurchase() +fun consumePurchase() +fun getPackageName() +fun buildModule(context: Context) -// Cross-platform +// Cross-platform API functions fun initConnection() fun fetchProducts() fun requestPurchase() @@ -311,10 +317,13 @@ fun requestPurchase() **❌ Incorrect**: ```kotlin -// Missing Android suffix -fun acknowledgePurchase() // Should be acknowledgePurchaseAndroid() +// Don't add Android suffix in Android-only package +fun acknowledgePurchaseAndroid() // Wrong! +fun buildModuleAndroid() // Wrong! ``` +**Exception**: Only use `Android` suffix for types that are part of a cross-platform API (e.g., `ProductAndroid`, `PurchaseAndroid` that contrast with iOS types). + --- ## iOS Library (`packages/apple`) diff --git a/packages/google/.github/workflows/ci-horizon.yml b/packages/google/.github/workflows/ci-horizon.yml new file mode 100644 index 00000000..ebf13cd5 --- /dev/null +++ b/packages/google/.github/workflows/ci-horizon.yml @@ -0,0 +1,54 @@ +name: CI Horizon + +on: + push: + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-horizon-${{ github.ref }} + cancel-in-progress: true + +jobs: + wrapper-validation: + name: Validate Gradle Wrapper + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Validate Wrapper + uses: gradle/wrapper-validation-action@v2 + + horizon-build: + name: Build Horizon flavors + runs-on: ubuntu-latest + needs: wrapper-validation + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Gradle + uses: gradle/gradle-build-action@v2 + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + + - name: Install required SDK packages + run: | + yes | sdkmanager --licenses + sdkmanager --install \ + "platform-tools" \ + "platforms;android-34" \ + "build-tools;34.0.0" + + - name: Build Horizon variants + working-directory: packages/google + run: ./gradlew --stacktrace --no-daemon :openiap:assembleHorizonDebug :Example:assembleHorizonDebug diff --git a/packages/google/CONTRIBUTING.md b/packages/google/CONTRIBUTING.md index 40ad7cf2..13445ac8 100644 --- a/packages/google/CONTRIBUTING.md +++ b/packages/google/CONTRIBUTING.md @@ -33,6 +33,58 @@ cd openiap-google adb shell am start -n dev.hyo.martie/.MainActivity ``` +## Horizon OS Development (Meta Quest) + +This library supports both Google Play Store and Meta Horizon OS (Quest devices) using product flavors. + +### Setting Up Horizon App ID + +1. **Create `local.properties`** in the project root (if it doesn't exist): + +```properties +# local.properties (DO NOT commit this file) +sdk.dir=/path/to/Android/sdk + +# Horizon OS App ID (Meta Quest) +EXAMPLE_HORIZON_APP_ID=your_horizon_app_id_here +``` + +1. **Alternative: Pass via command line**: + +```bash +# Build with App ID +./gradlew :Example:assembleHorizonDebug -PEXAMPLE_HORIZON_APP_ID=your_app_id + +# Install with App ID +./gradlew :Example:installHorizonDebug -PEXAMPLE_HORIZON_APP_ID=your_app_id +``` + +1. **Using Android Studio**: + - Open **View > Tool Windows > Build Variants** + - Set **Example** module to **horizonDebug** + - Set **openiap** module to **horizonDebug** + - Run the app (App ID will be read from `local.properties`) + +### Build Variants + +- **playDebug** / **playRelease** - Google Play Store billing +- **horizonDebug** / **horizonRelease** - Meta Horizon OS billing + +### Testing on Quest Devices + +```bash +# Connect Quest via ADB +adb devices + +# Install Horizon variant +./gradlew :Example:installHorizonDebug + +# View logs +adb logcat | grep -E "OpenIap|Horizon" +``` + +**Note**: The Horizon App ID is required for Horizon Billing to work. Without it, the billing client will fail to connect. + ## Generated Types - All GraphQL models in `openiap/src/main/java/dev/hyo/openiap/Types.kt` are generated from the [`openiap` monorepo](https://github.com/hyodotdev/openiap/tree/main/packages/gql). When you update API behavior, adjust the upstream type generator first so the Kotlin output stays in sync across platforms. diff --git a/packages/google/CONVENTION.md b/packages/google/CONVENTION.md index d542fff2..2fb8b6b1 100644 --- a/packages/google/CONVENTION.md +++ b/packages/google/CONVENTION.md @@ -2,6 +2,28 @@ ## Naming Conventions +### Android-Specific Functions + +**IMPORTANT**: Since this is an Android-only package, **DO NOT add `Android` suffix** to function names, even for Android-specific APIs. + +**✅ Correct**: +```kotlin +fun acknowledgePurchase() +fun consumePurchase() +fun getPackageName() +fun buildModule(context: Context) +fun isHorizonEnvironment(context: Context) +``` + +**❌ Incorrect**: +```kotlin +fun acknowledgePurchaseAndroid() // Don't add Android suffix +fun consumePurchaseAndroid() // Don't add Android suffix +fun buildModuleAndroid() // Don't add Android suffix +``` + +**Exception**: Only add `Android` suffix when the function is part of a cross-platform API that has platform-specific variants (e.g., `ProductAndroid`, `PurchaseAndroid` types that contrast with iOS types). + ### Enum Values - Enum values in this codebase must use **kebab-case** (e.g., `non-consumable`, `in-app`, `user-cancelled`) - This matches the convention used in the auto-generated Types.kt from GraphQL schemas diff --git a/packages/google/Example/build.gradle.kts b/packages/google/Example/build.gradle.kts index 7acf0640..447a77f6 100644 --- a/packages/google/Example/build.gradle.kts +++ b/packages/google/Example/build.gradle.kts @@ -1,9 +1,18 @@ +import java.util.Properties + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.plugin.compose") } +// Load local.properties +val localProperties = Properties() +val localPropertiesFile = rootProject.file("local.properties") +if (localPropertiesFile.exists()) { + localPropertiesFile.inputStream().use { localProperties.load(it) } +} + android { namespace = "dev.hyo.martie" compileSdk = 34 @@ -17,6 +26,50 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true + + val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID") + ?: localProperties.getProperty("EXAMPLE_OPENIAP_APP_ID") + ?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?) + ?: (project.findProperty("EXAMPLE_OPENIAP_APP_ID") as String?) + ?: "" + buildConfigField("String", "HORIZON_APP_ID", "\"${appId}\"") + // Ensure placeholder exists for all variants (play included) + manifestPlaceholders["OCULUS_APP_ID"] = appId + } + + flavorDimensions += "platform" + + productFlavors { + // Auto flavor (default) - includes both libraries, detects platform at runtime + create("auto") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"auto\"") + isDefault = true + + // Dynamically inject OCULUS_APP_ID into AndroidManifest (needed for Horizon) + val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID") + ?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?) + ?: "" + manifestPlaceholders["OCULUS_APP_ID"] = appId + } + + // Play flavor - Google Play Billing only + create("play") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"play\"") + } + + // Horizon flavor - Meta Horizon Billing only + create("horizon") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"horizon\"") + + // Dynamically inject OCULUS_APP_ID into AndroidManifest + val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID") + ?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?) + ?: "" + manifestPlaceholders["OCULUS_APP_ID"] = appId + } } buildTypes { @@ -44,6 +97,7 @@ android { buildFeatures { compose = true + buildConfig = true } packaging { diff --git a/packages/google/Example/src/horizon/AndroidManifest.xml b/packages/google/Example/src/horizon/AndroidManifest.xml new file mode 100644 index 00000000..daba2856 --- /dev/null +++ b/packages/google/Example/src/horizon/AndroidManifest.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/packages/google/Example/src/main/AndroidManifest.xml b/packages/google/Example/src/main/AndroidManifest.xml index e4494950..3e6d61b0 100644 --- a/packages/google/Example/src/main/AndroidManifest.xml +++ b/packages/google/Example/src/main/AndroidManifest.xml @@ -6,6 +6,11 @@ android:label="OpenIAP Example" android:supportsRtl="true"> + + + diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt b/packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt index 080261b6..c6f8102b 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt @@ -1,5 +1,7 @@ package dev.hyo.martie +import android.os.Build + object IapConstants { // App-defined SKU lists val INAPP_SKUS = listOf( @@ -8,13 +10,46 @@ object IapConstants { "dev.hyo.martie.certified" // Non-consumable ) - val SUBS_SKUS = listOf( + // Google Play: Two separate subscription products + private val SUBS_SKUS_PLAY = listOf( "dev.hyo.martie.premium", // Main subscription with multiple offers "dev.hyo.martie.premium_year" // Separate yearly subscription product ) - // Base plan IDs for dev.hyo.martie.premium subscription - const val PREMIUM_MONTHLY_BASE_PLAN = "premium" // Monthly base plan - const val PREMIUM_YEARLY_BASE_PLAN = "premium-year" // Yearly base plan + // Horizon OS: Two separate SKUs (both at Level 1 to prevent auto-upgrade) + // IMPORTANT: In Horizon Developer Console, both must be set to the SAME Level + // to prevent automatic tier upgrades. Currently configured as: + // - premium (Level 1): Has MONTHLY and ANNUAL offers + // - premium_year (Level 1): Has ANNUAL offer only + private val SUBS_SKUS_HORIZON = listOf( + "dev.hyo.martie.premium", // Premium with multiple term options + "dev.hyo.martie.premium_year" // Separate yearly-only subscription + ) + + // Detect if running on Horizon OS at runtime + fun isHorizonOS(): Boolean { + return Build.MANUFACTURER.equals("Meta", ignoreCase = true) || + Build.BRAND.equals("Meta", ignoreCase = true) || + (Build.MODEL?.contains("Quest", ignoreCase = true) == true) + } + + // Get subscription SKUs based on current device + fun getSubscriptionSkus(): List { + val isHorizon = isHorizonOS() + val skus = if (isHorizon) SUBS_SKUS_HORIZON else SUBS_SKUS_PLAY + println("IapConstants: getSubscriptionSkus() - isHorizon=$isHorizon, skus=$skus") + return skus + } + + // Legacy: For screens that don't have platform context yet + val SUBS_SKUS = getSubscriptionSkus() + + // Product IDs + const val PREMIUM_PRODUCT_ID = "dev.hyo.martie.premium" + const val PREMIUM_YEARLY_PRODUCT_ID_PLAY = "dev.hyo.martie.premium_year" // Play only + + // Base plan IDs (used by both Play and Horizon) + const val PREMIUM_MONTHLY_BASE_PLAN = "premium" // 1 month plan + const val PREMIUM_YEARLY_BASE_PLAN = "premium-year" // 12 months plan } diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt b/packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt index c311567c..83cc3fca 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt @@ -14,34 +14,52 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import dev.hyo.martie.models.AppColors import dev.hyo.martie.screens.* +import dev.hyo.openiap.IapContext +import dev.hyo.openiap.store.OpenIapStore class MainActivity : ComponentActivity() { + // CRITICAL FIX: Create OpenIapStore at Activity level to persist across navigation + private val iapStore by lazy { OpenIapStore(applicationContext) } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - + setContent { OpenIapExampleTheme { Surface( modifier = Modifier.fillMaxSize(), color = AppColors.background ) { - AppNavigation() + AppNavigation(iapStore) } } } } + + override fun onDestroy() { + // Clean up the store before lifecycleScope is cancelled + runCatching { + kotlinx.coroutines.runBlocking { + iapStore.endConnection() + } + } + super.onDestroy() + } } @Composable -fun AppNavigation() { +fun AppNavigation(store: OpenIapStore) { val navController = rememberNavController() 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", "alternative_billing")) route!! else "home" } - NavHost( + // Provide the shared store to all screens via IapContext + IapContext.OpenIapProvider(store = store) { + NavHost( navController = navController, startDestination = startRoute ) { @@ -73,6 +91,7 @@ fun AppNavigation() { AlternativeBillingScreen(navController) } } + } } @Composable diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt index afa16d21..bd1f175d 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt @@ -36,6 +36,7 @@ import dev.hyo.openiap.AlternativeBillingMode import dev.hyo.openiap.AlternativeBillingModeAndroid import dev.hyo.openiap.InitConnectionConfig import dev.hyo.martie.util.findActivity +import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -44,26 +45,33 @@ fun AlternativeBillingScreen(navController: NavController) { val activity = remember(context) { context.findActivity() } val appContext = remember(context) { context.applicationContext } + // Platform detection (runtime detection) + val isHorizon = remember { dev.hyo.martie.IapConstants.isHorizonOS() } + 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") + // Initialize store - use default constructor for auto-detection (compatible with both Play and Horizon) + val iapStore = remember { + android.util.Log.d("AlternativeBillingScreen", "Creating OpenIapStore with auto-detection") dev.hyo.openiap.OpenIapLog.isEnabled = true - val store = OpenIapStore(appContext, alternativeBillingMode = selectedMode) + // Use default constructor which auto-detects platform (Play or Horizon) + // Alternative billing mode will be set via initConnection config + OpenIapStore(appContext) + } - // Add event-based listener for User Choice Billing + // Set up User Choice Billing listener when mode changes + LaunchedEffect(selectedMode) { if (selectedMode == AlternativeBillingMode.USER_CHOICE) { - store.addUserChoiceBillingListener { details -> + iapStore.addUserChoiceBillingListener { details -> android.util.Log.d("UserChoiceEvent", "=== User Choice Billing Event ===") android.util.Log.d("UserChoiceEvent", "External Token: ${details.externalTransactionToken}") android.util.Log.d("UserChoiceEvent", "Products: ${details.products}") android.util.Log.d("UserChoiceEvent", "==============================") // Show result in UI - store.postStatusMessage( + iapStore.postStatusMessage( message = "User selected alternative billing\nToken: ${details.externalTransactionToken.take(20)}...\nProducts: ${details.products.joinToString()}", status = dev.hyo.openiap.store.PurchaseResultStatus.Info, productId = details.products.firstOrNull() @@ -72,9 +80,10 @@ fun AlternativeBillingScreen(navController: NavController) { // TODO: Process payment with your payment system // Then create token and report to backend } + } else { + // Remove listener when not in USER_CHOICE mode + iapStore.setUserChoiceBillingListener(null) } - - store } val products by iapStore.products.collectAsState() @@ -105,6 +114,17 @@ fun AlternativeBillingScreen(navController: NavController) { // Initialize connection when mode changes LaunchedEffect(selectedMode) { try { + android.util.Log.d("AlternativeBillingScreen", "Initializing with mode: $selectedMode") + + // IMPORTANT: End existing connection first before creating new one + android.util.Log.d("AlternativeBillingScreen", "Ending existing connection...") + iapStore.endConnection() + delay(500) // Give it time to fully disconnect + + // Set activity + iapStore.setActivity(activity) + + // Create config based on selected mode val config = when (selectedMode) { AlternativeBillingMode.USER_CHOICE -> InitConnectionConfig( alternativeBillingModeAndroid = AlternativeBillingModeAndroid.UserChoice @@ -115,16 +135,23 @@ fun AlternativeBillingScreen(navController: NavController) { else -> null } + android.util.Log.d("AlternativeBillingScreen", "Reconnecting with config: $config") val connected = iapStore.initConnection(config) + android.util.Log.d("AlternativeBillingScreen", "Connection result: $connected") + if (connected) { - iapStore.setActivity(activity) + android.util.Log.d("AlternativeBillingScreen", "Fetching products...") val request = ProductRequest( skus = IapConstants.INAPP_SKUS, type = ProductQueryType.InApp ) iapStore.fetchProducts(request) + } else { + android.util.Log.e("AlternativeBillingScreen", "Failed to connect to billing service") } - } catch (_: Exception) { } + } catch (e: Exception) { + android.util.Log.e("AlternativeBillingScreen", "Connection error: ${e.message}", e) + } } DisposableEffect(Unit) { @@ -161,6 +188,46 @@ fun AlternativeBillingScreen(navController: NavController) { contentPadding = PaddingValues(vertical = 20.dp), verticalArrangement = Arrangement.spacedBy(20.dp) ) { + // Horizon Info Banner (Alternative Billing is now supported!) + if (isHorizon) { + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = AppColors.primary.copy(alpha = 0.1f)) + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Info, + contentDescription = null, + tint = AppColors.primary, + modifier = Modifier.size(24.dp) + ) + Column(modifier = Modifier.weight(1f)) { + Text( + "Testing Meta Horizon Alternative Billing", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = AppColors.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + "Alternative Billing APIs are available through Horizon Billing Compatibility Library. Testing if they work correctly.", + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + } + } + } + } + } + // Mode Selection Dropdown item { Card( @@ -182,7 +249,9 @@ fun AlternativeBillingScreen(navController: NavController) { ExposedDropdownMenuBox( expanded = isModeDropdownExpanded, - onExpandedChange = { isModeDropdownExpanded = it } + onExpandedChange = { + isModeDropdownExpanded = it + } ) { OutlinedTextField( value = when (selectedMode) { diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt index 808b78a5..1b9a9b72 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt @@ -54,7 +54,9 @@ fun PurchaseFlowScreen( val activity = remember(context) { context.findActivity() } val uiScope = rememberCoroutineScope() val appContext = remember(context) { context.applicationContext } - val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) } + val iapStore = storeParam ?: remember(appContext) { + OpenIapStore(appContext) + } val products by iapStore.products.collectAsState() val purchases by iapStore.availablePurchases.collectAsState() val androidProducts = remember(products) { products.filterIsInstance() } diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt index fd19349e..8c996336 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import dev.hyo.martie.models.AppColors import dev.hyo.martie.IapConstants +import dev.hyo.martie.BuildConfig import dev.hyo.martie.screens.uis.* import dev.hyo.openiap.IapContext import dev.hyo.openiap.ProductAndroid @@ -84,7 +85,9 @@ fun SubscriptionFlowScreen( // SharedPreferences to track current offer (necessary since Google doesn't provide offer info) val prefs = remember { context.getSharedPreferences(SUBSCRIPTION_PREFS_NAME, Context.MODE_PRIVATE) } - val iapStore = storeParam ?: remember(appContext) { OpenIapStore(appContext) } + val iapStore = storeParam ?: remember(appContext) { + OpenIapStore(appContext) + } val products by iapStore.products.collectAsState() val subscriptions by iapStore.subscriptions.collectAsState() val purchases by iapStore.availablePurchases.collectAsState() @@ -111,9 +114,20 @@ fun SubscriptionFlowScreen( var subStatus by remember { mutableStateOf>(emptyMap()) } var now by remember { mutableStateOf(System.currentTimeMillis()) } + // Platform detection: Horizon vs Play Store (runtime detection) + val isHorizon = remember { IapConstants.isHorizonOS() } + + // Load subscription SKUs based on platform + val subscriptionSkus = remember { + // Use runtime platform detection from Constants + IapConstants.getSubscriptionSkus() + } + // Load subscription data on screen entry LaunchedEffect(Unit) { println("SubscriptionFlow: Loading subscription products and purchases") + println("SubscriptionFlow: Is Horizon = $isHorizon") + println("SubscriptionFlow: Subscription SKUs = $subscriptionSkus") iapStore.setActivity(activity) // Get fresh purchases first @@ -122,7 +136,7 @@ fun SubscriptionFlowScreen( // Fetch products val request = ProductRequest( - skus = IapConstants.SUBS_SKUS, + skus = subscriptionSkus, type = ProductQueryType.Subs ) iapStore.fetchProducts(request) @@ -135,6 +149,27 @@ fun SubscriptionFlowScreen( println(" - ${purchase.productId}: state=${purchase.purchaseState}") } } + + // Log product offers + delay(500) + val currentProducts = iapStore.products.value + println("SubscriptionFlow: Found ${currentProducts.size} products") + currentProducts.forEach { product -> + if (product is ProductAndroid) { + println(" - Product: ${product.id}") + println(" Title: ${product.title}") + println(" Price: ${product.displayPrice}") + product.subscriptionOfferDetailsAndroid?.forEachIndexed { index, offer -> + println(" Offer $index:") + println(" Base Plan: ${offer.basePlanId}") + println(" Offer ID: ${offer.offerId}") + println(" Offer Token: ${offer.offerToken.take(20)}...") + offer.pricingPhases.pricingPhaseList.forEachIndexed { phaseIndex, phase -> + println(" Phase $phaseIndex: ${phase.formattedPrice} for ${phase.billingPeriod}") + } + } + } + } } // Tick clock to update countdown once per second @@ -161,7 +196,7 @@ fun SubscriptionFlowScreen( LaunchedEffect(androidPurchases) { val map = mutableMapOf() androidPurchases - .filter { it.productId in IapConstants.SUBS_SKUS } + .filter { it.productId in subscriptionSkus } .forEach { purchase -> val token = purchase.purchaseToken ?: return@forEach val info = fetchSubStatusFromServer(purchase.productId, token) @@ -172,7 +207,34 @@ fun SubscriptionFlowScreen( subStatus = map } val statusMessage = status.lastPurchaseResult - + + // Auto-refresh purchases after successful purchase (like React Native implementation) + LaunchedEffect(statusMessage) { + if (statusMessage?.status == PurchaseResultStatus.Success) { + println("SubscriptionFlow: Purchase success detected, refreshing purchases...") + println("SubscriptionFlow: Success message productId: ${statusMessage.productId}") + delay(1000) // Wait 1 second for server to process + iapStore.getAvailablePurchases(null) + } + } + + // Log purchases whenever they change + LaunchedEffect(androidPurchases.size) { + println("SubscriptionFlow: Purchases changed, count=${androidPurchases.size}") + androidPurchases.forEach { purchase -> + println("SubscriptionFlow: - Purchase: productId=${purchase.productId}, state=${purchase.purchaseState}") + } + } + + // Auto-refresh purchases after products are loaded (like React Native implementation) + LaunchedEffect(androidSubscriptions.size) { + if (androidSubscriptions.isNotEmpty() && androidPurchases.isEmpty()) { + println("SubscriptionFlow: Products loaded, checking for existing purchases...") + delay(500) + iapStore.getAvailablePurchases(null) + } + } + // Modal states var selectedProduct by remember { mutableStateOf(null) } var selectedPurchase by remember { mutableStateOf(null) } @@ -182,18 +244,25 @@ fun SubscriptionFlowScreen( DisposableEffect(Unit) { startupScope.launch { try { + println("SubscriptionFlow: Calling initConnection...") val connected = iapStore.initConnection() + println("SubscriptionFlow: initConnection returned: $connected") if (connected) { iapStore.setActivity(activity) - println("SubscriptionFlow: Loading subscription products: ${IapConstants.SUBS_SKUS}") + println("SubscriptionFlow: Loading subscription products: $subscriptionSkus") val request = ProductRequest( - skus = IapConstants.SUBS_SKUS, + skus = subscriptionSkus, type = ProductQueryType.Subs ) iapStore.fetchProducts(request) iapStore.getAvailablePurchases(null) + } else { + println("SubscriptionFlow: Failed to connect to billing service") } - } catch (_: Exception) { } + } catch (e: Exception) { + println("SubscriptionFlow: Exception during initConnection: ${e.message}") + e.printStackTrace() + } } onDispose { @@ -222,7 +291,7 @@ fun SubscriptionFlowScreen( try { iapStore.setActivity(activity) val request = ProductRequest( - skus = IapConstants.SUBS_SKUS, + skus = subscriptionSkus, type = ProductQueryType.Subs ) iapStore.fetchProducts(request) @@ -317,13 +386,23 @@ fun SubscriptionFlowScreen( // Active Subscriptions Section // Treat any purchase with matching subscription SKU as subscribed - val activeSubscriptions = androidPurchases.filter { it.productId in IapConstants.SUBS_SKUS } + val activeSubscriptions = androidPurchases.filter { it.productId in subscriptionSkus } if (activeSubscriptions.isNotEmpty()) { item { SectionHeaderView(title = "Active Subscriptions") } items(activeSubscriptions) { subscription -> + // Platform-specific premium subscription detection + val isPremium = if (isHorizon) { + // Horizon: Both premium and premium_year are premium subscriptions + subscription.productId == IapConstants.PREMIUM_PRODUCT_ID || + subscription.productId == IapConstants.PREMIUM_YEARLY_PRODUCT_ID_PLAY + } else { + // Play: Only premium product ID (premium_year is separate) + subscription.productId == IapConstants.PREMIUM_PRODUCT_ID + } + val isPremiumYearlyPlay = subscription.productId == IapConstants.PREMIUM_YEARLY_PRODUCT_ID_PLAY val info = subStatus[subscription.productId] val fmt = java.text.SimpleDateFormat("MMM dd, yyyy HH:mm", java.util.Locale.getDefault()) val statusText = when { @@ -353,8 +432,240 @@ fun SubscriptionFlowScreen( onClick = { selectedPurchase = subscription } ) - // Show upgrade/downgrade option for dev.hyo.martie.premium subscription offers - if (subscription.productId == PREMIUM_SUBSCRIPTION_PRODUCT_ID) { + // Show upgrade/downgrade between monthly and yearly plans (works for both Play and Horizon) + if (isPremium) { + // Get the premium subscription product with all offers + val premiumSub = androidSubscriptions.find { it.id == IapConstants.PREMIUM_PRODUCT_ID } + + if (premiumSub != null) { + // Get monthly and yearly offers + // On Horizon: offers are distinguished by billing period (P1M vs P1Y) + // On Play: offers can use base plan IDs or billing periods + val monthlyOffer = premiumSub.subscriptionOfferDetailsAndroid?.find { offer -> + // Check base plan ID first (Play Store style) + offer.basePlanId == IapConstants.PREMIUM_MONTHLY_BASE_PLAN || + // Or check billing period (Horizon style) + offer.pricingPhases.pricingPhaseList.lastOrNull()?.billingPeriod == "P1M" + } + val yearlyOffer = premiumSub.subscriptionOfferDetailsAndroid?.find { offer -> + // Check base plan ID first (Play Store style) + offer.basePlanId == IapConstants.PREMIUM_YEARLY_BASE_PLAN || + // Or check billing period (Horizon style) + offer.pricingPhases.pricingPhaseList.lastOrNull()?.billingPeriod == "P1Y" + } + + // Determine which plan user is currently on + // Try to resolve from SharedPreferences first + val currentOfferInfo = resolvePremiumOfferInfo(prefs, subscription) + + // Detect current plan + // On Horizon: all offers have same base plan ID, use billing period to distinguish + // On Play: base plan ID differs (premium vs premium-year) + val isMonthlyPlan = if (currentOfferInfo != null) { + when { + // If billing period is available (Horizon), use that + currentOfferInfo.billingPeriod != null -> + currentOfferInfo.billingPeriod == "P1M" + // Otherwise use base plan ID (Play Store) + else -> + currentOfferInfo.basePlanId == IapConstants.PREMIUM_MONTHLY_BASE_PLAN + } + } else { + // Default to monthly + true + } + + val currentOffer = if (isMonthlyPlan) monthlyOffer else yearlyOffer + val targetOffer = if (isMonthlyPlan) yearlyOffer else monthlyOffer + + // Only show upgrade UI if both offers exist + if (monthlyOffer != null && yearlyOffer != null && currentOffer != null && targetOffer != null) { + Spacer(modifier = Modifier.height(12.dp)) + HorizontalDivider( + modifier = Modifier.fillMaxWidth(), + thickness = 1.dp, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f) + ) + Spacer(modifier = Modifier.height(12.dp)) + + // Current plan display + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = AppColors.success + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Current Plan: ${if (isMonthlyPlan) "Monthly" else "Yearly"}", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + val billingPeriod = currentOffer.pricingPhases.pricingPhaseList.lastOrNull()?.billingPeriod ?: "" + val price = currentOffer.pricingPhases.pricingPhaseList.lastOrNull()?.formattedPrice ?: "" + Text( + text = "Billing: $billingPeriod • $price", + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + } + Surface( + shape = RoundedCornerShape(6.dp), + color = AppColors.primary.copy(alpha = 0.12f) + ) { + Text( + text = if (isMonthlyPlan) "1 month" else "12 months", + modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = AppColors.primary + ) + } + } + + Spacer(modifier = Modifier.height(12.dp)) + + // Upgrade/Downgrade button + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = if (isMonthlyPlan) "Upgrade to Yearly Plan" else "Switch to Monthly Plan", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + val targetPrice = targetOffer.pricingPhases.pricingPhaseList.firstOrNull()?.formattedPrice ?: "" + Text( + text = "${targetOffer.basePlanId} - $targetPrice", + style = MaterialTheme.typography.bodySmall, + color = AppColors.textSecondary + ) + if (isMonthlyPlan) { + Text( + text = "Save with annual billing", + style = MaterialTheme.typography.bodySmall, + color = AppColors.success + ) + } + } + + Button( + onClick = { + scope.launch { + try { + iapStore.setActivity(activity) + + val purchaseToken = subscription.purchaseToken + if (purchaseToken == null) { + iapStore.postStatusMessage( + message = "Cannot change plan: missing purchase token", + status = PurchaseResultStatus.Error, + productId = IapConstants.PREMIUM_PRODUCT_ID + ) + return@launch + } + + println("SubscriptionFlow [Horizon/Play]: Changing from ${currentOffer.basePlanId} to ${targetOffer.basePlanId} with token: ${purchaseToken.take(10)}...") + + // Use CHARGE_FULL_PRICE for plan changes + val replacementMode = ReplacementMode.CHARGE_FULL_PRICE + + // Request subscription offer change (same product, different offer) + val offerInputs = listOf( + AndroidSubscriptionOfferInput( + sku = IapConstants.PREMIUM_PRODUCT_ID, + offerToken = targetOffer.offerToken + ) + ) + val props = RequestPurchaseProps( + request = RequestPurchaseProps.Request.Subscription( + RequestSubscriptionPropsByPlatforms( + android = RequestSubscriptionAndroidProps( + isOfferPersonalized = null, + obfuscatedAccountIdAndroid = null, + obfuscatedProfileIdAndroid = null, + purchaseTokenAndroid = purchaseToken, + replacementModeAndroid = replacementMode, + skus = listOf(IapConstants.PREMIUM_PRODUCT_ID), + subscriptionOffers = offerInputs + ) + ) + ), + type = ProductQueryType.Subs + ) + + val result = iapStore.requestPurchase(props) + val purchases = when (result) { + is dev.hyo.openiap.RequestPurchaseResultPurchases -> result.value.orEmpty() + is dev.hyo.openiap.RequestPurchaseResultPurchase -> result.value?.let { listOf(it) }.orEmpty() + else -> emptyList() + } + + if (purchases.isNotEmpty()) { + // Save the new offer to SharedPreferences + val newOfferBasePlanId = targetOffer.basePlanId + prefs.savePremiumOffer(IapConstants.PREMIUM_PRODUCT_ID, newOfferBasePlanId) + println("SubscriptionFlow: Subscription change successful, saved offer: $newOfferBasePlanId") + + iapStore.postStatusMessage( + message = if (isMonthlyPlan) "Upgraded to yearly plan successfully" else "Switched to monthly plan", + status = PurchaseResultStatus.Success, + productId = IapConstants.PREMIUM_PRODUCT_ID + ) + // Refresh purchases + iapStore.getAvailablePurchases(null) + delay(2000) + iapStore.getAvailablePurchases(null) + // Refresh products + scope.launch { + val request = ProductRequest( + skus = subscriptionSkus, + type = ProductQueryType.Subs + ) + iapStore.fetchProducts(request) + } + } + } catch (e: Exception) { + println("SubscriptionFlow: Error changing subscription: ${e.message}") + e.printStackTrace() + + iapStore.postStatusMessage( + message = "Subscription change failed: ${e.message}", + status = PurchaseResultStatus.Error, + productId = IapConstants.PREMIUM_PRODUCT_ID + ) + } + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = if (isMonthlyPlan) AppColors.success else AppColors.secondary + ), + enabled = !status.isPurchasing(IapConstants.PREMIUM_PRODUCT_ID) + ) { + if (status.isPurchasing(IapConstants.PREMIUM_PRODUCT_ID)) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = 2.dp + ) + } else { + Text(if (isMonthlyPlan) "Upgrade" else "Switch") + } + } + } + } + } + } + + // Play Store: Show upgrade/downgrade option for dev.hyo.martie.premium subscription offers + if (!isHorizon && subscription.productId == PREMIUM_SUBSCRIPTION_PRODUCT_ID) { // Find the subscription product with offers val premiumSub = androidSubscriptions.find { it.id == PREMIUM_SUBSCRIPTION_PRODUCT_ID } @@ -617,14 +928,14 @@ fun SubscriptionFlowScreen( item { SectionHeaderView(title = "Available Subscriptions") } - + items(androidProducts) { product -> ProductCard( product = product, isPurchasing = status.isPurchasing(product.id), isSubscribed = androidPurchases.any { it.productId == product.id && it.purchaseState == PurchaseState.Purchased }, onPurchase = { - // Prevent re-purchase if already subscribed + // Check if already subscribed to this product val alreadySubscribed = androidPurchases.any { it.productId == product.id && it.purchaseState == PurchaseState.Purchased } if (alreadySubscribed) { iapStore.postStatusMessage( @@ -634,10 +945,54 @@ fun SubscriptionFlowScreen( ) return@ProductCard } + + // Check if subscribed to other premium product (for upgrade/downgrade) + // Note: In Horizon, purchasing Tier 1 (premium) automatically upgrades to Tier 2 (premium_year) + val otherPremiumSubscription = androidPurchases.find { purchase -> + purchase.purchaseState == PurchaseState.Purchased && + purchase.productId in listOf(IapConstants.PREMIUM_PRODUCT_ID, IapConstants.PREMIUM_YEARLY_PRODUCT_ID_PLAY) && + purchase.productId != product.id + } + scope.launch { iapStore.setActivity(activity) val props = if (product.type == ProductType.Subs) { + // Determine if this is an upgrade or downgrade + val purchaseToken = otherPremiumSubscription?.purchaseToken + val replacementMode = if (purchaseToken != null) { + // Use CHARGE_FULL_PRICE (5) for immediate change + println("SubscriptionFlow: Changing subscription from ${otherPremiumSubscription.productId} to ${product.id}") + 5 // CHARGE_FULL_PRICE + } else { + println("SubscriptionFlow: New subscription: ${product.id}") + null + } + + // Platform-specific offer selection + val subscriptionOffers = if (isHorizon && product.id == "dev.hyo.martie.premium" && product is ProductAndroid) { + // HORIZON ONLY: Premium product has multiple offers (MONTHLY and ANNUAL) + // We default to MONTHLY offer for initial purchase + val monthlyOffer = product.subscriptionOfferDetailsAndroid?.find { offer -> + offer.pricingPhases.pricingPhaseList.any { phase -> + phase.billingPeriod == "P1M" + } + } + if (monthlyOffer != null) { + println("SubscriptionFlow: Using MONTHLY offer token: ${monthlyOffer.offerToken}") + listOf(AndroidSubscriptionOfferInput( + offerToken = monthlyOffer.offerToken, + sku = product.id + )) + } else { + println("SubscriptionFlow: No MONTHLY offer found, using default") + null + } + } else { + // PLAY STORE: Uses base plan IDs, no special offer selection needed + null + } + RequestPurchaseProps( request = RequestPurchaseProps.Request.Subscription( RequestSubscriptionPropsByPlatforms( @@ -645,10 +1000,10 @@ fun SubscriptionFlowScreen( isOfferPersonalized = null, obfuscatedAccountIdAndroid = null, obfuscatedProfileIdAndroid = null, - purchaseTokenAndroid = null, - replacementModeAndroid = null, + purchaseTokenAndroid = purchaseToken, + replacementModeAndroid = replacementMode, skus = listOf(product.id), - subscriptionOffers = null + subscriptionOffers = subscriptionOffers ) ) ), diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt b/packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt index e61688c5..fe3239dd 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt @@ -11,7 +11,8 @@ private const val OFFER_PREF_KEY_PREFIX = "current_offer_" data class SubscriptionOfferInfo( val basePlanId: String, - val displayName: String + val displayName: String, + val billingPeriod: String? = null // e.g., "P1M", "P1Y" (Horizon uses this) ) fun premiumOfferPreferenceKey(productId: String): String = "$OFFER_PREF_KEY_PREFIX$productId" @@ -39,13 +40,20 @@ fun resolvePremiumOfferInfo( val resolvedOffer = receiptOffer ?: storedOffer val basePlanId = resolvedOffer?.takeIf { it.isNotBlank() } ?: IapConstants.PREMIUM_MONTHLY_BASE_PLAN + // Extract billing period from receipt (for Horizon) + val billingPeriod = extractBillingPeriodFromReceipt(purchase.dataAndroid) + val displayName = when (basePlanId) { IapConstants.PREMIUM_YEARLY_BASE_PLAN -> "Yearly Plan (premium-year)" IapConstants.PREMIUM_MONTHLY_BASE_PLAN -> "Monthly Plan (premium)" else -> "Base Plan: $basePlanId" } - return SubscriptionOfferInfo(basePlanId = basePlanId, displayName = displayName) + return SubscriptionOfferInfo( + basePlanId = basePlanId, + displayName = displayName, + billingPeriod = billingPeriod + ) } private fun extractBasePlanFromReceipt(rawJson: String?): String? { @@ -67,3 +75,22 @@ private fun extractBasePlanFromReceipt(rawJson: String?): String? { null } } + +private fun extractBillingPeriodFromReceipt(rawJson: String?): String? { + val json = rawJson ?: return null + return try { + val root = JSONObject(json) + // Try to extract billing period from receipt JSON + // This is useful for Horizon where all offers have same base plan ID + when { + root.has("billingPeriod") -> root.optString("billingPeriod") + root.has("subscriptionPeriod") -> root.optString("subscriptionPeriod") + root.has("lineItems") -> root.optJSONArray("lineItems") + ?.optJSONObject(0) + ?.optString("billingPeriod") + else -> null + }?.takeIf { it.isNotBlank() } + } catch (_: Exception) { + null + } +} diff --git a/packages/google/gradle.properties b/packages/google/gradle.properties index 4f444a7d..deefd550 100644 --- a/packages/google/gradle.properties +++ b/packages/google/gradle.properties @@ -1,5 +1,5 @@ # Project-wide Gradle settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m -Dfile.encoding=UTF-8 org.gradle.parallel=true org.gradle.caching=true diff --git a/packages/google/openiap/build.gradle.kts b/packages/google/openiap/build.gradle.kts index 0ad92393..6520de9b 100644 --- a/packages/google/openiap/build.gradle.kts +++ b/packages/google/openiap/build.gradle.kts @@ -33,6 +33,26 @@ android { } } + flavorDimensions += "platform" + productFlavors { + // Auto flavor (default) - includes both libraries, detects platform at runtime + create("auto") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"auto\"") + isDefault = true + } + // Play flavor - Google Play Billing only + create("play") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"play\"") + } + // Horizon flavor - Meta Horizon Billing only + create("horizon") { + dimension = "platform" + buildConfigField("String", "OPENIAP_STORE", "\"horizon\"") + } + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -45,16 +65,43 @@ android { // Enable Compose for composables in this library (IapContext) buildFeatures { compose = true + buildConfig = true + } + + // Configure source sets for flavors + // Auto flavor includes horizon implementation only + sourceSets { + getByName("auto") { + java.srcDir("src/horizon/java") + } } } dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0") - - // Google Play Billing Library (align with app/lib v8) - api("com.android.billingclient:billing-ktx:8.0.0") - + + // Billing libraries strategy: + // - All flavors need Play Billing API for compilation (main/ source uses it) + // - Auto & Horizon use Horizon Compatibility Library at runtime + // - Play uses Google Play Billing at runtime + + // Compile-time dependency for main/ source set + compileOnly("com.android.billingclient:billing-ktx:8.0.0") + + // Runtime dependencies per flavor: + // Play flavor: Google Play Billing only + add("playApi", "com.android.billingclient:billing-ktx:8.0.0") + + // Auto flavor: BOTH libraries for true cross-platform support + // - Google Play Billing for Android phones + // - Horizon Compatibility Library for Horizon OS (includes duplicate classes, but runtime selects correct one) + add("autoApi", "com.android.billingclient:billing-ktx:8.0.0") + add("autoApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") + + // Horizon flavor: Horizon Compatibility Library only + add("horizonApi", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1") + // Kotlin Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") @@ -71,7 +118,9 @@ dependencies { // Testing dependencies testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") - + // Add Google Play Billing for tests (all flavors need it for OpenIapErrorTest) + testImplementation("com.android.billingclient:billing-ktx:8.0.0") + androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") } @@ -81,6 +130,13 @@ mavenPublishing { val groupId = project.findProperty("OPENIAP_GROUP_ID")?.toString() ?: "io.github.hyochan.openiap" coordinates(groupId, "openiap-google", openIapVersion) + // Publish the Auto flavor (supports both Play and Horizon) + configure(com.vanniktech.maven.publish.AndroidSingleVariantLibrary( + variant = "autoRelease", + sourcesJar = true, + publishJavadocJar = true + )) + // Use the new Central Portal publishing which avoids Nexus staging profile lookups. publishToMavenCentral(com.vanniktech.maven.publish.SonatypeHost.CENTRAL_PORTAL) signAllPublications() diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt new file mode 100644 index 00000000..921189ce --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt @@ -0,0 +1,953 @@ +package dev.hyo.openiap.horizon + +import android.app.Activity +import android.content.Context +import android.util.Log +import com.meta.horizon.billingclient.api.AcknowledgePurchaseParams +import com.meta.horizon.billingclient.api.AlternativeBillingOnlyInformationDialogListener +import com.meta.horizon.billingclient.api.AlternativeBillingOnlyReportingDetails +import com.meta.horizon.billingclient.api.BillingClient +import com.meta.horizon.billingclient.api.BillingClientStateListener +import com.meta.horizon.billingclient.api.BillingFlowParams +import com.meta.horizon.billingclient.api.BillingResult +import com.meta.horizon.billingclient.api.ConsumeParams +import com.meta.horizon.billingclient.api.GetBillingConfigParams +import com.meta.horizon.billingclient.api.PendingPurchasesParams +import com.meta.horizon.billingclient.api.ProductDetails as HorizonProductDetails +import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase +import com.meta.horizon.billingclient.api.PurchasesUpdatedListener +import com.meta.horizon.billingclient.api.QueryProductDetailsParams +import com.meta.horizon.billingclient.api.QueryPurchasesParams +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.FetchProductsResult +import dev.hyo.openiap.FetchProductsResultProducts +import dev.hyo.openiap.FetchProductsResultSubscriptions +import dev.hyo.openiap.IapPlatform +import dev.hyo.openiap.MutationAcknowledgePurchaseAndroidHandler +import dev.hyo.openiap.MutationConsumePurchaseAndroidHandler +import dev.hyo.openiap.MutationDeepLinkToSubscriptionsHandler +import dev.hyo.openiap.MutationEndConnectionHandler +import dev.hyo.openiap.MutationFinishTransactionHandler +import dev.hyo.openiap.MutationHandlers +import dev.hyo.openiap.MutationInitConnectionHandler +import dev.hyo.openiap.MutationRequestPurchaseHandler +import dev.hyo.openiap.MutationRestorePurchasesHandler +import dev.hyo.openiap.MutationValidateReceiptHandler +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapLog +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.Product +import dev.hyo.openiap.ProductAndroid +import dev.hyo.openiap.ProductQueryType +import dev.hyo.openiap.ProductSubscriptionAndroid +import dev.hyo.openiap.ProductType +import dev.hyo.openiap.Purchase +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.PurchaseInput +import dev.hyo.openiap.QueryFetchProductsHandler +import dev.hyo.openiap.QueryGetActiveSubscriptionsHandler +import dev.hyo.openiap.QueryGetAvailablePurchasesHandler +import dev.hyo.openiap.QueryHandlers +import dev.hyo.openiap.QueryHasActiveSubscriptionsHandler +import dev.hyo.openiap.ReceiptValidationProps +import dev.hyo.openiap.RequestPurchaseResultPurchase +import dev.hyo.openiap.RequestPurchaseResultPurchases +import dev.hyo.openiap.RequestPurchaseProps +import dev.hyo.openiap.SubscriptionHandlers +import dev.hyo.openiap.SubscriptionPurchaseErrorHandler +import dev.hyo.openiap.SubscriptionPurchaseUpdatedHandler +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener +import dev.hyo.openiap.helpers.onPurchaseError +import dev.hyo.openiap.helpers.onPurchaseUpdated +import dev.hyo.openiap.helpers.toAndroidPurchaseArgs +import dev.hyo.openiap.horizon.helpers.restorePurchasesHorizon +import dev.hyo.openiap.horizon.helpers.queryPurchasesHorizon +import dev.hyo.openiap.horizon.helpers.HorizonProductManager +import dev.hyo.openiap.horizon.helpers.queryProductDetailsHorizon +import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toActiveSubscription +import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toInAppProduct +import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toPurchase +import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toSubscriptionProduct +import dev.hyo.openiap.utils.toProduct +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import java.lang.ref.WeakReference +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private const val TAG = "OpenIapHorizonModule" + +class OpenIapHorizonModule( + private val context: Context, + private val appId: String? = null +) : OpenIapProtocol, PurchasesUpdatedListener { + + companion object { + // CRITICAL FIX: Shared purchase cache across all OpenIapHorizonModule instances + // This ensures purchases are available even when connection is closed and reopened + // Using ConcurrentHashMap for thread-safety across coroutines + private val sharedPurchaseCache = java.util.concurrent.ConcurrentHashMap() + + // Delay before proactively querying purchases after billing flow + private const val PURCHASE_QUERY_DELAY_MS = 500L + } + + private var billingClient: BillingClient? = null + private var currentActivityRef: WeakReference? = null + private var currentPurchaseCallback: ((Result>) -> Unit)? = null + private val productManager = HorizonProductManager() + private val fallbackActivity: Activity? = if (context is Activity) context else null + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val purchaseUpdateListeners = mutableSetOf() + private val purchaseErrorListeners = mutableSetOf() + + init { + android.util.Log.i(TAG, "=== OpenIapHorizonModule INIT (Modified version with fix) ===") + buildBillingClient() + } + + override fun setActivity(activity: Activity?) { + currentActivityRef = activity?.let { WeakReference(it) } + } + + override val initConnection: MutationInitConnectionHandler = { + withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + android.util.Log.i(TAG, "=== INIT CONNECTION CALLED ===") + + // CRITICAL FIX: Rebuild BillingClient if it was destroyed by endConnection + if (billingClient == null) { + android.util.Log.i(TAG, "BillingClient is null, rebuilding...") + buildBillingClient() + } else { + android.util.Log.i(TAG, "BillingClient already exists, using existing instance") + } + + val client = billingClient ?: run { + android.util.Log.w(TAG, "Failed to build BillingClient") + if (continuation.isActive) continuation.resume(false) + return@suspendCancellableCoroutine + } + + android.util.Log.i(TAG, "Starting BillingClient connection...") + client.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(result: BillingResult) { + android.util.Log.i(TAG, "onBillingSetupFinished: code=${result.responseCode}, message=${result.debugMessage}") + val ok = result.responseCode == BillingClient.BillingResponseCode.OK + if (!ok) { + android.util.Log.w(TAG, "Horizon setup failed: code=${result.responseCode}, ${result.debugMessage}") + } else { + android.util.Log.i(TAG, "Horizon billing connected successfully!") + } + if (continuation.isActive) continuation.resume(ok) + } + + override fun onBillingServiceDisconnected() { + android.util.Log.i(TAG, "Horizon service disconnected") + } + }) + } + } + } + + override val endConnection: MutationEndConnectionHandler = { + withContext(Dispatchers.IO) { + runCatching { + billingClient?.endConnection() + billingClient = null + true + }.getOrElse { false } + } + } + + override val fetchProducts: QueryFetchProductsHandler = { params -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + if (params.skus.isEmpty()) throw OpenIapError.EmptySkuList + + OpenIapLog.i("=== HORIZON fetchProducts ===", TAG) + OpenIapLog.i("Requested SKUs: ${params.skus}", TAG) + OpenIapLog.i("Query type: ${params.type}", TAG) + + val queryType = params.type ?: ProductQueryType.All + val includeInApp = queryType == ProductQueryType.InApp || queryType == ProductQueryType.All + val includeSubs = queryType == ProductQueryType.Subs || queryType == ProductQueryType.All + + val inAppProducts = if (includeInApp) { + try { + val details = queryProductDetailsHorizon(client, productManager, params.skus, BillingClient.ProductType.INAPP) + OpenIapLog.i("Successfully fetched ${details.size} INAPP products", TAG) + details.map { it.toInAppProduct() } + } catch (e: Exception) { + OpenIapLog.w("Failed to fetch INAPP products: ${e.message}", TAG) + emptyList() + } + } else emptyList() + + val subscriptionProducts = if (includeSubs) { + try { + val details = queryProductDetailsHorizon(client, productManager, params.skus, BillingClient.ProductType.SUBS) + OpenIapLog.i("Successfully fetched ${details.size} SUBS products", TAG) + details.forEach { product -> + OpenIapLog.d(" - SUBS: ${product.productId}", TAG) + } + details.map { it.toSubscriptionProduct() } + } catch (e: Exception) { + OpenIapLog.w("Failed to fetch SUBS products: ${e.message}", TAG) + emptyList() + } + } else emptyList() + + OpenIapLog.i("Total products: INAPP=${inAppProducts.size}, SUBS=${subscriptionProducts.size}", TAG) + OpenIapLog.i("=== END fetchProducts ===", TAG) + + when (queryType) { + ProductQueryType.InApp -> FetchProductsResultProducts(inAppProducts) + ProductQueryType.Subs -> FetchProductsResultSubscriptions(subscriptionProducts) + ProductQueryType.All -> { + val combined = buildList { + addAll(inAppProducts) + addAll(subscriptionProducts.map(ProductSubscriptionAndroid::toProduct)) + } + FetchProductsResultProducts(combined) + } + } + } + } + + override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ -> + android.util.Log.i("HORIZON_QUERY", "getAvailablePurchases BEFORE withContext") + withContext(Dispatchers.IO) { + android.util.Log.i("HORIZON_QUERY", "=== getAvailablePurchases INSIDE withContext ===") + OpenIapLog.i("=== HORIZON getAvailablePurchases ===", TAG) + + val purchases = restorePurchasesHorizon(billingClient) + android.util.Log.i("HORIZON_QUERY", "Retrieved ${purchases.size} purchases from query") + OpenIapLog.i("Retrieved ${purchases.size} total purchases (INAPP + SUBS)", TAG) + + // CRITICAL FIX: Merge with cached purchases + val cachedPurchases = sharedPurchaseCache.values.toList() + android.util.Log.i("HORIZON_QUERY", "Cached purchases: ${cachedPurchases.size}") + + // Combine query results with cache, preferring query results + val purchaseMap = mutableMapOf() + cachedPurchases.forEach { purchaseMap[it.productId] = it } + purchases.forEach { purchaseMap[it.productId] = it } // Override with fresh data + + val allPurchases = purchaseMap.values.toList() + android.util.Log.i("HORIZON_QUERY", "Total purchases (query + cache): ${allPurchases.size}") + + allPurchases.forEachIndexed { index, purchase -> + val txnId = when (purchase) { + is dev.hyo.openiap.PurchaseAndroid -> purchase.transactionId + else -> "N/A" + } + android.util.Log.i("HORIZON_QUERY", "Purchase[$index] productId=${purchase.productId} txnId=$txnId") + OpenIapLog.i( + " [$index] productId=${purchase.productId} " + + "transactionId=$txnId " + + "platform=${purchase.platform}", + TAG + ) + } + android.util.Log.i("HORIZON_QUERY", "=== getAvailablePurchases END ===") + OpenIapLog.i("=== END getAvailablePurchases ===", TAG) + allPurchases + } + } + + override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds -> + withContext(Dispatchers.IO) { + android.util.Log.i("HORIZON_QUERY", "=== getActiveSubscriptions START ===") + android.util.Log.i("HORIZON_QUERY", "Requested IDs: $subscriptionIds") + OpenIapLog.i("=== HORIZON getActiveSubscriptions ===", TAG) + OpenIapLog.i("Requested subscriptionIds: $subscriptionIds", TAG) + + val allPurchases = queryPurchasesHorizon(billingClient, BillingClient.ProductType.SUBS) + android.util.Log.i("HORIZON_QUERY", "Raw query returned ${allPurchases.size} SUBS purchases") + OpenIapLog.i("Total SUBS purchases from query: ${allPurchases.size}", TAG) + + allPurchases.forEachIndexed { index, purchase -> + android.util.Log.i("HORIZON_QUERY", "RawPurchase[$index] productId=${purchase.productId} type=${purchase.javaClass.simpleName}") + } + + val androidPurchases = allPurchases.filterIsInstance() + android.util.Log.i("HORIZON_QUERY", "Filtered to ${androidPurchases.size} PurchaseAndroid instances") + OpenIapLog.i("PurchaseAndroid instances: ${androidPurchases.size}", TAG) + + val ids = subscriptionIds.orEmpty() + val filtered = if (ids.isEmpty()) { + android.util.Log.i("HORIZON_QUERY", "No filter - returning all") + OpenIapLog.i("No filter - returning all subscriptions", TAG) + androidPurchases + } else { + android.util.Log.i("HORIZON_QUERY", "Filtering by IDs: $ids") + OpenIapLog.i("Filtering by IDs: $ids", TAG) + androidPurchases.filter { it.productId in ids } + } + + android.util.Log.i("HORIZON_QUERY", "After filtering: ${filtered.size} subscriptions") + OpenIapLog.i("Filtered subscriptions count: ${filtered.size}", TAG) + val activeSubscriptions = filtered.map { it.toActiveSubscription() } + + activeSubscriptions.forEachIndexed { index, sub -> + android.util.Log.i("HORIZON_QUERY", "ActiveSub[$index] productId=${sub.productId} active=${sub.isActive}") + OpenIapLog.i( + " [$index] productId=${sub.productId} " + + "isActive=${sub.isActive} " + + "autoRenewing=${sub.autoRenewingAndroid}", + TAG + ) + } + + android.util.Log.i("HORIZON_QUERY", "=== getActiveSubscriptions END - returning ${activeSubscriptions.size} ===") + OpenIapLog.i("=== END getActiveSubscriptions ===", TAG) + activeSubscriptions + } + } + + override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> + getActiveSubscriptions(subscriptionIds).isNotEmpty() + } + + override val requestPurchase: MutationRequestPurchaseHandler = { props -> + val purchases = withContext(Dispatchers.IO) { + val androidArgs = props.toAndroidPurchaseArgs() + val activity = currentActivityRef?.get() ?: fallbackActivity + + if (activity == null) { + val err = OpenIapError.MissingCurrentActivity + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + val client = billingClient + if (client == null) { + val err = OpenIapError.NotPrepared + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + if (androidArgs.skus.isEmpty()) { + val err = OpenIapError.EmptySkuList + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + return@withContext emptyList() + } + + suspendCancellableCoroutine> { continuation -> + currentPurchaseCallback = { result -> + if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) + } + + val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP + + val detailsBySku = mutableMapOf() + androidArgs.skus.forEach { sku -> + productManager.get(sku, desiredType)?.takeIf { it.productType == desiredType }?.let { detailsBySku[sku] = it } + } + + val missing = androidArgs.skus.filter { !detailsBySku.containsKey(it) } + + fun buildAndLaunch(details: List) { + val paramsList = mutableListOf() + val requestedOffersBySku = mutableMapOf>() + + if (androidArgs.type == ProductQueryType.Subs) { + androidArgs.subscriptionOffers.orEmpty().forEach { offer -> + if (offer.offerToken.isNotEmpty()) { + OpenIapLog.d("Adding offer token for SKU ${offer.sku}: ${offer.offerToken}", TAG) + val queue = requestedOffersBySku.getOrPut(offer.sku) { mutableListOf() } + queue.add(offer.offerToken) + } + } + } + + details.forEachIndexed { index, productDetails -> + val builder = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productDetails) + + if (androidArgs.type == ProductQueryType.Subs) { + val availableOffers = productDetails.subscriptionOfferDetails?.map { + "${it.basePlanId}:${it.offerToken}" + } ?: emptyList() + OpenIapLog.d("Available offers for ${productDetails.productId}: $availableOffers", TAG) + + val availableTokens = productDetails.subscriptionOfferDetails?.map { it.offerToken } ?: emptyList() + val fromQueue = requestedOffersBySku[productDetails.productId]?.let { queue -> + if (queue.isNotEmpty()) queue.removeAt(0) else null + } + val fromIndex = androidArgs.subscriptionOffers?.getOrNull(index)?.takeIf { it.sku == productDetails.productId }?.offerToken + val resolved = fromQueue ?: fromIndex ?: productDetails.subscriptionOfferDetails?.firstOrNull()?.offerToken + + OpenIapLog.d("Resolved offer token for ${productDetails.productId}: $resolved", TAG) + android.util.Log.i(TAG, "BILLING_FLOW_PARAM: SKU=${productDetails.productId}, resolvedOfferToken=$resolved") + + if (resolved.isNullOrEmpty() || (availableTokens.isNotEmpty() && !availableTokens.contains(resolved))) { + OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG) + val err = OpenIapError.SkuOfferMismatch + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + currentPurchaseCallback?.invoke(Result.success(emptyList())) + return + } + + builder.setOfferToken(resolved) + } + + paramsList += builder.build() + } + + val flowBuilder = BillingFlowParams.newBuilder() + .setProductDetailsParamsList(paramsList) + .setIsOfferPersonalized(androidArgs.isOfferPersonalized == true) + + androidArgs.obfuscatedAccountId?.let { flowBuilder.setObfuscatedAccountId(it) } + + // 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 + OpenIapLog.d("=== Subscription Upgrade Flow ===", TAG) + OpenIapLog.d(" - Old Token: ${androidArgs.purchaseTokenAndroid.take(10)}...", TAG) + OpenIapLog.d(" - Target SKUs: ${androidArgs.skus}", TAG) + OpenIapLog.d(" - Replacement mode: ${androidArgs.replacementModeAndroid}", TAG) + OpenIapLog.d(" - Product Details Count: ${paramsList.size}", TAG) + paramsList.forEachIndexed { idx, params -> + OpenIapLog.d(" - Product[$idx]: SKU=${details[idx].productId}, offerToken=...", TAG) + } + + val updateParamsBuilder = BillingFlowParams.SubscriptionUpdateParams.newBuilder() + .setOldPurchaseToken(androidArgs.purchaseTokenAndroid) + + // Set replacement mode - this is critical for upgrades + val replacementMode = androidArgs.replacementModeAndroid ?: 5 // Default to CHARGE_FULL_PRICE + updateParamsBuilder.setSubscriptionReplacementMode(replacementMode) + OpenIapLog.d(" - Final replacement mode: $replacementMode", TAG) + + val updateParams = updateParamsBuilder.build() + flowBuilder.setSubscriptionUpdateParams(updateParams) + OpenIapLog.d("=== Subscription Update Params Set ===", TAG) + } else { + // Only set obfuscatedProfileId for new purchases, not upgrades + androidArgs.obfuscatedProfileId?.let { + OpenIapLog.d("Setting obfuscatedProfileId for new purchase", TAG) + flowBuilder.setObfuscatedProfileId(it) + } + } + + val billingFlowParams = flowBuilder.build() + android.util.Log.i(TAG, "=== LAUNCHING BILLING FLOW ===") + android.util.Log.i(TAG, " - Is subscription? ${androidArgs.type == ProductQueryType.Subs}") + android.util.Log.i(TAG, " - Has purchaseToken? ${!androidArgs.purchaseTokenAndroid.isNullOrBlank()}") + + // Run on UI thread as required by Android Billing API + activity.runOnUiThread { + val result = client.launchBillingFlow(activity, billingFlowParams) + android.util.Log.i(TAG, "=== BILLING FLOW LAUNCHED ===") + android.util.Log.i(TAG, " - Response code: ${result.responseCode}") + android.util.Log.i(TAG, " - Debug message: ${result.debugMessage}") + OpenIapLog.d("launchBillingFlow result: ${result.responseCode} - ${result.debugMessage}", TAG) + + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + val err = when (result.responseCode) { + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> { + OpenIapLog.w("DEVELOPER_ERROR: Invalid arguments. Check if subscriptions are in the same group.", TAG) + OpenIapError.PurchaseFailed + } + BillingClient.BillingResponseCode.USER_CANCELED -> OpenIapError.UserCancelled + else -> OpenIapError.PurchaseFailed + } + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + currentPurchaseCallback?.invoke(Result.success(emptyList())) + } else { + // CRITICAL FIX: Proactively query purchases in case onPurchasesUpdated doesn't fire + // Horizon SDK may not always trigger the callback, so we query after a delay + OpenIapLog.i("launchBillingFlow started successfully, will query purchases proactively", TAG) + scope.launch { + delay(PURCHASE_QUERY_DELAY_MS) // Wait for purchase to complete + try { + val queried = restorePurchasesHorizon(billingClient) + val filtered = if (androidArgs.skus.isEmpty()) { + queried + } else { + queried.filter { it.productId in androidArgs.skus } + } + + if (filtered.isNotEmpty()) { + android.util.Log.i(TAG, "Proactive query found ${filtered.size} purchases") + filtered.forEach { purchase -> + purchaseUpdateListeners.forEach { listener -> + runCatching { listener.onPurchaseUpdated(purchase) } + } + } + currentPurchaseCallback?.invoke(Result.success(filtered)) + } + } catch (e: Exception) { + android.util.Log.e(TAG, "Error in proactive purchase query: ${e.message}") + } + } + } + } + } + + if (missing.isEmpty()) { + val ordered = androidArgs.skus.mapNotNull { detailsBySku[it] } + if (ordered.size != androidArgs.skus.size) { + val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) } + val err = OpenIapError.SkuNotFound(missingSku ?: "") + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + currentPurchaseCallback?.invoke(Result.success(emptyList())) + return@suspendCancellableCoroutine + } + buildAndLaunch(ordered) + } else { + // Need to query missing products + val productList = missing.map { sku -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(sku) + .setProductType(desiredType) + .build() + } + val params = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + client.queryProductDetailsAsync(params) { billingResult, productDetailsList -> + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { + val err = OpenIapError.QueryProduct + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + currentPurchaseCallback?.invoke(Result.success(emptyList())) + return@queryProductDetailsAsync + } + + val list = productDetailsList ?: emptyList() + productManager.putAll(list, desiredType) + + // Now build the full ordered list + val ordered = androidArgs.skus.mapNotNull { sku -> + productManager.get(sku, desiredType)?.takeIf { it.productType == desiredType } + } + + if (ordered.size != androidArgs.skus.size) { + val missingSku = androidArgs.skus.firstOrNull { sku -> + productManager.get(sku, desiredType)?.takeIf { it.productType == desiredType } == null + } + val err = OpenIapError.SkuNotFound(missingSku ?: "") + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } + currentPurchaseCallback?.invoke(Result.success(emptyList())) + return@queryProductDetailsAsync + } + + buildAndLaunch(ordered) + } + } + } + } + + RequestPurchaseResultPurchases(purchases) + } + + override val finishTransaction: MutationFinishTransactionHandler = { purchase, isConsumable -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val token = purchase.purchaseToken ?: return@withContext + if (isConsumable == true) { + val params = ConsumeParams.newBuilder().setPurchaseToken(token).build() + suspendCancellableCoroutine { continuation -> + client.consumeAsync(params) { result, _ -> + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + OpenIapLog.w("Failed to consume Horizon purchase: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(Unit) + } + } + } else { + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(token).build() + suspendCancellableCoroutine { continuation -> + client.acknowledgePurchase(params) { result -> + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + OpenIapLog.w("Failed to acknowledge Horizon purchase: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(Unit) + } + } + } + } + } + + override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build() + suspendCancellableCoroutine { continuation -> + client.acknowledgePurchase(params) { result -> + val success = result.responseCode == BillingClient.BillingResponseCode.OK + if (!success) { + OpenIapLog.w("Horizon acknowledge failed: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(success) + } + } + } + } + + override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken -> + withContext(Dispatchers.IO) { + val client = billingClient ?: throw OpenIapError.NotPrepared + val params = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build() + suspendCancellableCoroutine { continuation -> + client.consumeAsync(params) { result, _ -> + val success = result.responseCode == BillingClient.BillingResponseCode.OK + if (!success) { + OpenIapLog.w("Horizon consume failed: ${result.debugMessage}", TAG) + } + if (continuation.isActive) continuation.resume(success) + } + } + } + } + + override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { _ -> } + + override val restorePurchases: MutationRestorePurchasesHandler = { + withContext(Dispatchers.IO) { + OpenIapLog.i("=== HORIZON restorePurchases ===", TAG) + OpenIapLog.i("Number of purchase update listeners: ${purchaseUpdateListeners.size}", TAG) + + val all = restorePurchasesHorizon(billingClient) + OpenIapLog.i("Total restored purchases: ${all.size}", TAG) + + all.forEachIndexed { index, purchase -> + OpenIapLog.i(" Restoring [$index] productId=${purchase.productId}", TAG) + purchaseUpdateListeners.forEach { listener -> + runCatching { + listener.onPurchaseUpdated(purchase) + OpenIapLog.d(" - Listener notified", TAG) + }.onFailure { e -> + OpenIapLog.e(" - Listener failed", e, TAG) + } + } + } + + OpenIapLog.i("=== END restorePurchases ===", TAG) + Unit + } + } + + override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported } + + private val purchaseError: SubscriptionPurchaseErrorHandler = { + onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) + } + + private val purchaseUpdated: SubscriptionPurchaseUpdatedHandler = { + onPurchaseUpdated(this::addPurchaseUpdateListener, this::removePurchaseUpdateListener) + } + + override val queryHandlers: QueryHandlers = QueryHandlers( + fetchProducts = fetchProducts, + getActiveSubscriptions = getActiveSubscriptions, + getAvailablePurchases = getAvailablePurchases, + getStorefrontIOS = { getStorefront() }, + hasActiveSubscriptions = hasActiveSubscriptions + ) + + override val mutationHandlers: MutationHandlers = MutationHandlers( + acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, + consumePurchaseAndroid = consumePurchaseAndroid, + deepLinkToSubscriptions = deepLinkToSubscriptions, + endConnection = endConnection, + finishTransaction = finishTransaction, + initConnection = initConnection, + requestPurchase = requestPurchase, + restorePurchases = restorePurchases, + validateReceipt = validateReceipt + ) + + override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( + purchaseError = purchaseError, + purchaseUpdated = purchaseUpdated + ) + + private suspend fun getStorefront(): String = withContext(Dispatchers.IO) { + val client = billingClient ?: return@withContext "" + suspendCancellableCoroutine { continuation -> + runCatching { + client.getBillingConfigAsync( + GetBillingConfigParams.newBuilder().build() + ) { result, config -> + if (continuation.isActive) { + val code = if (result.responseCode == BillingClient.BillingResponseCode.OK) { + config?.countryCode.orEmpty() + } else "" + continuation.resume(code) + } + } + }.onFailure { error -> + OpenIapLog.w("Horizon getStorefront failed: ${error.message}", TAG) + if (continuation.isActive) continuation.resume("") + } + } + } + + override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + purchaseUpdateListeners.add(listener) + } + + override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + purchaseUpdateListeners.remove(listener) + } + + override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + purchaseErrorListeners.add(listener) + } + + override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + purchaseErrorListeners.remove(listener) + } + + override fun onPurchasesUpdated(result: BillingResult, purchases: List?) { + // Log with Android Log to ensure it appears even if OpenIapLog fails + android.util.Log.wtf("HORIZON_CALLBACK", "onPurchasesUpdated START - responseCode=${result.responseCode}, count=${purchases?.size ?: 0}") + + try { + OpenIapLog.i("=== HORIZON onPurchasesUpdated ===", TAG) + OpenIapLog.i("Response code: ${result.responseCode}", TAG) + OpenIapLog.i("Debug message: ${result.debugMessage}", TAG) + OpenIapLog.i("Purchases count: ${purchases?.size ?: 0}", TAG) + + purchases?.forEachIndexed { index, purchase -> + val redactedToken = purchase.purchaseToken?.take(8)?.plus("…") + val redactedOrder = purchase.orderId?.take(8)?.plus("…") + android.util.Log.i("HORIZON_CALLBACK", "Purchase[$index] products=${purchase.products} token=$redactedToken") + OpenIapLog.i( + "[HorizonPurchase $index] productIds=${purchase.products} token=$redactedToken orderId=$redactedOrder " + + "acknowledged=${purchase.isAcknowledged()} autoRenew=${purchase.isAutoRenewing()}", + TAG + ) + } + + if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + android.util.Log.i("HORIZON_CALLBACK", "Processing ${purchases.size} purchases") + OpenIapLog.i("Processing ${purchases.size} successful purchases", TAG) + + val mapped = purchases.map { purchase -> + // CRITICAL FIX: Determine product type from ProductManager cache, not from product ID string + val firstProductId = purchase.products?.firstOrNull() + // Try both types since we don't know which one was used + val cachedProduct = firstProductId?.let { + productManager.get(it, BillingClient.ProductType.SUBS) + ?: productManager.get(it, BillingClient.ProductType.INAPP) + } + val type = cachedProduct?.productType ?: run { + // Fallback: if not in cache, check if product ID contains "subs" + if (purchase.products?.any { it.contains("subs", ignoreCase = true) } == true) { + BillingClient.ProductType.SUBS + } else { + BillingClient.ProductType.INAPP + } + } + android.util.Log.i("HORIZON_CALLBACK", "Mapping purchase products=${purchase.products} to type=$type (cached=${cachedProduct != null})") + OpenIapLog.d("Mapped purchase productIds=${purchase.products} to type=$type (from cache: ${cachedProduct != null})", TAG) + + val converted = purchase.toPurchase() + android.util.Log.i("HORIZON_CALLBACK", "Converted purchase: productId=${converted.productId}, acknowledged=${purchase.isAcknowledged()}") + converted + } + + android.util.Log.i("HORIZON_CALLBACK", "Mapped ${mapped.size} purchases, notifying ${purchaseUpdateListeners.size} listeners") + OpenIapLog.i("Notifying ${purchaseUpdateListeners.size} purchase update listeners", TAG) + + mapped.forEach { converted -> + // CRITICAL FIX: Cache the purchase locally + sharedPurchaseCache[converted.productId] = converted + android.util.Log.i("HORIZON_CALLBACK", "Cached purchase: productId=${converted.productId}, cache size=${sharedPurchaseCache.size}") + + android.util.Log.i("HORIZON_CALLBACK", "Notifying about purchase: productId=${converted.productId}") + OpenIapLog.d("Notifying listeners about purchase: productId=${converted.productId}", TAG) + purchaseUpdateListeners.forEach { listener -> + runCatching { + android.util.Log.i("HORIZON_CALLBACK", "Calling listener.onPurchaseUpdated") + listener.onPurchaseUpdated(converted) + android.util.Log.i("HORIZON_CALLBACK", "Listener notified successfully") + OpenIapLog.d("Listener notified successfully", TAG) + }.onFailure { e -> + android.util.Log.e("HORIZON_CALLBACK", "Listener notification failed", e) + OpenIapLog.e("Listener notification failed", e, TAG) + } + } + } + + android.util.Log.i("HORIZON_CALLBACK", "Invoking currentPurchaseCallback with ${mapped.size} purchases") + currentPurchaseCallback?.invoke(Result.success(mapped)) + OpenIapLog.i("Purchase callback invoked with ${mapped.size} purchases", TAG) + } else { + android.util.Log.w("HORIZON_CALLBACK", "Purchase failed: code=${result.responseCode}") + OpenIapLog.w("Purchase failed or cancelled: code=${result.responseCode}", TAG) + val error = OpenIapError.fromBillingResponseCode(result.responseCode, result.debugMessage) + purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } } + currentPurchaseCallback?.invoke(Result.success(emptyList())) + } + currentPurchaseCallback = null + android.util.Log.i("HORIZON_CALLBACK", "onPurchasesUpdated END") + OpenIapLog.i("=== END onPurchasesUpdated ===", TAG) + } catch (e: Exception) { + android.util.Log.e("HORIZON_CALLBACK", "Exception in onPurchasesUpdated", e) + } + } + + private suspend fun queryProductDetails( + client: BillingClient, + skus: List, + productType: String + ): List = suspendCancellableCoroutine { continuation -> + val products = skus.map { sku -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(sku) + .setProductType(productType) + .build() + } + val params = QueryProductDetailsParams.newBuilder().setProductList(products).build() + client.queryProductDetailsAsync(params) { result, details -> + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + if (continuation.isActive) continuation.resume(details ?: emptyList()) + } else { + OpenIapLog.w("Horizon queryProductDetails failed: ${result.debugMessage}", TAG) + if (continuation.isActive) continuation.resume(emptyList()) + } + } + } + + private fun buildBillingClient() { + val pendingPurchasesParams = com.meta.horizon.billingclient.api.PendingPurchasesParams.newBuilder() + .enableOneTimeProducts() + .build() + + val builder = BillingClient + .newBuilder(context) + .setListener(this) + .enablePendingPurchases(pendingPurchasesParams) + if (!appId.isNullOrEmpty()) { + builder.setAppId(appId) + } + billingClient = builder.build() + } + + // Alternative Billing - Testing if supported by Horizon Billing Compatibility Library + override suspend fun checkAlternativeBillingAvailability(): Boolean = withContext(Dispatchers.IO) { + try { + val client = billingClient ?: throw Exception("Not connected") + + // Try to call the alternative billing method + val result = suspendCancellableCoroutine { cont -> + try { + client.isAlternativeBillingOnlyAvailableAsync { billingResult -> + cont.resume(billingResult) + } + } catch (e: NoSuchMethodError) { + // Method doesn't exist in Horizon library + OpenIapLog.w("Alternative Billing not supported by Horizon library", TAG) + cont.resumeWithException(Exception("Feature not supported")) + } catch (e: Exception) { + Log.e(TAG, "Error checking alternative billing: ${e.message}") + cont.resumeWithException(e) + } + } + + OpenIapLog.d("Alternative Billing availability: ${result.responseCode}", TAG) + result.responseCode == BillingClient.BillingResponseCode.OK + } catch (e: OpenIapError) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error in checkAlternativeBillingAvailability: ${e.message}") + false + } + } + + override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = withContext(Dispatchers.IO) { + try { + val client = billingClient ?: throw Exception("Not connected") + + val activityRef = WeakReference(activity) + val currentActivity = activityRef.get() ?: throw Exception("Activity not available") + + val result = suspendCancellableCoroutine { cont -> + try { + val listener = AlternativeBillingOnlyInformationDialogListener { billingResult -> + cont.resume(billingResult) + } + currentActivity.runOnUiThread { + client.showAlternativeBillingOnlyInformationDialog( + currentActivity, + listener + ) + } + } catch (e: NoSuchMethodError) { + OpenIapLog.w("showAlternativeBillingOnlyInformationDialog not supported", TAG) + cont.resumeWithException(Exception("Feature not supported")) + } catch (e: Exception) { + Log.e(TAG, "Error showing alternative billing dialog: ${e.message}") + cont.resumeWithException(e) + } + } + + OpenIapLog.d("Alternative Billing dialog result: ${result.responseCode}", TAG) + result.responseCode == BillingClient.BillingResponseCode.OK + } catch (e: OpenIapError) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error in showAlternativeBillingInformationDialog: ${e.message}") + false + } + } + + override suspend fun createAlternativeBillingReportingToken(): String? = withContext(Dispatchers.IO) { + try { + val client = billingClient ?: throw Exception("Not connected") + + val result = suspendCancellableCoroutine> { cont -> + try { + client.createAlternativeBillingOnlyReportingDetailsAsync { billingResult, details -> + cont.resume(Pair(billingResult, details)) + } + } catch (e: NoSuchMethodError) { + OpenIapLog.w("createAlternativeBillingOnlyReportingDetails not supported", TAG) + cont.resumeWithException(Exception("Feature not supported")) + } catch (e: Exception) { + Log.e(TAG, "Error creating alternative billing token: ${e.message}") + cont.resumeWithException(e) + } + } + + OpenIapLog.d("Alternative Billing token result: ${result.first.responseCode}", TAG) + if (result.first.responseCode == BillingClient.BillingResponseCode.OK) { + result.second?.externalTransactionToken + } else { + null + } + } catch (e: OpenIapError) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Error in createAlternativeBillingReportingToken: ${e.message}") + null + } + } + + override fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { + // Not supported on Horizon + } + + override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + // Not supported on Horizon + } + + override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + // Not supported on Horizon + } +} diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt new file mode 100644 index 00000000..cbc7f84b --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt @@ -0,0 +1,106 @@ +package dev.hyo.openiap.horizon.helpers + +import com.meta.horizon.billingclient.api.BillingClient +import com.meta.horizon.billingclient.api.QueryPurchasesParams +import com.meta.horizon.billingclient.api.ProductDetails as HorizonProductDetails +import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase +import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapLog +import dev.hyo.openiap.Purchase +import dev.hyo.openiap.horizon.utils.HorizonBillingConverters.toPurchase +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlin.coroutines.resume + +private const val TAG = "HorizonHelpers" + +/** + * Query and restore all purchases (both INAPP and SUBS) for Horizon + */ +internal suspend fun restorePurchasesHorizon(client: BillingClient?): List { + android.util.Log.i("HORIZON_QUERY", "restorePurchasesHorizon: Starting") + OpenIapLog.d("restorePurchasesHorizon: Starting", TAG) + if (client == null) { + android.util.Log.w("HORIZON_QUERY", "restorePurchasesHorizon: BillingClient is null") + OpenIapLog.w("restorePurchasesHorizon: BillingClient is null", TAG) + return emptyList() + } + + val purchases = mutableListOf() + val inapp = queryPurchasesHorizon(client, BillingClient.ProductType.INAPP) + android.util.Log.i("HORIZON_QUERY", "restorePurchasesHorizon: INAPP purchases = ${inapp.size}") + OpenIapLog.d("restorePurchasesHorizon: INAPP purchases = ${inapp.size}", TAG) + purchases += inapp + + val subs = queryPurchasesHorizon(client, BillingClient.ProductType.SUBS) + android.util.Log.i("HORIZON_QUERY", "restorePurchasesHorizon: SUBS purchases = ${subs.size}") + OpenIapLog.d("restorePurchasesHorizon: SUBS purchases = ${subs.size}", TAG) + purchases += subs + + android.util.Log.i("HORIZON_QUERY", "restorePurchasesHorizon: Total = ${purchases.size}") + OpenIapLog.d("restorePurchasesHorizon: Total = ${purchases.size}", TAG) + return purchases +} + +/** + * Query purchases for a specific product type for Horizon + */ +internal suspend fun queryPurchasesHorizon( + client: BillingClient?, + productType: String +): List = suspendCancellableCoroutine { continuation -> + android.util.Log.i("HORIZON_QUERY", "queryPurchasesHorizon: type=$productType") + OpenIapLog.d("queryPurchasesHorizon: type=$productType", TAG) + + val billingClient = client ?: run { + android.util.Log.w("HORIZON_QUERY", "queryPurchasesHorizon: BillingClient is null") + OpenIapLog.w("queryPurchasesHorizon: BillingClient is null", TAG) + continuation.resume(emptyList()) + return@suspendCancellableCoroutine + } + + // CRITICAL FIX: Check if BillingClient is ready before querying + if (!billingClient.isReady()) { + android.util.Log.w("HORIZON_QUERY", "queryPurchasesHorizon: BillingClient is not ready, returning empty list") + OpenIapLog.w("queryPurchasesHorizon: BillingClient is not ready", TAG) + continuation.resume(emptyList()) + return@suspendCancellableCoroutine + } + + android.util.Log.i("HORIZON_QUERY", "queryPurchasesHorizon: BillingClient is ready, querying purchases") + val params = QueryPurchasesParams.newBuilder().setProductType(productType).build() + billingClient.queryPurchasesAsync(params) { result, purchaseList -> + android.util.Log.i("HORIZON_QUERY", "queryPurchasesHorizon: type=$productType responseCode=${result.responseCode} count=${purchaseList?.size ?: 0}") + OpenIapLog.d( + "queryPurchasesHorizon: type=$productType responseCode=${result.responseCode} " + + "count=${purchaseList?.size ?: 0}", + TAG + ) + + if (result.responseCode == BillingClient.BillingResponseCode.OK) { + val mapped = purchaseList?.map { + android.util.Log.d("HORIZON_QUERY", " - Purchase: productIds=${it.products}") + OpenIapLog.d(" - Purchase: productIds=${it.products}", TAG) + it.toPurchase() + } ?: emptyList() + android.util.Log.i("HORIZON_QUERY", "queryPurchasesHorizon: Returning ${mapped.size} mapped purchases") + continuation.resume(mapped) + } else { + android.util.Log.w("HORIZON_QUERY", "queryPurchasesHorizon: Failed with code=${result.responseCode}") + OpenIapLog.w("queryPurchasesHorizon: Failed with code=${result.responseCode}", TAG) + continuation.resume(emptyList()) + } + } +} + +/** + * Query product details using ProductManager cache for Horizon + */ +internal suspend fun queryProductDetailsHorizon( + client: BillingClient?, + productManager: HorizonProductManager, + skus: List, + productType: String +): List { + val billingClient = client ?: throw OpenIapError.NotPrepared + return productManager.getOrQuery(billingClient, skus, productType) +} diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt new file mode 100644 index 00000000..a9c9650c --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt @@ -0,0 +1,124 @@ +package dev.hyo.openiap.horizon.helpers + +import com.meta.horizon.billingclient.api.BillingClient +import com.meta.horizon.billingclient.api.QueryProductDetailsParams +import com.meta.horizon.billingclient.api.ProductDetails as HorizonProductDetails +import dev.hyo.openiap.OpenIapLog +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume + +private const val TAG = "HorizonProductManager" + +/** + * Manages ProductDetails caching and queries for Horizon. + */ +internal class HorizonProductManager { + private data class CacheKey(val productId: String, val productType: String) + private val cache = ConcurrentHashMap() + + fun get(productId: String, productType: String): HorizonProductDetails? = + cache[CacheKey(productId, productType)] + + fun putAll(details: Collection, productType: String) { + details.forEach { cache[CacheKey(it.productId, productType)] = it } + } + + fun clear() = cache.clear() + + /** + * Returns ProductDetails for the requested productIds. + * - Uses cache when available + * - Queries missing ones and updates the cache + * - Preserves input ordering in the returned list + */ + suspend fun getOrQuery( + client: BillingClient, + productIds: List, + productType: String, + ): List { + OpenIapLog.d("getOrQuery: productIds=$productIds, type=$productType", TAG) + + if (productIds.isEmpty()) { + OpenIapLog.d("getOrQuery: Empty productIds list", TAG) + return emptyList() + } + + val missing = productIds.filter { cache[CacheKey(it, productType)] == null }.distinct() + OpenIapLog.d("getOrQuery: missing=$missing, cached=${productIds.size - missing.size}", TAG) + + if (missing.isEmpty()) { + val cached = productIds.mapNotNull { cache[CacheKey(it, productType)] } + OpenIapLog.d("getOrQuery: Returning ${cached.size} cached products", TAG) + return cached + } + + val productList = missing.map { sku -> + QueryProductDetailsParams.Product.newBuilder() + .setProductId(sku) + .setProductType(productType) + .build() + } + val params = QueryProductDetailsParams.newBuilder() + .setProductList(productList) + .build() + + OpenIapLog.d("getOrQuery: Querying ${missing.size} products from Horizon API", TAG) + + return suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { OpenIapLog.d("getOrQuery: cancelled", TAG) } + client.queryProductDetailsAsync(params) { billingResult, result -> + OpenIapLog.d( + "getOrQuery: Response code=${billingResult.responseCode}, " + + "message=${billingResult.debugMessage}, " + + "resultCount=${result?.size ?: 0}", + TAG + ) + + if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { + OpenIapLog.w( + "getOrQuery: Query failed with code=${billingResult.responseCode}, " + + "message=${billingResult.debugMessage}. Returning cached items only.", + TAG + ) + // Return whatever we have in cache instead of crashing + val cached = productIds.mapNotNull { cache[CacheKey(it, productType)] } + if (cont.isActive) cont.resume(cached) + return@queryProductDetailsAsync + } + + val list = result ?: emptyList() + OpenIapLog.d("getOrQuery: Received ${list.size} products", TAG) + + list.forEach { product -> + OpenIapLog.d(" - Product: ${product.productId}, type=${product.productType}", TAG) + + // Log subscription offer details + product.subscriptionOfferDetails?.forEachIndexed { index, offer -> + OpenIapLog.d(" Offer[$index]: token=${offer.offerToken}", TAG) + offer.pricingPhases?.pricingPhaseList?.forEachIndexed { phaseIndex, phase -> + OpenIapLog.d( + " Phase[$phaseIndex]: period=${phase.billingPeriod}, " + + "price=${phase.formattedPrice}, " + + "cycles=${phase.billingCycleCount}", + TAG + ) + } + } + + // Log one-time purchase details if applicable + product.oneTimePurchaseOfferDetails?.let { offer -> + OpenIapLog.d(" OneTime: price=${offer.formattedPrice}", TAG) + } + } + + putAll(list, productType) + + // Preserve requested order and include cached + newly-fetched + val finalList = productIds.mapNotNull { cache[CacheKey(it, productType)] } + OpenIapLog.d("getOrQuery: Returning ${finalList.size} total products", TAG) + if (cont.isActive) cont.resume(finalList) + } + } + } +} diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt new file mode 100644 index 00000000..f423c43a --- /dev/null +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt @@ -0,0 +1,154 @@ +package dev.hyo.openiap.horizon.utils + +import com.meta.horizon.billingclient.api.ProductDetails as HorizonProductDetails +import com.meta.horizon.billingclient.api.Purchase as HorizonPurchase +import dev.hyo.openiap.ActiveSubscription +import dev.hyo.openiap.IapPlatform +import dev.hyo.openiap.PricingPhaseAndroid +import dev.hyo.openiap.PricingPhasesAndroid +import dev.hyo.openiap.ProductAndroid +import dev.hyo.openiap.ProductAndroidOneTimePurchaseOfferDetail +import dev.hyo.openiap.ProductSubscriptionAndroid +import dev.hyo.openiap.ProductSubscriptionAndroidOfferDetails +import dev.hyo.openiap.ProductType +import dev.hyo.openiap.PurchaseAndroid +import dev.hyo.openiap.PurchaseState + +internal object HorizonBillingConverters { + + fun HorizonProductDetails.toInAppProduct(): ProductAndroid { + val offer = oneTimePurchaseOfferDetails + val displayPrice = offer?.formattedPrice.orEmpty() + val currency = offer?.priceCurrencyCode.orEmpty() + val priceAmountMicros = offer?.priceAmountMicros ?: 0L + + return ProductAndroid( + currency = currency, + debugDescription = description, + description = description, + displayName = name, + displayPrice = displayPrice, + id = productId, + nameAndroid = name, + oneTimePurchaseOfferDetailsAndroid = offer?.let { + ProductAndroidOneTimePurchaseOfferDetail( + formattedPrice = it.formattedPrice, + priceAmountMicros = it.priceAmountMicros.toString(), + priceCurrencyCode = it.priceCurrencyCode + ) + }, + platform = IapPlatform.Android, + price = priceAmountMicros.toDouble() / 1_000_000.0, + subscriptionOfferDetailsAndroid = null, + title = title, + type = ProductType.InApp + ) + } + + fun HorizonProductDetails.toSubscriptionProduct(): ProductSubscriptionAndroid { + val offers = subscriptionOfferDetails.orEmpty() + val firstPhase = offers.firstOrNull()?.pricingPhases?.pricingPhaseList?.firstOrNull() + val displayPrice = firstPhase?.formattedPrice.orEmpty() + val currency = firstPhase?.priceCurrencyCode.orEmpty() + + val pricingDetails = offers.map { offer -> + ProductSubscriptionAndroidOfferDetails( + basePlanId = offer.basePlanId, + offerId = offer.offerId, + offerTags = offer.offerTags, + offerToken = offer.offerToken, + pricingPhases = PricingPhasesAndroid( + pricingPhaseList = offer.pricingPhases.pricingPhaseList.map { phase -> + PricingPhaseAndroid( + billingCycleCount = phase.billingCycleCount, + billingPeriod = phase.billingPeriod, + formattedPrice = phase.formattedPrice, + priceAmountMicros = phase.priceAmountMicros.toString(), + priceCurrencyCode = phase.priceCurrencyCode, + recurrenceMode = phase.recurrenceMode, + ) + } + ) + ) + } + + return ProductSubscriptionAndroid( + currency = currency, + debugDescription = description, + description = description, + displayName = name, + displayPrice = displayPrice, + id = productId, + nameAndroid = name, + oneTimePurchaseOfferDetailsAndroid = oneTimePurchaseOfferDetails?.let { + ProductAndroidOneTimePurchaseOfferDetail( + formattedPrice = it.formattedPrice, + priceAmountMicros = it.priceAmountMicros.toString(), + priceCurrencyCode = it.priceCurrencyCode + ) + }, + platform = IapPlatform.Android, + price = firstPhase?.priceAmountMicros?.toDouble()?.div(1_000_000.0), + subscriptionOfferDetailsAndroid = pricingDetails, + title = title, + type = ProductType.Subs + ) + } + + fun HorizonPurchase.toPurchase(): PurchaseAndroid { + val token = purchaseToken + val productsList = products ?: emptyList() + val state = PurchaseState.fromHorizonState(getPurchaseState()) + + return PurchaseAndroid( + autoRenewingAndroid = isAutoRenewing(), + dataAndroid = originalJson, + developerPayloadAndroid = developerPayload, + id = orderId ?: token, + ids = productsList, + isAcknowledgedAndroid = isAcknowledged(), + isAutoRenewing = isAutoRenewing(), + obfuscatedAccountIdAndroid = null, + obfuscatedProfileIdAndroid = null, + packageNameAndroid = packageName, + platform = IapPlatform.Android, + productId = productsList.firstOrNull().orEmpty(), + purchaseState = state, + purchaseToken = token, + quantity = quantity ?: 1, + signatureAndroid = signature, + transactionDate = (purchaseTime ?: 0L).toDouble(), + transactionId = orderId ?: token + ) + } + + fun HorizonPurchase.toActiveSubscription(): ActiveSubscription = ActiveSubscription( + autoRenewingAndroid = isAutoRenewing(), + isActive = true, + productId = products?.firstOrNull().orEmpty(), + purchaseToken = purchaseToken, + transactionDate = (purchaseTime ?: 0L).toDouble(), + transactionId = orderId ?: purchaseToken + ) + + fun PurchaseAndroid.toActiveSubscription(): ActiveSubscription = ActiveSubscription( + autoRenewingAndroid = autoRenewingAndroid, + isActive = true, + productId = productId, + purchaseToken = purchaseToken.orEmpty(), + transactionDate = transactionDate, + transactionId = transactionId.orEmpty() + ) +} + +/** + * Maps Horizon Purchase state to internal PurchaseState enum. + * Horizon SDK implements Google Play Billing compatibility layer, + * so states match the Play Billing Library states. + */ +fun PurchaseState.Companion.fromHorizonState(state: Int): PurchaseState = when (state) { + com.meta.horizon.billingclient.api.Purchase.PurchaseState.PURCHASED -> PurchaseState.Purchased + com.meta.horizon.billingclient.api.Purchase.PurchaseState.PENDING -> PurchaseState.Pending + com.meta.horizon.billingclient.api.Purchase.PurchaseState.UNSPECIFIED_STATE -> PurchaseState.Unknown + else -> PurchaseState.Unknown +} diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt index 39437cd6..789093d2 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt @@ -89,7 +89,7 @@ class OpenIapModule( private val context: Context, private var alternativeBillingMode: AlternativeBillingMode = AlternativeBillingMode.NONE, private var userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null -) : PurchasesUpdatedListener { +) : OpenIapProtocol, PurchasesUpdatedListener { companion object { private const val TAG = "OpenIapModule" @@ -113,7 +113,7 @@ class OpenIapModule( private val userChoiceBillingListeners = mutableSetOf() private var currentPurchaseCallback: ((Result>) -> Unit)? = null - val initConnection: MutationInitConnectionHandler = { config -> + override val initConnection: MutationInitConnectionHandler = { config -> // Update alternativeBillingMode if provided in config config?.alternativeBillingModeAndroid?.let { modeAndroid -> OpenIapLog.d("Setting alternative billing mode from config: $modeAndroid", TAG) @@ -138,7 +138,7 @@ class OpenIapModule( } } - val endConnection: MutationEndConnectionHandler = { + override val endConnection: MutationEndConnectionHandler = { withContext(Dispatchers.IO) { runCatching { billingClient?.endConnection() @@ -148,7 +148,7 @@ class OpenIapModule( } } - val fetchProducts: QueryFetchProductsHandler = { params -> + override val fetchProducts: QueryFetchProductsHandler = { params -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -209,11 +209,11 @@ class OpenIapModule( } } } - val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ -> + override val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { _ -> withContext(Dispatchers.IO) { restorePurchasesHelper(billingClient) } } - val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds -> + override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler = { subscriptionIds -> withContext(Dispatchers.IO) { val androidPurchases = queryPurchases(billingClient, BillingClient.ProductType.SUBS) .filterIsInstance() @@ -227,7 +227,7 @@ class OpenIapModule( } } - val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> + override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler = { subscriptionIds -> getActiveSubscriptions(subscriptionIds).isNotEmpty() } @@ -235,7 +235,7 @@ class OpenIapModule( * Check if alternative billing is available for this user/device * Step 1 of alternative billing flow */ - suspend fun checkAlternativeBillingAvailability(): Boolean = withContext(Dispatchers.IO) { + override suspend fun checkAlternativeBillingAvailability(): Boolean = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -274,7 +274,7 @@ class OpenIapModule( * Step 2 of alternative billing flow * Must be called BEFORE processing payment */ - suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = withContext(Dispatchers.IO) { + override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -322,7 +322,7 @@ class OpenIapModule( * 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) { + override suspend fun createAlternativeBillingReportingToken(): String? = withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -363,7 +363,7 @@ class OpenIapModule( } } - val requestPurchase: MutationRequestPurchaseHandler = { props -> + override val requestPurchase: MutationRequestPurchaseHandler = { props -> val purchases = withContext(Dispatchers.IO) { // ALTERNATIVE_ONLY mode: Show information dialog and create token if (alternativeBillingMode == AlternativeBillingMode.ALTERNATIVE_ONLY) { @@ -684,7 +684,7 @@ class OpenIapModule( queryPurchases(billingClient, billingType) } - val finishTransaction: MutationFinishTransactionHandler = { purchase, isConsumable -> + override val finishTransaction: MutationFinishTransactionHandler = { purchase, isConsumable -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared if (!client.isReady) throw OpenIapError.NotPrepared @@ -715,7 +715,7 @@ class OpenIapModule( } } - val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken -> + override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler = { purchaseToken -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared val params = AcknowledgePurchaseParams.newBuilder().setPurchaseToken(purchaseToken).build() @@ -732,7 +732,7 @@ class OpenIapModule( } } - val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken -> + override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler = { purchaseToken -> withContext(Dispatchers.IO) { val client = billingClient ?: throw OpenIapError.NotPrepared val params = ConsumeParams.newBuilder().setPurchaseToken(purchaseToken).build() @@ -749,7 +749,7 @@ class OpenIapModule( } } - val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { options -> + override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { options -> val pkg = options?.packageNameAndroid ?: context.packageName val uri = if (!options?.skuAndroid.isNullOrBlank()) { Uri.parse("https://play.google.com/store/account/subscriptions?sku=${options!!.skuAndroid}&package=$pkg") @@ -760,14 +760,14 @@ class OpenIapModule( context.startActivity(intent) } - val restorePurchases: MutationRestorePurchasesHandler = { + override val restorePurchases: MutationRestorePurchasesHandler = { withContext(Dispatchers.IO) { restorePurchasesHelper(billingClient) Unit } } - val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported } + override val validateReceipt: MutationValidateReceiptHandler = { throw OpenIapError.NotSupported } private val purchaseError: SubscriptionPurchaseErrorHandler = { onPurchaseError(this::addPurchaseErrorListener, this::removePurchaseErrorListener) @@ -777,7 +777,7 @@ class OpenIapModule( onPurchaseUpdated(this::addPurchaseUpdateListener, this::removePurchaseUpdateListener) } - val queryHandlers: QueryHandlers = QueryHandlers( + override val queryHandlers: QueryHandlers = QueryHandlers( fetchProducts = fetchProducts, getActiveSubscriptions = getActiveSubscriptions, getAvailablePurchases = getAvailablePurchases, @@ -785,7 +785,7 @@ class OpenIapModule( hasActiveSubscriptions = hasActiveSubscriptions ) - val mutationHandlers: MutationHandlers = MutationHandlers( + override val mutationHandlers: MutationHandlers = MutationHandlers( acknowledgePurchaseAndroid = acknowledgePurchaseAndroid, consumePurchaseAndroid = consumePurchaseAndroid, deepLinkToSubscriptions = deepLinkToSubscriptions, @@ -797,7 +797,7 @@ class OpenIapModule( validateReceipt = validateReceipt ) - val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( + override val subscriptionHandlers: SubscriptionHandlers = SubscriptionHandlers( purchaseError = purchaseError, purchaseUpdated = purchaseUpdated ) @@ -826,27 +826,27 @@ class OpenIapModule( } } - fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { purchaseUpdateListeners.add(listener) } - fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { purchaseUpdateListeners.remove(listener) } - fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { purchaseErrorListeners.add(listener) } - fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { purchaseErrorListeners.remove(listener) } - fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { userChoiceBillingListeners.add(listener) } - fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { userChoiceBillingListeners.remove(listener) } @@ -1045,7 +1045,7 @@ class OpenIapModule( }) } - fun setActivity(activity: Activity?) { + override fun setActivity(activity: Activity?) { currentActivityRef = activity?.let { WeakReference(it) } } @@ -1054,7 +1054,7 @@ class OpenIapModule( * * @param listener User choice billing listener */ - fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { + override fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { userChoiceBillingListener = listener } } diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt new file mode 100644 index 00000000..f31faeff --- /dev/null +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt @@ -0,0 +1,47 @@ +package dev.hyo.openiap + +import android.app.Activity +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener + +/** + * Shared contract implemented by platform-specific OpenIAP billing modules. + * Provides access to generated handler typealiases so the store can remain provider-agnostic. + */ +interface OpenIapProtocol { + val initConnection: MutationInitConnectionHandler + val endConnection: MutationEndConnectionHandler + + val fetchProducts: QueryFetchProductsHandler + val getAvailablePurchases: QueryGetAvailablePurchasesHandler + val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler + val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler + + val requestPurchase: MutationRequestPurchaseHandler + val finishTransaction: MutationFinishTransactionHandler + val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler + val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler + val restorePurchases: MutationRestorePurchasesHandler + val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler + val validateReceipt: MutationValidateReceiptHandler + + val queryHandlers: QueryHandlers + val mutationHandlers: MutationHandlers + val subscriptionHandlers: SubscriptionHandlers + + fun setActivity(activity: Activity?) + + fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) + fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) + fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) + fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) + + // Alternative Billing (Google Play only) + suspend fun checkAlternativeBillingAvailability(): Boolean + suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean + suspend fun createAlternativeBillingReportingToken(): String? + fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) + fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) + fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) +} diff --git a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt index c62ec3f9..96c2e1f1 100644 --- a/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt +++ b/packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt @@ -33,9 +33,10 @@ import dev.hyo.openiap.MutationInitConnectionHandler import dev.hyo.openiap.MutationEndConnectionHandler import android.app.Activity import android.content.Context -import com.android.billingclient.api.BillingClient import dev.hyo.openiap.OpenIapError +import dev.hyo.openiap.OpenIapLog import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProtocol import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener import dev.hyo.openiap.utils.toProduct @@ -48,14 +49,20 @@ 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 + * Convenience store that wraps an [OpenIapProtocol] implementation (Play Store or Horizon) + * and exposes suspend APIs with observable StateFlows for UI layers to consume. */ -class OpenIapStore(private val module: OpenIapModule) { +class OpenIapStore(private val module: OpenIapProtocol) { + init { + android.util.Log.i("OpenIapStore", "Initialized with module: ${module.javaClass.simpleName}") + } + + constructor(context: Context) : this(buildModule(context, null, null)) + constructor(context: Context, store: String?) : this(buildModule(context, store, null)) + constructor(context: Context, store: String?, appId: String?) : this(buildModule(context, store, appId)) + /** - * Convenience constructor that creates OpenIapModule + * Convenience constructor that creates OpenIapModule with alternative billing support * * @param context Android context * @param alternativeBillingMode Alternative billing mode (default: NONE) @@ -65,7 +72,7 @@ class OpenIapStore(private val module: OpenIapModule) { context: Context, alternativeBillingMode: dev.hyo.openiap.AlternativeBillingMode = dev.hyo.openiap.AlternativeBillingMode.NONE, userChoiceBillingListener: dev.hyo.openiap.listener.UserChoiceBillingListener? = null - ) : this(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener)) + ) : this(OpenIapModule(context, alternativeBillingMode, userChoiceBillingListener) as OpenIapProtocol) /** * Convenience constructor for backward compatibility @@ -77,7 +84,7 @@ class OpenIapStore(private val module: OpenIapModule) { constructor( context: Context, enableAlternativeBilling: Boolean - ) : this(OpenIapModule(context, enableAlternativeBilling)) + ) : this(OpenIapModule(context, enableAlternativeBilling) as OpenIapProtocol) // Public state private val _isConnected = MutableStateFlow(false) @@ -116,6 +123,37 @@ class OpenIapStore(private val module: OpenIapModule) { ) _status.value = _status.value.copy(lastError = null) pendingRequestProductId = null + + // CRITICAL FIX: Refresh available purchases to update UI + // This ensures the purchase list reflects the new purchase immediately + kotlinx.coroutines.GlobalScope.launch { + try { + android.util.Log.i("OpenIapStore", "Purchase update received, refreshing available purchases") + + // Wait a bit for the purchase to be fully processed by Horizon + kotlinx.coroutines.delay(500) + + // Ensure connection is ready + if (!isConnected.value) { + android.util.Log.w("OpenIapStore", "Not connected, skipping purchase refresh (connection will be restored on next app start)") + // Don't attempt to reconnect here as it may cause issues + // The purchase will be available on next app launch + return@launch + } + + android.util.Log.i("OpenIapStore", "About to call module.getAvailablePurchases(null)") + val result = module.getAvailablePurchases(null) + android.util.Log.i("OpenIapStore", "module.getAvailablePurchases returned: ${result.size} purchases") + result.forEachIndexed { index, purchase -> + android.util.Log.i("OpenIapStore", " Purchase[$index]: ${purchase.productId}") + } + _availablePurchases.value = result + android.util.Log.i("OpenIapStore", "Available purchases updated: ${result.size} purchases") + } catch (e: Exception) { + android.util.Log.e("OpenIapStore", "Failed to refresh purchases after update", e) + e.printStackTrace() + } + } } private val purchaseErrorListener = OpenIapPurchaseErrorListener { error -> if (error is OpenIapError.UserCancelled || error is OpenIapError.PurchaseCancelled) { @@ -185,10 +223,13 @@ class OpenIapStore(private val module: OpenIapModule) { val initConnection: MutationInitConnectionHandler = { config -> setLoading { it.initConnection = true } try { + OpenIapLog.i("OpenIapStore.initConnection: Calling module.initConnection...", "OpenIapStore") val ok = module.initConnection(config) + OpenIapLog.i("OpenIapStore.initConnection: module.initConnection returned: $ok", "OpenIapStore") _isConnected.value = ok ok } catch (e: Exception) { + OpenIapLog.e("OpenIapStore.initConnection: Exception", e, "OpenIapStore") setError(e.message) throw e } finally { @@ -199,7 +240,10 @@ class OpenIapStore(private val module: OpenIapModule) { /** * Convenience overload that calls initConnection with null config */ - suspend fun initConnection(): Boolean = initConnection(null) + suspend fun initConnection(): Boolean { + OpenIapLog.i("OpenIapStore.initConnection(): Calling initConnection(null)...", "OpenIapStore") + return initConnection(null) + } val endConnection: MutationEndConnectionHandler = { removePurchaseUpdateListener(purchaseUpdateListener) @@ -220,9 +264,12 @@ class OpenIapStore(private val module: OpenIapModule) { // Product Management - Using GraphQL handler pattern // ------------------------------------------------------------------------- val fetchProducts: QueryFetchProductsHandler = { request -> + android.util.Log.i("OpenIapStore", "fetchProducts called with SKUs: ${request.skus}, type: ${request.type}") setLoading { it.fetchProducts = true } try { + android.util.Log.i("OpenIapStore", "Calling module.fetchProducts") val result = module.fetchProducts(request) + android.util.Log.i("OpenIapStore", "module.fetchProducts returned: $result") when (result) { is FetchProductsResultProducts -> { // Merge new products with existing ones @@ -261,12 +308,16 @@ class OpenIapStore(private val module: OpenIapModule) { // Purchases / Restore - Using GraphQL handler pattern // ------------------------------------------------------------------------- val getAvailablePurchases: QueryGetAvailablePurchasesHandler = { options -> + android.util.Log.i("OpenIapStore", "getAvailablePurchases called, module type: ${module.javaClass.simpleName}") setLoading { it.restorePurchases = true } try { + android.util.Log.i("OpenIapStore", "Calling module.getAvailablePurchases(options)") val result = module.getAvailablePurchases(options) + android.util.Log.i("OpenIapStore", "module.getAvailablePurchases returned ${result.size} purchases") _availablePurchases.value = result result } catch (e: Exception) { + android.util.Log.e("OpenIapStore", "getAvailablePurchases exception: ${e.message}", e) setError(e.message) throw e } finally { @@ -291,7 +342,8 @@ class OpenIapStore(private val module: OpenIapModule) { } try { - module.requestPurchase(props) + module.mutationHandlers.requestPurchase?.invoke(props) + ?: throw OpenIapError.NotSupported } finally { if (skuForStatus != null) removePurchasing(skuForStatus) } @@ -304,7 +356,7 @@ class OpenIapStore(private val module: OpenIapModule) { // Check if already processed - but we can't check isAcknowledgedAndroid on PurchaseInput if (token == null || !processedPurchaseTokens.contains(token)) { try { - module.finishTransaction(purchaseInput, isConsumable) + module.mutationHandlers.finishTransaction?.invoke(purchaseInput, isConsumable) if (token != null) processedPurchaseTokens.add(token) } catch (e: Exception) { setError(e.message) @@ -318,12 +370,12 @@ class OpenIapStore(private val module: OpenIapModule) { // Subscriptions // ------------------------------------------------------------------------- suspend fun getActiveSubscriptions(subscriptionIds: List? = null): List = - module.getActiveSubscriptions(subscriptionIds) + module.queryHandlers.getActiveSubscriptions?.invoke(subscriptionIds) ?: emptyList() suspend fun hasActiveSubscriptions(subscriptionIds: List? = null): Boolean = - module.hasActiveSubscriptions(subscriptionIds) + module.queryHandlers.hasActiveSubscriptions?.invoke(subscriptionIds) ?: false - suspend fun deepLinkToSubscriptions(options: DeepLinkOptions) = module.deepLinkToSubscriptions(options) + suspend fun deepLinkToSubscriptions(options: DeepLinkOptions) = module.mutationHandlers.deepLinkToSubscriptions?.invoke(options) // ------------------------------------------------------------------------- // Alternative Billing (Step-by-Step API) @@ -493,3 +545,85 @@ sealed class IapOperationResult { data class Failure(val message: String) : IapOperationResult() object Cancelled : IapOperationResult() } + +private fun buildModule(context: Context, store: String?, appId: String?): OpenIapProtocol { + // Get default store from BuildConfig if available + val defaultStore = try { + val buildConfig = Class.forName("io.github.hyochan.openiap.BuildConfig") + val storeValue = buildConfig.getField("OPENIAP_STORE").get(null) as? String ?: "play" + android.util.Log.i("OpenIapStore", "BuildConfig.OPENIAP_STORE = $storeValue") + storeValue + } catch (e: Throwable) { + android.util.Log.w("OpenIapStore", "Failed to read BuildConfig.OPENIAP_STORE: ${e.message}") + "play" + } + + val selected = (store ?: defaultStore).lowercase() + + // For Horizon flavors, try to get app ID from manifest if not provided + val resolvedAppId = if ((selected == "horizon" || selected == "meta" || selected == "quest" || selected == "auto") && appId.isNullOrEmpty()) { + try { + val applicationInfo = context.packageManager.getApplicationInfo( + context.packageName, + android.content.pm.PackageManager.GET_META_DATA + ) + val metaAppId = applicationInfo.metaData?.getString("com.meta.horizon.platform.ovr.OCULUS_APP_ID") + android.util.Log.i("OpenIapStore", "Read OCULUS_APP_ID from manifest: $metaAppId") + metaAppId ?: "" + } catch (e: Throwable) { + android.util.Log.w("OpenIapStore", "Failed to read OCULUS_APP_ID from manifest: ${e.message}") + "" + } + } else { + appId ?: "" + } + + android.util.Log.i("OpenIapStore", "buildModule: selected=$selected, appId=$resolvedAppId, defaultStore=$defaultStore") + OpenIapLog.d("buildModule: selected=$selected, appId=$resolvedAppId, defaultStore=$defaultStore", "OpenIapStore") + + return when (selected) { + "horizon", "meta", "quest" -> { + try { + OpenIapLog.d("Loading OpenIapHorizonModule with appId=$resolvedAppId", "OpenIapStore") + val clazz = Class.forName("dev.hyo.openiap.horizon.OpenIapHorizonModule") + val constructor = clazz.getConstructor(Context::class.java, String::class.java) + val instance = constructor.newInstance(context, resolvedAppId) as OpenIapProtocol + OpenIapLog.d("Successfully loaded OpenIapHorizonModule", "OpenIapStore") + instance + } catch (e: Throwable) { + // Fallback to Play Store implementation + OpenIapLog.e("Failed to load OpenIapHorizonModule, falling back to Play", e, "OpenIapStore") + OpenIapModule(context) as OpenIapProtocol + } + } + "auto" -> { + // Auto-detect environment + if (isHorizonEnvironment(context)) { + try { + val clazz = Class.forName("dev.hyo.openiap.horizon.OpenIapHorizonModule") + val constructor = clazz.getConstructor(Context::class.java, String::class.java) + constructor.newInstance(context, resolvedAppId) as OpenIapProtocol + } catch (e: Throwable) { + OpenIapModule(context) as OpenIapProtocol + } + } else { + OpenIapModule(context) as OpenIapProtocol + } + } + else -> { + // Default to Play Store (includes "play", "google", "gplay", "googleplay", "gms") + OpenIapModule(context) as OpenIapProtocol + } + } +} + +private fun isHorizonEnvironment(context: Context): Boolean { + val manufacturer = android.os.Build.MANUFACTURER.lowercase() + if (manufacturer.contains("meta") || manufacturer.contains("oculus")) return true + return try { + context.packageManager.getPackageInfo("com.oculus.vrshell", 0) + true + } catch (_: Throwable) { + false + } +} diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt new file mode 100644 index 00000000..f7053cc8 --- /dev/null +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt @@ -0,0 +1,131 @@ +package dev.hyo.openiap.horizon + +import android.app.Activity +import android.content.Context +import dev.hyo.openiap.MutationAcknowledgePurchaseAndroidHandler +import dev.hyo.openiap.MutationConsumePurchaseAndroidHandler +import dev.hyo.openiap.MutationDeepLinkToSubscriptionsHandler +import dev.hyo.openiap.MutationEndConnectionHandler +import dev.hyo.openiap.MutationFinishTransactionHandler +import dev.hyo.openiap.MutationHandlers +import dev.hyo.openiap.MutationInitConnectionHandler +import dev.hyo.openiap.MutationRequestPurchaseHandler +import dev.hyo.openiap.MutationRestorePurchasesHandler +import dev.hyo.openiap.MutationValidateReceiptHandler +import dev.hyo.openiap.OpenIapModule +import dev.hyo.openiap.OpenIapProtocol +import dev.hyo.openiap.QueryFetchProductsHandler +import dev.hyo.openiap.QueryGetActiveSubscriptionsHandler +import dev.hyo.openiap.QueryGetAvailablePurchasesHandler +import dev.hyo.openiap.QueryHandlers +import dev.hyo.openiap.QueryHasActiveSubscriptionsHandler +import dev.hyo.openiap.SubscriptionHandlers +import dev.hyo.openiap.listener.OpenIapPurchaseErrorListener +import dev.hyo.openiap.listener.OpenIapPurchaseUpdateListener +import dev.hyo.openiap.listener.OpenIapUserChoiceBillingListener + +/** + * Play flavor stub that reuses the Play Billing pipeline. + * Build the `horizon` product flavor to include Horizon billing dependencies. + */ +@Suppress("UNUSED_PARAMETER") +class OpenIapHorizonModule( + context: Context, + appId: String? = null +) : OpenIapProtocol { + + private val delegate = OpenIapModule(context) + + override fun setActivity(activity: Activity?) { + delegate.setActivity(activity) + } + + override val initConnection: MutationInitConnectionHandler + get() = delegate.initConnection + + override val endConnection: MutationEndConnectionHandler + get() = delegate.endConnection + + override val fetchProducts: QueryFetchProductsHandler + get() = delegate.fetchProducts + + override val getAvailablePurchases: QueryGetAvailablePurchasesHandler + get() = delegate.getAvailablePurchases + + override val getActiveSubscriptions: QueryGetActiveSubscriptionsHandler + get() = delegate.getActiveSubscriptions + + override val hasActiveSubscriptions: QueryHasActiveSubscriptionsHandler + get() = delegate.hasActiveSubscriptions + + override val requestPurchase: MutationRequestPurchaseHandler + get() = delegate.requestPurchase + + override val finishTransaction: MutationFinishTransactionHandler + get() = delegate.finishTransaction + + override val acknowledgePurchaseAndroid: MutationAcknowledgePurchaseAndroidHandler + get() = delegate.acknowledgePurchaseAndroid + + override val consumePurchaseAndroid: MutationConsumePurchaseAndroidHandler + get() = delegate.consumePurchaseAndroid + + override val restorePurchases: MutationRestorePurchasesHandler + get() = delegate.restorePurchases + + override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler + get() = delegate.deepLinkToSubscriptions + + override val validateReceipt: MutationValidateReceiptHandler + get() = delegate.validateReceipt + + override val queryHandlers: QueryHandlers + get() = delegate.queryHandlers + + override val mutationHandlers: MutationHandlers + get() = delegate.mutationHandlers + + override val subscriptionHandlers: SubscriptionHandlers + get() = delegate.subscriptionHandlers + + override fun addPurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + delegate.addPurchaseUpdateListener(listener) + } + + override fun removePurchaseUpdateListener(listener: OpenIapPurchaseUpdateListener) { + delegate.removePurchaseUpdateListener(listener) + } + + override fun addPurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + delegate.addPurchaseErrorListener(listener) + } + + override fun removePurchaseErrorListener(listener: OpenIapPurchaseErrorListener) { + delegate.removePurchaseErrorListener(listener) + } + + // Alternative Billing (delegate to OpenIapModule) + override suspend fun checkAlternativeBillingAvailability(): Boolean { + return delegate.checkAlternativeBillingAvailability() + } + + override suspend fun showAlternativeBillingInformationDialog(activity: Activity): Boolean { + return delegate.showAlternativeBillingInformationDialog(activity) + } + + override suspend fun createAlternativeBillingReportingToken(): String? { + return delegate.createAlternativeBillingReportingToken() + } + + override fun setUserChoiceBillingListener(listener: dev.hyo.openiap.listener.UserChoiceBillingListener?) { + delegate.setUserChoiceBillingListener(listener) + } + + override fun addUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + delegate.addUserChoiceBillingListener(listener) + } + + override fun removeUserChoiceBillingListener(listener: OpenIapUserChoiceBillingListener) { + delegate.removeUserChoiceBillingListener(listener) + } +}