From 4ca2f889f356e69026f3885550c19f5902c9a0c0 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 8 Nov 2025 17:17:35 +0900 Subject: [PATCH 1/6] fix(example): prevent product loading race condition on direct navigation Replace DisposableEffect with LaunchedEffect and add initialization state tracking to ensure products load before UI renders. Update all flow screens to properly wait for initConnection() and fetchProducts() to complete. --- .../screens/AvailablePurchasesScreen.kt | 41 +++++++++------ .../java/dev/hyo/martie/screens/HomeScreen.kt | 29 +++++++++-- .../hyo/martie/screens/PurchaseFlowScreen.kt | 50 ++++++++++--------- .../martie/screens/SubscriptionFlowScreen.kt | 24 +++++++-- 4 files changed, 97 insertions(+), 47 deletions(-) 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..0ff3251d 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 @@ -46,20 +46,29 @@ fun AvailablePurchasesScreen( // Modal state var selectedPurchase by remember { mutableStateOf(null) } - + var isInitializing by remember { mutableStateOf(true) } + + val uiScope = rememberCoroutineScope() + // 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) + } + } catch (_: Exception) { } + finally { + isInitializing = false } + } + + DisposableEffect(Unit) { onDispose { - startupScope.launch { runCatching { iapStore.endConnection() } } + uiScope.launch { + runCatching { iapStore.endConnection() } + runCatching { iapStore.clear() } + } } } @@ -91,7 +100,7 @@ fun AvailablePurchasesScreen( } } }, - enabled = !status.isLoading + enabled = !isInitializing && !status.isLoading ) { Icon(Icons.Default.Restore, contentDescription = "Restore") } @@ -161,7 +170,7 @@ fun AvailablePurchasesScreen( } // Loading State - if (status.isLoading) { + if (isInitializing || status.isLoading) { item { LoadingCard() } @@ -322,7 +331,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 +403,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 +428,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..2e1b4a18 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 @@ -75,27 +75,31 @@ fun PurchaseFlowScreen( // Modal states var selectedProduct by remember { mutableStateOf(null) } var selectedPurchase by remember { mutableStateOf(null) } - + var isInitializing by remember { mutableStateOf(true) } + // 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) + } + } catch (_: Exception) { } + finally { + isInitializing = false } + } + + DisposableEffect(Unit) { onDispose { // End connection and clear listeners when this screen leaves (per-screen lifecycle) - startupScope.launch { + uiScope.launch { runCatching { iapStore.endConnection() } runCatching { iapStore.clear() } } @@ -126,7 +130,7 @@ fun PurchaseFlowScreen( } catch (_: Exception) { } } }, - enabled = !status.isLoading + enabled = !isInitializing && !status.isLoading ) { Icon(Icons.Default.Refresh, contentDescription = "Refresh") } @@ -196,7 +200,7 @@ fun PurchaseFlowScreen( } // Loading State - if (status.isLoading) { + if (isInitializing || status.isLoading) { item { LoadingCard() } @@ -286,7 +290,7 @@ fun PurchaseFlowScreen( } ) } - } else if (!status.isLoading) { + } else if (!isInitializing && !status.isLoading) { item { EmptyStateCard( message = "No products available", @@ -326,13 +330,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 +350,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..f5b0fb59 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 @@ -123,12 +123,18 @@ fun SubscriptionFlowScreen( IapConstants.getSubscriptionSkus() } + var isInitializing by remember { mutableStateOf(true) } + // 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 +186,18 @@ fun SubscriptionFlowScreen( } } } + } finally { + isInitializing = false + } + } + + DisposableEffect(Unit) { + onDispose { + uiScope.launch { + runCatching { iapStore.endConnection() } + runCatching { iapStore.clear() } + } + } } // Tick clock to update countdown once per second @@ -392,7 +410,7 @@ fun SubscriptionFlowScreen( } // Loading State - if (status.isLoading) { + if (isInitializing || status.isLoading) { item { LoadingCard() } @@ -1063,7 +1081,7 @@ fun SubscriptionFlowScreen( } ) } - } else if (!status.isLoading && androidProducts.isEmpty()) { + } else if (!isInitializing && !status.isLoading && androidProducts.isEmpty()) { item { EmptyStateCard( message = "No subscription products available", From 244862eb08b70f41fa4e618b9e98d3098f639eb3 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 8 Nov 2025 22:17:21 +0900 Subject: [PATCH 2/6] fix(example): handle init errors and fix cleanup race cond --- .../screens/AvailablePurchasesScreen.kt | 50 +++++++++++++++++-- .../hyo/martie/screens/PurchaseFlowScreen.kt | 23 +++++++-- .../martie/screens/SubscriptionFlowScreen.kt | 23 ++++++++- 3 files changed, 85 insertions(+), 11 deletions(-) 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 0ff3251d..d73f5f20 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,9 @@ 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.launch @OptIn(ExperimentalMaterial3Api::class) @@ -47,8 +50,10 @@ fun AvailablePurchasesScreen( // Modal state var selectedPurchase by remember { mutableStateOf(null) } var isInitializing by remember { mutableStateOf(true) } + var initError by remember { mutableStateOf(null) } - val uiScope = rememberCoroutineScope() + // Use a dedicated scope for cleanup that won't be cancelled with composition + val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) } // Initialize and connect on first composition (spec-aligned names) LaunchedEffect(Unit) { @@ -56,16 +61,20 @@ fun AvailablePurchasesScreen( val connected = iapStore.initConnection() if (connected) { iapStore.getAvailablePurchases(null) + } else { + initError = "Failed to connect to billing service" } - } catch (_: Exception) { } - finally { + } catch (e: Exception) { + initError = e.message ?: "Failed to initialize IAP connection" + } finally { isInitializing = false } } DisposableEffect(Unit) { onDispose { - uiScope.launch { + // Use dedicated cleanup scope to avoid cancellation race + cleanupScope.launch { runCatching { iapStore.endConnection() } runCatching { iapStore.clear() } } @@ -175,7 +184,38 @@ fun AvailablePurchasesScreen( LoadingCard() } } - + + // Initialization Error + initError?.let { errorMsg -> + item { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + colors = CardDefaults.cardColors( + containerColor = AppColors.error.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.error + ) + Text( + errorMsg, + color = AppColors.error, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + statusMessage?.let { result -> item("status-message") { PurchaseResultCard( 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 2e1b4a18..6f0030c6 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,9 @@ 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.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -77,6 +80,9 @@ fun PurchaseFlowScreen( 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()) } + // Initialize and connect on first composition (spec-aligned names) LaunchedEffect(Unit) { try { @@ -89,17 +95,26 @@ fun PurchaseFlowScreen( ) iapStore.fetchProducts(request) iapStore.getAvailablePurchases(null) + } else { + iapStore.postStatusMessage( + message = "Failed to connect to billing service", + status = PurchaseResultStatus.Error + ) } - } catch (_: Exception) { } - finally { + } 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) - uiScope.launch { + // Use dedicated cleanup scope to avoid cancellation race + cleanupScope.launch { runCatching { iapStore.endConnection() } runCatching { iapStore.clear() } } 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 f5b0fb59..abacce6b 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,11 @@ 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.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 @@ -125,6 +128,9 @@ fun SubscriptionFlowScreen( 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()) } + // Load subscription data on screen entry LaunchedEffect(Unit) { try { @@ -185,7 +191,19 @@ 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 } @@ -193,7 +211,8 @@ fun SubscriptionFlowScreen( DisposableEffect(Unit) { onDispose { - uiScope.launch { + // Use dedicated cleanup scope to avoid cancellation race + cleanupScope.launch { runCatching { iapStore.endConnection() } runCatching { iapStore.clear() } } From e29784c822ce53430a4e12f010d0f49589298431 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 8 Nov 2025 22:26:47 +0900 Subject: [PATCH 3/6] Update packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt | 1 - 1 file changed, 1 deletion(-) 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 abacce6b..749b3936 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 @@ -45,7 +45,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob 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 From fb5115042d36400687867e6a9adf1a218e9d64f9 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 8 Nov 2025 22:34:44 +0900 Subject: [PATCH 4/6] Update packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt | 6 ++++++ 1 file changed, 6 insertions(+) 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 749b3936..06e944c4 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 @@ -129,6 +129,12 @@ fun SubscriptionFlowScreen( // Use a dedicated scope for cleanup that won't be cancelled with composition val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) } + + DisposableEffect(cleanupScope) { + onDispose { + cleanupScope.coroutineContext[Job]?.cancel() + } + } // Load subscription data on screen entry LaunchedEffect(Unit) { From f965a75c1f125d272db25dbf6f52485f9a4b7f79 Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 8 Nov 2025 22:47:39 +0900 Subject: [PATCH 5/6] fix(android): resolve code review issues in example screens - Add missing launch import for cleanupScope - Remove duplicate DisposableEffect blocks - Fix AppColors references in error UI --- .../screens/AvailablePurchasesScreen.kt | 6 +- .../martie/screens/SubscriptionFlowScreen.kt | 55 +++---------------- 2 files changed, 11 insertions(+), 50 deletions(-) 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 d73f5f20..a19fddfd 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 @@ -193,7 +193,7 @@ fun AvailablePurchasesScreen( .fillMaxWidth() .padding(horizontal = 16.dp), colors = CardDefaults.cardColors( - containerColor = AppColors.error.copy(alpha = 0.1f) + containerColor = AppColors.danger.copy(alpha = 0.1f) ) ) { Row( @@ -204,11 +204,11 @@ fun AvailablePurchasesScreen( Icon( Icons.Default.Error, contentDescription = null, - tint = AppColors.error + tint = AppColors.danger ) Text( errorMsg, - color = AppColors.error, + color = AppColors.danger, style = MaterialTheme.typography.bodyMedium ) } 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 06e944c4..76d9adb0 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 @@ -45,6 +45,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob 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 @@ -129,12 +130,6 @@ fun SubscriptionFlowScreen( // Use a dedicated scope for cleanup that won't be cancelled with composition val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) } - - DisposableEffect(cleanupScope) { - onDispose { - cleanupScope.coroutineContext[Job]?.cancel() - } - } // Load subscription data on screen entry LaunchedEffect(Unit) { @@ -196,12 +191,13 @@ fun SubscriptionFlowScreen( } } } - } else { - iapStore.postStatusMessage( - message = "Failed to connect to billing service", - status = PurchaseResultStatus.Error - ) } + } else { + iapStore.postStatusMessage( + message = "Failed to connect to billing service", + status = PurchaseResultStatus.Error + ) + } } catch (e: Exception) { println("SubscriptionFlow: Initialization error: ${e.message}") e.printStackTrace() @@ -294,42 +290,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( From 417a225e7014630c2fca62f8d345a1fbba46ba3e Mon Sep 17 00:00:00 2001 From: Hyo Date: Sat, 8 Nov 2025 23:00:48 +0900 Subject: [PATCH 6/6] fix(android): fix memory leaks and cleanup scope issues in example - Add DisposableEffect to cancel cleanupScope and prevent memory leaks - Remove duplicate initialization code in SubscriptionFlowScreen - Fix missing imports and AppColors references --- .../dev/hyo/martie/screens/AvailablePurchasesScreen.kt | 7 +++++++ .../main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt | 7 +++++++ .../java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt | 7 +++++++ 3 files changed, 21 insertions(+) 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 a19fddfd..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 @@ -29,6 +29,7 @@ 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) @@ -55,6 +56,12 @@ fun AvailablePurchasesScreen( // 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) LaunchedEffect(Unit) { try { 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 6f0030c6..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 @@ -29,6 +29,7 @@ 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 @@ -83,6 +84,12 @@ fun PurchaseFlowScreen( // 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) LaunchedEffect(Unit) { try { 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 76d9adb0..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 @@ -44,6 +44,7 @@ import dev.hyo.openiap.AndroidSubscriptionOfferInput 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 @@ -131,6 +132,12 @@ fun SubscriptionFlowScreen( // 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 {