Skip to content

Commit 2443374

Browse files
authored
SDK-113 Fix WebView CORS issue with self-hosted custom fonts (#952)
* Fix. * Fix. * Fix. * Revert file * Fix. * Fix. * Fix. * Further fix * Further fix * Fix. * Further fix * Further fix * Fix. * Further fix
1 parent 36254e8 commit 2443374

File tree

8 files changed

+307
-2
lines changed

8 files changed

+307
-2
lines changed

iterableapi-ui/src/main/java/com/iterable/iterableapi/ui/inbox/IterableInboxMessageFragment.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.iterable.iterableapi.IterableApi;
1515
import com.iterable.iterableapi.IterableInAppLocation;
1616
import com.iterable.iterableapi.IterableInAppMessage;
17+
import com.iterable.iterableapi.IterableUtil;
1718
import com.iterable.iterableapi.ui.R;
1819

1920
import java.util.List;
@@ -76,7 +77,8 @@ private IterableInAppMessage getMessageById(String messageId) {
7677
private void loadMessage() {
7778
message = getMessageById(messageId);
7879
if (message != null) {
79-
webView.loadDataWithBaseURL("", message.getContent().html, "text/html", "UTF-8", "");
80+
// Use configured base URL to enable CORS for external resources (e.g., custom fonts)
81+
webView.loadDataWithBaseURL(IterableUtil.getWebViewBaseUrl(), message.getContent().html, "text/html", "UTF-8", "");
8082
webView.setWebViewClient(webViewClient);
8183
if (!loaded) {
8284
IterableApi.getInstance().trackInAppOpen(message, IterableInAppLocation.INBOX);

iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1824,6 +1824,7 @@ public void trackEmbeddedSession(@NonNull IterableEmbeddedSession session) {
18241824

18251825
apiClient.trackEmbeddedSession(session);
18261826
}
1827+
18271828
//endregion
18281829

18291830
}

iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,23 @@ public class IterableConfig {
140140
@Nullable
141141
final IterableAPIMobileFrameworkInfo mobileFrameworkInfo;
142142

143+
/**
144+
* Base URL for Webview content loading. Specifically used to enable CORS for external resources.
145+
* If null or empty, defaults to empty string (original behavior with about:blank origin).
146+
* Set this to according to your CORS settings for example (e.g., "https://app.iterable.com") to allow external resource loading.
147+
*/
148+
@Nullable
149+
final String webViewBaseUrl;
150+
151+
/**
152+
* Get the configured WebView base URL
153+
* @return Base URL for WebView content, or null if not configured
154+
*/
155+
@Nullable
156+
public String getWebViewBaseUrl() {
157+
return webViewBaseUrl;
158+
}
159+
143160
private IterableConfig(Builder builder) {
144161
pushIntegrationName = builder.pushIntegrationName;
145162
urlHandler = builder.urlHandler;
@@ -165,6 +182,7 @@ private IterableConfig(Builder builder) {
165182
iterableUnknownUserHandler = builder.iterableUnknownUserHandler;
166183
decryptionFailureHandler = builder.decryptionFailureHandler;
167184
mobileFrameworkInfo = builder.mobileFrameworkInfo;
185+
webViewBaseUrl = builder.webViewBaseUrl;
168186
}
169187

170188
public static class Builder {
@@ -192,6 +210,7 @@ public static class Builder {
192210
private int eventThresholdLimit = 100;
193211
private IterableIdentityResolution identityResolution = new IterableIdentityResolution();
194212
private IterableUnknownUserHandler iterableUnknownUserHandler;
213+
private String webViewBaseUrl;
195214

196215
public Builder() {}
197216

@@ -434,9 +453,22 @@ public Builder setMobileFrameworkInfo(@NonNull IterableAPIMobileFrameworkInfo mo
434453
return this;
435454
}
436455

456+
/**
457+
* Set the base URL for WebView content loading. Used to enable CORS for external resources.
458+
* If not set or null, defaults to empty string (original behavior with about:blank origin).
459+
* Set this according to your CORS settings (e.g., "https://app.iterable.com") to allow external resource loading.
460+
* @param webViewBaseUrl Base URL for WebView content
461+
*/
462+
@NonNull
463+
public Builder setWebViewBaseUrl(@Nullable String webViewBaseUrl) {
464+
this.webViewBaseUrl = webViewBaseUrl;
465+
return this;
466+
}
467+
437468
@NonNull
438469
public IterableConfig build() {
439470
return new IterableConfig(this);
440471
}
441472
}
473+
442474
}

iterableapi/src/main/java/com/iterable/iterableapi/IterableUtil.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,8 @@ static boolean writeFile(File file, String content) {
8888
static boolean isUrlOpenAllowed(@NonNull String url) {
8989
return instance.isUrlOpenAllowed(url);
9090
}
91+
92+
public static String getWebViewBaseUrl() {
93+
return instance.getWebViewBaseUrl();
94+
}
9195
}

iterableapi/src/main/java/com/iterable/iterableapi/IterableUtilImpl.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,4 +193,21 @@ static boolean isUrlOpenAllowed(@NonNull String url) {
193193

194194
return false;
195195
}
196+
197+
/**
198+
* Returns the configured WebView base URL for enabling CORS for external resources.
199+
* If not configured, defaults to empty string (original behavior with about:blank origin).
200+
* @return Base URL string or empty string if not configured
201+
*/
202+
static String getWebViewBaseUrl() {
203+
try {
204+
IterableConfig config = IterableApi.getInstance().config;
205+
if (config != null && config.webViewBaseUrl != null) {
206+
return config.webViewBaseUrl;
207+
}
208+
} catch (Exception e) {
209+
IterableLogger.w(TAG, "Failed to get configured WebView baseURL, using empty default", e);
210+
}
211+
return "";
212+
}
196213
}

iterableapi/src/main/java/com/iterable/iterableapi/IterableWebView.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,8 @@ void createWithHtml(IterableWebView.HTMLNotificationCallbacks notificationDialog
4343

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

4950
interface HTMLNotificationCallbacks {

iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.iterable.iterableapi
22

33
import org.hamcrest.Matchers.`is`
4+
import org.hamcrest.Matchers.nullValue
45
import org.junit.Assert.*
56
import org.junit.Test
67

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

25+
@Test
26+
fun defaultWebViewBaseUrl() {
27+
val configBuilder: IterableConfig.Builder = IterableConfig.Builder()
28+
val config: IterableConfig = configBuilder.build()
29+
assertThat(config.webViewBaseUrl, `is`(nullValue()))
30+
}
31+
32+
@Test
33+
fun setWebViewBaseUrl() {
34+
val configBuilder: IterableConfig.Builder = IterableConfig.Builder()
35+
.setWebViewBaseUrl("https://app.iterable.com")
36+
val config: IterableConfig = configBuilder.build()
37+
assertThat(config.webViewBaseUrl, `is`("https://app.iterable.com"))
38+
}
39+
2440
@Test
2541
fun defaultDisableKeychainEncryption() {
2642
val configBuilder: IterableConfig.Builder = IterableConfig.Builder()
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package com.iterable.iterableapi;
2+
3+
import org.junit.After;
4+
import org.junit.Before;
5+
import org.junit.Test;
6+
import org.mockito.ArgumentCaptor;
7+
8+
import static org.junit.Assert.assertEquals;
9+
import static org.mockito.ArgumentMatchers.eq;
10+
import static org.mockito.Mockito.spy;
11+
import static org.mockito.Mockito.verify;
12+
13+
public class IterableWebViewTest extends BaseTest {
14+
15+
private IterableWebView webView;
16+
private IterableWebView webViewSpy;
17+
18+
@Before
19+
public void setUp() {
20+
IterableTestUtils.createIterableApiNew();
21+
webView = new IterableWebView(getContext());
22+
webViewSpy = spy(webView);
23+
}
24+
25+
@After
26+
public void tearDown() {
27+
IterableTestUtils.resetIterableApi();
28+
}
29+
30+
// ===== Base URL Configuration Tests =====
31+
32+
@Test
33+
public void testGetWebViewBaseUrl_DefaultConfiguration() {
34+
// Test: When webViewBaseUrl is not configured, should return empty string
35+
String baseUrl = IterableUtil.getWebViewBaseUrl();
36+
assertEquals("Default webViewBaseUrl should be empty string", "", baseUrl);
37+
}
38+
39+
@Test
40+
public void testGetWebViewBaseUrl_CustomConfiguration() {
41+
// Test: When webViewBaseUrl is configured, should return the configured value
42+
String customBaseUrl = "https://app.iterable.com";
43+
44+
IterableConfig config = new IterableConfig.Builder()
45+
.setWebViewBaseUrl(customBaseUrl)
46+
.build();
47+
48+
IterableApi.initialize(getContext(), "test-api-key", config);
49+
50+
String baseUrl = IterableUtil.getWebViewBaseUrl();
51+
assertEquals("Custom webViewBaseUrl should be returned", customBaseUrl, baseUrl);
52+
}
53+
54+
@Test
55+
public void testGetWebViewBaseUrl_EUConfiguration() {
56+
// Test: EU region configuration
57+
String euBaseUrl = "https://app.eu.iterable.com";
58+
59+
IterableConfig config = new IterableConfig.Builder()
60+
.setWebViewBaseUrl(euBaseUrl)
61+
.build();
62+
63+
IterableApi.initialize(getContext(), "test-api-key", config);
64+
65+
String baseUrl = IterableUtil.getWebViewBaseUrl();
66+
assertEquals("EU webViewBaseUrl should be returned", euBaseUrl, baseUrl);
67+
}
68+
69+
@Test
70+
public void testGetWebViewBaseUrl_NullConfiguration() {
71+
// Test: When webViewBaseUrl is explicitly set to null, should return empty string
72+
IterableConfig config = new IterableConfig.Builder()
73+
.setWebViewBaseUrl(null)
74+
.build();
75+
76+
IterableApi.initialize(getContext(), "test-api-key", config);
77+
78+
String baseUrl = IterableUtil.getWebViewBaseUrl();
79+
assertEquals("Null webViewBaseUrl should return empty string", "", baseUrl);
80+
}
81+
82+
@Test
83+
public void testGetWebViewBaseUrl_EmptyStringConfiguration() {
84+
// Test: When webViewBaseUrl is explicitly set to empty string, should return empty string
85+
IterableConfig config = new IterableConfig.Builder()
86+
.setWebViewBaseUrl("")
87+
.build();
88+
89+
IterableApi.initialize(getContext(), "test-api-key", config);
90+
91+
String baseUrl = IterableUtil.getWebViewBaseUrl();
92+
assertEquals("Empty webViewBaseUrl should return empty string", "", baseUrl);
93+
}
94+
95+
@Test
96+
public void testGetWebViewBaseUrl_ExceptionHandling() {
97+
// Test: Exception handling when SDK is not initialized properly
98+
IterableTestUtils.resetIterableApi();
99+
100+
// This should not throw an exception and should return empty string
101+
String baseUrl = IterableUtil.getWebViewBaseUrl();
102+
assertEquals("Exception case should return empty string", "", baseUrl);
103+
}
104+
105+
// ===== WebView Integration Tests =====
106+
107+
@Test
108+
public void testCreateWithHtml_DefaultConfiguration_UsesEmptyBaseUrl() {
109+
// Test: WebView uses empty string as base URL when not configured (about:blank origin)
110+
MockHTMLNotificationCallbacks mockCallbacks = new MockHTMLNotificationCallbacks();
111+
String testHtml = "<html><body>Test Content</body></html>";
112+
113+
webViewSpy.createWithHtml(mockCallbacks, testHtml);
114+
115+
// Verify loadDataWithBaseURL was called with empty string (default behavior)
116+
ArgumentCaptor<String> baseUrlCaptor = ArgumentCaptor.forClass(String.class);
117+
verify(webViewSpy).loadDataWithBaseURL(
118+
baseUrlCaptor.capture(),
119+
eq(testHtml),
120+
eq(IterableWebView.MIME_TYPE),
121+
eq(IterableWebView.ENCODING),
122+
eq("")
123+
);
124+
125+
assertEquals("Default base URL should be empty string (about:blank origin)", "", baseUrlCaptor.getValue());
126+
}
127+
128+
@Test
129+
public void testCreateWithHtml_CustomConfiguration_UsesConfiguredBaseUrl() {
130+
// Test: WebView uses configured base URL to enable CORS for external resources
131+
String customBaseUrl = "https://app.iterable.com";
132+
133+
IterableConfig config = new IterableConfig.Builder()
134+
.setWebViewBaseUrl(customBaseUrl)
135+
.build();
136+
137+
IterableApi.initialize(getContext(), "test-api-key", config);
138+
139+
MockHTMLNotificationCallbacks mockCallbacks = new MockHTMLNotificationCallbacks();
140+
String testHtml = "<html><head><link href='https://webfonts.wolt.com/index.css' rel='stylesheet'></head><body>Custom Fonts</body></html>";
141+
142+
webViewSpy.createWithHtml(mockCallbacks, testHtml);
143+
144+
// Verify loadDataWithBaseURL was called with custom base URL
145+
ArgumentCaptor<String> baseUrlCaptor = ArgumentCaptor.forClass(String.class);
146+
verify(webViewSpy).loadDataWithBaseURL(
147+
baseUrlCaptor.capture(),
148+
eq(testHtml),
149+
eq(IterableWebView.MIME_TYPE),
150+
eq(IterableWebView.ENCODING),
151+
eq("")
152+
);
153+
154+
assertEquals("Custom base URL should enable CORS for external resources", customBaseUrl, baseUrlCaptor.getValue());
155+
}
156+
157+
@Test
158+
public void testCreateWithHtml_EUConfiguration_EnablesCORSForWoltFonts() {
159+
// Test: WebView uses EU base URL for CORS compliance with Wolt's self-hosted fonts
160+
String euBaseUrl = "https://app.eu.iterable.com";
161+
162+
IterableConfig config = new IterableConfig.Builder()
163+
.setWebViewBaseUrl(euBaseUrl)
164+
.build();
165+
166+
IterableApi.initialize(getContext(), "test-api-key", config);
167+
168+
MockHTMLNotificationCallbacks mockCallbacks = new MockHTMLNotificationCallbacks();
169+
String woltHtml = "<html><head><link href='https://webfonts.wolt.com/index.css' rel='stylesheet'></head><body>Wolt Content with Custom Fonts</body></html>";
170+
171+
webViewSpy.createWithHtml(mockCallbacks, woltHtml);
172+
173+
// Verify loadDataWithBaseURL was called with EU base URL
174+
ArgumentCaptor<String> baseUrlCaptor = ArgumentCaptor.forClass(String.class);
175+
verify(webViewSpy).loadDataWithBaseURL(
176+
baseUrlCaptor.capture(),
177+
eq(woltHtml),
178+
eq(IterableWebView.MIME_TYPE),
179+
eq(IterableWebView.ENCODING),
180+
eq("")
181+
);
182+
183+
assertEquals("EU base URL should enable CORS for Wolt's custom fonts", euBaseUrl, baseUrlCaptor.getValue());
184+
}
185+
186+
@Test
187+
public void testCreateWithHtml_CustomDomain_EnablesCORSForAnyDomain() {
188+
// Test: Custom domain configuration works for any customer domain
189+
String customDomain = "https://custom.example.com";
190+
191+
IterableConfig config = new IterableConfig.Builder()
192+
.setWebViewBaseUrl(customDomain)
193+
.build();
194+
195+
IterableApi.initialize(getContext(), "test-api-key", config);
196+
197+
MockHTMLNotificationCallbacks mockCallbacks = new MockHTMLNotificationCallbacks();
198+
String testHtml = "<html><body>Custom Domain Content</body></html>";
199+
200+
webViewSpy.createWithHtml(mockCallbacks, testHtml);
201+
202+
// Verify loadDataWithBaseURL was called with custom domain
203+
ArgumentCaptor<String> baseUrlCaptor = ArgumentCaptor.forClass(String.class);
204+
verify(webViewSpy).loadDataWithBaseURL(
205+
baseUrlCaptor.capture(),
206+
eq(testHtml),
207+
eq(IterableWebView.MIME_TYPE),
208+
eq(IterableWebView.ENCODING),
209+
eq("")
210+
);
211+
212+
assertEquals("Custom domain should be used for CORS", customDomain, baseUrlCaptor.getValue());
213+
}
214+
215+
// Mock implementation for testing
216+
private static class MockHTMLNotificationCallbacks implements IterableWebView.HTMLNotificationCallbacks {
217+
@Override
218+
public void onUrlClicked(String url) {
219+
// Mock implementation
220+
}
221+
222+
@Override
223+
public void setLoaded(boolean loaded) {
224+
// Mock implementation
225+
}
226+
227+
@Override
228+
public void runResizeScript() {
229+
// Mock implementation
230+
}
231+
}
232+
}

0 commit comments

Comments
 (0)