Skip to content

feat: prepare the bridge for native 6.1.0 (anonymous user id, proxy, Web2App redemption) - #293

Draft
kherembourg wants to merge 12 commits into
mainfrom
feat/6.1.0-native-apis
Draft

feat: prepare the bridge for native 6.1.0 (anonymous user id, proxy, Web2App redemption)#293
kherembourg wants to merge 12 commits into
mainfrom
feat/6.1.0-native-apis

Conversation

@kherembourg

@kherembourg kherembourg commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Prepares the React Native SDK for Purchasely 6.1.0: the anonymous user id, the API proxy, the Web2App redemption listener and the two new analytics events.

Blocked on the native release

Native 6.1.0 is not published. Verified today, 2026-09-03:

curl -s "https://repo1.maven.org/maven2/io/purchasely/core/maven-metadata.xml" | grep -E "<latest>|<version>6\.1"
# <latest>6.0.2</latest>          no 6.1.0, no SNAPSHOT

curl -s "https://cdn.cocoapods.org/all_pods_versions_b_2_6.txt" | grep -E '^Purchasely/' | tr ',' '\n' | tail -3
# ... 6.0.0-rc.3 / 6.0.1         no 6.1.0

The new native APIs exist only on the develop branch of each SDK repo. This PR is written against those sources. CI has run once. Seven of the nine checks fail, and every one of them fails for the same reason: the 6.1.0 artifact does not exist.

Check Result Failure
lint green
test green 269 tests
build-android red Could not find io.purchasely:core:6.1.0.
build-rn-0-86-android red Could not find io.purchasely:core:6.1.0.
E2E Tests (Android T1-T30) red Could not find io.purchasely:google-play:6.1.0. and io.purchasely:core:6.1.0
build-ios red CocoaPods could not find compatible versions for pod "Purchasely"
build-rn-0-86-ios red same
iOS Unit Tests (bridge) red same
E2E Tests (iOS T1-T30) red same

Android, from the build-android log:

> Could not resolve all dependencies for configuration ':app:debugCompileClasspath'.
   > Could not find io.purchasely:core:6.1.0.
     Required by:
         project ':app' > project :react-native-purchasely

iOS, from the iOS Unit Tests (bridge) log:

[!] CocoaPods could not find compatible versions for pod "Purchasely":
  In snapshot (Podfile.lock):
    Purchasely (= 6.0.0)

  In Podfile:
    react-native-purchasely (from `../node_modules/react-native-purchasely`) was resolved to 6.1.0, which depends on
      Purchasely (= 6.1.0)

Both failures happen at dependency resolution, before compilation. That proves the artifact is missing, and nothing about the bridge code. To show that the bridge code itself is correct apart from the missing symbols, the Android module was compiled locally against the old pin io.purchasely:core:6.0.1. Every error is a 6.1.0 symbol, and there are no others:

PurchaselyModule.kt:16:29   Unresolved reference 'PLYWebRedemptionResult'.
PurchaselyModule.kt:92:45   Unresolved reference 'PLYWebRedemptionListener'.
PurchaselyModule.kt:273:30  Unresolved reference 'proxy'.
PurchaselyModule.kt:274:43  Unresolved reference 'anonymousUserId'.
PurchaselyModule.kt:281:14  Unresolved reference 'webRedemptionListener'.
PurchaselyModule.kt:1579:47 Unresolved reference 'PLYWebRedemptionResult'.
PurchaselyModule.kt:1580:6  Unresolved reference 'PLYWebRedemptionResult'.
PurchaselyModule.kt:1582:28 Unresolved reference 'context'.
PurchaselyModule.kt:1583:42 Unresolved reference 'subscription'.
PurchaselyModule.kt:1585:27 Unresolved reference 'replay'.
PurchaselyModule.kt:1589:6  Unresolved reference 'PLYWebRedemptionResult'.
PurchaselyModule.kt:1593:30 Unresolved reference 'errorCode'.
PurchaselyModule.kt:1594:33 Unresolved reference 'errorMessage'.

The iOS equivalent was not exercised locally: it needs a full pod install against a pod that does not exist yet. Stated as unverified, not as passing. Three Objective-C selectors are therefore derived from the Swift signatures rather than from a compiled header: appAnonymousUserId:override:, webRedemptionDelegate:appHandlesRedemptionAlert: and webRedemptionCompletedWithResult:. They follow the standard Swift-to-ObjC naming rules and match the onUserAttributeSetWithKey:type:value:source:processingLegalBasis: precedent already in this bridge, but a compiler has not confirmed them.

