From 82b3668b07ecb3fc0bbff19ed2db87d76753a5c6 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 24 Aug 2026 16:44:45 +0100 Subject: [PATCH 1/3] [SDK-500] Follow the system light/dark theme in HTML in-app messages HTML in-app messages always reported prefers-color-scheme: dark to the campaign HTML, so a campaign with an @media (prefers-color-scheme: dark) block rendered its dark styles even on a light-mode device. Two things combined, and either fix alone is inert: - Both in-app containers hard-coded a dark, non-DayNight AppCompat theme, so isLightTheme resolved false regardless of the configuration. - The fragment path built its WebView from the host activity context rather than the dialog's themed context. Reparent both containers onto Theme.AppCompat.DayNight.NoActionBar and give the fragment path's WebView the dialog context. DayNight resolves from the configuration uiMode, which is also what AppCompatDelegate.setDefaultNightMode manipulates - the analogue of iOS, where WKWebView inherits the system trait collection. Dark mode is unchanged: DayNight resolves to Theme.AppCompat.NoActionBar under values-night, which is the theme in use today. This development was started by contributor @AndrazP. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 1 + ...IterableInAppFragmentHTMLNotification.java | 6 ++- iterableapi/src/main/res/values/styles.xml | 16 +++--- .../IterableInAppDialogNotificationTest.java | 45 ++++++++++++++++ .../IterableInAppHTMLNotificationTest.java | 53 +++++++++++++++++++ 5 files changed, 112 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0feb8cb95..e93765125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - `IterableConfig.Builder.setExpiringAuthTokenRefreshPeriod(double)` accepts fractional seconds, matching the iOS, React Native and Flutter SDKs. Previously Android only accepted whole seconds, so a value like `0.5` behaved differently here than on other platforms. The existing `Long` overload is deprecated but still works, so no code changes are required. ### Fixed +- HTML in-app messages now follow the device/app light-dark setting instead of always rendering as if the device were in dark mode. Previously the in-app container always reported `prefers-color-scheme: dark` to the message HTML, so a campaign with an `@media (prefers-color-scheme: dark)` block rendered its dark styles even in light mode. Those campaigns now render their light styles in light mode; campaigns that don't declare dark styles, and rendering in dark mode, are unaffected. - Fixed the keychain treating a transient crypto timeout as a permanent decryption failure. A slow AndroidKeyStore operation that exceeded the 500 ms timeout would wipe the stored email, userId, and auth token and disable encryption, forcing the user to re-authenticate (and request a new auth token) on the next launch. Crypto timeouts are now handled as transient without wiping credentials or disabling encryption for the device: a read that times out returns no value for that call (the stored ciphertext is left intact for the next attempt), and a write that times out stores that one value unencrypted (as the non-encrypted fallback already did) rather than clearing everything. The timed-out crypto operation is also cancelled so it no longer blocks subsequent reads/writes. - `setExpiringAuthTokenRefreshPeriod` now validates its input instead of silently producing a broken refresh schedule. Previously a negative value was converted to a negative millisecond period and then *subtracted* when computing the refresh time, scheduling the refresh after the token had already expired; a very large value overflowed to a negative period with the same effect; and `null` threw a `NullPointerException` on unboxing. Invalid values (`null`, `NaN`, negatives) are now logged and ignored, leaving the period at whatever it was before the call — the 60 second default unless an earlier call set something else. Values above ~10 years are clamped to that ceiling rather than ignored. Zero remains valid and means the token is refreshed only once it has expired. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java index df8aecc75..7beb2e4f8 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java @@ -123,7 +123,7 @@ public IterableInAppFragmentHTMLNotification() { this.backgroundAlpha = 0; this.messageId = ""; insetPadding = new Rect(); - this.setStyle(DialogFragment.STYLE_NO_FRAME, androidx.appcompat.R.style.Theme_AppCompat_NoActionBar); + this.setStyle(DialogFragment.STYLE_NO_FRAME, androidx.appcompat.R.style.Theme_AppCompat_DayNight_NoActionBar); } @Override @@ -210,7 +210,9 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c return null; } - webView = createWebViewSafely(getContext()); + // The WebView derives the CSS prefers-color-scheme value from its context theme's + // isLightTheme, so it needs the dialog's themed context and not the host activity's. + webView = createWebViewSafely(getDialog().getContext()); if (webView == null) { dismissAllowingStateLoss(); return null; diff --git a/iterableapi/src/main/res/values/styles.xml b/iterableapi/src/main/res/values/styles.xml index fbda81825..3515c463f 100644 --- a/iterableapi/src/main/res/values/styles.xml +++ b/iterableapi/src/main/res/values/styles.xml @@ -11,13 +11,15 @@ - - \ No newline at end of file + + + + + + + + + diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt index 646105f1f..53e9d5eff 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt @@ -36,6 +36,37 @@ class IterableConfigTest { val config: IterableConfig = configBuilder.build() assertThat(config.webViewBaseUrl, `is`("https://app.iterable.com")) } + + @Test + fun defaultInAppColorSchemeIsAutomatic() { + val config = IterableConfig.Builder().build() + + assertEquals(IterableInAppColorScheme.AUTOMATIC, config.inAppColorScheme) + assertNull(config.inAppColorSchemeProvider) + } + + @Test + fun setInAppColorSchemeUsesFixedValue() { + val config = IterableConfig.Builder() + .setInAppColorSchemeProvider { IterableInAppColorScheme.LIGHT } + .setInAppColorScheme(IterableInAppColorScheme.DARK) + .build() + + assertEquals(IterableInAppColorScheme.DARK, config.inAppColorScheme) + assertNull(config.inAppColorSchemeProvider) + } + + @Test + fun setInAppColorSchemeProviderUsesProvider() { + val provider = IterableInAppColorSchemeProvider { IterableInAppColorScheme.DARK } + val config = IterableConfig.Builder() + .setInAppColorScheme(IterableInAppColorScheme.LIGHT) + .setInAppColorSchemeProvider(provider) + .build() + + assertEquals(IterableInAppColorScheme.AUTOMATIC, config.inAppColorScheme) + assertSame(provider, config.inAppColorSchemeProvider) + } @Test fun defaultDisableKeychainEncryption() { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java index 7bf299f6d..2d3bd20e3 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppDialogNotificationTest.java @@ -27,6 +27,8 @@ import org.robolectric.annotation.Config; import org.robolectric.shadows.ShadowDialog; +import java.util.concurrent.atomic.AtomicReference; + public class IterableInAppDialogNotificationTest extends BaseTest { private ActivityController controller; @@ -572,6 +574,50 @@ public void dialogTheme_shouldResolveDark_inNightMode() { isLightTheme(dialog.getContext())); } + @Test + public void dialogTheme_shouldUseExplicitDarkScheme_inLightMode() { + configureColorScheme(IterableInAppColorScheme.DARK); + + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + + assertFalse("Explicit DARK should override the host activity's light mode", + isLightTheme(dialog.getContext())); + } + + @Test + @Config(qualifiers = "night") + public void dialogTheme_shouldUseExplicitLightScheme_inNightMode() { + configureColorScheme(IterableInAppColorScheme.LIGHT); + + IterableInAppDialogNotification dialog = createDialog(); + dialog.show(); + + assertTrue("Explicit LIGHT should override the host activity's night mode", + isLightTheme(dialog.getContext())); + } + + @Test + public void dialogTheme_shouldQueryProvider_forEachInApp() { + AtomicReference currentScheme = + new AtomicReference<>(IterableInAppColorScheme.DARK); + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew( + builder -> builder.setInAppColorSchemeProvider(currentScheme::get)); + + IterableInAppDialogNotification darkDialog = createDialog(); + darkDialog.show(); + assertFalse("The first in-app should use the provider's DARK value", + isLightTheme(darkDialog.getContext())); + darkDialog.dismiss(); + + currentScheme.set(IterableInAppColorScheme.LIGHT); + IterableInAppDialogNotification lightDialog = createDialog(); + lightDialog.show(); + assertTrue("The next in-app should use the provider's updated LIGHT value", + isLightTheme(lightDialog.getContext())); + } + @Test public void webView_shouldUseDialogThemedContext() { IterableInAppDialogNotification dialog = createDialog(); @@ -591,6 +637,12 @@ private boolean isLightTheme(Context context) { return value.data != 0; } + private void configureColorScheme(IterableInAppColorScheme colorScheme) { + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew( + builder -> builder.setInAppColorScheme(colorScheme)); + } + private IterableInAppDialogNotification createDialog() { return createDialogWithPadding(new Rect(0, 0, 0, 0)); } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppHTMLNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppHTMLNotificationTest.java index 224cdb7cc..46a2874a1 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppHTMLNotificationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppHTMLNotificationTest.java @@ -430,6 +430,27 @@ public void testDialogThemeFollowsNightMode() { isLightTheme(notification.getDialog().getContext())); } + @Test + public void testDialogThemeUsesExplicitDarkSchemeInLightMode() { + configureColorScheme(IterableInAppColorScheme.DARK); + + IterableInAppFragmentHTMLNotification notification = showNotification(); + + assertFalse("Explicit DARK should override the host activity's light mode", + isLightTheme(notification.getDialog().getContext())); + } + + @Test + @Config(qualifiers = "night") + public void testDialogThemeUsesExplicitLightSchemeInNightMode() { + configureColorScheme(IterableInAppColorScheme.LIGHT); + + IterableInAppFragmentHTMLNotification notification = showNotification(); + + assertTrue("Explicit LIGHT should override the host activity's night mode", + isLightTheme(notification.getDialog().getContext())); + } + @Test public void testWebViewUsesDialogThemedContext() { IterableInAppFragmentHTMLNotification notification = showNotification(); @@ -451,6 +472,12 @@ private IterableInAppFragmentHTMLNotification showNotification() { return notification; } + private void configureColorScheme(IterableInAppColorScheme colorScheme) { + IterableTestUtils.resetIterableApi(); + IterableTestUtils.createIterableApiNew( + builder -> builder.setInAppColorScheme(colorScheme)); + } + private boolean isLightTheme(Context context) { TypedValue value = new TypedValue(); assertTrue("isLightTheme should be resolvable on the in-app dialog theme", From ee342eb67cfc48ce753a095ba796de53421cc0c1 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 26 Aug 2026 11:06:20 +0100 Subject: [PATCH 3/3] [SDK-500] Scope color scheme attributes to API 29 --- .../src/main/res/values-v29/styles.xml | 19 +++++++++++++++++++ iterableapi/src/main/res/values/styles.xml | 16 ++++------------ 2 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 iterableapi/src/main/res/values-v29/styles.xml diff --git a/iterableapi/src/main/res/values-v29/styles.xml b/iterableapi/src/main/res/values-v29/styles.xml new file mode 100644 index 000000000..05fad6672 --- /dev/null +++ b/iterableapi/src/main/res/values-v29/styles.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + diff --git a/iterableapi/src/main/res/values/styles.xml b/iterableapi/src/main/res/values/styles.xml index 092b571cb..e0c6c801a 100644 --- a/iterableapi/src/main/res/values/styles.xml +++ b/iterableapi/src/main/res/values/styles.xml @@ -30,21 +30,13 @@ 100% - + + + +