diff --git a/CHANGELOG.md b/CHANGELOG.md index a29097a2b..3b56c84d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,10 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - `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. +- Added `IterableConfig.Builder.setInAppColorScheme()` and `setInAppColorSchemeProvider()` to control the color scheme reported to HTML in-app messages. The provider is evaluated for each new in-app message, allowing apps that keep their theme in Jetpack Compose state to return the current `LIGHT` or `DARK` scheme. ### Fixed +- HTML in-app messages now follow the host activity's Android light-dark configuration 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. - Fixed a `NullPointerException` in `EmbeddedSessionManager.updateDisplayCountAndDuration()` that could crash apps calling embedded session methods off the main thread. `EmbeddedSessionManager` is now internally synchronized, which also fixes concurrent modification of its impression map and duplicate session tracking when `endSession()` raced with itself. Thanks to [@Shamyyoun](https://github.com/Shamyyoun) for the report and initial fix. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index a7c532d0b..710d7c5a7 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -156,6 +156,17 @@ public class IterableConfig { */ final IterableInAppDisplayMode inAppDisplayMode; + /** + * Controls the color scheme reported to HTML in-app messages. + */ + final IterableInAppColorScheme inAppColorScheme; + + /** + * Supplies the current in-app color scheme when an in-app message is created. + */ + @Nullable + final IterableInAppColorSchemeProvider inAppColorSchemeProvider; + /** * Base URL for Webview content loading. Specifically used to enable CORS for external resources. * If null or empty, defaults to empty string (original behavior with about:blank origin). @@ -200,6 +211,8 @@ private IterableConfig(Builder builder) { mobileFrameworkInfo = builder.mobileFrameworkInfo; webViewBaseUrl = builder.webViewBaseUrl; inAppDisplayMode = builder.inAppDisplayMode; + inAppColorScheme = builder.inAppColorScheme; + inAppColorSchemeProvider = builder.inAppColorSchemeProvider; } public static class Builder { @@ -229,6 +242,8 @@ public static class Builder { private IterableUnknownUserHandler iterableUnknownUserHandler; private String webViewBaseUrl; private IterableInAppDisplayMode inAppDisplayMode = IterableInAppDisplayMode.FORCE_EDGE_TO_EDGE; + private IterableInAppColorScheme inAppColorScheme = IterableInAppColorScheme.AUTOMATIC; + private IterableInAppColorSchemeProvider inAppColorSchemeProvider; public Builder() {} @@ -530,6 +545,36 @@ public Builder setInAppDisplayMode(@NonNull IterableInAppDisplayMode inAppDispla return this; } + /** + * Set the color scheme reported to HTML in-app messages. Defaults to + * {@link IterableInAppColorScheme#AUTOMATIC}, which follows the host activity's + * Android UI mode. Setting a fixed value clears any color scheme provider. + * + * @param inAppColorScheme the color scheme for HTML in-app messages + */ + @NonNull + public Builder setInAppColorScheme(@NonNull IterableInAppColorScheme inAppColorScheme) { + this.inAppColorScheme = inAppColorScheme; + this.inAppColorSchemeProvider = null; + return this; + } + + /** + * Set a provider that supplies the current color scheme whenever an HTML in-app + * message is created. Use this when the app's theme is held outside Android's + * configuration, such as theme state managed in Jetpack Compose. Setting a provider + * replaces any fixed color scheme. + * + * @param provider the provider for the current in-app color scheme + */ + @NonNull + public Builder setInAppColorSchemeProvider( + @NonNull IterableInAppColorSchemeProvider provider) { + this.inAppColorScheme = IterableInAppColorScheme.AUTOMATIC; + this.inAppColorSchemeProvider = provider; + return this; + } + /** * Set the base URL for WebView content loading. Used to enable CORS for external resources. * If not set or null, defaults to empty string (original behavior with about:blank origin). @@ -548,4 +593,4 @@ public IterableConfig build() { } } -} \ No newline at end of file +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorScheme.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorScheme.java new file mode 100644 index 000000000..0b8cda640 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorScheme.java @@ -0,0 +1,21 @@ +package com.iterable.iterableapi; + +/** + * Controls the color scheme reported to HTML in-app messages. + */ +public enum IterableInAppColorScheme { + /** + * Follow the host activity's Android UI mode. + */ + AUTOMATIC, + + /** + * Report a light color scheme to in-app message HTML. + */ + LIGHT, + + /** + * Report a dark color scheme to in-app message HTML. + */ + DARK +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorSchemeProvider.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorSchemeProvider.java new file mode 100644 index 000000000..364d2802c --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorSchemeProvider.java @@ -0,0 +1,17 @@ +package com.iterable.iterableapi; + +import androidx.annotation.NonNull; + +/** + * Supplies the color scheme to use when an HTML in-app message is created. + */ +@FunctionalInterface +public interface IterableInAppColorSchemeProvider { + /** + * Returns the current color scheme for HTML in-app messages. + * + * @return the color scheme to use + */ + @NonNull + IterableInAppColorScheme getInAppColorScheme(); +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorSchemeResolver.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorSchemeResolver.java new file mode 100644 index 000000000..47d04d0a9 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppColorSchemeResolver.java @@ -0,0 +1,60 @@ +package com.iterable.iterableapi; + +import androidx.annotation.NonNull; + +final class IterableInAppColorSchemeResolver { + private static final String TAG = "IterableInAppColorScheme"; + + private IterableInAppColorSchemeResolver() { + } + + @NonNull + static IterableInAppColorScheme resolve() { + IterableConfig config = IterableApi.sharedInstance == null + ? null + : IterableApi.sharedInstance.config; + if (config == null) { + return IterableInAppColorScheme.AUTOMATIC; + } + + IterableInAppColorSchemeProvider provider = config.inAppColorSchemeProvider; + if (provider == null) { + return config.inAppColorScheme; + } + + try { + IterableInAppColorScheme colorScheme = provider.getInAppColorScheme(); + if (colorScheme != null) { + return colorScheme; + } + IterableLogger.e(TAG, "Color scheme provider returned null; using AUTOMATIC"); + } catch (Exception e) { + IterableLogger.e(TAG, "Color scheme provider failed; using AUTOMATIC", e); + } + return IterableInAppColorScheme.AUTOMATIC; + } + + static int resolveDialogTheme() { + switch (resolve()) { + case LIGHT: + return R.style.Iterable_InAppDialog_Light; + case DARK: + return R.style.Iterable_InAppDialog_Dark; + case AUTOMATIC: + default: + return R.style.Iterable_InAppDialog; + } + } + + static int resolveFragmentTheme() { + switch (resolve()) { + case LIGHT: + return R.style.Iterable_InAppFragment_Light; + case DARK: + return R.style.Iterable_InAppFragment_Dark; + case AUTOMATIC: + default: + return R.style.Iterable_InAppFragment; + } + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt index cd14982bc..265d25d94 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppDialogNotification.kt @@ -47,7 +47,10 @@ class IterableInAppDialogNotification internal constructor( private val webViewService: InAppWebViewService = InAppServices.webView, private val orientationService: InAppOrientationService = InAppServices.orientation, private val displayModeService: InAppDisplayModeService = InAppServices.displayMode -) : Dialog(hostActivity, R.style.Iterable_InAppDialog), IterableWebView.HTMLNotificationCallbacks { +) : Dialog( + hostActivity, + IterableInAppColorSchemeResolver.resolveDialogTheme() +), IterableWebView.HTMLNotificationCallbacks { private var webView: IterableWebView? = null private var loaded: Boolean = false @@ -522,4 +525,3 @@ class IterableInAppDialogNotification internal constructor( trackingService.removeMessage(message) } } - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java index df8aecc75..e484d3034 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java @@ -123,7 +123,9 @@ 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, + IterableInAppColorSchemeResolver.resolveFragmentTheme()); } @Override @@ -210,7 +212,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-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 fbda81825..e0c6c801a 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 + +