Skip to content

feat(horizon): add horizon support with flavor#17

Merged
hyochan merged 9 commits intomainfrom
feat/google-horizon-os-support
Oct 18, 2025
Merged

feat(horizon): add horizon support with flavor#17
hyochan merged 9 commits intomainfrom
feat/google-horizon-os-support

Conversation

@hyochan
Copy link
Member

@hyochan hyochan commented Oct 17, 2025

Summary

  • add OpenIapProtocol contract and Horizon billing implementation on top of the generated types
  • allow selecting play | horizon | auto via BuildConfig and auto-detect hardware when in auto mode
  • update the example app and docs to reflect provider selection and Horizon configuration

Follow-up from hyodotdev/openiap-google#10

Summary by CodeRabbit

  • New Features

    • Meta Horizon OS billing support with runtime store selection and Horizon-aware subscription UI and upgrade/downgrade flows.
    • New build variants for Play/Auto/Horizon enabling platform-specific SKU and offer behavior; Horizon App ID injected into app metadata at build time.
  • Chores

    • Added CI workflow for Horizon builds and increased Gradle JVM memory.
  • Documentation

    • Added Horizon OS developer guidance and updated Android naming conventions.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Oct 17, 2025

Walkthrough

Adds Horizon (Meta Quest) billing support and an OpenIapProtocol abstraction, implements Horizon and Play modules, introduces product-flavor build configuration and runtime store selection, adds Horizon-specific helpers/converters and product-flavor build/publish changes, and updates example app UI, manifests, and Gradle settings for multi-store behavior.

Changes

