Skip to content

Commit 3a3d7c6

Browse files
authored
Merge pull request #528 from Countly/await_all_rsrcs
feat: await all resources for webview
2 parents eb63b5a + 8b9bcb5 commit 3a3d7c6

2 files changed

Lines changed: 428 additions & 11 deletions

File tree

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
package ly.count.android.sdk;
2+
3+
import android.annotation.SuppressLint;
4+
import android.net.Uri;
5+
import android.webkit.WebResourceRequest;
6+
import android.webkit.WebResourceResponse;
7+
import android.webkit.WebView;
8+
import androidx.test.core.app.ApplicationProvider;
9+
import androidx.test.ext.junit.runners.AndroidJUnit4;
10+
import androidx.test.platform.app.InstrumentationRegistry;
11+
import java.util.ArrayList;
12+
import java.util.HashMap;
13+
import java.util.List;
14+
import java.util.Map;
15+
import java.util.concurrent.CountDownLatch;
16+
import java.util.concurrent.TimeUnit;
17+
import org.junit.After;
18+
import org.junit.Assert;
19+
import org.junit.Before;
20+
import org.junit.Test;
21+
import org.junit.runner.RunWith;
22+
23+
@RunWith(AndroidJUnit4.class)
24+
public class CountlyWebViewClientTests {
25+
26+
private CountlyWebViewClient client;
27+
private final List<Boolean> callbackResults = new ArrayList<>();
28+
private WebView webView;
29+
30+
@Before
31+
public void setUp() {
32+
client = new CountlyWebViewClient();
33+
callbackResults.clear();
34+
client.afterPageFinished = callbackResults::add;
35+
}
36+
37+
@After
38+
public void tearDown() {
39+
if (webView != null) {
40+
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
41+
webView.destroy();
42+
webView = null;
43+
});
44+
}
45+
}
46+
47+
// =====================================
48+
// Helper methods
49+
// =====================================
50+
51+
@SuppressLint("SetJavaScriptEnabled")
52+
private WebView createWebView() {
53+
final WebView[] holder = new WebView[1];
54+
InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
55+
holder[0] = new WebView(ApplicationProvider.getApplicationContext());
56+
holder[0].getSettings().setJavaScriptEnabled(true);
57+
});
58+
webView = holder[0];
59+
return webView;
60+
}
61+
62+
private void runOnMainSync(Runnable r) {
63+
InstrumentationRegistry.getInstrumentation().runOnMainSync(r);
64+
}
65+
66+
private WebResourceRequest fakeRequest(String url, boolean isForMainFrame) {
67+
Uri uri = Uri.parse(url);
68+
return new WebResourceRequest() {
69+
@Override public Uri getUrl() {
70+
return uri;
71+
}
72+
73+
@Override public boolean isForMainFrame() {
74+
return isForMainFrame;
75+
}
76+
77+
@Override public boolean isRedirect() {
78+
return false;
79+
}
80+
81+
@Override public boolean hasGesture() {
82+
return false;
83+
}
84+
85+
@Override public String getMethod() {
86+
return "GET";
87+
}
88+
89+
@Override public Map<String, String> getRequestHeaders() {
90+
return new HashMap<>();
91+
}
92+
};
93+
}
94+
95+
private WebResourceResponse fakeHttpErrorResponse(int statusCode) {
96+
return new WebResourceResponse("text/html", "utf-8", null) {
97+
@Override public int getStatusCode() {
98+
return statusCode;
99+
}
100+
};
101+
}
102+
103+
// =====================================
104+
// onReceivedHttpError - abort logic
105+
// =====================================
106+
107+
/**
108+
* "onReceivedHttpError" with main frame error
109+
* should abort and fire callback with failed=true
110+
*/
111+
@Test
112+
public void onReceivedHttpError_mainFrame_abortsPage() {
113+
client.onReceivedHttpError(null, fakeRequest("https://example.com", true), fakeHttpErrorResponse(404));
114+
Assert.assertEquals(1, callbackResults.size());
115+
Assert.assertTrue(callbackResults.get(0));
116+
}
117+
118+
/**
119+
* "onReceivedHttpError" with critical sub-resource error (js, css, png, jpg, jpeg, webp)
120+
* should abort immediately and fire callback with failed=true
121+
*/
122+
@Test
123+
public void onReceivedHttpError_criticalSubResource_abortsImmediately() {
124+
client.onReceivedHttpError(null, fakeRequest("https://example.com/app.js", false), fakeHttpErrorResponse(404));
125+
Assert.assertEquals(1, callbackResults.size());
126+
Assert.assertTrue(callbackResults.get(0));
127+
}
128+
129+
/**
130+
* "onReceivedHttpError" with non-critical sub-resource (no matching extension)
131+
* should not abort
132+
*/
133+
@Test
134+
public void onReceivedHttpError_nonCriticalSubResource_doesNotAbort() {
135+
client.onReceivedHttpError(null, fakeRequest("https://example.com/api/data", false), fakeHttpErrorResponse(500));
136+
Assert.assertEquals(0, callbackResults.size());
137+
}
138+
139+
// =====================================
140+
// Single-fire guarantee
141+
// =====================================
142+
143+
/**
144+
* "onReceivedHttpError" called twice (main frame + critical sub-resource)
145+
* should fire callback only once
146+
*/
147+
@Test
148+
public void singleFire_multipleErrors_onlyFirstFires() {
149+
client.onReceivedHttpError(null, fakeRequest("https://example.com", true), fakeHttpErrorResponse(404));
150+
client.onReceivedHttpError(null, fakeRequest("https://example.com/app.js", false), fakeHttpErrorResponse(500));
151+
152+
Assert.assertEquals(1, callbackResults.size());
153+
Assert.assertTrue(callbackResults.get(0));
154+
}
155+
156+
// =====================================
157+
// Null listener safety
158+
// =====================================
159+
160+
/**
161+
* "onReceivedHttpError" with null afterPageFinished listener
162+
* should not crash
163+
*/
164+
@Test
165+
public void onReceivedHttpError_nullListener_noCrash() {
166+
client.afterPageFinished = null;
167+
client.onReceivedHttpError(null, fakeRequest("https://example.com", true), fakeHttpErrorResponse(404));
168+
Assert.assertEquals(0, callbackResults.size());
169+
}
170+
171+
// =====================================
172+
// onPageFinished callback behavior
173+
// =====================================
174+
175+
/**
176+
* "onPageFinished" should fire callback via evaluateJavascript with failed=false
177+
* when page loads within timeout
178+
*/
179+
@Test
180+
public void onPageFinished_firesCallback() throws InterruptedException {
181+
WebView wv = createWebView();
182+
CountDownLatch latch = new CountDownLatch(1);
183+
client.afterPageFinished = (failed) -> {
184+
callbackResults.add(failed);
185+
latch.countDown();
186+
};
187+
runOnMainSync(() -> {
188+
client.onPageFinished(wv, "https://example.com");
189+
});
190+
Assert.assertTrue(latch.await(5, TimeUnit.SECONDS));
191+
192+
Assert.assertEquals(1, callbackResults.size());
193+
Assert.assertFalse(callbackResults.get(0));
194+
}
195+
196+
/**
197+
* "onPageFinished" called multiple times
198+
* should fire callback only once
199+
*/
200+
@Test
201+
public void onPageFinished_firesOnlyOnce() throws InterruptedException {
202+
WebView wv = createWebView();
203+
CountDownLatch latch = new CountDownLatch(1);
204+
client.afterPageFinished = (failed) -> {
205+
callbackResults.add(failed);
206+
latch.countDown();
207+
};
208+
runOnMainSync(() -> {
209+
client.onPageFinished(wv, "https://example.com");
210+
});
211+
Assert.assertTrue(latch.await(5, TimeUnit.SECONDS));
212+
213+
// Second call - callback should not fire again (webViewClosed is true)
214+
runOnMainSync(() -> {
215+
client.onPageFinished(wv, "https://example.com");
216+
});
217+
Thread.sleep(500);
218+
219+
Assert.assertEquals(1, callbackResults.size());
220+
}
221+
222+
/**
223+
* "onPageFinished" callback followed by main frame error
224+
* should fire callback only once via onPageFinished
225+
*/
226+
@Test
227+
public void onPageFinished_thenError_onlyOneFires() throws InterruptedException {
228+
WebView wv = createWebView();
229+
CountDownLatch latch = new CountDownLatch(1);
230+
client.afterPageFinished = (failed) -> {
231+
callbackResults.add(failed);
232+
latch.countDown();
233+
};
234+
runOnMainSync(() -> {
235+
client.onPageFinished(wv, "https://example.com");
236+
});
237+
Assert.assertTrue(latch.await(5, TimeUnit.SECONDS));
238+
239+
// Error after page finished should not produce second callback
240+
client.onReceivedHttpError(null, fakeRequest("https://example.com", true), fakeHttpErrorResponse(500));
241+
242+
Assert.assertEquals(1, callbackResults.size());
243+
Assert.assertFalse(callbackResults.get(0)); // from onPageFinished, not error
244+
}
245+
246+
// =====================================
247+
// Timeout detection
248+
// =====================================
249+
250+
/**
251+
* "onPageFinished" with page load exceeding 60 seconds but no pending CSS
252+
* should report success (failed=false) since all resources are ready
253+
*/
254+
@Test
255+
public void pageLoadTimeout_over60Seconds_noPendingCss_reportsSuccess() throws InterruptedException {
256+
WebView wv = createWebView();
257+
CountDownLatch latch = new CountDownLatch(1);
258+
client.afterPageFinished = (failed) -> {
259+
callbackResults.add(failed);
260+
latch.countDown();
261+
};
262+
runOnMainSync(() -> {
263+
// Simulate a page load that took 61 seconds by backdating pageLoadTime
264+
client.pageLoadTime = System.currentTimeMillis() - 61_000;
265+
client.onPageFinished(wv, "https://example.com");
266+
});
267+
Assert.assertTrue(latch.await(5, TimeUnit.SECONDS));
268+
269+
Assert.assertEquals(1, callbackResults.size());
270+
Assert.assertFalse(callbackResults.get(0));
271+
}
272+
273+
// =====================================
274+
// Critical resource detection edge cases
275+
// =====================================
276+
277+
/**
278+
* "onReceivedHttpError" with URL that has query params after .js extension
279+
* should still detect as critical JS resource
280+
*/
281+
@Test
282+
public void criticalResource_jsWithQueryParams_detected() {
283+
client.onReceivedHttpError(null, fakeRequest("https://example.com/app.js?v=123", false), fakeHttpErrorResponse(404));
284+
Assert.assertEquals(1, callbackResults.size());
285+
Assert.assertTrue(callbackResults.get(0));
286+
}
287+
288+
/**
289+
* "onReceivedHttpError" with URL that has uppercase extension
290+
* should still detect as critical resource (case insensitive)
291+
*/
292+
@Test
293+
public void criticalResource_uppercaseExtension_detected() {
294+
client.onReceivedHttpError(null, fakeRequest("https://example.com/app.JS", false), fakeHttpErrorResponse(404));
295+
Assert.assertEquals(1, callbackResults.size());
296+
}
297+
298+
/**
299+
* "onReceivedHttpError" with URL that has no path
300+
* should not crash and not abort
301+
*/
302+
@Test
303+
public void criticalResource_noPath_doesNotCrash() {
304+
client.onReceivedHttpError(null, fakeRequest("https://example.com", false), fakeHttpErrorResponse(404));
305+
Assert.assertEquals(0, callbackResults.size());
306+
}
307+
308+
/**
309+
* "onReceivedHttpError" with image sub-resource (png)
310+
* should abort because png is a critical resource
311+
*/
312+
@Test
313+
public void criticalResource_imageExtensions_detected() {
314+
client.onReceivedHttpError(null, fakeRequest("https://example.com/photo.png", false), fakeHttpErrorResponse(404));
315+
Assert.assertEquals(1, callbackResults.size());
316+
Assert.assertTrue(callbackResults.get(0));
317+
}
318+
}

0 commit comments

Comments
 (0)