Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -46,20 +50,41 @@ fun AvailablePurchasesScreen(

// Modal state
var selectedPurchase by remember { mutableStateOf<PurchaseAndroid?>(null) }

var isInitializing by remember { mutableStateOf(true) }
var initError by remember { mutableStateOf<String?>(null) }

// Use a dedicated scope for cleanup that won't be cancelled with composition
val cleanupScope = remember { CoroutineScope(Dispatchers.Main + SupervisorJob()) }
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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() }
}
}
}

Expand Down Expand Up @@ -91,7 +116,7 @@ fun AvailablePurchasesScreen(
}
}
},
enabled = !status.isLoading
enabled = !isInitializing && !status.isLoading
) {
Icon(Icons.Default.Restore, contentDescription = "Restore")
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -75,27 +79,49 @@ fun PurchaseFlowScreen(
// Modal states
var selectedProduct by remember { mutableStateOf<ProductAndroid?>(null) }
var selectedPurchase by remember { mutableStateOf<PurchaseAndroid?>(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() }
}
Expand Down Expand Up @@ -126,7 +152,7 @@ fun PurchaseFlowScreen(
} catch (_: Exception) { }
}
},
enabled = !status.isLoading
enabled = !isInitializing && !status.isLoading
) {
Icon(Icons.Default.Refresh, contentDescription = "Refresh")
}
Expand Down Expand Up @@ -196,7 +222,7 @@ fun PurchaseFlowScreen(
}

// Loading State
if (status.isLoading) {
if (isInitializing || status.isLoading) {
item {
LoadingCard()
}
Expand Down Expand Up @@ -286,7 +312,7 @@ fun PurchaseFlowScreen(
}
)
}
} else if (!status.isLoading) {
} else if (!isInitializing && !status.isLoading) {
item {
EmptyStateCard(
message = "No products available",
Expand Down Expand Up @@ -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 {
Expand All @@ -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))
Expand Down
Loading
Loading