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
33 changes: 21 additions & 12 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,14 @@ openiap/

## Required Pre-Work

- Before writing or editing anything, load and review the relevant `CONVENTION.md` file in the specific package directory
- For cross-package changes, review conventions for all affected packages
**CRITICAL**: Before writing or editing anything in a package, **ALWAYS** load and review the relevant `CONVENTION.md` file:

- **GraphQL Types** (`packages/gql`): See [`packages/gql/CONVENTION.md`](packages/gql/CONVENTION.md)
- **Android Library** (`packages/google`): See [`packages/google/CONVENTION.md`](packages/google/CONVENTION.md)
- **Apple Library** (`packages/apple`): See [`packages/apple/CONVENTION.md`](packages/apple/CONVENTION.md)
- **Documentation** (`packages/docs`): Follow conventions in this file (below)

For cross-package changes, review conventions for all affected packages.

---

Expand Down Expand Up @@ -289,20 +295,20 @@ Before committing any changes:

### Android Function Naming Conventions

- **Android-specific functions MUST have `Android` suffix**
- **Cross-platform functions have NO suffix**
**IMPORTANT**: See [`packages/google/CONVENTION.md`](packages/google/CONVENTION.md) for detailed Android naming conventions.

#### Android Examples
**Key Rule**: Since `packages/google` is Android-only, **DO NOT add `Android` suffix** to function names, even for Android-specific APIs.

**✅ Correct**:

```kotlin
// Android-specific
fun acknowledgePurchaseAndroid()
fun consumePurchaseAndroid()
fun getPackageNameAndroid()
// Android-specific functions (no suffix needed)
fun acknowledgePurchase()
fun consumePurchase()
fun getPackageName()
fun buildModule(context: Context)

// Cross-platform
// Cross-platform API functions
fun initConnection()
fun fetchProducts()
fun requestPurchase()
Expand All @@ -311,10 +317,13 @@ fun requestPurchase()
**❌ Incorrect**:

```kotlin
// Missing Android suffix
fun acknowledgePurchase() // Should be acknowledgePurchaseAndroid()
// Don't add Android suffix in Android-only package
fun acknowledgePurchaseAndroid() // Wrong!
fun buildModuleAndroid() // Wrong!
```

**Exception**: Only use `Android` suffix for types that are part of a cross-platform API (e.g., `ProductAndroid`, `PurchaseAndroid` that contrast with iOS types).

---

## iOS Library (`packages/apple`)
Expand Down
54 changes: 54 additions & 0 deletions packages/google/.github/workflows/ci-horizon.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: CI Horizon

on:
push:
pull_request:

permissions:
contents: read

concurrency:
group: ci-horizon-${{ github.ref }}
cancel-in-progress: true

jobs:
wrapper-validation:
name: Validate Gradle Wrapper
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Validate Wrapper
uses: gradle/wrapper-validation-action@v2

horizon-build:
name: Build Horizon flavors
runs-on: ubuntu-latest
needs: wrapper-validation
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Java 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- name: Set up Gradle
uses: gradle/gradle-build-action@v2

- name: Set up Android SDK
uses: android-actions/setup-android@v3

- name: Install required SDK packages
run: |
yes | sdkmanager --licenses
sdkmanager --install \
"platform-tools" \
"platforms;android-34" \
"build-tools;34.0.0"

Comment thread
coderabbitai[bot] marked this conversation as resolved.
- name: Build Horizon variants
working-directory: packages/google
run: ./gradlew --stacktrace --no-daemon :openiap:assembleHorizonDebug :Example:assembleHorizonDebug
Comment thread
coderabbitai[bot] marked this conversation as resolved.
52 changes: 52 additions & 0 deletions packages/google/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,58 @@ cd openiap-google
adb shell am start -n dev.hyo.martie/.MainActivity
```

## Horizon OS Development (Meta Quest)

This library supports both Google Play Store and Meta Horizon OS (Quest devices) using product flavors.

### Setting Up Horizon App ID