Cohort / File(s) Summary
CI & Build
packages/google/.github/workflows/ci-horizon.yml, packages/google/gradle.properties
Add new "CI Horizon" GitHub Actions workflow; increase Gradle JVM heap and add MaxMetaspaceSize.
Protocol & Core
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt, packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt
Add OpenIapProtocol interface; refactor OpenIapModule to implement the protocol and expose handler properties, lifecycle and listener APIs.
Horizon Implementation
packages/google/openiap/src/horizon/java/.../OpenIapHorizonModule.kt, packages/google/openiap/src/horizon/java/.../helpers/HorizonHelpers.kt, packages/google/openiap/src/horizon/java/.../helpers/HorizonProductManager.kt, packages/google/openiap/src/horizon/java/.../utils/HorizonBillingConverters.kt
New Horizon module with BillingClient integration, product-manager cache, query/restore helpers, and converters mapping Horizon types to app domain models; exposes protocol handlers.
Play-flavor Shim
packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
Add Play-flavor shim delegating OpenIapProtocol calls to existing OpenIapModule.
Store Abstraction & Runtime Selection
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
OpenIapStore now accepts OpenIapProtocol, adds constructors and buildModule/isHorizonEnvironment runtime selection and fallback logic.
Gradle Flavors & Publishing
packages/google/Example/build.gradle.kts, packages/google/openiap/build.gradle.kts
Add flavor dimension platform with auto/play/horizon flavors, enable buildConfig, per-flavor dependencies/sourceSets, and publishing config for autoRelease.
Example App: Manifests & BuildConfig
packages/google/Example/src/main/AndroidManifest.xml, packages/google/Example/src/horizon/AndroidManifest.xml, packages/google/Example/build.gradle.kts
Add OCULUS_APP_ID manifest placeholder and Horizon manifest; read local.properties for Horizon App ID and expose via buildConfig/manifestPlaceholders.
Example App: Runtime & UI
packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt, packages/google/Example/src/main/java/dev/hyo/martie/screens/*
Provide shared OpenIapStore via provider, pass store into navigation, init/teardown lifecycle, add platform detection and Horizon/Play-specific subscription/purchase flows and UI branches.
IAP Constants & Utilities
packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt, packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt
Add isHorizonOS() and getSubscriptionSkus() for platform-aware SKUs; add premium product IDs and include billingPeriod in SubscriptionOfferInfo.
Library Dependency & Source Layout
packages/google/openiap/build.gradle.kts
Per-flavor sourceSets and dependency changes to support horizon/play/auto runtime variants and tests.
Docs & Conventions
packages/google/CONTRIBUTING.md, packages/google/CONVENTION.md, CLAUDE.md
Add Horizon OS development guidance, Android-specific naming conventions, and updated review guidance.
Misc (formatting)
packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt
Minor formatting change in store initialization (no behavioral change).

Sequence Diagram(s)

sequenceDiagram
    participant App as MainActivity
    participant Store as OpenIapStore
    participant Builder as buildModule()
    participant Detect as isHorizonEnvironment()
    participant HorizonMod as OpenIapHorizonModule
    participant PlayMod as OpenIapModule
    participant Protocol as OpenIapProtocol

    App->>Store: instantiate OpenIapStore(context)
    Store->>Builder: buildModule(context, store?, appId?)
    Builder->>Detect: isHorizonEnvironment(context)
    alt Horizon detected
        Detect-->>Builder: true
        Builder->>HorizonMod: instantiate (appId)
        HorizonMod-->>Builder: returns OpenIapProtocol
    else Play fallback
        Detect-->>Builder: false
        Builder->>PlayMod: instantiate
        PlayMod-->>Builder: returns OpenIapProtocol
    end
    Builder-->>Store: module (OpenIapProtocol)
    App->>Protocol: initConnection()
    Protocol-->>App: connected
Loading
sequenceDiagram
    participant Screen as SubscriptionScreen
    participant Store as OpenIapStore
    participant Protocol as OpenIapProtocol
    participant Module as PlatformModule
    participant Billing as BillingClient

    Screen->>Store: remember iapStore
    Screen->>Protocol: initConnection()
    Protocol->>Module: establish connection
    Module->>Billing: connect()
    Billing-->>Module: ready
    Module-->>Protocol: connected

    rect rgba(200,240,220,0.12)
      note over Screen,Module: Fetch products & select offer
      Screen->>Module: fetchProducts(skus)
      Module->>Billing: queryProductDetails()
      Billing-->>Module: product details / offers
      Module-->>Screen: product list
    end

    rect rgba(255,230,230,0.10)
      note over Screen,Module: Purchase flow
      Screen->>Module: requestPurchase(productId, offer, activity)
      Module->>Billing: launchBillingFlow()
      Billing-->>Module: purchase result
      Module-->>Screen: purchase success/failure
    end

    Screen->>Protocol: endConnection()
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 A hop, a build, a Quest in sight,

Flavors split so stores take flight.
Protocols stitched, modules choose a lane,
Horizon and Play now speak the same.
I nibble carrots, then applaud the train.

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.55% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title Check ✅ Passed The title "feat(horizon): add horizon support with flavor" directly and accurately reflects the primary objective of the changeset. The PR introduces Meta Horizon OS as a supported billing platform alongside Google Play Store, implemented through Gradle product flavors (auto, play, horizon) with runtime platform detection capabilities. The title clearly conveys this main change with the phrase "add horizon support with flavor," uses appropriate semantic versioning conventions with "feat(horizon):", and avoids vague or misleading terminology. A developer reviewing the commit history would immediately understand that this PR adds Horizon OS support using a flavor-based architecture.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/google-horizon-os-support

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
packages/google/openiap/build.gradle.kts (1)

11-14: versions file path is brittle; add fallback to property and existence check.

Current reference breaks when the build runs at repo root. Guard reads and allow overriding via -POPENIAP_GOOGLE_VERSION.

-// Read version from monorepo root openiap-versions.json
-val versionsFile = File(rootDir.parentFile.parentFile, "openiap-versions.json")
-val versionsJson = JsonSlurper().parseText(versionsFile.readText()) as Map<*, *>
-val openIapVersion: String = versionsJson["google"]?.toString() ?: "1.0.0"
+// Read version from monorepo root openiap-versions.json, with safe fallbacks
+val openIapVersion: String = (project.findProperty("OPENIAP_GOOGLE_VERSION") as String?)
+    ?: run {
+        val candidates = listOfNotNull(
+            File(rootDir, "openiap-versions.json"),
+            rootDir.parentFile?.let { File(it, "openiap-versions.json") },
+            rootDir.parentFile?.parentFile?.let { File(it, "openiap-versions.json") }
+        )
+        val vf = candidates.firstOrNull { it.exists() }
+        if (vf != null) {
+            val json = JsonSlurper().parseText(vf.readText()) as Map<*, *>
+            json["google"]?.toString() ?: "1.0.0"
+        } else {
+            "1.0.0"
+        }
+    }
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (4)

618-621: launchBillingFlow must run on the main thread.

Calling launchBillingFlow() from Dispatchers.IO risks crashes/ANRs; move to Dispatchers.Main.

Apply:

-                    val result = client.launchBillingFlow(activity, flowBuilder.build())
+                    val result = withContext(Dispatchers.Main) {
+                        client.launchBillingFlow(activity, flowBuilder.build())
+                    }

620-631: Clear currentPurchaseCallback on immediate launch failure.

Prevent stale callback retention on non-OK result.

                         purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } }
                         currentPurchaseCallback?.invoke(Result.success(emptyList()))
+                        currentPurchaseCallback = null

855-861: Redact sensitive data in logs (purchaseToken, orderId).

Purchase tokens/order IDs are sensitive; avoid full logging.

-            Log.d(
-                TAG,
-                "[Purchase $index] token=${purchase.purchaseToken} orderId=${purchase.orderId} state=${purchase.purchaseState} autoRenew=${purchase.isAutoRenewing} acknowledged=${purchase.isAcknowledged} products=${purchase.products}"
-            )
+            val redactedToken = purchase.purchaseToken.take(8) + "…"
+            val redactedOrder = purchase.orderId?.take(8)?.plus("…")
+            Log.d(
+                TAG,
+                "[Purchase $index] token=$redactedToken orderId=$redactedOrder state=${purchase.purchaseState} " +
+                    "autoRenew=${purchase.isAutoRenewing} acknowledged=${purchase.isAcknowledged} products=${purchase.products}"
+            )

242-269: Guard reflection for Alternative Billing availability.

On older Billing libraries, getMethod(...) can throw; fail closed and log instead of crashing.

-        val checkAvailabilityMethod = client.javaClass.getMethod(
-            "isAlternativeBillingOnlyAvailableAsync",
-            com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener::class.java
-        )
+        val checkAvailabilityMethod = runCatching {
+            client.javaClass.getMethod(
+                "isAlternativeBillingOnlyAvailableAsync",
+                Class.forName("com.android.billingclient.api.AlternativeBillingOnlyAvailabilityListener")
+            )
+        }.getOrElse {
+            OpenIapLog.w("Alternative billing availability API not found: ${it.message}", TAG)
+            return@withContext false
+        }
🧹 Nitpick comments (12)
packages/google/Example/build.gradle.kts (1)

21-23: Remove duplicated BuildConfig fields from defaultConfig; keep them per-flavor.

They’re redefined in each flavor; defaultConfig copies are redundant and risk drift.

-        buildConfigField("String", "OPENIAP_STORE", "\"play\"")
-        buildConfigField("String", "HORIZON_APP_ID", "\"\"")
@@
     productFlavors {
         create("play") {
             dimension = "store"
             buildConfigField("String", "OPENIAP_STORE", "\"play\"")
             buildConfigField("String", "HORIZON_APP_ID", "\"\"")
         }
         create("horizon") {
             dimension = "store"
             buildConfigField("String", "OPENIAP_STORE", "\"horizon\"")
             buildConfigField("String", "HORIZON_APP_ID", "\"\"")
         }
     }
@@
     buildFeatures {
         compose = true
         buildConfig = true
     }

Also applies to: 27-37, 65-65

packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt (2)

98-123: Remove unused parameter from toPurchase.

productType is never used; drop it to avoid confusion.

-    fun HorizonPurchase.toPurchase(productType: String): PurchaseAndroid {
+    fun HorizonPurchase.toPurchase(): PurchaseAndroid {
         val token = purchaseToken
         val productsList = products ?: emptyList()
         val purchaseState = PurchaseState.Purchased

Additionally, ensure call sites pass no arg (horizon-only sources).


125-133: Consider deriving isActive from renewal/expiry when available.

Hardcoding isActive = true may mislead UIs post-expiry. If Horizon exposes expiry/auto-renew, set isActive accordingly.

packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (4)

312-313: Idempotency guard is fine; consider logging when finishTransaction handler is missing.

Optional: log a warning to aid debugging if handler is null.


325-332: Return-type clarity for deepLinkToSubscriptions.

Expose an explicit return type (e.g., Unit or Boolean?) to stabilize API surface.

-suspend fun deepLinkToSubscriptions(options: DeepLinkOptions) = module.mutationHandlers.deepLinkToSubscriptions?.invoke(options)
+suspend fun deepLinkToSubscriptions(options: DeepLinkOptions): Unit =
+    module.mutationHandlers.deepLinkToSubscriptions?.invoke(options) ?: Unit

Optionally, rename to deepLinkToSubscriptionsAndroid to match guidelines for Android-specific APIs. As per coding guidelines.


511-513: Locale‑safe lowercasing.

Use Locale.ROOT to avoid edge cases (e.g., Turkish i).

-    val selected = (store ?: defaultStore).lowercase()
+    val selected = (store ?: defaultStore).lowercase(java.util.Locale.ROOT)

546-555: Use non-deprecated getPackageInfo on API 33+.

Current call is deprecated; add SDK check and use PackageInfoFlags.

-private fun isHorizonEnvironment(context: Context): Boolean {
+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)
+        val pm = context.packageManager
+        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
+            pm.getPackageInfo("com.oculus.vrshell", android.content.pm.PackageManager.PackageInfoFlags.of(0))
+        } else {
+            @Suppress("DEPRECATION")
+            pm.getPackageInfo("com.oculus.vrshell", 0)
+        }
         true
     } catch (_: Throwable) {
         false
     }
 }
packages/google/openiap/build.gradle.kts (1)

69-73: Scope Play Billing Library to the play flavor to reduce unnecessary dependencies in horizon builds.

The suggestion to use add("playApi", ...) is valid and recommended. The Horizon artifact com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1 is the latest stable version, and the build correctly defines both flavors ("play" and "horizon"). Changing from api() to playApi() will scope the Google Play Billing Library exclusively to the play flavor, preventing it from being included in horizon artifact builds—aligning with the established pattern of horizonImplementation for the Horizon Billing Compatibility Library.

-// Google Play Billing Library (align with app/lib v8)
-api("com.android.billingclient:billing-ktx:8.0.0")
+// Google Play Billing Library (align with app/lib v8) - only for play flavor
+add("playApi", "com.android.billingclient:billing-ktx:8.0.0")
 
 // Horizon Billing Compatibility Library (for horizon flavor only)
 add("horizonImplementation", "com.meta.horizon.billingclient.api:horizon-billing-compatibility:1.1.1")
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt (1)

33-47: Android-specific API names should end with Android (guideline).

In main/java, Android-specific functions must carry the Android suffix. Consider renaming:

  • setActivity → setActivityAndroid
  • checkAlternativeBillingAvailability → checkAlternativeBillingAvailabilityAndroid
  • showAlternativeBillingInformationDialog → showAlternativeBillingInformationDialogAndroid
  • createAlternativeBillingReportingToken → createAlternativeBillingReportingTokenAndroid
  • set/add/removeUserChoiceBillingListener → set/add/removeUserChoiceBillingListenerAndroid

As per coding guidelines

packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (2)

282-304: Also guard reflection for dialog/token APIs.

Mirror the above runCatching pattern for showAlternativeBillingOnlyInformationDialog and createAlternativeBillingOnlyReportingDetailsAsync to avoid runtime NoSuchMethodException.

Also applies to: 330-363


458-466: Remove commented-out code per repo guidelines.

Keep guidance in docs; drop commented callback block.

As per coding guidelines

-                        // TODO: In production, emit this token via callback for payment processing
-                        // alternativeBillingCallback?.onTokenCreated(
-                        //     token = tokenResult,
-                        //     productId = props.skus.first(),
-                        //     onPaymentComplete = { transactionId ->
-                        //         // App reports to backend after payment success
-                        //     }
-                        // )
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1)

325-326: Consider signaling unsupported deepLinkToSubscriptions.

No-op hides behavior; either log a notice or throw OpenIapError.NotSupported for consistency.

-    override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = { _ -> }
+    override val deepLinkToSubscriptions: MutationDeepLinkToSubscriptionsHandler = {
+        OpenIapLog.i("Horizon does not support subscription deep links", TAG)
+    }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 31ed096 and d7f8129.

📒 Files selected for processing (12)
  • packages/google/.github/workflows/ci-horizon.yml (1 hunks)
  • packages/google/Example/build.gradle.kts (2 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt (1 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (1 hunks)
  • packages/google/gradle.properties (1 hunks)
  • packages/google/openiap/build.gradle.kts (4 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt (1 hunks)
  • packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (19 hunks)
  • packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt (1 hunks)
  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (8 hunks)
  • packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
packages/google/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Remove unused code and commented-out code in Kotlin sources

Files:

  • packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt
  • packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
  • packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
  • packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
  • packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt
  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
packages/google/openiap/src/main/java/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Android-specific functions must end with Android; cross-platform functions have no suffix

Files:

  • packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt
  • packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt
  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
🧬 Code graph analysis (2)
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (2)
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)
  • getActiveSubscriptions (325-326)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1)
  • getStorefront (369-388)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (2)
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (2)
  • buildBillingClient (895-1014)
  • getStorefront (809-827)
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)
  • getActiveSubscriptions (325-326)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Test Android
🔇 Additional comments (8)
packages/google/gradle.properties (1)

2-2: Resource tuning appropriate for expanded scope.

Doubling heap memory (2GB→4GB) and explicitly setting metaspace (1GB) are reasonable given the addition of Horizon billing support, build flavors, and generated types. This prevents out-of-memory errors during multi-variant compilations and is aligned with build best practices.

packages/google/openiap/build.gradle.kts (1)

100-106: Publishing only playRelease is OK; confirm if horizon artifacts are intentionally not published.

If horizon should remain internal, this is fine. Otherwise, add a second AndroidSingleVariantLibrary for horizonRelease.

packages/google/Example/src/main/java/dev/hyo/martie/screens/PurchaseFlowScreen.kt (1)

57-59: LGTM: remember OpenIapStore with appContext avoids leaking Activity.

packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (1)

87-89: LGTM: remember OpenIapStore with appContext avoids leaking Activity.

packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (2)

52-60: Constructors look good; verify auto/store selection across flavors.

Ensure BuildConfig.OPENIAP_STORE exists in both play and horizon variants and that appId is wired from Example’s HORIZON_APP_ID when needed. Add an instrumented init smoke test?


298-300: Good: explicit NotSupported when requestPurchase handler is absent.

This surfaces capability mismatches cleanly.

packages/google/openiap/src/play/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1)

31-38: Delegation looks good; stub is minimal and consistent.

The Play-flavor stub cleanly delegates to OpenIapModule and suppresses the unused appId as intended. No blockers.

Also applies to: 91-131

packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (1)

780-786: Naming mismatch: getStorefrontIOS set from Android code.

QueryHandlers.getStorefrontIOS is wired to Android getStorefront(); verify this is intentional, or rename to getStorefrontAndroid for clarity.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

♻️ Duplicate comments (1)
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)

36-36: Remove unused import.

BillingClient is not used in this file.

-import com.android.billingclient.api.BillingClient

As per coding guidelines (remove unused/commented code).

🧹 Nitpick comments (2)
packages/google/Example/build.gradle.kts (1)

30-35: Asymmetric app ID resolution between defaultConfig and horizon flavor.

The app ID fallback chain differs between defaultConfig and the horizon flavor:

  • defaultConfig (lines 30-34): tries EXAMPLE_HORIZON_APP_IDEXAMPLE_OPENIAP_APP_ID → project props → ""
  • horizon flavor (lines 54-56): tries EXAMPLE_HORIZON_APP_ID → project props → "" (skips EXAMPLE_OPENIAP_APP_ID)

This asymmetry means that if a user sets EXAMPLE_OPENIAP_APP_ID in local.properties, it will be used as a fallback in defaultConfig but not in the horizon flavor. Clarify the intent: should the horizon flavor also check EXAMPLE_OPENIAP_APP_ID, or is excluding it deliberate?

Additionally, consider extracting this resolution logic into a helper function to reduce duplication and make the precedence explicit:

fun resolveAppId(horizonKey: String, fallbackKey: String?): String {
  return localProperties.getProperty(horizonKey)
    ?: (fallbackKey?.let { localProperties.getProperty(it) } ?: "")
    ?: (project.findProperty(horizonKey) as String?)
    ?: (fallbackKey?.let { project.findProperty(it) as String? } ?: "")
    ?: ""
}

Then use it consistently:

// In defaultConfig
buildConfigField("String", "HORIZON_APP_ID", 
  "\"${resolveAppId("EXAMPLE_HORIZON_APP_ID", "EXAMPLE_OPENIAP_APP_ID")}\"")

// In horizon flavor
manifestPlaceholders["OCULUS_APP_ID"] = 
  resolveAppId("EXAMPLE_HORIZON_APP_ID", null)

Also applies to: 54-56

packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)

503-551: Improve exception logging for easier debugging.

The reflection-based module loading has appropriate fallback behavior, but exception details are not always logged, making troubleshooting difficult.

Apply this diff to improve logging consistency:

     val defaultStore = try {
         val buildConfig = Class.forName("io.github.hyochan.openiap.BuildConfig")
         buildConfig.getField("OPENIAP_STORE").get(null) as? String ?: "play"
     } catch (e: Throwable) {
+        OpenIapLog.d("Failed to load BuildConfig, using default 'play': ${e.message}", "OpenIapStore")
         "play"
     }
             } catch (e: Throwable) {
-                OpenIapModule(context) as OpenIapProtocol
+                OpenIapLog.e("Failed to load OpenIapHorizonModule in auto mode, falling back to Play", e, "OpenIapStore")
+                OpenIapModule(context) as OpenIapProtocol
             }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d7f8129 and 30d5ac8.

📒 Files selected for processing (4)
  • packages/google/CONTRIBUTING.md (1 hunks)
  • packages/google/Example/build.gradle.kts (3 hunks)
  • packages/google/Example/src/horizon/AndroidManifest.xml (1 hunks)
  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (8 hunks)
✅ Files skipped from review due to trivial changes (2)
  • packages/google/CONTRIBUTING.md
  • packages/google/Example/src/horizon/AndroidManifest.xml
🧰 Additional context used
📓 Path-based instructions (2)
packages/google/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Remove unused code and commented-out code in Kotlin sources

Files:

  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
packages/google/openiap/src/main/java/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Android-specific functions must end with Android; cross-platform functions have no suffix

Files:

  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
🪛 detekt (1.23.8)
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt

[warning] 508-508: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 539-539: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Test Android
🔇 Additional comments (7)
packages/google/Example/build.gradle.kts (3)

41-51: Verify storeOverride pattern doesn't allow both flavors to be set to the same store.

The storeOverride pattern (line 41) allows EXAMPLE_OPENIAP_STORE to override both flavors. If set to "play", both the play and horizon flavors would have OPENIAP_STORE="play", defeating the flavor distinction.

Is this intentional? If storeOverride is meant to force a specific store globally (for testing), document this clearly. Otherwise, consider flavored overrides like EXAMPLE_OPENIAP_STORE_PLAY and EXAMPLE_OPENIAP_STORE_HORIZON to preserve flavor semantics.


86-86: Verify necessity of buildConfig feature flag.

Line 86 enables buildConfig = true. Verify whether this is required given that the file already declares buildConfigField calls (lines 35, 46, 51). Modern Android Gradle plugin versions may enable this by default; confirm it's necessary or remove it if redundant.


48-57: Manifest verification confirms proper OCULUS_APP_ID placeholder usage.

The horizon-specific manifest exists and correctly references the ${OCULUS_APP_ID} placeholder on line 9, which is properly injected by the build.gradle.kts flavor configuration. No issues detected.

packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (4)

57-85: Well-designed protocol abstraction.

The refactoring to use OpenIapProtocol provides good flexibility for supporting multiple billing implementations (Play Store and Horizon) while maintaining backward compatibility through multiple constructor overloads.


326-332: Appropriate fallback behavior for optional handlers.

The nullable handler invocations with safe default values (empty list, false, Unit) are appropriate for optional subscription features.


553-562: Horizon environment detection logic is sound.

The implementation correctly detects Meta Horizon environments by checking both the device manufacturer and the presence of Oculus system packages.


308-320: Architectural inconsistency in null-handler pattern: finishTransaction silently fails while requestPurchase throws.

All current OpenIapProtocol implementations (Play Store and Horizon) do provide a finishTransaction handler. However, the design creates an inconsistency: at line 313, finishTransaction uses safe invoke with silent failure, while requestPurchase (line 299-300) throws NotSupported if null. Additionally, the MutationHandlers data class defines finishTransaction as nullable with default null, which could allow silent failures in future implementations.

Consider either making finishTransaction non-nullable in MutationHandlers (matching the protocol interface requirement), or throwing OpenIapError.NotSupported when null for consistency with requestPurchase.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (1)
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)

530-542: Add logging for auto-detection fallback.

The auto-detection mode (lines 537-538) falls back silently to Play Store when Horizon module loading fails, unlike the explicit "horizon" mode which logs the fallback (line 526). This makes debugging harder when auto-detection doesn't work as expected.

Apply this diff:

             } else {
                 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) {
+                    OpenIapLog.e("Auto-detect: Failed to load Horizon module, falling back to Play", e, "OpenIapStore")
                     OpenIapModule(context) as OpenIapProtocol
                 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 30d5ac8 and 942d2ad.

📒 Files selected for processing (4)
  • packages/google/.github/workflows/ci-horizon.yml (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt (1 hunks)
  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (8 hunks)
✅ Files skipped from review due to trivial changes (1)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
🧰 Additional context used
📓 Path-based instructions (2)
packages/google/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Remove unused code and commented-out code in Kotlin sources

Files:

  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt
packages/google/openiap/src/main/java/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Android-specific functions must end with Android; cross-platform functions have no suffix

Files:

  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Test Android
🔇 Additional comments (9)
packages/google/.github/workflows/ci-horizon.yml (1)

44-54: ✅ Past issues successfully addressed.

The developer has resolved both previous review concerns:

  • Line 46: License acceptance (yes | sdkmanager --licenses) now executes before the SDK install step, preventing potential hangs on non-interactive runners.
  • Line 53: working-directory: packages/google is properly specified, ensuring Gradle tasks find the wrapper and build configuration.

The workflow is well-structured and should execute correctly.

packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (3)

37-54: LGTM! Protocol abstraction documented clearly.

The imports are appropriate and the updated class documentation accurately reflects the refactored design that wraps an OpenIapProtocol implementation.


55-83: LGTM! Constructor refactoring supports protocol abstraction well.

The new constructor overloads provide flexible initialization paths while maintaining backward compatibility through proper delegation to the buildModule factory.


284-330: Document or standardize null-handler behavior in purchase flow operations.

The inconsistent null-handling across handlers is real and warrants verification:

  • requestPurchase (line 297-298): throws OpenIapError.NotSupported when handler is null
  • finishTransaction (line 311): silently succeeds when handler is null
  • Query methods (lines 325, 328, 330): return safe defaults when handlers are null

This pattern correlates with return types—data-returning operations throw, side-effect operations degrade gracefully—but is undocumented. The risk: silent failure in finishTransaction could mask missing implementations and cause purchase processing issues (token leaks, duplication).

Consider either:

  1. Adding KDoc explaining why mutation vs. query handlers have different null-handling strategies, or
  2. Making finishTransaction throw or log a warning when its handler is null, aligning it with requestPurchase behavior since both are critical transaction operations
packages/google/openiap/src/horizon/java/dev/hyo/openiap/utils/HorizonBillingConverters.kt (5)

1-16: LGTM!

Clean imports with helpful type aliases to distinguish Horizon SDK types from domain models.


19-46: LGTM!

The mapping logic correctly converts Horizon in-app product details with appropriate null-safe defaults.


83-89: Verify inclusion of oneTimePurchaseOfferDetails for subscription products.

The mapping includes oneTimePurchaseOfferDetails for subscription products, which is unusual. Please verify whether the Horizon SDK supports one-time purchase details for subscriptions or if this should be set to null for subscription-only products.


125-132: Verify hardcoded isActive flag.

Line 127 hardcodes isActive = true. Verify that this function is only called for active subscriptions, or if the Horizon Purchase object has an active/expired state that should be checked instead.


134-141: LGTM!

The mapping correctly converts PurchaseAndroid to ActiveSubscription. The hardcoded isActive = true aligns with the function name, suggesting it's only called for active subscriptions.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (5)
packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (1)

7-9: Remove unused icon imports

Icons and ArrowBack aren’t used here.

As per coding guidelines

-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt (2)

67-82: Do not log full externalTransactionToken/products.

Tokens/ids are sensitive. Mask tokens and avoid logging full product lists in production logs.

- android.util.Log.d("UserChoiceEvent", "External Token: ${details.externalTransactionToken}")
- android.util.Log.d("UserChoiceEvent", "Products: ${details.products}")
+ android.util.Log.d("UserChoiceEvent", "External Token: ${details.externalTransactionToken.takeLast(6).padStart(12, '*')}")
+ android.util.Log.d("UserChoiceEvent", "Products count: ${details.products.size}")
@@
- message = "User selected alternative billing\nToken: ${details.externalTransactionToken.take(20)}...\nProducts: ${details.products.joinToString()}",
+ message = "User selected alternative billing\nToken: ****${details.externalTransactionToken.takeLast(6)}\nProducts: ${details.products.size}",

Also applies to: 74-79


537-545: Potential NPE: activity!!

findActivity() may return null in some contexts. Avoid non-null assertion.

- val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(activity!!)
+ val act = activity ?: run {
+   iapStore.postStatusMessage("No Activity available", PurchaseResultStatus.Error)
+   return@launch
+ }
+ val dialogAccepted = iapStore.showAlternativeBillingInformationDialog(act)
packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (2)

682-683: Token logging: keep masked.

Even truncated tokens are sensitive. Prefer last 4–6 chars and avoid printing full state dumps in release.

- println("SubscriptionFlow: Current purchase details - productId: ${subscription.productId}, token: ${subscription.purchaseToken?.take(10)}")
+ println("SubscriptionFlow: Current purchase details - productId: ${subscription.productId}, token: ****${subscription.purchaseToken?.takeLast(6)}")

Also applies to: 662-674


927-1059: Use androidSubscriptions instead of androidProducts in "Available Subscriptions" section (lines 927–932).

androidProducts includes both ProductType.Subs and ProductType.InApp, but this section is labeled "Available Subscriptions" and should display subscriptions only. Use the dedicated androidSubscriptions stream instead.

Update required:

  • Line 932: Change items(androidProducts) to items(androidSubscriptions)
  • Update ProductCard signature in packages/google/Example/src/main/java/dev/hyo/martie/screens/uis/ProductCard.kt to accept ProductSubscriptionAndroid instead of ProductAndroid (both types implement ProductCommon and have identical properties)
♻️ Duplicate comments (4)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt (1)

79-86: Remove unused parameter from toPurchase call.

HorizonBillingConverters.toPurchase(productType) doesn’t use productType. Drop the argument and update the converter signature. (Duplicate of prior review.)

- it.toPurchase(productType)
+ it.toPurchase()
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt (1)

98-123: Map real purchase state; don’t hardcode Purchased.

Hardcoding PurchaseState.Purchased can misreport pending/failed states. Read HorizonPurchase state and map to internal enum. Also remove the unused productType parameter.

-    fun HorizonPurchase.toPurchase(productType: String): PurchaseAndroid {
+    fun HorizonPurchase.toPurchase(): PurchaseAndroid {
-        val token = purchaseToken
-        val productsList = products ?: emptyList()
-        val purchaseState = PurchaseState.Purchased
+        val token = purchaseToken
+        val productsList = products ?: emptyList()
+        val purchaseState = when (this.purchaseState) { // Kotlin maps getPurchaseState()
+            1 -> PurchaseState.Purchased
+            2 -> PurchaseState.Pending
+            else -> PurchaseState.Unspecified
+        }
@@
         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 = purchaseState,
             purchaseToken = token,
             quantity = quantity ?: 1,
             signatureAndroid = signature,
             transactionDate = (purchaseTime ?: 0L).toDouble(),
             transactionId = orderId ?: token
         )
     }

And update call sites (HorizonHelpers.queryPurchasesHorizon):

- it.toPurchase(productType)
+ it.toPurchase()
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (2)

60-63: Rename Android-specific helper per guidelines.

buildModule uses Android APIs; suffix with Android and update constructor call sites.

Apply:

-    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))
+    constructor(context: Context) : this(buildModuleAndroid(context, null, null))
+    constructor(context: Context, store: String?) : this(buildModuleAndroid(context, store, null))
+    constructor(context: Context, store: String?, appId: String?) : this(buildModuleAndroid(context, store, appId))
-private fun buildModule(context: Context, store: String?, appId: String?): OpenIapProtocol {
+private fun buildModuleAndroid(context: Context, store: String?, appId: String?): OpenIapProtocol {

As per coding guidelines.

Also applies to: 549-549


600-606: Suffix environment detector with Android and modernize PackageManager use.

Function and call site should be Android-suffixed; also use API 33+ flags.

Apply:

-            if (isHorizonEnvironment(context)) {
+            if (isHorizonEnvironmentAndroid(context)) {
-private fun isHorizonEnvironment(context: Context): Boolean {
+private fun isHorizonEnvironmentAndroid(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)
+        val pm = context.packageManager
+        if (android.os.Build.VERSION.SDK_INT >= 33) {
+            pm.getPackageInfo("com.oculus.vrshell", android.content.pm.PackageManager.PackageInfoFlags.of(0))
+        } else {
+            @Suppress("DEPRECATION")
+            pm.getPackageInfo("com.oculus.vrshell", 0)
+        }
         true
     } catch (_: Throwable) {
         false
     }
 }

As per coding guidelines.

Also applies to: 620-629

🧹 Nitpick comments (19)
packages/google/openiap/build.gradle.kts (1)

71-77: Clarify the source set configuration comment.

The comment at line 72 states "Auto flavor includes horizon implementation only," which is potentially misleading. If the auto flavor is designed to support both Play and Horizon platforms at runtime, the comment should clarify that:

  1. src/main/java contains shared code (likely Play-centric baseline)
  2. src/horizon/java contains Horizon-specific implementations that are added to auto
  3. Why play flavor doesn't require a separate src/play/java configuration

Verify this architecture and update the comment for clarity.

packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt (1)

79-96: Harden period extraction and add tests

  • Consider normalizing (uppercase) and validating the period string to avoid downstream surprises.
  • Add unit tests with sample Horizon receipts covering:
    • { billingPeriod }, { subscriptionPeriod }, and lineItems[0].billingPeriod.
    • Missing/empty fields and malformed JSON.

I can add a small test matrix with canned receipts if helpful.

packages/google/Example/build.gradle.kts (2)

60-70: DRY: reuse the same horizonAppId computed in defaultConfig

Avoid recomputing; reuse the already computed value or a shared helper.

Apply this diff once you move placeholder to defaultConfig:

-            val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID")
-                ?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?)
-                ?: ""
-            manifestPlaceholders["OCULUS_APP_ID"] = appId
+            // Placeholder already set in defaultConfig; keep if you need to override per-flavor.
+            // manifestPlaceholders["OCULUS_APP_ID"] = android.defaultConfig.manifestPlaceholders["OCULUS_APP_ID"]!!

41-53: Extract duplicate appId resolution logic into a single variable

The OCULUS_APP_ID appId resolution is duplicated across flavors at lines 48-50 and lines 67-69 in packages/google/Example/build.gradle.kts. Extract this into a single val variable outside the flavors block (e.g., in defaultConfig or at module scope) and reuse it.

packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt (1)

33-52: Update getOrQuery to use typed cache keys and preserve order

Minimal changes to align with the new key.

Apply this diff:

-        val missing = productIds.filter { cache[it] == null }.distinct()
+        val missing = productIds.filter { cache[CacheKey(it, productType)] == null }.distinct()
 ...
-        if (missing.isEmpty()) {
-            val cached = productIds.mapNotNull { cache[it] }
+        if (missing.isEmpty()) {
+            val cached = productIds.mapNotNull { cache[CacheKey(it, productType)] }
             OpenIapLog.d("getOrQuery: Returning ${cached.size} cached products", TAG)
             return cached
         }
packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (1)

55-58: Nit: avoid ‘!!’ by using takeIf

Small readability tweak.

Apply this diff:

-    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"
-    }
+    val startRoute = remember {
+        val allowed = setOf("home","purchase_flow","subscription_flow","available_purchases","offer_code","alternative_billing")
+        (context as? android.app.Activity)
+            ?.intent?.getStringExtra("openiap_route")
+            ?.takeIf { it in allowed } ?: "home"
+    }
packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt (2)

36-42: Avoid println in Android code; use Log with a TAG.

Swap println for Log.d and add a TAG to keep logs consistent and filterable.

+ private const val TAG = "IapConstants"
@@
- println("IapConstants: getSubscriptionSkus() - isHorizon=$isHorizon, skus=$skus")
+ android.util.Log.d(TAG, "getSubscriptionSkus() - isHorizon=$isHorizon, skus=$skus")

48-50: Constant name/comment mismatch across platforms.

PREMIUM_YEARLY_PRODUCT_ID_PLAY is also used in Horizon flows; name “_PLAY” can mislead.

Option A (preferred): rename to PREMIUM_YEARLY_PRODUCT_ID and update usages.
Option B: add alias and deprecate the old constant.

+ @Deprecated("Use PREMIUM_YEARLY_PRODUCT_ID")
  const val PREMIUM_YEARLY_PRODUCT_ID_PLAY = "dev.hyo.martie.premium_year" // Play only
+ const val PREMIUM_YEARLY_PRODUCT_ID = PREMIUM_YEARLY_PRODUCT_ID_PLAY
packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt (1)

119-123: Fixed delay after endConnection risks flakiness.

Waiting a hardcoded 500 ms may race on slow devices. Poll for disconnect or await store state instead.

Example:

iapStore.endConnection()
withTimeoutOrNull(2000) {
  while (iapStore.isConnected.value) delay(100)
}
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt (1)

28-38: Optional: parallelize INAPP and SUBS queries.

Restore can be faster by querying both types concurrently.

val (inapp, subs) = coroutineScope {
  val a = async { queryPurchasesHorizon(client, BillingClient.ProductType.INAPP) }
  val b = async { queryPurchasesHorizon(client, BillingClient.ProductType.SUBS) }
  a.await() to b.await()
}
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt (1)

125-142: Active subscription should reflect real state.

Currently always sets isActive = true. Consider deriving from mapped purchaseState to avoid false positives.

- fun HorizonPurchase.toActiveSubscription(): ActiveSubscription = ActiveSubscription(
-     autoRenewingAndroid = isAutoRenewing(),
-     isActive = true,
+ fun HorizonPurchase.toActiveSubscription(): ActiveSubscription = ActiveSubscription(
+     autoRenewingAndroid = isAutoRenewing(),
+     isActive = (this.purchaseState == 1), // PURCHASED
packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (2)

25-25: Remove unused import or use it to gate logs.

BuildConfig is imported but unused. Either remove it or wrap debug prints with BuildConfig.DEBUG.

As per coding guidelines

- import dev.hyo.martie.BuildConfig
+ // import dev.hyo.martie.BuildConfig // Uncomment if used to gate debug logs

389-406: Naming mismatch can confuse: YEARLY_PRODUCT_ID_PLAY used for Horizon.

You treat PREMIUM_YEARLY_PRODUCT_ID_PLAY as premium on Horizon too. Align constant naming (see Constants.kt) to reduce confusion.

packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)

127-156: Avoid GlobalScope; use a class scope and stop printing stack traces.

GlobalScope leaks work across lifecycles; prefer a structured scope tied to this store and cancel it in clear(). Also remove printStackTrace.

Apply:

-        kotlinx.coroutines.GlobalScope.launch {
+        scope.launch {
@@
-            } catch (e: Exception) {
-                android.util.Log.e("OpenIapStore", "Failed to refresh purchases after update", e)
-                e.printStackTrace()
-            }
+            } catch (e: Exception) {
+                android.util.Log.e("OpenIapStore", "Failed to refresh purchases after update", e)
+            }
         }
     fun clear() {
         module.removePurchaseUpdateListener(purchaseUpdateListener)
         module.removePurchaseErrorListener(purchaseErrorListener)
         processedPurchaseTokens.clear()
         pendingRequestProductId = null
+        // cancel in-flight work started by this store
+        scope.coroutineContext.cancelChildren()
     }

Add near the top of the class:

private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)

Imports:

import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren

Also applies to: 213-217

packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (5)

217-256: Redact transactionId in logs.

Avoid logging full purchase identifiers.

Apply:

-            allPurchases.forEachIndexed { index, purchase ->
+            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")
+                val redactedTxn = txnId.take(8).plus("…")
+                android.util.Log.i("HORIZON_QUERY", "Purchase[$index] productId=${purchase.productId} txnId=$redactedTxn")
                 OpenIapLog.i(
                     "  [$index] productId=${purchase.productId} " +
-                    "transactionId=$txnId " +
+                    "transactionId=$redactedTxn " +
                     "platform=${purchase.platform}",
                     TAG
                 )
             }

88-92: Make shared cache concurrent (and consider bounding).

mutableMapOf isn’t thread-safe; multiple coroutines access it. Use ConcurrentHashMap and optionally cap size/TTL.

Apply:

-        private val sharedPurchaseCache = mutableMapOf<String, Purchase>() // productId -> Purchase
+        private val sharedPurchaseCache: MutableMap<String, Purchase> =
+            java.util.concurrent.ConcurrentHashMap() // productId -> Purchase

Optionally add simple eviction if size grows unexpectedly.


162-168: Guard queries with isReady().

If the BillingClient isn’t ready, fail fast to avoid inconsistent results.

Apply:

-            val client = billingClient ?: throw OpenIapError.NotPrepared
+            val client = billingClient ?: throw OpenIapError.NotPrepared
+            if (!client.isReady()) throw OpenIapError.NotPrepared

679-683: Avoid Log.wtf for normal callbacks.

wtf is too severe and can crash under strict handlers. Use i/w.

Apply:

-        android.util.Log.wtf("HORIZON_CALLBACK", "onPurchasesUpdated START - responseCode=${result.responseCode}, count=${purchases?.size ?: 0}")
+        android.util.Log.i("HORIZON_CALLBACK", "onPurchasesUpdated START - responseCode=${result.responseCode}, count=${purchases?.size ?: 0}")

803-831: Handle exceptions explicitly (detekt).

Catches currently swallow exceptions. Ensure they’re logged and mapped (e.g., to NotSupported) consistently so callers can branch reliably.

Minimal approach:

  • Log with OpenIapLog.e including message and exception.
  • For unsupported features (NoSuchMethodError), return false/null and/or throw OpenIapError.NotSupported to make semantics explicit.

Also applies to: 833-866, 868-898

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 942d2ad and a71ea49.

📒 Files selected for processing (13)
  • packages/google/Example/build.gradle.kts (3 hunks)
  • packages/google/Example/src/main/AndroidManifest.xml (1 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt (2 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (2 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt (7 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (13 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt (3 hunks)
  • packages/google/openiap/build.gradle.kts (3 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt (1 hunks)
  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (13 hunks)
🧰 Additional context used
📓 Path-based instructions (2)
packages/google/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Remove unused code and commented-out code in Kotlin sources

Files:

  • packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt
  • packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt
  • packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt
  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
  • packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt
  • packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt
packages/google/openiap/src/main/java/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Android-specific functions must end with Android; cross-platform functions have no suffix

Files:

  • packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt
🧬 Code graph analysis (3)
packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (1)
packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt (1)
  • resolvePremiumOfferInfo (27-57)
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapProtocol.kt (1)
  • initConnection (12-47)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (2)
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (2)
  • buildBillingClient (895-1014)
  • getStorefront (809-827)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt (3)
  • queryProductDetailsHorizon (98-106)
  • restorePurchasesHorizon (19-42)
  • queryPurchasesHorizon (47-93)
🪛 detekt (1.23.8)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt

[warning] 813-813: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 849-849: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 877-877: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Test Android
🔇 Additional comments (12)
packages/google/openiap/build.gradle.kts (2)

131-136: Publishing strategy is sound.

Publishing the autoRelease variant as the default provides consumers with cross-platform support out of the box. This is a good choice for a library that aims to support both Google Play and Horizon OS.


96-100: Remove this review comment — it mischaracterizes the dependency architecture.

The Meta Horizon Billing Compatibility SDK is designed to facilitate the porting of Android applications that utilize Google's Play Service Billing Library for In-App Purchasing and Subscriptions to the Quest ecosystem. The Horizon library is a compatibility wrapper with SDK compatible with Google Play Billing Library version 7.0, not a duplicate. Gradle automatically creates build variants based on your build types and product flavors, ensuring that Gradle will automatically pull the dependencies for the selected productFlavors only. This means the autoApi configuration is flavor-scoped: the final APK includes only the runtime dependencies for its selected flavor (either playApi or horizonApi), never both. The libraries use different package namespaces and there is no ClassLoader conflict. The current comment is architecturally correct.

packages/google/Example/src/main/java/dev/hyo/martie/util/SubscriptionOfferUtils.kt (3)

14-16: LGTM: adding billingPeriod to SubscriptionOfferInfo

Optional field with sensible default; backward compatible.


43-45: Good: derive billingPeriod from receipt

Straightforward extraction; resilient to missing data.

If Horizon expects ISO‑8601 durations (e.g., P1M/P1Y) strictly, consider normalizing to uppercase and validating with a light regex before storing.


52-56: LGTM: propagating billingPeriod in resolved info

Keeps monthly/yearly display while carrying Horizon period when present.

packages/google/Example/build.gradle.kts (2)

9-14: Nice: local.properties loading is safe and guarded

No issues.


98-98: LGTM: buildConfig enabled

Required for BuildConfig fields used by runtime selection.

packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (2)

23-25: LGTM: hoisting OpenIapStore to Activity scope

Ensures a single shared connection across screens.


60-65: LGTM: providing store via IapContext

Correctly scopes the store to the composition subtree.

packages/google/Example/src/main/java/dev/hyo/martie/screens/AlternativeBillingScreen.kt (1)

54-63: Constructor usage looks good; minor suggestion.

Init via default OpenIapStore(appContext) and passing mode via config is aligned. Consider disabling OpenIapLog in release builds.

If you want, I can gate logs by BuildConfig.DEBUG across the Example module.

packages/google/Example/src/main/java/dev/hyo/martie/screens/SubscriptionFlowScreen.kt (2)

88-90: OpenIapStore init looks correct.

Constructor change is aligned with protocol-based init; no issues spotted.


120-125: Good: platform-aware SKU selection.

Using IapConstants.getSubscriptionSkus() centralizes platform logic.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1)

435-461: Critical: launchBillingFlow must run on the main thread.

The launchBillingFlow call at line 440 is a UI operation that must execute on the main thread, but it's currently invoked within withContext(Dispatchers.IO). This can cause crashes or undefined behavior.

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

-                val result = client.launchBillingFlow(activity, billingFlowParams)
+                val result = withContext(Dispatchers.Main) {
+                    client.launchBillingFlow(activity, billingFlowParams)
+                }
                 android.util.Log.i(TAG, "=== BILLING FLOW LAUNCHED ===")
🧹 Nitpick comments (2)
packages/google/CONVENTION.md (1)

5-26: Clarify convention change relative to prior learning and fix markdown formatting.

The new Android-Specific Functions convention states "DO NOT add Android suffix" for function names, which contradicts the retrieved learning indicating "Android-specific functions must end with Android". Confirm this is an intentional convention shift for the Horizon integration.

Additionally, the static analysis tool flagged markdown formatting: code blocks at lines 10–16 and 19–23 should be surrounded by blank lines per MD031.

Apply this diff to fix the markdown formatting:

 **✅ Correct**:
+
 ```kotlin
 fun acknowledgePurchase()
 fun consumePurchase()
 fun getPackageName()
 fun buildModule(context: Context)
 fun isHorizonEnvironment(context: Context)

❌ Incorrect:
+

fun acknowledgePurchaseAndroid()  // Don't add Android suffix
fun consumePurchaseAndroid()      // Don't add Android suffix
fun buildModuleAndroid()          // Don't add Android suffix

</blockquote></details>
<details>
<summary>packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt (1)</summary><blockquote>

`29-34`: **Add safe-call operators for consistency and defensive programming.**

While `Build.MANUFACTURER` and `Build.BRAND` are marked non-null in the SDK, line 33 already uses a safe-call for `Build.MODEL`. For consistency and to guard against rare device edge cases, apply safe-calls to all Build fields.



```diff
 fun isHorizonOS(): Boolean {
-    return Build.MANUFACTURER.equals("Meta", ignoreCase = true) ||
-           Build.BRAND.equals("Meta", ignoreCase = true) ||
+    return (Build.MANUFACTURER?.equals("Meta", ignoreCase = true) == true) ||
+           (Build.BRAND?.equals("Meta", ignoreCase = true) == true) ||
            (Build.MODEL?.contains("Quest", ignoreCase = true) == true)
 }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a71ea49 and afbfb61.

📒 Files selected for processing (10)
  • CLAUDE.md (3 hunks)
  • packages/google/CONVENTION.md (1 hunks)
  • packages/google/Example/build.gradle.kts (3 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt (2 hunks)
  • packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (2 hunks)
  • packages/google/openiap/build.gradle.kts (4 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt (1 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt (1 hunks)
✅ Files skipped from review due to trivial changes (1)
  • CLAUDE.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt
🧰 Additional context used
📓 Path-based instructions (1)
packages/google/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Remove unused code and commented-out code in Kotlin sources

Files:

  • packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt
  • packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt
🧠 Learnings (1)
📚 Learning: 2025-10-17T07:09:45.156Z
Learnt from: CR
PR: hyodotdev/openiap#0
File: CLAUDE.md:0-0
Timestamp: 2025-10-17T07:09:45.156Z
Learning: Applies to packages/google/openiap/src/main/java/**/*.kt : Android-specific functions must end with Android; cross-platform functions have no suffix

