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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,20 @@ class SuperwallExpoModule : Module() {
promise.resolve(null)
}

AsyncFunction("restorePurchases") { promise: Promise ->
ioScope.launch {
Superwall.instance.restorePurchases().fold({ result ->
scope.launch {
promise.resolve(restorationResultToJson(result))
}
}, { error ->
scope.launch {
promise.resolve(restorationResultToJson(RestorationResult.Failed(error)))
}
})
}
}
Comment on lines +421 to +433
Copy link
Contributor

Choose a reason for hiding this comment

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

P1 Platform inconsistency: Android can reject while iOS always resolves

On iOS, restorePurchases() returns a RestorationResult value directly; the promise always resolves (with { result: "restored" } or { result: "failed", ... }). On Android, the .fold() failure branch calls promise.reject(CodedException(error)), meaning if the native SDK wraps an unexpected exception in Result.failure(...), the JS promise rejects rather than resolves.

The TypeScript return type Promise<RestorationResult> implies the promise always resolves on both platforms. A caller who writes:

const result = await superwall.restorePurchases()
// assumes result.result === "restored" | "failed"

…will see an unhandled exception on Android in unexpected failure scenarios rather than a { result: "failed" } object.

Consider aligning platforms so that any Android-side exception is converted into a RestorationResult.Failed shape (resolved, not rejected), mirroring iOS's behavior.

Prompt To Fix With AI
This is a comment left during a code review.
Path: android/src/main/java/expo/modules/superwallexpo/SuperwallExpoModule.kt
Line: 421-433

Comment:
**Platform inconsistency: Android can reject while iOS always resolves**

On iOS, `restorePurchases()` returns a `RestorationResult` value directly; the promise always resolves (with `{ result: "restored" }` or `{ result: "failed", ... }`). On Android, the `.fold()` failure branch calls `promise.reject(CodedException(error))`, meaning if the native SDK wraps an unexpected exception in `Result.failure(...)`, the JS promise rejects rather than resolves.

The TypeScript return type `Promise<RestorationResult>` implies the promise always resolves on both platforms. A caller who writes:

```kotlin
const result = await superwall.restorePurchases()
// assumes result.result === "restored" | "failed"
```

…will see an unhandled exception on Android in unexpected failure scenarios rather than a `{ result: "failed" }` object.

Consider aligning platforms so that any Android-side exception is converted into a `RestorationResult.Failed` shape (resolved, not rejected), mirroring iOS's behavior.

How can I resolve this? If you propose a fix, please make it concise.


AsyncFunction("dismiss") { promise: Promise ->
ioScope.launch {
Superwall.instance.dismiss()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,13 @@ fun restorationResultFromJson(json: Map<String, Any>): RestorationResult {
else -> RestorationResult.Failed(Error("Unknown restoration result"))
}
}

fun restorationResultToJson(result: RestorationResult): Map<String, Any?> {
return when (result) {
is RestorationResult.Restored -> mapOf("result" to "restored")
is RestorationResult.Failed -> mapOf(
"result" to "failed",
"errorMessage" to (result.error?.message ?: "Unknown error")
)
}
}
9 changes: 9 additions & 0 deletions ios/Json/RestorationResult+Json.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,13 @@ extension RestorationResult {
return nil
}
}

func toJson() -> [String: Any] {
switch self {
case .restored:
return ["result": "restored"]
case .failed(let error):
return ["result": "failed", "errorMessage": error?.localizedDescription ?? "Unknown error"]
}
}
}
7 changes: 7 additions & 0 deletions ios/SuperwallExpoModule.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,13 @@ public class SuperwallExpoModule: Module {
promise.resolve(nil)
}

AsyncFunction("restorePurchases") { (promise: Promise) in
Task {
let result = await Superwall.shared.restorePurchases()
promise.resolve(result.toJson())
}
}