1. **Create `local.properties`** in the project root (if it doesn't exist):

```properties
# local.properties (DO NOT commit this file)
sdk.dir=/path/to/Android/sdk

# Horizon OS App ID (Meta Quest)
EXAMPLE_HORIZON_APP_ID=your_horizon_app_id_here
```

1. **Alternative: Pass via command line**:

```bash
# Build with App ID
./gradlew :Example:assembleHorizonDebug -PEXAMPLE_HORIZON_APP_ID=your_app_id

# Install with App ID
./gradlew :Example:installHorizonDebug -PEXAMPLE_HORIZON_APP_ID=your_app_id
```

1. **Using Android Studio**:
- Open **View > Tool Windows > Build Variants**
- Set **Example** module to **horizonDebug**
- Set **openiap** module to **horizonDebug**
- Run the app (App ID will be read from `local.properties`)

### Build Variants

- **playDebug** / **playRelease** - Google Play Store billing
- **horizonDebug** / **horizonRelease** - Meta Horizon OS billing

### Testing on Quest Devices

```bash
# Connect Quest via ADB
adb devices

# Install Horizon variant
./gradlew :Example:installHorizonDebug

# View logs
adb logcat | grep -E "OpenIap|Horizon"
```

**Note**: The Horizon App ID is required for Horizon Billing to work. Without it, the billing client will fail to connect.

## Generated Types

- All GraphQL models in `openiap/src/main/java/dev/hyo/openiap/Types.kt` are generated from the [`openiap` monorepo](https://github.com/hyodotdev/openiap/tree/main/packages/gql). When you update API behavior, adjust the upstream type generator first so the Kotlin output stays in sync across platforms.
Expand Down
22 changes: 22 additions & 0 deletions packages/google/CONVENTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@

## Naming Conventions

### Android-Specific Functions

**IMPORTANT**: Since this is an Android-only package, **DO NOT add `Android` suffix** to function names, even for Android-specific APIs.

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

**❌ Incorrect**:
```kotlin
fun acknowledgePurchaseAndroid() // Don't add Android suffix
fun consumePurchaseAndroid() // Don't add Android suffix
fun buildModuleAndroid() // Don't add Android suffix
```

**Exception**: Only add `Android` suffix when the function is part of a cross-platform API that has platform-specific variants (e.g., `ProductAndroid`, `PurchaseAndroid` types that contrast with iOS types).

### Enum Values
- Enum values in this codebase must use **kebab-case** (e.g., `non-consumable`, `in-app`, `user-cancelled`)
- This matches the convention used in the auto-generated Types.kt from GraphQL schemas
Expand Down
54 changes: 54 additions & 0 deletions packages/google/Example/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,9 +1,18 @@
import java.util.Properties

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
}

// Load local.properties
val localProperties = Properties()
val localPropertiesFile = rootProject.file("local.properties")
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { localProperties.load(it) }
}