Applied to files:

  • packages/google/CONVENTION.md
🧬 Code graph analysis (1)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (3)
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (1)
  • buildBillingClient (895-1014)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt (3)
  • queryProductDetailsHorizon (98-106)
  • restorePurchasesHorizon (19-42)
  • queryPurchasesHorizon (47-93)
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)
  • getActiveSubscriptions (372-373)
🪛 markdownlint-cli2 (0.18.1)
packages/google/CONVENTION.md

10-10: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)


19-19: Fenced code blocks should be surrounded by blank lines

(MD031, blanks-around-fences)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Test Android
🔇 Additional comments (17)
packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (2)

22-23: LGTM: Activity-level store with lazy initialization.

The lazy initialization at Activity level ensures the store persists across navigation and avoids memory leaks by using applicationContext.


61-62: LGTM: Provider pattern for store sharing.

The IapContext.OpenIapProvider wrapper cleanly shares the store instance across all navigation destinations, following Compose best practices for dependency injection.

packages/google/Example/build.gradle.kts (2)

30-38: LGTM! Past review concern addressed.

The OCULUS_APP_ID placeholder is now set in defaultConfig, ensuring all flavors (including play) inherit it. This resolves the previous critical issue where play builds would fail due to an unresolved manifest placeholder.


42-73: LGTM! Flavor configuration is correct.

