Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import com.iterable.iterableapi.IterableApi;
import com.iterable.iterableapi.IterableInAppLocation;
import com.iterable.iterableapi.IterableInAppMessage;
import com.iterable.iterableapi.IterableUtil;
import com.iterable.iterableapi.ui.R;

import java.util.List;
Expand Down Expand Up @@ -76,7 +77,8 @@ private IterableInAppMessage getMessageById(String messageId) {
private void loadMessage() {
message = getMessageById(messageId);
if (message != null) {
webView.loadDataWithBaseURL("", message.getContent().html, "text/html", "UTF-8", "");
// Use configured base URL to enable CORS for external resources (e.g., custom fonts)
webView.loadDataWithBaseURL(IterableUtil.getWebViewBaseUrl(), message.getContent().html, "text/html", "UTF-8", "");
webView.setWebViewClient(webViewClient);
if (!loaded) {
IterableApi.getInstance().trackInAppOpen(message, IterableInAppLocation.INBOX);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1824,6 +1824,7 @@ public void trackEmbeddedSession(@NonNull IterableEmbeddedSession session) {

apiClient.trackEmbeddedSession(session);
}

//endregion

}
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,23 @@ public class IterableConfig {
@Nullable
final IterableAPIMobileFrameworkInfo mobileFrameworkInfo;

/**
* 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).
* Set this to according to your CORS settings for example (e.g., "https://app.iterable.com") to allow external resource loading.
*/
@Nullable
final String webViewBaseUrl;

/**
* Get the configured WebView base URL
* @return Base URL for WebView content, or null if not configured
*/
@Nullable
public String getWebViewBaseUrl() {
return webViewBaseUrl;
}

private IterableConfig(Builder builder) {
pushIntegrationName = builder.pushIntegrationName;
urlHandler = builder.urlHandler;
Expand All @@ -165,6 +182,7 @@ private IterableConfig(Builder builder) {
iterableUnknownUserHandler = builder.iterableUnknownUserHandler;
decryptionFailureHandler = builder.decryptionFailureHandler;
mobileFrameworkInfo = builder.mobileFrameworkInfo;
webViewBaseUrl = builder.webViewBaseUrl;
}

public static class Builder {
Expand Down Expand Up @@ -192,6 +210,7 @@ public static class Builder {
private int eventThresholdLimit = 100;
private IterableIdentityResolution identityResolution = new IterableIdentityResolution();
private IterableUnknownUserHandler iterableUnknownUserHandler;
private String webViewBaseUrl;

public Builder() {}

Expand Down Expand Up @@ -434,9 +453,22 @@ public Builder setMobileFrameworkInfo(@NonNull IterableAPIMobileFrameworkInfo mo
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).
* Set this according to your CORS settings (e.g., "https://app.iterable.com") to allow external resource loading.
* @param webViewBaseUrl Base URL for WebView content
*/
@NonNull
public Builder setWebViewBaseUrl(@Nullable String webViewBaseUrl) {
this.webViewBaseUrl = webViewBaseUrl;
return this;
}

@NonNull
public IterableConfig build() {
return new IterableConfig(this);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,8 @@ static boolean writeFile(File file, String content) {
static boolean isUrlOpenAllowed(@NonNull String url) {
return instance.isUrlOpenAllowed(url);
}

public static String getWebViewBaseUrl() {
return instance.getWebViewBaseUrl();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -193,4 +193,21 @@ static boolean isUrlOpenAllowed(@NonNull String url) {

return false;
}

/**
* Returns the configured WebView base URL for enabling CORS for external resources.
* If not configured, defaults to empty string (original behavior with about:blank origin).
* @return Base URL string or empty string if not configured
*/
static String getWebViewBaseUrl() {
try {
IterableConfig config = IterableApi.getInstance().config;
if (config != null && config.webViewBaseUrl != null) {
return config.webViewBaseUrl;
}
} catch (Exception e) {
IterableLogger.w(TAG, "Failed to get configured WebView baseURL, using empty default", e);
}
return "";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ void createWithHtml(IterableWebView.HTMLNotificationCallbacks notificationDialog

// start loading the in-app
// specifically use loadDataWithBaseURL and not loadData, as mentioned in https://stackoverflow.com/a/58181704/13111386
loadDataWithBaseURL("", html, MIME_TYPE, ENCODING, "");
// Use configured base URL to enable CORS for external resources (e.g., custom fonts)
loadDataWithBaseURL(IterableUtil.getWebViewBaseUrl(), html, MIME_TYPE, ENCODING, "");
}

interface HTMLNotificationCallbacks {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.iterable.iterableapi

import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.junit.Assert.*
import org.junit.Test

Expand All @@ -21,6 +22,21 @@ class IterableConfigTest {
assertThat(config.dataRegion, `is`(IterableDataRegion.EU))
}

@Test
fun defaultWebViewBaseUrl() {
val configBuilder: IterableConfig.Builder = IterableConfig.Builder()
val config: IterableConfig = configBuilder.build()
assertThat(config.webViewBaseUrl, `is`(nullValue()))
}

@Test
fun setWebViewBaseUrl() {
val configBuilder: IterableConfig.Builder = IterableConfig.Builder()
.setWebViewBaseUrl("https://app.iterable.com")
val config: IterableConfig = configBuilder.build()
assertThat(config.webViewBaseUrl, `is`("https://app.iterable.com"))
}

@Test
fun defaultDisableKeychainEncryption() {
val configBuilder: IterableConfig.Builder = IterableConfig.Builder()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
package com.iterable.iterableapi;

import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.mockito.ArgumentCaptor;

import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.verify;

public class IterableWebViewTest extends BaseTest {

private IterableWebView webView;
private IterableWebView webViewSpy;

@Before
public void setUp() {
IterableTestUtils.createIterableApiNew();
webView = new IterableWebView(getContext());
webViewSpy = spy(webView);
}

@After
public void tearDown() {
IterableTestUtils.resetIterableApi();
}

// ===== Base URL Configuration Tests =====

@Test
public void testGetWebViewBaseUrl_DefaultConfiguration() {
// Test: When webViewBaseUrl is not configured, should return empty string
String baseUrl = IterableUtil.getWebViewBaseUrl();
assertEquals("Default webViewBaseUrl should be empty string", "", baseUrl);
}

@Test
public void testGetWebViewBaseUrl_CustomConfiguration() {
// Test: When webViewBaseUrl is configured, should return the configured value
String customBaseUrl = "https://app.iterable.com";

IterableConfig config = new IterableConfig.Builder()
.setWebViewBaseUrl(customBaseUrl)
.build();

IterableApi.initialize(getContext(), "test-api-key", config);

String baseUrl = IterableUtil.getWebViewBaseUrl();
assertEquals("Custom webViewBaseUrl should be returned", customBaseUrl, baseUrl);
}

@Test
public void testGetWebViewBaseUrl_EUConfiguration() {
// Test: EU region configuration
String euBaseUrl = "https://app.eu.iterable.com";

IterableConfig config = new IterableConfig.Builder()
.setWebViewBaseUrl(euBaseUrl)
.build();

IterableApi.initialize(getContext(), "test-api-key", config);

String baseUrl = IterableUtil.getWebViewBaseUrl();
assertEquals("EU webViewBaseUrl should be returned", euBaseUrl, baseUrl);
}

@Test
public void testGetWebViewBaseUrl_NullConfiguration() {
// Test: When webViewBaseUrl is explicitly set to null, should return empty string
IterableConfig config = new IterableConfig.Builder()
.setWebViewBaseUrl(null)
.build();

IterableApi.initialize(getContext(), "test-api-key", config);

String baseUrl = IterableUtil.getWebViewBaseUrl();
assertEquals("Null webViewBaseUrl should return empty string", "", baseUrl);
}

@Test
public void testGetWebViewBaseUrl_EmptyStringConfiguration() {
// Test: When webViewBaseUrl is explicitly set to empty string, should return empty string
IterableConfig config = new IterableConfig.Builder()
.setWebViewBaseUrl("")
.build();

IterableApi.initialize(getContext(), "test-api-key", config);

String baseUrl = IterableUtil.getWebViewBaseUrl();
assertEquals("Empty webViewBaseUrl should return empty string", "", baseUrl);
}

@Test
public void testGetWebViewBaseUrl_ExceptionHandling() {
// Test: Exception handling when SDK is not initialized properly
IterableTestUtils.resetIterableApi();

// This should not throw an exception and should return empty string
String baseUrl = IterableUtil.getWebViewBaseUrl();
assertEquals("Exception case should return empty string", "", baseUrl);
}

// ===== WebView Integration Tests =====

@Test
public void testCreateWithHtml_DefaultConfiguration_UsesEmptyBaseUrl() {
// Test: WebView uses empty string as base URL when not configured (about:blank origin)
MockHTMLNotificationCallbacks mockCallbacks = new MockHTMLNotificationCallbacks();
String testHtml = "<html><body>Test Content</body></html>";

webViewSpy.createWithHtml(mockCallbacks, testHtml);

// Verify loadDataWithBaseURL was called with empty string (default behavior)
ArgumentCaptor<String> baseUrlCaptor = ArgumentCaptor.forClass(String.class);
verify(webViewSpy).loadDataWithBaseURL(
baseUrlCaptor.capture(),
eq(testHtml),
eq(IterableWebView.MIME_TYPE),
eq(IterableWebView.ENCODING),
eq("")
);

assertEquals("Default base URL should be empty string (about:blank origin)", "", baseUrlCaptor.getValue());
}

@Test
public void testCreateWithHtml_CustomConfiguration_UsesConfiguredBaseUrl() {
// Test: WebView uses configured base URL to enable CORS for external resources
String customBaseUrl = "https://app.iterable.com";

IterableConfig config = new IterableConfig.Builder()
.setWebViewBaseUrl(customBaseUrl)
.build();

IterableApi.initialize(getContext(), "test-api-key", config);

MockHTMLNotificationCallbacks mockCallbacks = new MockHTMLNotificationCallbacks();
String testHtml = "<html><head><link href='https://webfonts.wolt.com/index.css' rel='stylesheet'></head><body>Custom Fonts</body></html>";

webViewSpy.createWithHtml(mockCallbacks, testHtml);

// Verify loadDataWithBaseURL was called with custom base URL
ArgumentCaptor<String> baseUrlCaptor = ArgumentCaptor.forClass(String.class);
verify(webViewSpy).loadDataWithBaseURL(
baseUrlCaptor.capture(),
eq(testHtml),
eq(IterableWebView.MIME_TYPE),
eq(IterableWebView.ENCODING),
eq("")
);

assertEquals("Custom base URL should enable CORS for external resources", customBaseUrl, baseUrlCaptor.getValue());
}

@Test
public void testCreateWithHtml_EUConfiguration_EnablesCORSForWoltFonts() {
// Test: WebView uses EU base URL for CORS compliance with Wolt's self-hosted fonts
String euBaseUrl = "https://app.eu.iterable.com";

IterableConfig config = new IterableConfig.Builder()
.setWebViewBaseUrl(euBaseUrl)
.build();

IterableApi.initialize(getContext(), "test-api-key", config);

MockHTMLNotificationCallbacks mockCallbacks = new MockHTMLNotificationCallbacks();
String woltHtml = "<html><head><link href='https://webfonts.wolt.com/index.css' rel='stylesheet'></head><body>Wolt Content with Custom Fonts</body></html>";

webViewSpy.createWithHtml(mockCallbacks, woltHtml);

// Verify loadDataWithBaseURL was called with EU base URL
ArgumentCaptor<String> baseUrlCaptor = ArgumentCaptor.forClass(String.class);
verify(webViewSpy).loadDataWithBaseURL(
baseUrlCaptor.capture(),
eq(woltHtml),
eq(IterableWebView.MIME_TYPE),
eq(IterableWebView.ENCODING),
eq("")
);

assertEquals("EU base URL should enable CORS for Wolt's custom fonts", euBaseUrl, baseUrlCaptor.getValue());
}

@Test
public void testCreateWithHtml_CustomDomain_EnablesCORSForAnyDomain() {
// Test: Custom domain configuration works for any customer domain
String customDomain = "https://custom.example.com";

IterableConfig config = new IterableConfig.Builder()
.setWebViewBaseUrl(customDomain)
.build();

IterableApi.initialize(getContext(), "test-api-key", config);

MockHTMLNotificationCallbacks mockCallbacks = new MockHTMLNotificationCallbacks();
String testHtml = "<html><body>Custom Domain Content</body></html>";

webViewSpy.createWithHtml(mockCallbacks, testHtml);

// Verify loadDataWithBaseURL was called with custom domain
ArgumentCaptor<String> baseUrlCaptor = ArgumentCaptor.forClass(String.class);
verify(webViewSpy).loadDataWithBaseURL(
baseUrlCaptor.capture(),
eq(testHtml),
eq(IterableWebView.MIME_TYPE),
eq(IterableWebView.ENCODING),
eq("")
);

assertEquals("Custom domain should be used for CORS", customDomain, baseUrlCaptor.getValue());
}

// Mock implementation for testing
private static class MockHTMLNotificationCallbacks implements IterableWebView.HTMLNotificationCallbacks {
@Override
public void onUrlClicked(String url) {
// Mock implementation
}

@Override
public void setLoaded(boolean loaded) {
// Mock implementation
}

@Override
public void runResizeScript() {
// Mock implementation
}
}
}
Loading