feat(in-app): allow overriding the in-app message color scheme - #660
Conversation
Hosts with their own appearance setting could not tell the SDK which of the light/dark variants authored in the editor to render — it always followed the device. Adds `inApp.colorScheme` to the SDK config and a runtime `CustomerIO.inAppMessaging.setColorScheme`, both taking the new `CioColorScheme` enum. Both native SDKs already implement this, including re-theming messages that are already on screen, so this only exposes what is there. iOS needed no change on the init path: `MessagingInAppConfigBuilder.build(from:)` already parses the `colorScheme` key out of the wrapper config, so only the Android half of that path was missing. The enum's string values are the wire contract both native SDKs match lowercase, and each resolves an unrecognized value to `auto`. A re-cased value would therefore render the device's theme with no error raised, so the values are pinned by test and validated in JavaScript, which is the only layer that can report the mistake to the developer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sample app builds 📱Below you will find the list of the latest versions of the sample apps. It's recommended to always download the latest builds of the sample apps to accurately test the pull request. Builds are in progress. This comment will be updated when they finish.
|
Adds `CioColorScheme`, the `inApp.colorScheme` field and `setColorScheme` to the checked-in public API report, and gives the new `@param` the hyphen TSDoc requires — api-extractor treats that warning as a failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…on Android Review follow-ups on the color scheme override. Both were platform divergences in the new wrapper code, not in the native SDKs. iOS dropped a `setColorScheme` call made before `CustomerIO.initialize` in near silence: it forwards through the module's `implementation?`, which is nil until the SDK is initialized, and the only trace was an `.info` line that the default `.error` log level discards. Android already logged that case at error, so the same mistake was actionable on one platform and invisible on the other. Both wrappers now guard on `hasBeenInitialized` and log at error, and still complete the call, which is what Android does. Android could crash on a null scheme. Codegen declares the parameter non-null, so the non-null Kotlin type meant `Intrinsics.checkNotNullParameter` threw before the body ran — reachable from untyped JavaScript passing a stored theme that is null on first launch, where iOS merely logs. The override is now nullable, like every other argument-taking override in that class, and a null value is reported rather than thrown. The mapper lost its logging so each caller can describe its own fallback: initialization drops to the device appearance, the setter keeps the scheme the app already chose. Also drops three tests that could not fail. `initialize` forwards the config verbatim, so asserting `colorScheme` on the forwarded payload only re-read the literal the test itself built — it stayed green whether or not the Android key and either native mapper worked. Removed rather than reworked: the integration points that can actually break live on the native side of each bridge, and this package has no harness for them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mrehan27
left a comment
There was a problem hiding this comment.
Color scheme wiring looks right — same wire values as customerio-flutter#410, and the strict setter / lenient init split is consistent on both platforms.
cioNativeiOSSdkVersion is still = 4.8.0 while 4.8.1 is out and customerio-flutter#410 takes it. Worth bumping here so both wrappers ship the same native iOS version; Android is already on 4.21.1.
Picks up the native SDK's swift-eventsource pin. 4.8.0 declared it as `.upToNextMajor(from: "3.3.0")`, and LaunchDarkly's 3.3.1 raised that package's iOS floor to 15.0; 4.8.1 pins it back to exactly 3.3.0. This wrapper resolves the native SDK through CocoaPods rather than Package.swift, so it was not affected by that resolution break — this keeps the pin current alongside the same bump on the Flutter side, and duplicates the bot's PR #661, which can be closed. `package.json`'s cioNativeiOSSdkVersion is the only pin site; the podspec and the example app's Podfile both read it from there. Verified locally: pod install upgrades the CustomerIO pods to 4.8.1 and the example app's SDK target still builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
| /// it to `.auto`, so a typo cannot quietly undo a scheme the app set correctly earlier — the | ||
| /// Android bridge behaves the same way. | ||
| @objc(setColorScheme:) | ||
| public func setColorScheme(_ colorScheme: String) { |
There was a problem hiding this comment.
Could we make this parameter optional and handle nil before mapping? JavaScript callers can still pass null or undefined at runtime even though the Codegen spec says string; the React Native bridge forwards that as nil, and bridging it into Swift non-optional String can trap before colorScheme(fromRawValue:) runs. Android explicitly accepts nullable input in this PR, so the platforms currently diverge. A String? plus an early guard would keep the invalid-input path non-fatal on iOS too.
There was a problem hiding this comment.
Done in dac2ef9 — good catch, the divergence was real.
The Swift parameter and its mapper now take String?, and nil is reported like any other unrecognized value (Unrecognized in-app colorScheme 'nil'…) rather than trapping.
One deviation from the suggestion worth flagging: I left the ObjC forwarder in NativeMessagingInApp.mm on the protocol's nonnull signature rather than annotating it nullable. The generated RNCustomerIOSpec.h sits inside NS_ASSUME_NONNULL_BEGIN, so the declaration there is implicitly nonnull and a nullable implementation would conflict with it. ObjC does not enforce nonnull at runtime, so a JavaScript null still arrives as nil and is forwarded to Swift, which is where the tolerance now lives.
Verified on a simulator: calling the setter before CustomerIO.initialize logs at error, and a bad value leaves the current scheme in place instead of resetting it to auto.
| * @param colorScheme - scheme to render with; `CioColorScheme.Auto` returns to following | ||
| * the device appearance | ||
| */ | ||
| setColorScheme(colorScheme: CioColorScheme) { |
There was a problem hiding this comment.
Could we apply the same validation to the runtime setter as well? validateColorScheme only runs from validateConfig, so initialization warns for colorScheme: DARK, while CustomerIO.inAppMessaging.setColorScheme(DARK) forwards the invalid value without a JavaScript warning. Since this public method is also reachable from untyped JavaScript, reusing the validator here would keep the two entry points consistent and make the mistake visible before it reaches the native fallback.
There was a problem hiding this comment.
Done in dac2ef9. setColorScheme now runs the validator too, so both entry points warn.
I split the validator rather than reusing it as-is, because the two paths have genuinely different semantics:
- Unrecognized value — initialization resolves to
autonatively, while the setter leaves whatever scheme the app already chose in place. The warnings now say which of those happened, so the message matches the actual outcome. - Absent value — legitimate in the config (the native SDKs already default to AUTO, so it is skipped), but a mistake in the setter, where there is nothing to fall back to. The setter therefore reports null and undefined instead of skipping them, which also covers the null case from the Swift thread.
Added tests for all three cases.
Both from PR review. The iOS setter took a non-optional `String` while Android took a nullable one, so the platforms disagreed on the same input: untyped JavaScript can pass null or undefined, the bridge forwards it as nil, and bridging nil into a non-optional `String` traps before the value can be reported. The Swift parameter and its mapper are now optional, and nil is logged like any other unrecognized value. The ObjC forwarder keeps the Codegen protocol's nonnull signature — ObjC does not enforce it at runtime, so nil still reaches Swift, and annotating it nullable would only conflict with the generated header. `setColorScheme` also now validates its argument. `validateColorScheme` ran only from `validateConfig`, so `initialize` warned about a bad value while the public setter forwarded one silently. The validator is split in two because the native fallback differs per entry point: an unrecognized value at initialization resolves to `auto`, while the setter leaves the scheme the app already chose in place. Absent is also treated differently — legitimate in the config, where the native default stands, but a mistake in the setter, which has nothing to fall back to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## [6.12.0](6.11.0...6.12.0) (2026-09-17) ### Features * **in-app:** allow overriding the in-app message color scheme ([#660](#660)) ([223363c](223363c))
Exposes the native SDKs' in-app color scheme override so a host app with its own appearance setting can pin the light or dark variant instead of always following the device.
inApp.colorSchemeon the SDK config, andCustomerIO.inAppMessaging.setColorSchemefor changing it at runtime, both taking the newCioColorSchemeenum.MessagingInAppConfigBuilder.build(from:)already parses the key, so only the Android half of that path was missing. The runtime setter is new on both.Fixes MBL-2479
Closes #659
🤖 Generated with Claude Code