The product flavors are properly configured:

  • Auto flavor (default) includes both Play and Horizon support with appropriate OCULUS_APP_ID
  • Play flavor correctly inherits OCULUS_APP_ID from defaultConfig
  • Horizon flavor overrides with Horizon-specific app ID
packages/google/openiap/build.gradle.kts (2)

36-77: LGTM! Flavor and source set configuration is correct.

The configuration properly sets up three platform flavors with appropriate build settings. The auto flavor sourcing from src/horizon/java is intentional, enabling runtime platform detection while maintaining horizon implementation.


84-103: LGTM! Well-documented dependency strategy.

The billing dependency configuration is correct and clearly documented. Using compileOnly for main source and per-flavor runtime dependencies ensures proper compilation while minimizing APK size for single-platform builds.

packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt (1)

36-54: LGTM! Runtime SKU selection is well-implemented.

The platform-aware SKU selection with clear constants and delegation pattern properly supports both Horizon and Play Store environments.

packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/utils/HorizonBillingConverters.kt (4)

19-46: LGTM! In-app product converter is correct.

The converter properly handles Horizon product details with appropriate null-safety and maps to the internal ProductAndroid model.


48-96: LGTM! Subscription product converter handles complex nested structure correctly.

The converter properly maps Horizon's subscription offer details including nested pricing phases to the internal ProductSubscriptionAndroid model.


