feat: Purchasely 6.1.0 (anonymous user id, API proxy, Web2App redemption) - #293
Conversation
972323d to
7dd20fd
Compare
|
| Filename | Overview |
|---|---|
| packages/purchasely/android/src/main/java/com/reactnativepurchasely/PurchaselyModule.kt | Adds Android 6.1.0 builder options, redemption event mapping, canonical UUID parsing, and shared subscription serialization. |
| packages/purchasely/ios/PurchaselyRN.m | Adds iOS anonymous-user and redemption configuration plus the cross-platform redemption result event. |
| packages/purchasely/src/startBuilder.ts | Adds the anonymous-user, Android proxy, and redemption-alert builder APIs and forwards them atomically at startup. |
| packages/purchasely/src/index.ts | Exposes the Web2App redemption listener API, with new code requiring formatting to meet repository rules. |
| packages/purchasely/src/types.ts | Adds redemption event/result types and corrects subscription field optionality, with some new indentation conflicting with repository rules. |
| sdk_public_doc.md | Documents the new 6.1.0 APIs, lifecycle ordering, platform limitations, and redemption privacy considerations. |
| .github/workflows/e2e-android.yml | Scopes Android E2E concurrency by ref and limits cancellation to superseded pull-request runs. |
| .github/workflows/e2e-ios.yml | Scopes iOS E2E concurrency by ref and limits cancellation to superseded pull-request runs. |
Sequence Diagram
sequenceDiagram
participant App as React Native app
participant JS as Purchasely TypeScript API
participant Bridge as Native bridge
participant SDK as Purchasely 6.1.0 SDK
App->>JS: addWebRedemptionListener(callback)
App->>JS: builder(apiKey).options().start()
JS->>Bridge: start(..., startOptions)
Bridge->>SDK: configure UUID/proxy/redemption delegate
SDK-->>Bridge: settled redemption result
Bridge-->>JS: WEB_REDEMPTION_LISTENER
JS-->>App: callback(PLYWebRedemptionResult)
Prompt To Fix All With AI
### Issue 1
packages/purchasely/src/index.ts:130-193
**Inconsistent TypeScript Indentation**
The new listener declarations and functions use two-space indentation, but the repository guides require four-space indentation. The same pattern appears in the added object properties around lines 611–612 and in `packages/purchasely/src/types.ts` around lines 125–133. This explicit repository requirement must be satisfied before merging.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (2): Last reviewed commit: "docs: document anonymousUserId, proxy an..." | Re-trigger Greptile
The native iOS PLYSubscription has no purchase token property, so PLYSubscription+Hybrid.m asDictionary never emitted the key. The type promised a required string on every platform. nextRenewalDate and cancelledDate have the same gap: the iOS bridge omits each key when the native date is nil. Reported by Greptile on #293. The gap predates 6.1.0 and also affects userSubscriptions(), but the new web redemption context surfaces the same shape on a new API, so the contract is corrected here.
|
All 3 Greptile findings addressed. Details in the inline threads.
Verification after the fixes: Finding 2 is worth a reviewer's attention beyond this PR: making |
#297) ## The warning `packages/purchasely/ios/PurchaselyRN.m` line 581 gives the native SDK the bridge module as the user attribute delegate: ```objc [Purchasely setUserAttributeDelegate: self]; ``` `packages/purchasely/ios/PurchaselyRN.h` line 12 does not declare `PLYUserAttributeDelegate` on the class. The compiler reports: ``` packages/purchasely/ios/PurchaselyRN.m:581:43: warning: sending 'PurchaselyRN *const __strong' to parameter of incompatible type 'id<PLYUserAttributeDelegate> _Nonnull' 581 | [Purchasely setUserAttributeDelegate: self]; | ^~~~ note: passing argument to parameter 'userAttributeDelegate' here 1419 | + (void)setUserAttributeDelegate:(id <PLYUserAttributeDelegate> _Nonnull)userAttributeDelegate; ``` ## Why it matters The warning is a type safety hole, not only untidy output. The compiler cannot verify the delegate contract on a class that does not declare the protocol. `PurchaselyRN` implements two delegate methods: `onUserAttributeSetWithKey:type:value:source:processingLegalBasis:` and `onUserAttributeRemovedWithKey:source:`. Both bridge to the `USER_ATTRIBUTE_SET_LISTENER` and `USER_ATTRIBUTE_REMOVED_LISTENER` events in JavaScript. Without the declaration the compiler gives no check at all on this contract. The call site accepts any object, and a change in the protocol stays invisible until a runtime test exercises the callback. The JavaScript listener then does not receive events any more, and nothing in the build reports it. The declaration restores three compile-time checks: 1. The call site type-checks. `-Wprotocol` and the argument type check both apply, so the warning disappears for the right reason. 2. A signature drift on a selector that the bridge implements becomes a warning. The compiler compares each implemented method against the protocol declaration. 3. A required method that a later SDK version adds to the protocol becomes a `-Wprotocol` warning on this class. A pure rename in the native SDK stays silent, because all current methods are optional. The declaration still makes the class role explicit, and it gives the compiler the contract to check the two points above. ## Why the fix is safe All three methods of `PLYUserAttributeDelegate` are `@objc optional`. The generated Objective-C header in the pinned pod (`Purchasely 6.0.0`, `Purchasely.framework/Headers/Purchasely-Swift.h` line 1345) shows: ```objc SWIFT_PROTOCOL("_TtP10Purchasely24PLYUserAttributeDelegate_") @protocol PLYUserAttributeDelegate @optional - (void)onUserAttributeSetWithKey:(NSString * _Nonnull)key type:(enum PLYUserAttributeType)type value:(id _Nullable)value source:(enum PLYUserAttributeSource)source; - (void)onUserAttributeSetWithKey:(NSString * _Nonnull)key type:(enum PLYUserAttributeType)type value:(id _Nullable)value source:(enum PLYUserAttributeSource)source processingLegalBasis:(enum PLYDataProcessingLegalBasis)processingLegalBasis; - (void)onUserAttributeRemovedWithKey:(NSString * _Nonnull)key source:(enum PLYUserAttributeSource)source; @EnD ``` The declaration adds no required method. The two selectors that the bridge implements match the protocol exactly. The behaviour does not change. ## Build evidence Command, on the CocoaPods workspace: ``` xcodebuild -workspace example/ios/example.xcworkspace -scheme react-native-purchasely \ -configuration Debug -destination 'generic/platform=iOS' \ -derivedDataPath example/ios/DerivedData CODE_SIGNING_ALLOWED=NO build ``` Before the fix: ``` PurchaselyRN.m:581:43: warning: sending 'PurchaselyRN *const __strong' to parameter of incompatible type 'id<PLYUserAttributeDelegate> _Nonnull' ** BUILD SUCCEEDED ** ``` After the fix: ``` (no PLYUserAttributeDelegate warning) ** BUILD SUCCEEDED ** ``` The XCTest bundle `react-native-purchasely-Unit-Tests` also passes on a booted simulator. ## Scope The change is one line in one header file. The pull request touches no other file. It changes no behaviour on iOS, and it touches nothing on Android. The header has never declared this protocol. The delegate call site arrived in 5.0.4 (#173). The warning is confirmed against the pinned pod of 6.0.0, so it ships in 6.0.0. Earlier releases are not checked against their own pod pin. The warning is not related to the 6.1.0 work in #293. ## Out of scope The same build prints unrelated `-Wdeprecated-declarations` warnings, for example on `setThemeMode:`. This pull request leaves them unchanged. --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mption payload types
…lder modifiers and the web redemption listener
…e web redemption listener
The native iOS PLYSubscription has no purchase token property, so PLYSubscription+Hybrid.m asDictionary never emitted the key. The type promised a required string on every platform. nextRenewalDate and cancelledDate have the same gap: the iOS bridge omits each key when the native date is nil. Reported by Greptile on #293. The gap predates 6.1.0 and also affects userSubscriptions(), but the new web redemption context surfaces the same shape on a new API, so the contract is corrected here.
44faad9 to
baa95aa
Compare
MOB-308 landed in the iOS 6.1.0 release. The builder exposes proxy() and proxy(api:), bridged as proxyWithApi:. The bridge and the docs wrongly described the modifier as Android-only, based on a check against an earlier develop commit. The native parameter is an NSURL?, where nil means turn the proxy off rather than ignore the value. A string that NSURL cannot parse therefore skips the modifier and logs an error, instead of passing nil and silently disabling a proxy the app asked for.
proxy(api) rejected null and the forwarding dropped it, so a proxy could be set but never cleared. Both native SDKs document null as the way back to api.purchasely.io. The three states are now distinct end to end: an absent key leaves each SDK's current setting untouched, null clears the proxy, a string sets it. The iOS bridge maps NSNull to proxyWithApi:nil and Android calls proxy(null) only when the key is present. Four tests cover the three states and the never-called/cleared distinction. Verified they fail against the old forwarding.
The listener belongs on the chain, the same shape as the native SDKs (webRedemptionDelegate on iOS, webRedemptionListener on Android). The callback never crosses the bridge: the native side registers itself as the delegate and forwards each outcome as an event, so the builder only has to subscribe the JS callback. Chaining it also removes the footgun the standalone form left behind. A redemption can settle during start(), so a listener added afterwards misses exactly the case it is needed for. The modifier subscribes at chain time, which makes that ordering impossible to get wrong. The optional second argument sets appHandlesRedemptionAlert, mirroring Android's two-arity form. The listener moves to its own module so the builder can subscribe without importing index.ts, which would be a cycle. Its emitter is built on first use, not at import time, so importing the builder does not construct one. addWebRedemptionListener and removeWebRedemptionListener stay for the runtime case. Also drops the commented-out anonymousUserId and proxy call sites from the example app, and the appHandlesRedemptionAlert(false) line that only restated the default.
…eaks webRedemptionListener() subscribed on the spot and dropped the removal handle. Two consequences, both reported on #293: calling the modifier twice left two live subscriptions, so one redemption invoked both callbacks and could show two result screens; and a builder that was never started kept a live subscription forever, retaining a stale callback. The callback is now held in builder state and subscribed in start(), immediately before the native call. That still guarantees the listener is in place for a redemption settling during start(), which is the reason the modifier is on the chain at all. redemption.ts keeps the chain-owned subscription so a later chain replaces an earlier one. A listener added with addWebRedemptionListener is app-owned and is left untouched. Three tests reproduce the reported behaviour and were verified to fail against it.
The iOS bridge had three tests on the redemption feature and all three only checked that a symbol exists: the event name, the protocol conformance and the selector. Nothing checked the payload the delegate builds, which is where the null policy and the context nesting live. PLYWebRedemptionResult has no public initializer, so a test cannot build one. The body construction is split into a class method that takes the parts, which makes the policy testable: which key holds NSNull, that a present context with no subscription stays distinct from no context at all, and that the same five keys appear on success and on failure. Six tests, verified to fail when the two null cases are collapsed. Android already covered the equivalent mapping in PurchaselyModuleTest.kt.
yarn prepare generates declarations with a stricter tsconfig than yarn typecheck uses, so an unguarded emitterSpy.subscriptions[0] passed typecheck and then failed the declaration build with TS18048. Both E2E jobs stop at that step, which is how it surfaced. docs: correct the CLAUDE.md claim that native tests cannot run in CI. Both suites have run in CI since 2026-07-23: Android JUnit in the build-android job and iOS XCTest in its own iOS Unit Tests (bridge) job, both driven through the example project because they need the React Native dependencies. The CI job list was also two years stale and missing four jobs. Both documented commands were run before committing.
removeWebRedemptionListener() left builderSubscription pointing at a subscription it had already torn down. React Native's removeAllListeners goes straight to RCTDeviceEventEmitter and settles the native count itself, while the per-subscription remove() closure still believes it owns a listener, so the next chain registration sent a second removeListeners(1) for a listener already accounted for. On iOS that is not a harmless miscount. RCTEventEmitter.m does _listenerCount = MAX(_listenerCount - count, 0) and calls stopObserving the moment it reaches zero, and stopObserving clears shouldEmit, which gates every event the module sends. One extra decrement could silence analytics and the presentation lifecycle while their listeners were still registered. Reported on #293. The reporter's mechanism was a double remove, which React Native already guards by nulling the closure; the real path is removeAllListeners bypassing that closure entirely.
A subscription bought through web checkout reported no source at all on Android: the storeType mapping listed four values and StoreType.WEB_CHECKOUT_STRIPE fell into else -> null. iOS passed its raw value through, but the JS enum had no member for it, so neither platform gave a caller a usable answer. This matters for 6.1.0 specifically. Web2App redemption grants subscriptions from that source, so PLYWebRedemptionResult context.subscription is the payload most likely to carry it. Adds the Android when branch, the sourceStripe constant on both bridges, the Constants field, and SubscriptionSource.WEB_CHECKOUT_STRIPE. Also widens purchaseToken, nextRenewalDate and cancelledDate to string | null. Making them optional was not enough: iOS omits the keys, but Android's PLYSubscription.toMap() assigns each one unconditionally from a nullable field, so a caller receives an explicit null. Verified against the 6.1.0 tag.
The docs said the expired-link email hint was iOS only. It is not. Android 6.1.0 RedemptionOutcome.Expired.toResult() builds errorMessage as "<message> A new link was sent to <emailHint>.", the same string the docs showed as the iOS example, and its own source comments it as "masked email = PII". The wording was actively harmful: an integrator would gate the do-not-log rule on Platform.OS === 'ios' and forward personal data to analytics on Android. Corrected in the type doc, the listener doc, the public guide and the example app, with an explicit instruction not to make the rule platform-specific. The other half of the claim holds on both platforms: Android's toEvent drops the hint, so REDEMPTION_FAILED never carries it. Found by an independent review of the pull request.
RCTLogError shows a full-screen RedBox in a debug build, so an invalid anonymousUserId or an unparsable proxy string looked like a crash even though start() continues and only the modifier is skipped. Android logs with Log.e and shows no UI, so the two platforms disagreed on how loud a skippable misconfiguration is. RCTLogWarn matches the rest of this file, matches Android, and still surfaces the mistake at integration time. Also moves a docblock in redemption.ts onto the function it describes, and replaces an empty-string cancelledDate in the redemption fixture with null, which is what Android actually reports.
…source The Retrieve User Subscriptions section printed subscriptionSource, nextRenewalDate and cancelledDate without saying any of them can be absent, and the enum's new WEB_CHECKOUT_STRIPE member was undocumented. Adds the platform asymmetry (iOS omits the key, Android sends an explicit null), the Android-only nature of purchaseToken, and a table of subscriptionSource values noting that a Web2App redemption reports the web checkout source.
Brings the React Native SDK to Purchasely 6.1.0: the anonymous user id, the API proxy, the Web2App redemption listener and the two redemption analytics events.
Native pins: iOS pod
6.1.0, Androidio.purchasely:*:6.1.0. Package version6.1.0on all five npm packages.Changelog 6.1.0
Added
builder(key).anonymousUserId(id: string, override = false)Sets the anonymous user id that the SDK reports for this device.
idmust be a canonical UUID string. The SDK stores it uppercase, and applies it only when the device holds no anonymous id yet unlessoverrideistrue.JavaScript has no UUID type, so the id crosses as a string and each bridge parses it. A value that is not a canonical UUID is refused with a log and the modifier is skipped.
start()still succeeds. Android'sUUID.fromStringaccepts a short form that iOSNSUUIDrefuses, so the Android bridge adds a round-trip check to make both platforms agree on what canonical means.builder(key).proxy(api: string | null)Routes Purchasely API traffic through a proxy instead of
api.purchasely.io, for a region where that host is unreachable, such as mainland China. Only the API host changes; the paywall host and the tracking host stay on production.Three states, and they are not interchangeable:
.proxy('https://…').proxy(null)api.purchasely.ioapimust be anhttpsbase URL with a host, and carry no query, fragment or credentials. Start-time only on both platforms; neither native SDK has a runtime setter.builder(key).webRedemptionListener(cb, appHandlesRedemptionAlert?)Reports the outcome of a Web2App redemption (
{scheme}://ply/redeem/{token}), on the start chain, matching the native shape (webRedemptionDelegateon iOS,webRedemptionListeneron Android).The callback never crosses the bridge. The native side registers itself as the delegate and forwards each outcome as an event, so the modifier only subscribes the JS callback.
It is on the chain for a reason. A redemption can settle during
start(), from a cold start that the link itself triggered or a token a previous launch left pending. The modifier subscribes immediately before the nativestart()call, so that ordering cannot be got wrong. A later call replaces the earlier chain listener rather than stacking a second one, and a builder that is never started subscribes nothing.Purchasely.addWebRedemptionListener(cb)andremoveWebRedemptionListener()remain for an app that must add or replace the listener while the SDK already runs. A redemption settling duringstart()is then missed.PLYWebRedemptionResultis{ isSuccess, context, replay, errorCode, errorMessage }.contextandcontext.subscriptionare separately nullable: a success can describe nothing, and a present context can carry no subscription. A failure still reportsreplay: falseandcontext: null, so the shape never changes.Three behaviours integrators need:
replayistruewhen the server reports the token was already redeemed. It is a verdict about the token, not about the user.allowDeeplink.errorMessagefor an expired link can carry a masked email address. That hint is personal data. Show it to the user, do not send it to an analytics stack or a crash reporter, and do not make the rule platform-specific. TheREDEMPTION_FAILEDevent drops the hint on iOS and on Android alike.builder(key).appHandlesRedemptionAlert(handles: boolean)Decides who shows the outcome.
false(the default) keeps the SDK popin and calls the listener after the user acknowledges it.trueshows nothing and calls the listener as soon as the redemption settles.PLYEventNamegains'REDEMPTION_CONSUMED'and'REDEMPTION_FAILED'Both are new on the two native platforms in 6.1.0, verified absent in iOS 6.0.1 and Android 6.0.2.
PLYEventProperties.redemptioncarries the payload:token,receipt(id,validation_status),subscriptionslimited to active and non-consumable, andpurchase_contexton success;tokenanderror_codeplus a top-levelerror_messageon failure.SubscriptionSource.WEB_CHECKOUT_STRIPEBoth natives expose a web-checkout source (Android
StoreType.WEB_CHECKOUT_STRIPE, iOSPLYSubscriptionSource.stripe) and the JS enum had no member for it. Web2App redemption grants subscriptions from that source, socontext.subscription.subscriptionSourceis the payload most likely to carry it.Changed
sdk_public_doc.mddocuments the four new builder modifiers, the listener, the redemption result, the nullable subscription fields and thesubscriptionSourcevalues.CLAUDE.md: the claim that native tests cannot run in CI was wrong. Android JUnit has run in thebuild-androidjob and iOS XCTest in its own job since 2026-07-23. The CI job list was also missing four jobs.Fixed
A web checkout subscription reported no source. The Android store-type mapping listed four values and
StoreType.WEB_CHECKOUT_STRIPEfell intoelse -> null. iOS passed its raw value through to an enum with no member for it. Fixed on both bridges plus the enum and the constants type.purchaseToken,nextRenewalDateandcancelledDateare now?: string | null. The native iOSPLYSubscriptionhas no purchase token property, so the iOS bridge never emitted the key and omits each date when the native value isnil. Android assigns all three unconditionally from nullable fields, so it sends an explicitnull. Optional alone was not enough, since it admitsundefinedbut notnull. This also affectsuserSubscriptions()anduserSubscriptionsHistory(), which share the mapper.Action for integrators: typed code that reads
purchaseTokenwithout a guard now fails to compile. The fix exposes runtime behaviour that already shipped; it does not change it.Platform availability
anonymousUserIdproxy, includingnullto clearappHandlesRedemptionAlertwebRedemptionListeneron the chainREDEMPTION_CONSUMED/REDEMPTION_FAILEDSubscriptionSource.WEB_CHECKOUT_STRIPEiOS privacy manifest
The iOS 6.1.0 pod adds three entries to its
PrivacyInfo.xcprivacy:PerformanceData,OtherDiagnosticDataandCrashData, allAppFunctionality, none linked to the user and none used for tracking. An app that ships this pod inherits them and may need to update its App Store privacy answers.Test plan
yarn all:prepareclean,yarn test283 passed,yarn lint0 errors,yarn typecheckcleanio.purchasely:core:6.1.0; 60 JUnit tests pass6.1.0; 83 XCTest tests passReview findings addressed
Two independent reviews ran against this branch. Every finding was verified against the released 6.1.0 sources before acting, and all are fixed:
webRedemptionListenersubscribed on the spot and dropped the removal handle, so two calls left two live subscriptions and an unstarted builder leaked one.removeWebRedemptionListener()left a stale handle, whose later release drove the iOS listener count one too low.RCTEventEmittercallsstopObservingat zero, which clears the flag gating every event the module sends, so analytics and the paywall lifecycle could go silent with their listeners still registered.proxycould be set but never cleared.proxywas wrongly documented as Android-only; MOB-308 did land in iOS 6.1.0.| null, not just optional.RCTLogErrorred-boxed the host app in debug for a skipped option, while Android only logged. Both now warn.Notes
PLYRedemptionPropertiessealed-interface change needs no bridge work:PurchaselyModule.ktalready reads the payload withevent.properties.toMap().core-6.1.0POM pinskotlin-stdlib 2.3.21, as 6.0.x did. A host app still needs akotlinVersionoverride.