android {
namespace = "dev.hyo.martie"
compileSdk = 34
Expand All @@ -17,6 +26,50 @@ android {

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID")
?: localProperties.getProperty("EXAMPLE_OPENIAP_APP_ID")
?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?)
?: (project.findProperty("EXAMPLE_OPENIAP_APP_ID") as String?)
?: ""
buildConfigField("String", "HORIZON_APP_ID", "\"${appId}\"")
// Ensure placeholder exists for all variants (play included)
manifestPlaceholders["OCULUS_APP_ID"] = appId
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

flavorDimensions += "platform"

productFlavors {
// Auto flavor (default) - includes both libraries, detects platform at runtime
create("auto") {
dimension = "platform"
buildConfigField("String", "OPENIAP_STORE", "\"auto\"")
isDefault = true

// Dynamically inject OCULUS_APP_ID into AndroidManifest (needed for Horizon)
val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID")
?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?)
?: ""
manifestPlaceholders["OCULUS_APP_ID"] = appId
}

// Play flavor - Google Play Billing only
create("play") {
dimension = "platform"
buildConfigField("String", "OPENIAP_STORE", "\"play\"")
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Horizon flavor - Meta Horizon Billing only
create("horizon") {
dimension = "platform"
buildConfigField("String", "OPENIAP_STORE", "\"horizon\"")

// Dynamically inject OCULUS_APP_ID into AndroidManifest
val appId = localProperties.getProperty("EXAMPLE_HORIZON_APP_ID")
?: (project.findProperty("EXAMPLE_HORIZON_APP_ID") as String?)
?: ""
manifestPlaceholders["OCULUS_APP_ID"] = appId
}
}

buildTypes {
Expand Down Expand Up @@ -44,6 +97,7 @@ android {

buildFeatures {
compose = true
buildConfig = true
}

packaging {
Expand Down
12 changes: 12 additions & 0 deletions packages/google/Example/src/horizon/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

<application>
<!-- Meta Horizon App ID for Horizon Billing -->
<!-- Value is injected from local.properties via Gradle -->
<meta-data
android:name="com.meta.horizon.platform.ovr.OCULUS_APP_ID"
android:value="${OCULUS_APP_ID}" />
</application>

</manifest>
5 changes: 5 additions & 0 deletions packages/google/Example/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
android:label="OpenIAP Example"
android:supportsRtl="true">

<!-- Horizon OS App ID (set via gradle manifestPlaceholders) -->
<meta-data
android:name="com.meta.horizon.platform.ovr.OCULUS_APP_ID"
android:value="${OCULUS_APP_ID}" />

Comment thread
coderabbitai[bot] marked this conversation as resolved.
<activity
android:name=".MainActivity"
android:exported="true">
Expand Down
43 changes: 39 additions & 4 deletions packages/google/Example/src/main/java/dev/hyo/martie/Constants.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package dev.hyo.martie

import android.os.Build

object IapConstants {
// App-defined SKU lists
val INAPP_SKUS = listOf(
Expand All @@ -8,13 +10,46 @@ object IapConstants {
"dev.hyo.martie.certified" // Non-consumable
)

val SUBS_SKUS = listOf(
// Google Play: Two separate subscription products
private val SUBS_SKUS_PLAY = listOf(
"dev.hyo.martie.premium", // Main subscription with multiple offers
"dev.hyo.martie.premium_year" // Separate yearly subscription product
)

// Base plan IDs for dev.hyo.martie.premium subscription
const val PREMIUM_MONTHLY_BASE_PLAN = "premium" // Monthly base plan
const val PREMIUM_YEARLY_BASE_PLAN = "premium-year" // Yearly base plan
// Horizon OS: Two separate SKUs (both at Level 1 to prevent auto-upgrade)
// IMPORTANT: In Horizon Developer Console, both must be set to the SAME Level
// to prevent automatic tier upgrades. Currently configured as:
// - premium (Level 1): Has MONTHLY and ANNUAL offers
// - premium_year (Level 1): Has ANNUAL offer only
private val SUBS_SKUS_HORIZON = listOf(
"dev.hyo.martie.premium", // Premium with multiple term options
"dev.hyo.martie.premium_year" // Separate yearly-only subscription
)

// Detect if running on Horizon OS at runtime
fun isHorizonOS(): Boolean {
return Build.MANUFACTURER.equals("Meta", ignoreCase = true) ||
Build.BRAND.equals("Meta", ignoreCase = true) ||
(Build.MODEL?.contains("Quest", ignoreCase = true) == true)
}
Comment thread
hyochan marked this conversation as resolved.

// Get subscription SKUs based on current device
fun getSubscriptionSkus(): List<String> {
val isHorizon = isHorizonOS()
val skus = if (isHorizon) SUBS_SKUS_HORIZON else SUBS_SKUS_PLAY
println("IapConstants: getSubscriptionSkus() - isHorizon=$isHorizon, skus=$skus")
return skus
}

// Legacy: For screens that don't have platform context yet
val SUBS_SKUS = getSubscriptionSkus()

// Product IDs
const val PREMIUM_PRODUCT_ID = "dev.hyo.martie.premium"
const val PREMIUM_YEARLY_PRODUCT_ID_PLAY = "dev.hyo.martie.premium_year" // Play only

// Base plan IDs (used by both Play and Horizon)
const val PREMIUM_MONTHLY_BASE_PLAN = "premium" // 1 month plan
const val PREMIUM_YEARLY_BASE_PLAN = "premium-year" // 12 months plan
}

Loading
Loading