98-123: LGTM! Purchase converter now correctly maps state.

The past critical issue regarding hardcoded purchase state has been resolved. The converter now properly calls PurchaseState.fromHorizonState() to map the actual purchase state.


149-154: LGTM! State mapping is comprehensive and correct.

The companion function properly maps all Horizon purchase states to internal enum values with appropriate fallback to Unknown.

packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonProductManager.kt (2)

16-27: LGTM! Past thread-safety concerns fully addressed.

The implementation now uses ConcurrentHashMap with a composite CacheKey(productId, productType) to ensure thread-safe operations and prevent collisions between product types.


35-123: LGTM! Coroutine cancellation properly handled.

The getOrQuery implementation correctly guards continuation resume with isActive checks and includes cancellation callback. The logic preserves input order and provides comprehensive logging.

packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (4)

83-110: LGTM! Module initialization is correct.

The class properly implements OpenIapProtocol and PurchasesUpdatedListener, initializes billing client, and sets up listener management.


582-605: LGTM! restorePurchases now properly emits updates.

The past issue where restorePurchases was a no-op has been resolved. The implementation now queries all purchases and notifies registered listeners correctly.


679-767: LGTM! Sensitive data properly redacted in logs.

The past security concern has been addressed. Purchase tokens and order IDs are now redacted at lines 690-691, showing only the first 8 characters.


