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
5 changes: 5 additions & 0 deletions .claude/commands/audit-code.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: audit-code
description: Audit OpenIAP code against the knowledge-base rules and current platform APIs, then fix the violations it finds. Use when the user asks to audit code, check convention compliance, or verify sources against knowledge/internal.
---

# Audit Code Against Knowledge Rules

Automated workflow to check and fix code based on knowledge rules and latest platform APIs.
Expand Down
5 changes: 5 additions & 0 deletions .claude/commands/commit.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: commit
description: Branch, commit, push, and optionally open a pull request for the current changes. Use when the user asks to commit, push, or create a PR, including forms like `/commit --all --pr`.
---

# Commit Changes

Complete workflow: branch → commit → push → PR
Expand Down
5 changes: 5 additions & 0 deletions .claude/commands/compile-knowledge.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: compile-knowledge
description: Compile the OpenIAP knowledge base into the context files AI assistants load. Use after editing anything under knowledge/, or when the user asks to compile, recompile, or refresh the knowledge base or agent context.
---

# Compile Knowledge Base

Compile the OpenIAP knowledge base to generate context files for AI assistants.
Expand Down
9 changes: 8 additions & 1 deletion .claude/commands/e2e-tests.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: e2e-tests
description: Run device-backed OpenIAP regression across native packages and framework examples using real devices and store accounts. Use when the user asks for e2e tests, device testing, store purchase-flow verification, or a full PR regression on hardware.
---

# E2E Tests — Device-Backed OpenIAP Regression