AsyncFunction("dismiss") { (promise: Promise) in
Superwall.shared.dismiss {
promise.resolve(nil)
Expand Down
3 changes: 3 additions & 0 deletions src/SuperwallExpoModule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NativeModule, requireNativeModule } from "expo"
import type {
EntitlementsInfo,
IntegrationAttributes,
RestorationResultResponse,
SuperwallExpoModuleEvents,
} from "./SuperwallExpoModule.types"

Expand Down Expand Up @@ -51,6 +52,8 @@ declare class SuperwallExpoModule extends NativeModule<SuperwallExpoModuleEvents
didHandleBackPressed(shouldConsume: boolean): void
didHandleCustomCallback(callbackId: string, status: string, data?: Record<string, any>): Promise<void>

restorePurchases(): Promise<RestorationResultResponse>

dismiss(): Promise<void>
confirmAllAssignments(): Promise<any[]>

Expand Down
9 changes: 9 additions & 0 deletions src/SuperwallExpoModule.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2333,6 +2333,15 @@ export type OnPurchaseParamsAndroid = {

export type OnPurchaseParams = OnPurchaseParamsIOS | OnPurchaseParamsAndroid

/**
* Represents the result of a purchase restoration attempt.
* - `restored`: The restoration completed successfully.
* - `failed`: The restoration failed, with an accompanying error message.
*/
export type RestorationResultResponse =
| { result: "restored" }
| { result: "failed"; errorMessage: string | null }

/**
* Defines the events emitted by the native Superwall Expo module that can be listened to.
* These events provide a way to react to various SDK activities and user interactions.
Expand Down
15 changes: 15 additions & 0 deletions src/compat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export { PaywallResult } from "./lib/PaywallResult"
import { EventEmitter } from "expo"
import { version } from "../../package.json"
import SuperwallExpoModule from "../SuperwallExpoModule"
import type { RestorationResultResponse } from "../SuperwallExpoModule.types"
import { filterUndefined } from "../utils/filterUndefined"

export { ComputedPropertyRequest } from "./lib/ComputedPropertyRequest"
Expand Down Expand Up @@ -60,6 +61,7 @@ export {
} from "./lib/PurchaseResult"
export * from "./lib/RedemptionResults"
export { RestorationResult } from "./lib/RestorationResult"
export type { RestorationResultResponse } from "../SuperwallExpoModule.types"
export { RestoreType } from "./lib/RestoreType"
export { StoreProduct } from "./lib/StoreProduct"
export { StoreTransaction } from "./lib/StoreTransaction"
Expand Down Expand Up @@ -707,6 +709,19 @@ export default class Superwall {
await SuperwallExpoModule.setUserAttributes(filterUndefined(userAttributes))
}

/**
* Programmatically restores purchases.
*
* Use this to trigger a restore from outside a paywall context,
* e.g. from a "Restore Purchases" button in app settings.
*
* @returns {Promise<RestorationResultResponse>} A promise that resolves with the restoration result.
*/
async restorePurchases(): Promise<RestorationResultResponse> {
await this.awaitConfig()
return await SuperwallExpoModule.restorePurchases()
}

/**
* Dismisses the presented paywall, if one exists.
*
Expand Down
10 changes: 10 additions & 0 deletions src/useSuperwall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import SuperwallExpoModule from "./SuperwallExpoModule"
import type {
EntitlementsInfo,
IntegrationAttributes,
RestorationResultResponse,
SubscriptionStatus,
} from "./SuperwallExpoModule.types"
import { DefaultSuperwallOptions, type PartialSuperwallOptions } from "./SuperwallOptions"
Expand Down Expand Up @@ -127,6 +128,12 @@ export interface SuperwallStore {
* @returns A promise that resolves with the presentation result.
*/
getPresentationResult: (placement: string, params?: Record<string, any>) => Promise<any>
/**
* Programmatically restores purchases.
* @returns A promise that resolves with a {@link RestorationResultResponse} indicating success or failure.
*/
restorePurchases: () => Promise<RestorationResultResponse>

/**
* Dismisses any currently presented Superwall paywall.
* @returns A promise that resolves when the dismissal is complete.
Expand Down Expand Up @@ -298,6 +305,9 @@ export const useSuperwallStore = create<SuperwallStore>((set, get) => ({
getPresentationResult: async (placement, params) => {
return SuperwallExpoModule.getPresentationResult(placement, params)
},
restorePurchases: async () => {
return SuperwallExpoModule.restorePurchases()
},
dismiss: async () => {
await SuperwallExpoModule.dismiss()
},
Expand Down
Loading