837-872: LGTM! Alternative billing dialog correctly shown on main thread.

The past issue has been resolved. The dialog invocation at lines 849-854 is now properly wrapped in runOnUiThread to ensure UI operations occur on the main thread.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between afbfb61 and 5d13cfa.

📒 Files selected for processing (1)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
packages/google/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Remove unused code and commented-out code in Kotlin sources

Files:

  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
🧬 Code graph analysis (1)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (3)
packages/google/openiap/src/main/java/dev/hyo/openiap/OpenIapModule.kt (2)
  • buildBillingClient (895-1014)
  • buildAndLaunch (518-632)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt (3)
  • queryProductDetailsHorizon (98-106)
  • restorePurchasesHorizon (19-42)
  • queryPurchasesHorizon (47-93)
packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt (1)
  • getActiveSubscriptions (372-373)
🪛 detekt (1.23.8)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt

[warning] 849-849: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 887-887: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 915-915: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Test Android
🔇 Additional comments (3)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (3)

801-836: LGTM!

The helper methods for querying product details and building the billing client are well-implemented with proper error handling and clean logic.


838-904: LGTM! Alternative billing implementation is correct.

The alternative billing methods properly:

  • Call UI operations on the main thread via runOnUiThread
  • Log exceptions before rethrowing
  • Handle unsupported methods gracefully