Run this when a PR or release candidate needs real-device regression across
Expand Down Expand Up @@ -607,8 +612,10 @@ FireOS/Amazon Android build and launch smoke:
cd libraries/flutter_inapp_purchase/example/android
./gradlew :app:assembleDebug -PfireOsEnabled=true
# Build-only regression can stop here.
# Flutter redirects its gradle output to `example/build/app/outputs/flutter-apk/`,
# so this path is not the `android/app/build/...` layout the other examples use.
: "${FIREOS_SERIAL:?Set FIREOS_SERIAL to the target FireOS device serial}"
adb -s "$FIREOS_SERIAL" install -r app/build/outputs/apk/debug/app-debug.apk
adb -s "$FIREOS_SERIAL" install -r ../build/app/outputs/flutter-apk/app-debug.apk
adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1
```

Expand Down
5 changes: 5 additions & 0 deletions .claude/commands/release.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: release
description: Release OpenIAP packages (stable or prerelease) one at a time, verifying the public registry before continuing. Use when the user asks to release, publish, or deploy a package or run a release train.
---

# Release Packages

Use this workflow for stable or prerelease package deployment. Release one
Expand Down
5 changes: 5 additions & 0 deletions .claude/commands/resolve-issue.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: resolve-issue
description: Analyze a GitHub issue, apply labels, then either fix it with a PR or reply with the analysis. Use when the user names an issue number and asks to resolve, triage, fix, or investigate it.
---

# Resolve Issue

Analyze a GitHub issue, add labels, and either fix it with a PR or comment with analysis.
Expand Down
5 changes: 5 additions & 0 deletions .claude/commands/review-pr.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: review-pr
description: Review and address pull-request review comments by fixing valid findings, replying to invalid ones, running lint/typecheck/tests, then resolving the threads. Use when the user asks to review a PR, handle review feedback, or address reviewer comments on a pull request.
---

# Review PR Comments

Review and address PR review comments for this repository.
Expand Down
5 changes: 5 additions & 0 deletions .claude/commands/verify-all.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
---
name: verify-all
description: Run the full monorepo health check across every package and library. Use before committing or opening a PR, or when the user asks to verify everything, run all checks, or confirm the repo is healthy.
---

# Verify All — Full Monorepo Health Check

Run this before committing or creating a PR to verify the entire monorepo is healthy.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import expo.modules.kotlin.Promise
import expo.modules.kotlin.exception.Exceptions
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -58,6 +59,20 @@ internal suspend fun endExpoConnectionWithCleanup(
internal fun endConnectionErrorCode(error: Exception): String =
(error as? OpenIapError)?.code ?: OpenIapError.ServiceDisconnected.CODE

internal fun deliverPurchaseRequestFailure(
reachedOpenIapRequest: Boolean,
isCancellation: Boolean = false,
errorCode: String,
errorEnvelope: String,
emitLocalError: () -> Unit,
rejectPendingPromises: (String, String) -> Unit,
) {
if (!reachedOpenIapRequest && !isCancellation) {
emitLocalError()
}
rejectPendingPromises(errorCode, errorEnvelope)
}

class ExpoIapModule : Module() {
companion object {
const val TAG = "ExpoIapModule"
Expand Down Expand Up @@ -387,9 +402,11 @@ class ExpoIapModule : Module() {

ExpoIapHelper.addPurchasePromise(promise)
scope.launch {
var reachedOpenIapRequest = false
try {
val activity = currentActivity
openIap.setActivity(activity)
reachedOpenIapRequest = true
val result = openIap.requestPurchase(requestProps)
val purchases =
when (result) {
Expand All @@ -415,23 +432,33 @@ class ExpoIapModule : Module() {
)
}
val errorCode = errorMap["code"] as? String ?: OpenIapError.PurchaseFailed.CODE
runCatching {
ExpoIapHelper.emitOrQueue(
this@ExpoIapModule,
scope,
connectionReady,
pendingEvents,
EVENT_PURCHASE_ERROR,
errorMap,
)
}.onFailure { ex ->
ExpoIapLog.failure("send PURCHASE_ERROR event requestPurchase", ex)
}
ExpoIapHelper.rejectPurchasePromises(
errorCode,
ExpoIapHelper.serializeErrorEnvelope(errorMap),
null,
val errorEnvelope = ExpoIapHelper.serializeErrorEnvelope(errorMap)
deliverPurchaseRequestFailure(
reachedOpenIapRequest = reachedOpenIapRequest,
isCancellation = e is CancellationException,
errorCode = errorCode,
errorEnvelope = errorEnvelope,
emitLocalError = {
runCatching {
ExpoIapHelper.emitOrQueue(
this@ExpoIapModule,
scope,
connectionReady,
pendingEvents,
EVENT_PURCHASE_ERROR,
errorMap,
)
}.onFailure { ex ->
ExpoIapLog.failure("send PURCHASE_ERROR event requestPurchase", ex)
}
},
rejectPendingPromises = { code, message ->
ExpoIapHelper.rejectPurchasePromises(code, message, null)
},
)
if (e is CancellationException) {
throw e
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,58 @@ class ExpoIapHelperTest {
assertEquals(OpenIapError.NetworkError.CODE, endConnectionErrorCode(OpenIapError.NetworkError))
}

@Test
fun `purchase failure before core emits and rejects`() {
var emitted = 0
var rejected: Pair<String, String>? = null

deliverPurchaseRequestFailure(
reachedOpenIapRequest = false,
errorCode = "purchase-error",
errorEnvelope = "envelope",
emitLocalError = { emitted += 1 },
rejectPendingPromises = { code, message -> rejected = code to message },
)

assertEquals(1, emitted)
assertEquals("purchase-error" to "envelope", rejected)
}

@Test
fun `purchase failure after core suppresses duplicate event but still rejects`() {
var emitted = 0
var rejected: Pair<String, String>? = null

deliverPurchaseRequestFailure(
reachedOpenIapRequest = true,
errorCode = "user-cancelled",
errorEnvelope = "envelope",
emitLocalError = { emitted += 1 },
rejectPendingPromises = { code, message -> rejected = code to message },
)

assertEquals(0, emitted)
assertEquals("user-cancelled" to "envelope", rejected)
}

@Test
fun `purchase coroutine cancellation rejects without publishing a purchase error`() {
var emitted = 0
var rejected: Pair<String, String>? = null

deliverPurchaseRequestFailure(
reachedOpenIapRequest = false,
isCancellation = true,
errorCode = "service-disconnected",
errorEnvelope = "envelope",
emitLocalError = { emitted += 1 },
rejectPendingPromises = { code, message -> rejected = code to message },
)

assertEquals(0, emitted)
assertEquals("service-disconnected" to "envelope", rejected)
}

@Test
fun `end connection falls back to service disconnected`() {
assertEquals(
Expand Down
27 changes: 27 additions & 0 deletions libraries/expo-iap/ios/ExpoIapHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,33 @@ enum ExpoIapHelper {
array
}

// Keep Expo IAP compatible with the currently published OpenIAP native
// package while treating authoritative query serialization atomically.
// Its non-throwing helpers use an empty dictionary as the failure sentinel.
static func encodeRequired<T: Encodable>(_ value: T) throws -> [String: Any] {
let encoded = OpenIapSerialization.encode(value)
guard !encoded.isEmpty else {
throw PurchaseError.make(
code: .billingResponseJsonParseError,
message: "Failed to serialize native \(T.self) payload"
)
}
return encoded
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

static func purchasesRequired(_ purchases: [Purchase]) throws -> [[String: Any]] {
try purchases.map { purchase in
let encoded = OpenIapSerialization.purchase(purchase)
guard !encoded.isEmpty else {
throw PurchaseError.make(
code: .billingResponseJsonParseError,
message: "Failed to serialize native purchase payload"
)
}
return encoded
}
}

static func parseProductQueryType(_ rawValue: String?) throws -> ProductQueryType {
guard let raw = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty
else {
Expand Down
20 changes: 14 additions & 6 deletions libraries/expo-iap/ios/ExpoIapModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public final class ExpoIapModule: Module {
ExpoIapLog.payload("getAvailablePurchases", payload: options ?? [:])
let purchaseOptions = try options.map { try OpenIapSerialization.purchaseOptions(from: $0) }
let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions)
let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
let sanitized = ExpoIapHelper.sanitizeArray(try ExpoIapHelper.purchasesRequired(purchases))
ExpoIapLog.result("getAvailablePurchases", value: sanitized)
return sanitized
}
Expand All @@ -162,23 +162,27 @@ public final class ExpoIapModule: Module {
]
let options = try OpenIapSerialization.purchaseOptions(from: optionsDictionary)
let purchases = try await OpenIapModule.shared.getAvailablePurchases(options)
let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases))
let sanitized = ExpoIapHelper.sanitizeArray(try ExpoIapHelper.purchasesRequired(purchases))
ExpoIapLog.result("getAvailableItems", value: sanitized)
return sanitized
}

AsyncFunction("getPendingTransactionsIOS") { () async throws -> [[String: Any]] in
ExpoIapLog.payload("getPendingTransactionsIOS", payload: nil)
let pending = try await OpenIapModule.shared.getPendingTransactionsIOS()
let sanitized = pending.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
let sanitized = try pending.map {
ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))
}
ExpoIapLog.result("getPendingTransactionsIOS", value: sanitized)
return sanitized
}

AsyncFunction("getAllTransactionsIOS") { () async throws -> [[String: Any]] in
ExpoIapLog.payload("getAllTransactionsIOS", payload: nil)
let all = try await OpenIapModule.shared.getAllTransactionsIOS()
let sanitized = all.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
let sanitized = try all.map {
ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))
}
ExpoIapLog.result("getAllTransactionsIOS", value: sanitized)
return sanitized
}
Expand Down Expand Up @@ -270,7 +274,9 @@ public final class ExpoIapModule: Module {
AsyncFunction("showManageSubscriptionsIOS") { () async throws -> [[String: Any]] in
ExpoIapLog.payload("showManageSubscriptionsIOS", payload: nil)
let purchases = try await OpenIapModule.shared.showManageSubscriptionsIOS()
let sanitized = purchases.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
let sanitized = try purchases.map {
ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))
}
ExpoIapLog.result("showManageSubscriptionsIOS", value: sanitized)
return sanitized
}
Expand Down Expand Up @@ -384,7 +390,9 @@ public final class ExpoIapModule: Module {
AsyncFunction("getActiveSubscriptions") { (subscriptionIds: [String]?) async throws -> [[String: Any]] in
ExpoIapLog.payload("getActiveSubscriptions", payload: subscriptionIds.map { ["subscriptionIds": $0] } ?? [:])
let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds)
let sanitized = subscriptions.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) }
let sanitized = try subscriptions.map {
ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))
}
ExpoIapLog.result("getActiveSubscriptions", value: sanitized)
return sanitized
}
Expand Down
22 changes: 22 additions & 0 deletions libraries/expo-iap/src/__tests__/index.kepler.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
fetchProducts,
getAvailablePurchases,
openRedeemOfferCodeAndroid,
requestPurchase,
} from '../index.kepler';
Expand All @@ -11,12 +12,14 @@ jest.mock('../vega', () => ({

describe('Amazon Vega public API', () => {
const fetchProductsNative = jest.fn().mockResolvedValue([]);
const getAvailablePurchasesNative = jest.fn().mockResolvedValue([]);
const requestPurchaseNative = jest.fn().mockResolvedValue([]);

beforeEach(() => {
jest.clearAllMocks();
(getVegaIapModule as jest.Mock).mockReturnValue({
fetchProducts: fetchProductsNative,
getAvailableItems: getAvailablePurchasesNative,
requestPurchase: requestPurchaseNative,
});
});
Expand Down Expand Up @@ -67,4 +70,23 @@ describe('Amazon Vega public API', () => {

expect(requestPurchaseNative).not.toHaveBeenCalled();
});

it('rejects an Apple purchase returned by the Vega bridge', async () => {
getAvailablePurchasesNative.mockResolvedValueOnce([
{
id: 'foreign',
transactionId: 'foreign',
productId: 'premium',
transactionDate: Date.now(),
store: 'apple',
quantity: 1,
purchaseState: 'purchased',
isAutoRenewing: false,
},
]);

await expect(getAvailablePurchases()).rejects.toMatchObject({
code: 'billing-response-json-parse-error',
});
});
});
Loading