Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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() {}

Expand Down Expand Up @@ -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).
Expand All @@ -548,4 +593,4 @@ public IterableConfig build() {
}
}

}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -522,4 +525,3 @@ class IterableInAppDialogNotification internal constructor(
trackingService.removeMessage(message)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@
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
Expand Down Expand Up @@ -210,7 +212,9 @@
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());
Comment thread
franco-zalamena-iterable marked this conversation as resolved.
Dismissed
if (webView == null) {
dismissAllowingStateLoss();
return null;
Expand Down
19 changes: 19 additions & 0 deletions iterableapi/src/main/res/values-v29/styles.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>

<style name="Iterable.InAppDialog.Light">
<item name="android:isLightTheme">true</item>
</style>

<style name="Iterable.InAppDialog.Dark">
<item name="android:isLightTheme">false</item>
</style>

<style name="Iterable.InAppFragment.Light">
<item name="android:isLightTheme">true</item>
</style>

<style name="Iterable.InAppFragment.Dark">
<item name="android:isLightTheme">false</item>
</style>
</resources>
28 changes: 20 additions & 8 deletions iterableapi/src/main/res/values/styles.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
</style>

<!-- Theme for IterableInAppDialogNotification. Mirrors the DialogFragment-side
STYLE_NO_FRAME + Theme_AppCompat_NoActionBar combination. windowIsFloating stays
true (the Dialog default) so the host activity's status/navigation bars remain
visible — RESPECT_BOUNDS depends on this. windowMinWidth*=100% overrides the
floating-dialog small-card sizing so the in-app can fill the activity content
area when it wants to. Modes that need to cover the bars (FORCE_FULLSCREEN,
FORCE_EDGE_TO_EDGE) add FLAG_LAYOUT_NO_LIMITS explicitly at runtime. -->
<style name="Iterable.InAppDialog" parent="Theme.AppCompat.NoActionBar">
STYLE_NO_FRAME + Theme_AppCompat_DayNight_NoActionBar combination. The DayNight
parent is what lets the WebView report the right prefers-color-scheme to the
in-app HTML. windowIsFloating stays true (the Dialog default) so the host
activity's status/navigation bars remain visible — RESPECT_BOUNDS depends on
this. windowMinWidth*=100% overrides the floating-dialog small-card sizing so
the in-app can fill the activity content area when it wants to. Modes that need
to cover the bars (FORCE_FULLSCREEN, FORCE_EDGE_TO_EDGE) add
FLAG_LAYOUT_NO_LIMITS explicitly at runtime. -->
<style name="Iterable.InAppDialog" parent="Theme.AppCompat.DayNight.NoActionBar">
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowNoTitle">true</item>
Expand All @@ -27,4 +29,14 @@
<item name="android:windowMinWidthMajor">100%</item>
<item name="android:windowMinWidthMinor">100%</item>
</style>
</resources>

<style name="Iterable.InAppDialog.Light" />

<style name="Iterable.InAppDialog.Dark" />

<style name="Iterable.InAppFragment" parent="Theme.AppCompat.DayNight.NoActionBar" />

<style name="Iterable.InAppFragment.Light" />

<style name="Iterable.InAppFragment.Dark" />
</resources>
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading
Loading