The static analysis warnings about swallowed exceptions (lines 849, 887, 915) are false positives—exceptions are logged via OpenIapLog.w before being wrapped and rethrown.


938-948: User choice billing no-ops are appropriate.

User choice billing is correctly marked as unsupported for Horizon. The no-op implementations prevent errors when protocol methods are called.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (1)

7-8: Remove unused icon imports.

The Icons and Icons.filled.ArrowBack imports are not used in this file.

As per coding guidelines

Apply this diff:

-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowBack
♻️ Duplicate comments (3)
packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (1)

39-47: Potential ANR risk with runBlocking on main thread.

This issue was already flagged in a previous review. Using runBlocking on the main thread during onDestroy() to call endConnection() (which performs I/O operations) can cause an Application Not Responding (ANR) error.

As previously recommended, consider moving the cleanup to onStop() where lifecycleScope is still active.

packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (2)

740-762: Fragile product type detection fallback remains.

The fallback heuristic that checks if product ID contains "subs" (lines 749-755) is unreliable and was previously flagged. Product IDs are arbitrary strings and may not follow naming conventions.

Consider implementing the previously suggested alternatives:

  1. Query both product types (SUBS and INAPP) when cache misses and use whichever returns a valid result
  2. Ensure fetchProducts explicitly stores and persists productType for all IDs
  3. Replace the string-matching fallback with a deterministic lookup that queries productManager for both types

113-113: Remove excessive android.util.Log statements per coding guidelines.

The file contains extensive android.util.Log calls that duplicate OpenIapLog functionality throughout. These debug artifacts should be removed per the coding guidelines for Kotlin sources.

Based on coding guidelines.

Remove the redundant android.util.Log statements and retain only OpenIapLog calls. For example:

-    android.util.Log.i(TAG, "=== OpenIapHorizonModule INIT (Modified version with fix) ===")

Apply similar removals for the android.util.Log calls at lines 124-127, 136-152, 227-229, 233-243, 265-271, 274-276, 279-280, 283-285, 288-290, 293-294, 297-299, 307-308, 387-388, 441-443, 448-450, 467, 479-480, 488-489, 717, 724, 728-729, 733-734, 737-738, 752-753, 756-757, 760-761, 764-765, 766-768, 770-772, 776-778, 781-782, 787-788, 791, 794-795, 798-799, and 801.

Also applies to: 124-127, 136-152, 227-229, 233-243, 265-271, 274-276, 279-280, 283-285, 288-290, 293-294, 297-299, 307-308, 387-388, 441-443, 448-450, 467-467, 479-480, 488-489, 717-717, 724-724, 728-729, 733-734, 737-738, 752-753, 756-757, 760-761, 764-765, 766-768, 770-772, 776-778, 781-782, 787-788, 791-791, 794-795, 798-799, 801-801

🧹 Nitpick comments (1)
packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (1)

42-42: Consider importing runBlocking for consistency.

Using the fully qualified name kotlinx.coroutines.runBlocking is unusual. For consistency with typical Kotlin conventions, consider importing it at the top of the file.

Apply this diff:

Add the import:

 import dev.hyo.openiap.IapContext
 import dev.hyo.openiap.store.OpenIapStore
+import kotlinx.coroutines.runBlocking

Then update the usage:

         runCatching {
-            kotlinx.coroutines.runBlocking {
+            runBlocking {
                 iapStore.endConnection()
             }
         }
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 5d13cfa and e8da622.

📒 Files selected for processing (2)
  • packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (2 hunks)
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1 hunks)
🧰 Additional context used
📓 Path-based instructions (1)
packages/google/**/*.kt

📄 CodeRabbit inference engine (CLAUDE.md)

Remove unused code and commented-out code in Kotlin sources

Files:

  • packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt
  • packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt
🧠 Learnings (1)
📚 Learning: 2025-10-18T05:46:51.571Z
Learnt from: hyochan
PR: hyodotdev/openiap#17
File: packages/google/openiap/src/main/java/dev/hyo/openiap/store/OpenIapStore.kt:620-629
Timestamp: 2025-10-18T05:46:51.571Z
Learning: In packages/google/openiap/src/main/java/**/*.kt (Android-only package): DO NOT add Android suffix to function names, even for Android-specific APIs. Exception: Only use Android suffix for cross-platform API types (e.g., ProductAndroid, PurchaseAndroid) that contrast with iOS types. Examples of correct naming: isHorizonEnvironment(context: Context), buildModule(context: Context), acknowledgePurchase(), consumePurchase().

Applied to files:

  • packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt
🧬 Code graph analysis (1)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt (1)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/helpers/HorizonHelpers.kt (3)
  • queryProductDetailsHorizon (98-106)
  • restorePurchasesHorizon (19-42)
  • queryPurchasesHorizon (47-93)
🪛 detekt (1.23.8)
packages/google/openiap/src/horizon/java/dev/hyo/openiap/horizon/OpenIapHorizonModule.kt

[warning] 853-853: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 891-891: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)


[warning] 919-919: The caught exception is swallowed. The original exception could be lost.

(detekt.exceptions.SwallowedException)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Test Android
🔇 Additional comments (2)
packages/google/Example/src/main/java/dev/hyo/martie/MainActivity.kt (2)

21-22: LGTM!

The lazy initialization of OpenIapStore at the Activity level with applicationContext is correct and ensures the store persists across navigation while avoiding context leaks.


50-95: LGTM!

The updated AppNavigation signature and the IapContext.OpenIapProvider wrapping correctly enable the shared OpenIapStore to be accessible across all navigation destinations. The implementation follows the provider pattern appropriately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant