From 2ebe8e10bf186361eeac1026761daa6ed23e7977 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Thu, 17 Sep 2026 12:42:42 +0400 Subject: [PATCH 1/5] feat(in-app): allow overriding the in-app message color scheme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/in-app-color-scheme.test.ts | 167 ++++++++++++++++++ .../customer/reactnative/sdk/constant/Keys.kt | 1 + .../NativeMessagingInAppModule.kt | 58 ++++++ ios/wrappers/inapp/NativeMessagingInApp.mm | 5 + ios/wrappers/inapp/NativeMessagingInApp.swift | 31 ++++ src/customerio-inapp.ts | 16 +- .../modules/NativeCustomerIOMessagingInApp.ts | 2 + src/types/data-pipelines.ts | 9 + src/types/in-app.ts | 23 +++ src/utils/param-validation.ts | 25 +++ 10 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 __tests__/in-app-color-scheme.test.ts diff --git a/__tests__/in-app-color-scheme.test.ts b/__tests__/in-app-color-scheme.test.ts new file mode 100644 index 00000000..63bf2939 --- /dev/null +++ b/__tests__/in-app-color-scheme.test.ts @@ -0,0 +1,167 @@ +/** + * Verifies the in-app color scheme override survives the JS -> native hop, by both routes it can + * travel: once through `initialize`, and again through the runtime setter. + * + * The native SDKs do the real work — each resolves the scheme and re-themes messages already on + * screen, inline views included. What only JavaScript can get wrong is the wire value: both + * native layers match `auto`/`light`/`dark` lowercase, and iOS's config parser resolves anything + * else to `.auto`, so a re-cased or renamed value would silently render the device's theme + * instead of the one the app asked for. That is a styling bug with no error attached, which is + * why the serialized values are pinned here. + * + * Scope: the JavaScript half only. These do NOT pin the native key name — renaming `colorScheme` + * in either bridge leaves them green while the override stops arriving. + * + * `jest.mock` factories are hoisted above module-scope declarations, so each mock is created + * inside its factory and read back from the imported (mocked) module. + */ + +// Importing `customerio-cdp` pulls in every sibling module, and each one resolves its TurboModule +// at import time — so the mock needs TurboModuleRegistry as well as Platform. +jest.mock('react-native', () => ({ + Platform: { + OS: 'ios', + select: (spec: { [key: string]: unknown }) => + spec.ios ?? spec.default ?? undefined, + }, + TurboModuleRegistry: { + get: jest.fn(() => null), + getEnforcing: jest.fn(() => ({})), + }, + NativeEventEmitter: jest.fn(() => ({ + addListener: jest.fn(() => ({ remove: jest.fn() })), + })), +})); + +// The native Fabric components pull in codegen internals this test does not need. +jest.mock('../src/components', () => ({})); + +jest.mock('../src/native-logger-listener', () => ({ + NativeLoggerListener: { + warn: jest.fn(), + initialize: jest.fn(), + // customerio-cdp calls this at module scope. + initNativeLogger: jest.fn(), + }, +})); + +jest.mock('../src/specs/modules/NativeCustomerIO', () => ({ + __esModule: true, + default: { initialize: jest.fn(() => Promise.resolve(true)) }, +})); + +jest.mock('../src/specs/modules/NativeCustomerIOMessagingInApp', () => ({ + __esModule: true, + default: { setColorScheme: jest.fn() }, +})); + +import { CustomerIO } from '../src/customerio-cdp'; +import NativeModule from '../src/specs/modules/NativeCustomerIO'; +import NativeInAppModule from '../src/specs/modules/NativeCustomerIOMessagingInApp'; +import { CioColorScheme, type CioConfig } from '../src/types'; + +const nativeInitialize = NativeModule.initialize as jest.Mock; +const nativeSetColorScheme = NativeInAppModule.setColorScheme as jest.Mock; + +const configWith = (inApp: CioConfig['inApp']): CioConfig => + ({ cdpApiKey: 'test-key', inApp }) as CioConfig; + +const forwardedInApp = () => nativeInitialize.mock.calls[0][0].inApp; + +describe('in-app color scheme', () => { + beforeEach(() => { + nativeInitialize.mockClear(); + nativeSetColorScheme.mockClear(); + }); + + describe('wire values', () => { + // Both native layers match these lowercase and treat anything else as `auto`. Renaming a + // member is safe; changing one of these strings silently breaks the override on both + // platforms, so they are asserted literally rather than through the enum. + it('serializes every member to the value both native SDKs match', () => { + expect(CioColorScheme.Auto).toBe('auto'); + expect(CioColorScheme.Light).toBe('light'); + expect(CioColorScheme.Dark).toBe('dark'); + }); + }); + + describe('initialize', () => { + it('forwards the configured scheme under the key the native parsers read', async () => { + await CustomerIO.initialize( + configWith({ siteId: 'site', colorScheme: CioColorScheme.Dark }) + ); + + expect(forwardedInApp().colorScheme).toBe('dark'); + }); + + it('omits the scheme when the app configures none', async () => { + await CustomerIO.initialize(configWith({ siteId: 'site' })); + + // Absent rather than 'auto': the native default is already AUTO, and the JS layer should + // not manufacture a value the host never set. + expect(forwardedInApp().colorScheme).toBeUndefined(); + }); + }); + + describe('setColorScheme', () => { + it('sends the scheme to the native module as its wire value', () => { + CustomerIO.inAppMessaging.setColorScheme(CioColorScheme.Light); + + expect(nativeSetColorScheme).toHaveBeenCalledWith('light'); + }); + + it('can return to following the device appearance', () => { + CustomerIO.inAppMessaging.setColorScheme(CioColorScheme.Auto); + + expect(nativeSetColorScheme).toHaveBeenCalledWith('auto'); + }); + }); + + // TypeScript rejects a bad value, but JavaScript callers reach this untyped. Neither native + // layer can report it usefully, so the warning is raised here. + describe('invalid value warning', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('warns when the value is not a CioColorScheme', async () => { + await CustomerIO.initialize( + configWith({ + siteId: 'site', + colorScheme: 'DARK' as unknown as CioColorScheme, + }) + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('"inApp.colorScheme"') + ); + }); + + it('still forwards the value, leaving the native fallback to decide', async () => { + await CustomerIO.initialize( + configWith({ + siteId: 'site', + colorScheme: 'DARK' as unknown as CioColorScheme, + }) + ); + + // Warn, do not sanitize: dropping the key here would make the JS layer's opinion + // indistinguishable from the host omitting it. + expect(forwardedInApp().colorScheme).toBe('DARK'); + }); + + it('stays quiet for a valid scheme', async () => { + await CustomerIO.initialize( + configWith({ siteId: 'site', colorScheme: CioColorScheme.Dark }) + ); + + expect(warn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/android/src/main/java/io/customer/reactnative/sdk/constant/Keys.kt b/android/src/main/java/io/customer/reactnative/sdk/constant/Keys.kt index d32c6e61..9a7741b9 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/constant/Keys.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/constant/Keys.kt @@ -15,6 +15,7 @@ internal object Keys { const val API_HOST = "apiHost" const val CDN_HOST = "cdnHost" const val NOTIFICATION_INBOX_ACCESSIBILITY_LABELS = "notificationInboxAccessibilityLabels" + const val COLOR_SCHEME = "colorScheme" // Push messaging const val PUSH_CLICK_BEHAVIOR = "pushClickBehavior" } diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt index 43d0f2bf..080f651b 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt @@ -10,6 +10,7 @@ import io.customer.messaginginapp.di.inAppMessaging import io.customer.messaginginapp.gist.data.model.InboxMessage import io.customer.messaginginapp.gist.data.model.response.InboxMessageFactory import io.customer.messaginginapp.inbox.NotificationInbox +import io.customer.messaginginapp.type.ColorScheme import io.customer.messaginginapp.type.NotificationInboxAccessibilityLabels import io.customer.reactnative.sdk.NativeCustomerIOMessagingInAppSpec import io.customer.reactnative.sdk.constant.Keys @@ -73,6 +74,27 @@ class NativeMessagingInAppModule( inAppMessagingModule?.dismissMessage() } + override fun setColorScheme(colorScheme: String) { + val resolved = colorSchemeFromRawValue(colorScheme, logger) + if (resolved == null) { + // Unrecognized value: leave the current scheme alone rather than resetting it to + // AUTO, so a typo cannot quietly undo a scheme the app set correctly earlier. + return + } + val module = inAppMessagingModule + if (module == null) { + // Reachable when the host calls this before CustomerIO.initialize, or without the + // in-app module configured. Logged rather than ignored: the scheme is silently not + // applied, and nothing else surfaces that. + logger.error( + "In-app messaging is not available, so the color scheme was not applied. " + + "Ensure CustomerIO SDK is initialized with the inApp configuration." + ) + return + } + module.setColorScheme(resolved) + } + override fun setupInboxListener() { setupInboxChangeListener() } @@ -247,6 +269,9 @@ class NativeMessagingInAppModule( val module = ModuleMessagingInApp( MessagingInAppModuleConfig.Builder(siteId = siteId, region = region).apply { setEventListener(eventListener = ReactInAppEventListener.instance) + colorSchemeFromConfig(config)?.let { colorScheme -> + setColorScheme(colorScheme) + } inboxAccessibilityLabelsFromConfig(config)?.let { labels -> setNotificationInboxAccessibilityLabels(labels) } @@ -255,6 +280,39 @@ class NativeMessagingInAppModule( builder.addCustomerIOModule(module) } + /** + * Maps the wrapper's `colorScheme` value onto the native [ColorScheme], or null when the + * host provided none — in which case the caller leaves the SDK's own AUTO default alone. + * + * The accepted values are lowercase because that is the wire contract the JavaScript + * `CioColorScheme` enum serializes to and the one iOS already matches. An unrecognized + * value returns null and logs, rather than falling back to AUTO silently: the failure mode + * is a message rendered in the wrong theme, which looks like a styling bug rather than a + * configuration mistake. + */ + internal fun colorSchemeFromRawValue(rawValue: String?, logger: Logger): ColorScheme? { + if (rawValue == null) return null + + return when (rawValue) { + "auto" -> ColorScheme.AUTO + "light" -> ColorScheme.LIGHT + "dark" -> ColorScheme.DARK + else -> { + logger.error( + "Unrecognized in-app colorScheme '$rawValue', expected one of " + + "auto, light, dark. Leaving the color scheme unchanged." + ) + null + } + } + } + + private fun colorSchemeFromConfig(config: Map): ColorScheme? = + colorSchemeFromRawValue( + config.getTypedValue(Keys.Config.COLOR_SCHEME), + SDKComponent.logger + ) + /** * Builds the host's inbox accessibility labels from the wrapper configuration, or null when * the app provided none — in which case the SDK keeps its default of emitting no labels at diff --git a/ios/wrappers/inapp/NativeMessagingInApp.mm b/ios/wrappers/inapp/NativeMessagingInApp.mm index f9830652..74790ec5 100644 --- a/ios/wrappers/inapp/NativeMessagingInApp.mm +++ b/ios/wrappers/inapp/NativeMessagingInApp.mm @@ -66,6 +66,11 @@ - (void)dismissMessage { [_swiftBridge dismissMessage]; } +- (void)setColorScheme:(NSString *)colorScheme { + [self assertBridgeAvailable:@"during setColorScheme"]; + [_swiftBridge setColorScheme:colorScheme]; +} + - (void)setupInboxListener { [self assertBridgeAvailable:@"during setupInboxListener"]; [_swiftBridge setupInboxListener]; diff --git a/ios/wrappers/inapp/NativeMessagingInApp.swift b/ios/wrappers/inapp/NativeMessagingInApp.swift index d81f766d..239b542d 100644 --- a/ios/wrappers/inapp/NativeMessagingInApp.swift +++ b/ios/wrappers/inapp/NativeMessagingInApp.swift @@ -100,6 +100,37 @@ public class NativeMessagingInApp: NSObject { MessagingInApp.shared.dismissMessage() } + /// Overrides the color scheme used to render in-app messages. + /// + /// Receives the raw value of the JavaScript `CioColorScheme`, since the enum itself cannot + /// cross Codegen. An unrecognized value leaves the current scheme alone instead of resetting + /// 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) { + guard let resolved = Self.colorScheme(fromRawValue: colorScheme) else { + logger.error( + "Unrecognized in-app colorScheme '\(colorScheme)', expected one of auto, light, dark. Leaving the color scheme unchanged." + ) + return + } + MessagingInApp.shared.setColorScheme(resolved) + } + + /// Maps the wrapper's lowercase wire value onto the native `ColorScheme`. + /// + /// Matched explicitly rather than handed to `MessagingInAppConfigBuilder`, which resolves + /// anything unrecognized to `.auto`; here an unrecognized value has to stay distinguishable + /// so it can be reported. + private static func colorScheme(fromRawValue rawValue: String) -> ColorScheme? { + switch rawValue { + case "auto": return .auto + case "light": return .light + case "dark": return .dark + default: return nil + } + } + // MARK: - Inbox Methods @objc(setupInboxListener) diff --git a/src/customerio-inapp.ts b/src/customerio-inapp.ts index aec6d8e4..0cd3e8d5 100644 --- a/src/customerio-inapp.ts +++ b/src/customerio-inapp.ts @@ -9,7 +9,7 @@ import { import NativeCustomerIOMessagingInApp, { type Spec as CodegenSpec, } from './specs/modules/NativeCustomerIOMessagingInApp'; -import type { InAppMessageEventType } from './types'; +import type { CioColorScheme, InAppMessageEventType } from './types'; import { InboxEventType, InboxMessageEvent } from './types'; import { callNativeModule, ensureNativeModule } from './utils/native-bridge'; @@ -180,6 +180,20 @@ class CustomerIOInAppMessaging implements NativeInAppSpec { withNativeModule((native) => native.dismissMessage()); } + /** + * Overrides the color scheme used to render in-app messages. + * + * Takes effect immediately: messages already on screen — inline views included — are + * re-themed in place, so this can be called whenever the app's appearance setting + * changes rather than only before a message is shown. + * + * @param colorScheme scheme to render with; `CioColorScheme.Auto` returns to following + * the device appearance + */ + setColorScheme(colorScheme: CioColorScheme) { + withNativeModule((native) => native.setColorScheme(colorScheme)); + } + /** * Gets the message inbox instance for managing inbox messages * diff --git a/src/specs/modules/NativeCustomerIOMessagingInApp.ts b/src/specs/modules/NativeCustomerIOMessagingInApp.ts index ec21a23b..d663d5f1 100644 --- a/src/specs/modules/NativeCustomerIOMessagingInApp.ts +++ b/src/specs/modules/NativeCustomerIOMessagingInApp.ts @@ -14,6 +14,8 @@ import type { /** TurboModule interface for CustomerIO In-App Messaging native operations */ export interface Spec extends TurboModule { dismissMessage(): void; + // Carries the CioColorScheme string value; the enum itself cannot cross Codegen. + setColorScheme(colorScheme: string): void; readonly onInAppEventReceived: EventEmitter; // Notification Inbox event listener methods. // Registers/unregisters a native forwarder with the SDK so inbox events diff --git a/src/types/data-pipelines.ts b/src/types/data-pipelines.ts index c64fe5f1..921cb9ae 100644 --- a/src/types/data-pipelines.ts +++ b/src/types/data-pipelines.ts @@ -1,3 +1,4 @@ +import type { CioColorScheme } from './in-app'; import type { NotificationInboxAccessibilityLabels } from './inbox'; import type { LiveActivitiesConfig } from './live-activities'; import type { PushClickBehaviorAndroid } from './push'; @@ -68,6 +69,14 @@ export type CioConfig = { autoTrackDeviceAttributes?: boolean; inApp?: { siteId: string; + /** + * Color scheme used to render in-app messages. Defaults to `CioColorScheme.Auto`, which + * follows the device appearance. Set it when the app has its own appearance setting that + * can disagree with the operating system. + * + * Can be changed after initialization with `CustomerIO.inAppMessaging.setColorScheme`. + */ + colorScheme?: CioColorScheme; /** * Accessibility labels for the Visual Notification Inbox. Optional; an omitted label leaves * that element unlabeled rather than falling back to English. diff --git a/src/types/in-app.ts b/src/types/in-app.ts index 3ffc3581..6ec560ec 100644 --- a/src/types/in-app.ts +++ b/src/types/in-app.ts @@ -27,6 +27,29 @@ export enum InAppMessageEventType { messageShown = 'messageShown', } +/** + * Color scheme used to render in-app messages. + * + * Selects which of the light/dark variants authored in the Customer.io editor is + * rendered. Use it when the app has its own appearance setting that can disagree with + * the operating system: `Auto` follows the device, while `Light` and `Dark` pin the + * variant regardless of it. + * + * The string values are the wire contract shared with both native SDKs, which match + * them lowercase and resolve anything unrecognized to `auto` — so they must stay + * exactly as written here even if the members are renamed. + * + * @public + */ +export enum CioColorScheme { + /** Follow the device's current appearance. The default when unset. */ + Auto = 'auto', + /** Always render the light variant, whatever the device is set to. */ + Light = 'light', + /** Always render the dark variant, whatever the device is set to. */ + Dark = 'dark', +} + /** * Represents an inbox message for a user. * diff --git a/src/utils/param-validation.ts b/src/utils/param-validation.ts index 8819738e..b215c2d5 100644 --- a/src/utils/param-validation.ts +++ b/src/utils/param-validation.ts @@ -1,6 +1,7 @@ // Argument validation utilities for SDK internal use // Ensures input safety and throws clear errors when validation fails +import { CioColorScheme } from '../types'; import type { CioConfig, CustomAttributes } from '../types'; /** @@ -178,6 +179,29 @@ function validateInboxAccessibilityLabels(value: unknown): void { ); } +/** + * Warns when `inApp.colorScheme` is not one of the `CioColorScheme` values. + * + * TypeScript rejects a bad value already, but JavaScript callers reach this untyped and + * neither native layer can report the mistake: both resolve an unrecognized value to + * `auto`, so a typo renders whichever variant the device asks for instead of the one the + * app asked for — a wrong theme rather than a visible failure. Warning from JavaScript is + * the only place the developer sees it, on either platform and at any SDK log level. + */ +function validateColorScheme(value: unknown): void { + if (isUndefined(value)) { + return; + } + + const allowed: string[] = Object.values(CioColorScheme); + warnIf( + !(typeof value === 'string' && allowed.includes(value)), + () => + `"inApp.colorScheme" is not a valid CioColorScheme (expected one of ` + + `${allowed.join(', ')}), so in-app messages will follow the device appearance.` + ); +} + /** * Validates that the given value is a valid CioConfig object. * Throws if required fields are missing or incorrectly typed. @@ -196,6 +220,7 @@ function validateConfig(value: unknown): asserts value is CioConfig { allowEmpty: false, usage: usage, }); + validateColorScheme(obj.inApp?.colorScheme); validateInboxAccessibilityLabels( obj.inApp?.notificationInboxAccessibilityLabels ); From 24fb0512c046ad86fe8788b5f84e9a286e05c98a Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Thu, 17 Sep 2026 12:45:38 +0400 Subject: [PATCH 2/5] chore(in-app): update the api-extractor report for the color scheme API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api-extractor-output/customerio-reactnative.api.md | 9 +++++++++ src/customerio-inapp.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/api-extractor-output/customerio-reactnative.api.md b/api-extractor-output/customerio-reactnative.api.md index 44278cce..b8c754fd 100644 --- a/api-extractor-output/customerio-reactnative.api.md +++ b/api-extractor-output/customerio-reactnative.api.md @@ -15,6 +15,13 @@ import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypes'; import type { ViewProps } from 'react-native'; import { ViewStyle } from 'react-native'; +// @public +export enum CioColorScheme { + Auto = "auto", + Dark = "dark", + Light = "light" +} + // @public export type CioConfig = { cdpApiKey: string; @@ -30,6 +37,7 @@ export type CioConfig = { autoTrackDeviceAttributes?: boolean; inApp?: { siteId: string; + colorScheme?: CioColorScheme; notificationInboxAccessibilityLabels?: NotificationInboxAccessibilityLabels; }; push?: { @@ -144,6 +152,7 @@ export class CustomerIOInAppMessaging implements NativeInAppSpec { // (undocumented) registerEventsListener(listener: (event: InAppMessageEvent) => void): EventSubscription; registerInboxEventListener(listener: (event: InboxMessageEvent) => void): EventSubscription; + setColorScheme(colorScheme: CioColorScheme): void; } // Warning: (ae-forgotten-export) The symbol "NativeLiveActivitiesSpec" needs to be exported by the entry point index.d.ts diff --git a/src/customerio-inapp.ts b/src/customerio-inapp.ts index 0cd3e8d5..9736c38a 100644 --- a/src/customerio-inapp.ts +++ b/src/customerio-inapp.ts @@ -187,7 +187,7 @@ class CustomerIOInAppMessaging implements NativeInAppSpec { * re-themed in place, so this can be called whenever the app's appearance setting * changes rather than only before a message is shown. * - * @param colorScheme scheme to render with; `CioColorScheme.Auto` returns to following + * @param colorScheme - scheme to render with; `CioColorScheme.Auto` returns to following * the device appearance */ setColorScheme(colorScheme: CioColorScheme) { From dfe89b0780846ad0ffa359c3bcc2125e643b576c Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Thu, 17 Sep 2026 15:29:28 +0400 Subject: [PATCH 3/5] fix(in-app): report a dropped color scheme on iOS, accept a null one on Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/in-app-color-scheme.test.ts | 45 +++----------- .../NativeMessagingInAppModule.kt | 61 +++++++++++-------- ios/wrappers/inapp/NativeMessagingInApp.swift | 11 ++++ 3 files changed, 53 insertions(+), 64 deletions(-) diff --git a/__tests__/in-app-color-scheme.test.ts b/__tests__/in-app-color-scheme.test.ts index 63bf2939..faea7056 100644 --- a/__tests__/in-app-color-scheme.test.ts +++ b/__tests__/in-app-color-scheme.test.ts @@ -1,6 +1,6 @@ /** - * Verifies the in-app color scheme override survives the JS -> native hop, by both routes it can - * travel: once through `initialize`, and again through the runtime setter. + * Covers the two things the JavaScript layer actually owns for the in-app color scheme override: + * the wire value, and the runtime setter reaching the native module. * * The native SDKs do the real work — each resolves the scheme and re-themes messages already on * screen, inline views included. What only JavaScript can get wrong is the wire value: both @@ -9,8 +9,12 @@ * instead of the one the app asked for. That is a styling bug with no error attached, which is * why the serialized values are pinned here. * - * Scope: the JavaScript half only. These do NOT pin the native key name — renaming `colorScheme` - * in either bridge leaves them green while the override stops arriving. + * Deliberately NOT covered: the `initialize` path. `CustomerIO.initialize` forwards the config + * object verbatim, so asserting `colorScheme` on the forwarded payload only re-reads the literal + * the test itself built — it would stay green if the Android config key or either native mapper + * broke. The integration points that can actually break are `Keys.Config.COLOR_SCHEME` and + * `colorSchemeFromRawValue` on Android and `colorScheme(fromRawValue:)` on iOS; guarding those + * needs a test on the native side of each bridge, which this package has no harness for. * * `jest.mock` factories are hoisted above module-scope declarations, so each mock is created * inside its factory and read back from the imported (mocked) module. @@ -66,8 +70,6 @@ const nativeSetColorScheme = NativeInAppModule.setColorScheme as jest.Mock; const configWith = (inApp: CioConfig['inApp']): CioConfig => ({ cdpApiKey: 'test-key', inApp }) as CioConfig; -const forwardedInApp = () => nativeInitialize.mock.calls[0][0].inApp; - describe('in-app color scheme', () => { beforeEach(() => { nativeInitialize.mockClear(); @@ -85,24 +87,6 @@ describe('in-app color scheme', () => { }); }); - describe('initialize', () => { - it('forwards the configured scheme under the key the native parsers read', async () => { - await CustomerIO.initialize( - configWith({ siteId: 'site', colorScheme: CioColorScheme.Dark }) - ); - - expect(forwardedInApp().colorScheme).toBe('dark'); - }); - - it('omits the scheme when the app configures none', async () => { - await CustomerIO.initialize(configWith({ siteId: 'site' })); - - // Absent rather than 'auto': the native default is already AUTO, and the JS layer should - // not manufacture a value the host never set. - expect(forwardedInApp().colorScheme).toBeUndefined(); - }); - }); - describe('setColorScheme', () => { it('sends the scheme to the native module as its wire value', () => { CustomerIO.inAppMessaging.setColorScheme(CioColorScheme.Light); @@ -143,19 +127,6 @@ describe('in-app color scheme', () => { ); }); - it('still forwards the value, leaving the native fallback to decide', async () => { - await CustomerIO.initialize( - configWith({ - siteId: 'site', - colorScheme: 'DARK' as unknown as CioColorScheme, - }) - ); - - // Warn, do not sanitize: dropping the key here would make the JS layer's opinion - // indistinguishable from the host omitting it. - expect(forwardedInApp().colorScheme).toBe('DARK'); - }); - it('stays quiet for a valid scheme', async () => { await CustomerIO.initialize( configWith({ siteId: 'site', colorScheme: CioColorScheme.Dark }) diff --git a/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt index 080f651b..eb9917c7 100644 --- a/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt +++ b/android/src/main/java/io/customer/reactnative/sdk/messaginginapp/NativeMessagingInAppModule.kt @@ -74,11 +74,19 @@ class NativeMessagingInAppModule( inAppMessagingModule?.dismissMessage() } - override fun setColorScheme(colorScheme: String) { - val resolved = colorSchemeFromRawValue(colorScheme, logger) + // Nullable like every other argument-taking override here, and for the same reason: Codegen + // declares the parameter non-null, so a non-null Kotlin type would throw + // `Intrinsics.checkNotNullParameter` before the body runs when JavaScript passes null — which + // is what an untyped caller reading a stored theme sends on first launch. + override fun setColorScheme(colorScheme: String?) { + val resolved = colorSchemeFromRawValue(colorScheme) if (resolved == null) { - // Unrecognized value: leave the current scheme alone rather than resetting it to - // AUTO, so a typo cannot quietly undo a scheme the app set correctly earlier. + // Leave the current scheme alone rather than resetting it to AUTO, so a bad value + // cannot quietly undo a scheme the app set correctly earlier. + logger.error( + "Unrecognized in-app colorScheme '$colorScheme', expected one of auto, light, " + + "dark. Leaving the color scheme unchanged." + ) return } val module = inAppMessagingModule @@ -282,37 +290,36 @@ class NativeMessagingInAppModule( /** * Maps the wrapper's `colorScheme` value onto the native [ColorScheme], or null when the - * host provided none — in which case the caller leaves the SDK's own AUTO default alone. + * value is absent or unrecognized. * * The accepted values are lowercase because that is the wire contract the JavaScript - * `CioColorScheme` enum serializes to and the one iOS already matches. An unrecognized - * value returns null and logs, rather than falling back to AUTO silently: the failure mode - * is a message rendered in the wrong theme, which looks like a styling bug rather than a - * configuration mistake. + * `CioColorScheme` enum serializes to and the one iOS already matches. Kept free of + * logging so each caller can report a bad value in its own terms: at initialization an + * unrecognized value falls back to AUTO, while the runtime setter leaves the scheme the + * app already chose untouched. */ - internal fun colorSchemeFromRawValue(rawValue: String?, logger: Logger): ColorScheme? { - if (rawValue == null) return null - - return when (rawValue) { - "auto" -> ColorScheme.AUTO - "light" -> ColorScheme.LIGHT - "dark" -> ColorScheme.DARK - else -> { - logger.error( - "Unrecognized in-app colorScheme '$rawValue', expected one of " + - "auto, light, dark. Leaving the color scheme unchanged." + internal fun colorSchemeFromRawValue(rawValue: String?): ColorScheme? = when (rawValue) { + "auto" -> ColorScheme.AUTO + "light" -> ColorScheme.LIGHT + "dark" -> ColorScheme.DARK + else -> null + } + + private fun colorSchemeFromConfig(config: Map): ColorScheme? { + // Absent is not a mistake — the SDK's own AUTO default stands — so only a value the + // host actually provided is worth reporting. + val rawValue = config.getTypedValue(Keys.Config.COLOR_SCHEME) ?: return null + + return colorSchemeFromRawValue(rawValue).also { resolved -> + if (resolved == null) { + SDKComponent.logger.error( + "Unrecognized in-app colorScheme '$rawValue', expected one of auto, " + + "light, dark. Falling back to the device appearance." ) - null } } } - private fun colorSchemeFromConfig(config: Map): ColorScheme? = - colorSchemeFromRawValue( - config.getTypedValue(Keys.Config.COLOR_SCHEME), - SDKComponent.logger - ) - /** * Builds the host's inbox accessibility labels from the wrapper configuration, or null when * the app provided none — in which case the SDK keeps its default of emitting no labels at diff --git a/ios/wrappers/inapp/NativeMessagingInApp.swift b/ios/wrappers/inapp/NativeMessagingInApp.swift index 239b542d..c1bf5c4b 100644 --- a/ios/wrappers/inapp/NativeMessagingInApp.swift +++ b/ios/wrappers/inapp/NativeMessagingInApp.swift @@ -114,6 +114,17 @@ public class NativeMessagingInApp: NSObject { ) return } + // Without this the call is dropped in silence: `setColorScheme` forwards through the + // module's `implementation?`, which is nil until the SDK is initialized, and the only + // trace is an `.info` line the default `.error` log level discards. Android logs this + // case at error, so reporting it here is what keeps the two platforms diagnosable in the + // same way. Logged rather than failed, again to match Android, which completes the call. + guard MessagingInApp.shared.hasBeenInitialized else { + logger.error( + "In-app messaging is not available, so the color scheme was not applied. Ensure CustomerIO SDK is initialized with the inApp configuration." + ) + return + } MessagingInApp.shared.setColorScheme(resolved) } From 2cd9924875ce52f3a3a032f738af9abf97733277 Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Thu, 17 Sep 2026 16:19:47 +0400 Subject: [PATCH 4/5] chore(deps): update Customer.io iOS SDK to 4.8.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 288db38a..19c36040 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ }, "./package.json": "./package.json" }, - "cioNativeiOSSdkVersion": "= 4.8.0", + "cioNativeiOSSdkVersion": "= 4.8.1", "cioiOSFirebaseWrapperSdkVersion": "= 1.0.0", "files": [ "src", From dac2ef9a421054bc3fd6055d092adc5a15bfb77e Mon Sep 17 00:00:00 2001 From: Mahmoud Elmorabea Date: Thu, 17 Sep 2026 17:05:33 +0400 Subject: [PATCH 5/5] fix(in-app): tolerate a null color scheme on iOS and validate the setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- __tests__/in-app-color-scheme.test.ts | 43 +++++++++++++ ios/wrappers/inapp/NativeMessagingInApp.mm | 3 + ios/wrappers/inapp/NativeMessagingInApp.swift | 12 +++- src/customerio-inapp.ts | 4 ++ src/utils/param-validation.ts | 62 ++++++++++++++----- 5 files changed, 107 insertions(+), 17 deletions(-) diff --git a/__tests__/in-app-color-scheme.test.ts b/__tests__/in-app-color-scheme.test.ts index faea7056..da0a51f5 100644 --- a/__tests__/in-app-color-scheme.test.ts +++ b/__tests__/in-app-color-scheme.test.ts @@ -99,6 +99,49 @@ describe('in-app color scheme', () => { expect(nativeSetColorScheme).toHaveBeenCalledWith('auto'); }); + + // The setter is public and reachable from untyped JavaScript, so it validates too. Its + // fallback differs from the config path's — native leaves the current scheme in place + // rather than dropping to `auto` — so the warning has to say something different. + describe('invalid argument', () => { + let warn: jest.SpyInstance; + + beforeEach(() => { + warn = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + }); + + it('warns and says the scheme is left unchanged', () => { + CustomerIO.inAppMessaging.setColorScheme( + 'DARK' as unknown as CioColorScheme + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('left unchanged') + ); + }); + + it('warns when no scheme is given at all', () => { + // Absent is the mistake here, unlike in the config, where it just means "use the + // native default" — so null must not be skipped the way the config path skips it. + CustomerIO.inAppMessaging.setColorScheme( + null as unknown as CioColorScheme + ); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('"colorScheme"') + ); + }); + + it('stays quiet for a valid scheme', () => { + CustomerIO.inAppMessaging.setColorScheme(CioColorScheme.Dark); + + expect(warn).not.toHaveBeenCalled(); + }); + }); }); // TypeScript rejects a bad value, but JavaScript callers reach this untyped. Neither native diff --git a/ios/wrappers/inapp/NativeMessagingInApp.mm b/ios/wrappers/inapp/NativeMessagingInApp.mm index 74790ec5..28a471a8 100644 --- a/ios/wrappers/inapp/NativeMessagingInApp.mm +++ b/ios/wrappers/inapp/NativeMessagingInApp.mm @@ -66,6 +66,9 @@ - (void)dismissMessage { [_swiftBridge dismissMessage]; } +// Signature matches the Codegen protocol, which declares the argument nonnull. ObjC does not +// enforce that at runtime, so a JavaScript null still arrives here as nil and is forwarded; the +// Swift side takes an optional and reports it rather than trapping. - (void)setColorScheme:(NSString *)colorScheme { [self assertBridgeAvailable:@"during setColorScheme"]; [_swiftBridge setColorScheme:colorScheme]; diff --git a/ios/wrappers/inapp/NativeMessagingInApp.swift b/ios/wrappers/inapp/NativeMessagingInApp.swift index c1bf5c4b..73a9aba8 100644 --- a/ios/wrappers/inapp/NativeMessagingInApp.swift +++ b/ios/wrappers/inapp/NativeMessagingInApp.swift @@ -106,11 +106,17 @@ public class NativeMessagingInApp: NSObject { /// cross Codegen. An unrecognized value leaves the current scheme alone instead of resetting /// it to `.auto`, so a typo cannot quietly undo a scheme the app set correctly earlier — the /// Android bridge behaves the same way. + /// + /// The parameter is optional even though Codegen declares it non-null: untyped JavaScript can + /// still pass `null` or `undefined`, which the bridge forwards as `nil`. Bridging that into a + /// non-optional `String` would trap before the value could be reported, and Android accepts a + /// nullable argument for the same reason, so a non-optional type here would also leave the two + /// platforms behaving differently on the same input. @objc(setColorScheme:) - public func setColorScheme(_ colorScheme: String) { + public func setColorScheme(_ colorScheme: String?) { guard let resolved = Self.colorScheme(fromRawValue: colorScheme) else { logger.error( - "Unrecognized in-app colorScheme '\(colorScheme)', expected one of auto, light, dark. Leaving the color scheme unchanged." + "Unrecognized in-app colorScheme '\(colorScheme ?? "nil")', expected one of auto, light, dark. Leaving the color scheme unchanged." ) return } @@ -133,7 +139,7 @@ public class NativeMessagingInApp: NSObject { /// Matched explicitly rather than handed to `MessagingInAppConfigBuilder`, which resolves /// anything unrecognized to `.auto`; here an unrecognized value has to stay distinguishable /// so it can be reported. - private static func colorScheme(fromRawValue rawValue: String) -> ColorScheme? { + private static func colorScheme(fromRawValue rawValue: String?) -> ColorScheme? { switch rawValue { case "auto": return .auto case "light": return .light diff --git a/src/customerio-inapp.ts b/src/customerio-inapp.ts index 9736c38a..79834117 100644 --- a/src/customerio-inapp.ts +++ b/src/customerio-inapp.ts @@ -12,6 +12,7 @@ import NativeCustomerIOMessagingInApp, { import type { CioColorScheme, InAppMessageEventType } from './types'; import { InboxEventType, InboxMessageEvent } from './types'; import { callNativeModule, ensureNativeModule } from './utils/native-bridge'; +import { assert } from './utils/param-validation'; /** * Ensures all methods defined in codegen spec are implemented by the public module @@ -191,6 +192,9 @@ class CustomerIOInAppMessaging implements NativeInAppSpec { * the device appearance */ setColorScheme(colorScheme: CioColorScheme) { + // Validated here as well as in `initialize`: this is a public method reachable from + // untyped JavaScript, and without it a bad value reaches the native fallback silently. + assert.colorScheme(colorScheme); withNativeModule((native) => native.setColorScheme(colorScheme)); } diff --git a/src/utils/param-validation.ts b/src/utils/param-validation.ts index b215c2d5..8d03ef70 100644 --- a/src/utils/param-validation.ts +++ b/src/utils/param-validation.ts @@ -180,25 +180,57 @@ function validateInboxAccessibilityLabels(value: unknown): void { } /** - * Warns when `inApp.colorScheme` is not one of the `CioColorScheme` values. + * Warns when a value is not one of the `CioColorScheme` values. * - * TypeScript rejects a bad value already, but JavaScript callers reach this untyped and - * neither native layer can report the mistake: both resolve an unrecognized value to - * `auto`, so a typo renders whichever variant the device asks for instead of the one the - * app asked for — a wrong theme rather than a visible failure. Warning from JavaScript is - * the only place the developer sees it, on either platform and at any SDK log level. + * TypeScript rejects a bad value already, but JavaScript callers reach both entry points + * untyped, and neither native layer reports the mistake at the default log level. The + * failure mode is a message rendered in the wrong theme with no error attached, so + * JavaScript is the only place the developer reliably sees it. + * + * `consequence` differs per entry point because the native fallback does: an unrecognized + * value at initialization resolves to `auto`, while the runtime setter leaves whatever + * scheme the app already chose in place. */ -function validateColorScheme(value: unknown): void { - if (isUndefined(value)) { - return; - } - +function warnIfNotColorScheme( + value: unknown, + fieldName: string, + consequence: string +): void { const allowed: string[] = Object.values(CioColorScheme); warnIf( !(typeof value === 'string' && allowed.includes(value)), () => - `"inApp.colorScheme" is not a valid CioColorScheme (expected one of ` + - `${allowed.join(', ')}), so in-app messages will follow the device appearance.` + `"${fieldName}" is not a valid CioColorScheme (expected one of ` + + `${allowed.join(', ')}), so ${consequence}.` + ); +} + +/** + * Validates `inApp.colorScheme`. An absent value is legitimate here — the native SDKs + * already default to `auto` — so only a value the host actually set is checked. + */ +function validateConfigColorScheme(value: unknown): void { + if (isUndefined(value)) { + return; + } + + warnIfNotColorScheme( + value, + 'inApp.colorScheme', + 'in-app messages will follow the device appearance' + ); +} + +/** + * Validates the argument to `setColorScheme`. Unlike the config field, an absent value is + * itself the mistake: the caller asked for a scheme change and named none, and there is no + * default to fall back to, so null and undefined are reported rather than skipped. + */ +function validateColorSchemeArgument(value: unknown): void { + warnIfNotColorScheme( + value, + 'colorScheme', + 'the color scheme will be left unchanged' ); } @@ -220,7 +252,7 @@ function validateConfig(value: unknown): asserts value is CioConfig { allowEmpty: false, usage: usage, }); - validateColorScheme(obj.inApp?.colorScheme); + validateConfigColorScheme(obj.inApp?.colorScheme); validateInboxAccessibilityLabels( obj.inApp?.notificationInboxAccessibilityLabels ); @@ -235,11 +267,13 @@ export const assert: { record: typeof validateRecord; attributes: typeof validateAttributes; config: ConfigValidator; + colorScheme: typeof validateColorSchemeArgument; } = { string: validateString, record: validateRecord, attributes: validateAttributes, config: validateConfig, + colorScheme: validateColorSchemeArgument, }; export const validate = {