diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AvailablePurchasesScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AvailablePurchasesScreen.kt index 152e0c57..c413e88e 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/AvailablePurchasesScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/AvailablePurchasesScreen.kt @@ -26,6 +26,10 @@ import dev.hyo.openiap.PurchaseAndroid import dev.hyo.openiap.PurchaseState import dev.hyo.openiap.store.OpenIapStore import dev.hyo.openiap.store.PurchaseResultStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -46,20 +50,41 @@ fun AvailablePurchasesScreen( // Modal state var selectedPurchase by remember { mutableStateOf(null) } - + var isInitializing by remember { mutableStateOf(true) } + var initError by remember { mutableStateOf(null) } + + // Use a dedicated scope for cleanup that won't be cancelled with composition + val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) } + + DisposableEffect(cleanupScope) { + onDispose { + cleanupScope.cancel() + } + } + // Initialize and connect on first composition (spec-aligned names) - val startupScope = rememberCoroutineScope() - DisposableEffect(Unit) { - startupScope.launch { - try { - val connected = iapStore.initConnection() - if (connected) { - iapStore.getAvailablePurchases(null) - } - } catch (_: Exception) { } + LaunchedEffect(Unit) { + try { + val connected = iapStore.initConnection() + if (connected) { + iapStore.getAvailablePurchases(null) + } else { + initError = "Failed to connect to billing service" + } + } catch (e: Exception) { + initError = e.message ?: "Failed to initialize IAP connection" + } finally { + isInitializing = false } + } + + DisposableEffect(Unit) { onDispose { - startupScope.launch { runCatching { iapStore.endConnection() } } + // Use dedicated cleanup scope to avoid cancellation race + cleanupScope.launch { + runCatching { iapStore.endConnection() } + runCatching { iapStore.clear() } + } } } @@ -91,7 +116,7 @@ fun AvailablePurchasesScreen( } } }, - enabled = !status.isLoading + enabled = !isInitializing && !status.isLoading ) { Icon(Icons.Default.Restore, contentDescription = "Restore") } @@ -161,12 +186,43 @@ fun AvailablePurchasesScreen( } // Loading State - if (status.isLoading) { + if (isInitializing || status.isLoading) { item { LoadingCard() } } - + + // Initialization Error + initError?.let { errorMsg -> + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = AppColors.danger.copy(alpha = 0.1f) + ) + ) { + Row( + modifier = Modifier.padding(16.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + Icons.Default.Error, + contentDescription = null, + tint = AppColors.danger + ) + Text( + errorMsg, + color = AppColors.danger, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + statusMessage?.let { result -> item("status-message") { PurchaseResultCard( @@ -322,7 +378,7 @@ fun AvailablePurchasesScreen( } // Empty State - if (androidPurchases.isEmpty() && !status.isLoading) { + if (androidPurchases.isEmpty() && !isInitializing && !status.isLoading) { item { EmptyStateCard( message = "No purchases found. Try restoring purchases from your Google account.", @@ -394,7 +450,7 @@ fun AvailablePurchasesScreen( OutlinedButton( onClick = { scope.launch { iapStore.getAvailablePurchases(null) } }, modifier = Modifier.weight(1f), - enabled = !status.isLoading + enabled = !isInitializing && !status.isLoading ) { Icon(Icons.Default.Refresh, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) @@ -419,7 +475,7 @@ fun AvailablePurchasesScreen( } }, modifier = Modifier.weight(1f), - enabled = !status.isLoading + enabled = !isInitializing && !status.isLoading ) { Icon(Icons.Default.Restore, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) diff --git a/packages/google/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt b/packages/google/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt index 780869af..4cc53220 100644 --- a/packages/google/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt +++ b/packages/google/Example/src/main/java/dev/hyo/martie/screens/HomeScreen.kt @@ -18,12 +18,34 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.navigation.NavController +import dev.hyo.martie.BuildConfig import dev.hyo.martie.models.AppColors import dev.hyo.martie.screens.uis.FeatureCard @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen(navController: NavController) { + // Use BuildConfig to determine which billing system is included in this build + // This ensures UI messages match the actual compiled implementation + val isHorizonBuild = BuildConfig.OPENIAP_STORE == "horizon" + + val testText = + if (isHorizonBuild) "Test in-app purchases and subscription features with Meta Horizon Billing integration." + else "Test in-app purchases and subscription features with Google Play Billing integration." + + val testingNotesText = + if (isHorizonBuild) { + "• Use test accounts configured in Meta Quest Developer Center\n" + + "• Products must be configured in Horizon Store\n" + + "• App must be uploaded to Meta Quest Developer Center\n" + + "• Device must be signed in with a test account" + } else { + "• Use test accounts configured in Google Play Console\n" + + "• Products must be configured in Play Console\n" + + "• App must be uploaded to Play Console (at least internal testing)\n" + + "• Device must be signed in with a test account" + } + Scaffold { paddingValues -> Column( modifier = Modifier @@ -75,7 +97,7 @@ fun HomeScreen(navController: NavController) { } Text( - "Test in-app purchases and subscription features with Google Play Billing integration.", + testText, style = MaterialTheme.typography.bodyMedium, color = AppColors.textSecondary ) @@ -183,10 +205,7 @@ fun HomeScreen(navController: NavController) { } Text( - "• Use test accounts configured in Google Play Console\n" + - "• Products must be configured in Play Console\n" + - "• App must be uploaded to Play Console (at least internal testing)\n" + - "• Device must be signed in with a test account", + testingNotesText, style = MaterialTheme.typography.bodySmall, color = AppColors.textSecondary ) 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 1b9a9b72..652714a4 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 @@ -26,6 +26,10 @@ import dev.hyo.openiap.IapContext import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.store.OpenIapStore import dev.hyo.openiap.store.PurchaseResultStatus +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -75,27 +79,49 @@ fun PurchaseFlowScreen( // Modal states var selectedProduct by remember { mutableStateOf(null) } var selectedPurchase by remember { mutableStateOf(null) } - + var isInitializing by remember { mutableStateOf(true) } + + // Use a dedicated scope for cleanup that won't be cancelled with composition + val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) } + + DisposableEffect(cleanupScope) { + onDispose { + cleanupScope.cancel() + } + } + // Initialize and connect on first composition (spec-aligned names) - val startupScope = rememberCoroutineScope() - DisposableEffect(Unit) { - startupScope.launch { - try { - val connected = iapStore.initConnection() - if (connected) { - iapStore.setActivity(activity) - val request = ProductRequest( - skus = IapConstants.INAPP_SKUS, - type = ProductQueryType.InApp - ) - iapStore.fetchProducts(request) - iapStore.getAvailablePurchases(null) - } - } catch (_: Exception) { } + LaunchedEffect(Unit) { + try { + val connected = iapStore.initConnection() + if (connected) { + iapStore.setActivity(activity) + val request = ProductRequest( + skus = IapConstants.INAPP_SKUS, + type = ProductQueryType.InApp + ) + iapStore.fetchProducts(request) + iapStore.getAvailablePurchases(null) + } else { + iapStore.postStatusMessage( + message = "Failed to connect to billing service", + status = PurchaseResultStatus.Error + ) + } + } catch (e: Exception) { + iapStore.postStatusMessage( + message = "Failed to initialize: ${e.message}", + status = PurchaseResultStatus.Error + ) + } finally { + isInitializing = false } + } + + DisposableEffect(Unit) { onDispose { - // End connection and clear listeners when this screen leaves (per-screen lifecycle) - startupScope.launch { + // Use dedicated cleanup scope to avoid cancellation race + cleanupScope.launch { runCatching { iapStore.endConnection() } runCatching { iapStore.clear() } } @@ -126,7 +152,7 @@ fun PurchaseFlowScreen( } catch (_: Exception) { } } }, - enabled = !status.isLoading + enabled = !isInitializing && !status.isLoading ) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } @@ -196,7 +222,7 @@ fun PurchaseFlowScreen( } // Loading State - if (status.isLoading) { + if (isInitializing || status.isLoading) { item { LoadingCard() } @@ -286,7 +312,7 @@ fun PurchaseFlowScreen( } ) } - } else if (!status.isLoading) { + } else if (!isInitializing && !status.isLoading) { item { EmptyStateCard( message = "No products available", @@ -326,13 +352,13 @@ fun PurchaseFlowScreen( } }, modifier = Modifier.weight(1f), - enabled = !status.isLoading + enabled = !isInitializing && !status.isLoading ) { Icon(Icons.Default.Restore, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) Text("Restore") } - + Button( onClick = { scope.launch { @@ -346,7 +372,7 @@ fun PurchaseFlowScreen( } }, modifier = Modifier.weight(1f), - enabled = !status.isLoading + enabled = !isInitializing && !status.isLoading ) { Icon(Icons.Default.Refresh, contentDescription = null) Spacer(modifier = Modifier.width(8.dp)) 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 b752b993..f9b0a30a 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 @@ -41,8 +41,12 @@ import dev.hyo.openiap.RequestPurchasePropsByPlatforms import dev.hyo.openiap.RequestSubscriptionAndroidProps import dev.hyo.openiap.RequestSubscriptionPropsByPlatforms import dev.hyo.openiap.AndroidSubscriptionOfferInput -import kotlinx.coroutines.launch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import dev.hyo.martie.util.findActivity import dev.hyo.martie.util.PREMIUM_SUBSCRIPTION_PRODUCT_ID import dev.hyo.martie.util.SUBSCRIPTION_PREFS_NAME @@ -123,12 +127,27 @@ fun SubscriptionFlowScreen( IapConstants.getSubscriptionSkus() } + var isInitializing by remember { mutableStateOf(true) } + + // Use a dedicated scope for cleanup that won't be cancelled with composition + val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) } + + DisposableEffect(cleanupScope) { + onDispose { + cleanupScope.cancel() + } + } + // Load subscription data on screen entry LaunchedEffect(Unit) { + try { println("SubscriptionFlow: Loading subscription products and purchases") println("SubscriptionFlow: Is Horizon = $isHorizon") println("SubscriptionFlow: Subscription SKUs = $subscriptionSkus") - iapStore.setActivity(activity) + + val connected = iapStore.initConnection() + if (connected) { + iapStore.setActivity(activity) // TEST: Use getActiveSubscriptions instead of getAvailablePurchases for example usage println("SubscriptionFlow: Testing getActiveSubscriptions...") @@ -180,6 +199,32 @@ fun SubscriptionFlowScreen( } } } + } else { + iapStore.postStatusMessage( + message = "Failed to connect to billing service", + status = PurchaseResultStatus.Error + ) + } + } catch (e: Exception) { + println("SubscriptionFlow: Initialization error: ${e.message}") + e.printStackTrace() + iapStore.postStatusMessage( + message = "Failed to initialize: ${e.message}", + status = PurchaseResultStatus.Error + ) + } finally { + isInitializing = false + } + } + + DisposableEffect(Unit) { + onDispose { + // Use dedicated cleanup scope to avoid cancellation race + cleanupScope.launch { + runCatching { iapStore.endConnection() } + runCatching { iapStore.clear() } + } + } } // Tick clock to update countdown once per second @@ -252,42 +297,7 @@ fun SubscriptionFlowScreen( // Modal states var selectedProduct by remember { mutableStateOf(null) } var selectedPurchase by remember { mutableStateOf(null) } - - // Initialize and connect on first composition (spec-aligned names) - val startupScope = rememberCoroutineScope() - 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: $subscriptionSkus") - val request = ProductRequest( - skus = subscriptionSkus, - type = ProductQueryType.Subs - ) - iapStore.fetchProducts(request) - iapStore.getAvailablePurchases(null) - } else { - println("SubscriptionFlow: Failed to connect to billing service") - } - } catch (e: Exception) { - println("SubscriptionFlow: Exception during initConnection: ${e.message}") - e.printStackTrace() - } - } - - onDispose { - // End connection and clear listeners when this screen leaves (per-screen lifecycle) - startupScope.launch { - runCatching { iapStore.endConnection() } - runCatching { iapStore.clear() } - } - } - } - + Scaffold( topBar = { TopAppBar( @@ -392,7 +402,7 @@ fun SubscriptionFlowScreen( } // Loading State - if (status.isLoading) { + if (isInitializing || status.isLoading) { item { LoadingCard() } @@ -1063,7 +1073,7 @@ fun SubscriptionFlowScreen( } ) } - } else if (!status.isLoading && androidProducts.isEmpty()) { + } else if (!isInitializing && !status.isLoading && androidProducts.isEmpty()) { item { EmptyStateCard( message = "No subscription products available",