One more step is needed after the native release

example/ios/Podfile.lock is checked in and pins Purchasely (6.0.0). The iOS log above shows CocoaPods refusing the snapshot against the new podspec requirement. That lockfile cannot be regenerated until the pod is published, and its SPEC CHECKSUM must not be hand-written. So once iOS 6.1.0 reaches the CocoaPods trunk, someone must run:

cd example/ios && pod install --repo-update

and commit the updated Podfile.lock. Android needs no equivalent step: this repo keeps no Gradle lockfile, so the four Android checks go green on the artifact alone.

No workaround was added. There is no reflection, no try/catch around a missing symbol, no version-conditional compilation, no vendored native source. When the native 6.1.0 artifacts are published, both checks turn green with no code change. The pin bump is isolated in chore(deps): pin the native iOS and Android SDKs to 6.1.0, so it reverts on its own if the native release slips.

Naming, ruled

The two native platforms named this feature differently:

iOS 6.1.0 Android 6.1.0
Method appAnonymousUserId(_ value: UUID?, override: Bool) anonymousUserId(anonymousUserId: UUID?, override: Boolean)

React Native needs one name. This PR uses anonymousUserId.

Reason: the RN builder already exposes appUserId(id) for the logged-in identity. appAnonymousUserId sits one word away from it and readers would confuse the two constantly. anonymousUserId also matches Android, and it matches the existing Purchasely.getAnonymousUserId() getter already in the bridge.

Ruled on 2026-09-04: keep anonymousUserId. The RN builder stays on the Android spelling. No rename is needed, and this point is closed.

What this PR adds

JS signature iOS 6.1.0 Android 6.1.0
builder(key).anonymousUserId(id: string, override = false) wired, appAnonymousUserId(_:override:) wired, anonymousUserId(_, override)
builder(key).proxy(api: string) not available. MOB-308 is still in review and is not in 6.1.0. The iOS bridge ignores the modifier, the same way it ignores automaticDeeplinkHandling wired, proxy(api)
builder(key).appHandlesRedemptionAlert(handles: boolean) wired, second argument of webRedemptionDelegate(_:appHandlesRedemptionAlert:) wired, first argument of webRedemptionListener(appHandlesRedemptionAlert, listener)
Purchasely.addWebRedemptionListener(cb) / removeWebRedemptionListener() wired, PLYWebRedemptionDelegate wired, PLYWebRedemptionListener
PLYEventName gains 'REDEMPTION_CONSUMED' and 'REDEMPTION_FAILED' new in 6.1.0 new in 6.1.0
PLYEventProperties.redemption new in 6.1.0 new in 6.1.0

proxy is the only asymmetric one. The JS method exists on both platforms so cross-platform code compiles, and the iOS bridge documents that it has no iOS equivalent.

The anonymous user id is validated at the bridge

JavaScript has no UUID type, so the id crosses as a string. Both native builders take a UUID, which is where the guarantee used to live. A string-typed bridge is the only place left to catch a bad value, so both bridges parse it and refuse a bad one loudly:

  • iOS: [[NSUUID alloc] initWithUUIDString:], and RCTLogError on nil.
  • Android: UUID.fromString, and Log.e on IllegalArgumentException.

start() never throws for this. The bridge logs an error and skips the modifier, which matches how the native SDKs treat an unusable proxy url.

One difference the bridge closes: UUID.fromString is lenient and accepts a short form such as "1-2-3-4-5" that NSUUID refuses. The Android bridge adds a round-trip check, so one id string is accepted, or refused, on both platforms. parseCanonicalUuid is a pure function and is unit-tested.

The redemption listener is an event, not a builder callback

A JS function cannot cross the bridge into a native builder, so this follows the USER_ATTRIBUTE_SET_LISTENER pattern already in the repo: a native WEB_REDEMPTION_LISTENER event plus a JS subscription. appHandlesRedemptionAlert stays on the builder, because it changes what the native SDK presents.

Both bridges register the native delegate/listener unconditionally at start(). The native SDKs have no runtime setter on purpose: a redemption can settle during start(), from a cold start that the link itself triggered, or from a token a previous launch left pending. Registering always is behaviour-neutral when JS added no listener. The TSDoc tells integrators to add the listener before start() for the same reason.

The result type keeps both nullable levels honest:

type PLYWebRedemptionResult = {
  isSuccess: boolean
  context: { subscription: PLYSubscription | null } | null
  replay: boolean
  errorCode: string | null
  errorMessage: string | null
}

context is null when the server's 200 carried nothing to describe. A present context can still hold a null subscription. Neither was flattened.

Three behaviours are documented in the TSDoc, all verified in the native sources:

  • replay is true when the server reports the token was already redeemed. The SDK keeps no cache and calls the server every time. It is a verdict about the token, not an observation of the user.
  • A redemption deeplink is not subject to allowDeeplink. The native SDK intercepts ply/redeem out of band, before the routing branch the gate sits behind.
  • On iOS only, errorMessage for an expired link can contain a masked email address, so the app can tell the user where the fresh link went. The REDEMPTION_FAILED event drops it. The TSDoc warns integrators to show it to the user and not to forward it to an analytics stack or a crash reporter.

The two new analytics events

PLYEventName is a closed string union, so typed integrator code could not switch on these even though the native SDK emits them. Both are added, with PLYEventProperties.redemption and its payload types.

The payload shape was read from iOS RedemptionOutcome.swift and Android's RedemptionProperties, which the Android PLYEventPropertiesRedemptionJsonRegressionTest pins byte for byte:

  • REDEMPTION_CONSUMED: redemption with token, receipt (id, validation_status), subscriptions (active and non-consumable only), and purchase_context when the backend returned one.
  • REDEMPTION_FAILED: redemption with token and error_code, plus the top-level error_message. Known codes: EXPIRED_REDEMPTION_TOKEN, INVALID_REDEMPTION_TOKEN. A transport or parsing failure carries no code.

The masked email hint is not in the event type. It reaches the listener only.

What needs no bridge surface

Checked, and deliberately left alone:

  • The Android breaking change does not affect this bridge. Native 6.1.0 closes PLYRedemptionProperties and PLYRedemptionPurchaseContext behind empty sealed interfaces. PurchaselyModule.kt already reads the payload with event.properties.toMap(), which is the supported contract and is unchanged byte for byte. Confirmed, nothing to do.
  • The Console screen preview change. Deeplink behaviour inside the native SDK. No public API.
  • The purchase-context restore. The restored attributes already reach the app through the existing user-attribute listener.
  • iOS OpenTelemetry. No public API. See the privacy note below.

iOS privacy manifest, for RN app developers

The iOS 6.1.0 pod ships three new entries in its PrivacyInfo.xcprivacy, from the OpenTelemetry and MetricKit work:

Data type Purpose Linked Tracking
NSPrivacyCollectedDataTypePerformanceData AppFunctionality no no
NSPrivacyCollectedDataTypeOtherDiagnosticData AppFunctionality no no
NSPrivacyCollectedDataTypeCrashData AppFunctionality no no

An app that ships this pod inherits them and may need to update its App Store privacy answers. None is linked to the user and none is used for tracking.

Android Kotlin floor

Checked, and it does not move. Native Android pins kotlin = "2.3.21" in its own libs.versions.toml on both the 6.0.2 tag and develop, so 6.1.0 raises no floor over 6.0.x.

The floor that already applies still applies: a host app must override kotlinVersion to 2.3.21 or later. packages/purchasely/android/gradle.properties already carries Purchasely_kotlinVersion=2.3.21 as the fallback, and example/android/build.gradle sets kotlinVersion = "2.3.21". The RN template's default is lower, so this stays an integrator step. Flagged here rather than changed: bumping the host template is out of scope for this PR.

Verification

yarn test       269 passed, 6 suites, 0 failed
yarn lint       0 errors (5 warnings, all in the gitignored coverage/ output, none from this diff)
yarn typecheck  clean, no output

Unicode scan for U+2010 to U+2015 over the added lines: 0 hits.

Out of scope

  • sdk_public_doc.md and the Documentation/ repo are not updated. That is a doc follow-up once the naming ruling lands.
  • test-projects/ is untouched.
  • example/ios/Podfile.lock is not regenerated. It cannot be, until the pod is published. See the step above.
  • The example app shows .anonymousUserId(...) and .proxy(...) as commented call sites, not live ones: a hardcoded UUID would pin every install of the demo app to one anonymous user, which the E2E suite asserts on, and a live proxy would repoint the demo app away from production. .appHandlesRedemptionAlert(false) and the listener are live, both behaviour-neutral.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant