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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly.
* Added a content configuration option to provide a handler for links opened from the content web view, so the app can route its own deep links instead of the SDK opening the system browser, set via `setContentUrlHandler(ContentUrlHandler)`.

* Mitigated an issue where content could fail to be displayed on some devices, as the content web view could stay hidden even after its resources had finished loading.

## 26.1.4
* ! Minor breaking change ! Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks". It is now a no-op; use "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()" instead, otherwise the stricter push intent redirection checks stay disabled.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -453,4 +453,37 @@ public void shouldInterceptRequest_allowlistMode() {
CountlyWebViewClient httpAllowed = new CountlyWebViewClient(new HashSet<>(Arrays.asList("http")));
Assert.assertNull(httpAllowed.shouldInterceptRequest(null, fakeRequest("http://example.com/a.png", false)));
}

// =====================================
// Readiness gate (regression)
// =====================================

/**
* Regression: a nomodule {@code <script src>} is in the DOM but is never fetched by a module-capable
* WebView, so it has no Resource-Timing entry. The gate must SKIP it (a plain {@code script[src]}
* check hung on it until the 60s timeout, leaving the widget blank). Firing well under TIMEOUT_MS
* proves the nomodule script was excluded from the readiness gate, not shown via the fail-open path.
*/
@Test
public void nomoduleScript_excludedFromReadinessGate_showsPromptly() throws InterruptedException {
WebView wv = createWebView();
CountDownLatch latch = new CountDownLatch(1);
client.afterPageFinished = (failed) -> {
callbackResults.add(failed);
latch.countDown();
};
// nomodule <script src>: in the DOM but not fetched by a modern WebView → no timing entry.
String html = "<!DOCTYPE html><html><head>"
+ "<script nomodule src=\"data:text/javascript,0\"></script>"
+ "</head><body>content<script>window.__ready=1;</script></body></html>";
runOnMainSync(() -> {
wv.setWebViewClient(client);
wv.loadDataWithBaseURL("https://example.com/", html, "text/html", "utf-8", null);
});

Assert.assertTrue("readiness callback did not fire under the 60s timeout — readyState gate regressed",
latch.await(20, TimeUnit.SECONDS));
Assert.assertEquals(1, callbackResults.size());
Assert.assertFalse("content must be shown (failed=false), not discarded", callbackResults.get(0));
}
}
61 changes: 37 additions & 24 deletions sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java
Original file line number Diff line number Diff line change
Expand Up @@ -89,20 +89,25 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque
private static final long POLL_INTERVAL_MS = 100;
private static final long TIMEOUT_MS = 60_000;

// Checks all <link rel="stylesheet"> and <script src> elements have loaded.
// CSS: .sheet is non-null when ready. Cross-origin sheets may throw SecurityError — treated as loaded.
// JS: checks Performance API for completed resource entries matching each script's src.
private static final String CHECK_CSS_JS_LOADED =
// Readiness gate: all stylesheets loaded, and all scripts THIS browser actually fetches have a timing
// entry. Skips scripts the browser won't fetch — [nomodule] on module-capable WebViews, type=module
// on legacy ones — which have no entry and would otherwise hang the gate. Waits for CSS/JS, not images.
private static final String CHECK_CSS_JS_READY =
"(function(){"
+ "var pending=[];"
+ "var links=document.querySelectorAll('link[rel=\"stylesheet\"]');"
+ "for(var i=0;i<links.length;i++){"
+ "try{if(!links[i].sheet)return 'LOADING';}catch(e){}"
+ "try{if(!links[i].sheet)pending.push('css:'+links[i].href);}catch(e){}"
+ "}"
+ "var mod='noModule' in HTMLScriptElement.prototype;"
+ "var scripts=document.querySelectorAll('script[src]');"
+ "for(var i=0;i<scripts.length;i++){"
+ "try{if(performance.getEntriesByName(scripts[i].src).length===0)return 'LOADING';}catch(e){}"
+ "var s=scripts[i];"
+ "if(mod&&s.hasAttribute('nomodule'))continue;"
+ "if(!mod&&s.type==='module')continue;"
+ "try{if(performance.getEntriesByName(s.src).length===0)pending.push('js:'+s.src);}catch(e){}"
+ "}"
+ "return 'READY';"
+ "return pending.length===0?'READY':('LOADING '+pending.join(' | '));"
+ "})()";

@Override
Expand All @@ -116,7 +121,7 @@ private void pollForCriticalResources(WebView view) {
return;
}

view.evaluateJavascript(CHECK_CSS_JS_LOADED, result -> {
view.evaluateJavascript(CHECK_CSS_JS_READY, result -> {
if (webViewClosed.get()) {
return;
}
Expand All @@ -126,37 +131,42 @@ private void pollForCriticalResources(WebView view) {
} else {
long elapsed = System.currentTimeMillis() - pageLoadTime;
if (elapsed >= TIMEOUT_MS) {
Log.w(Countly.TAG, "[CountlyWebViewClient] pollForCriticalResources, timed out waiting for CSS/JS");
notifyPageLoaded(true);
// Fail-open: show anyway rather than discard a possibly-rendered widget. Only hard
// errors (SSL / main-frame / critical sub-resource) close the overlay.
Log.w(Countly.TAG, "[CountlyWebViewClient] pollForCriticalResources, timed out; showing content anyway (fail-open). state: " + result);
notifyPageLoaded(false);
} else {
pollHandler.postDelayed(() -> pollForCriticalResources(view), POLL_INTERVAL_MS);
}
}
});
}

private void notifyPageLoaded(boolean timedOut) {
private void notifyPageLoaded(boolean failed) {
if (webViewClosed.compareAndSet(false, true)) {
pageLoadTime = System.currentTimeMillis() - pageLoadTime;
Log.d(Countly.TAG, "[CountlyWebViewClient] notifyPageLoaded, pageLoadTime: " + pageLoadTime + " ms, timedOut: " + timedOut);
Log.d(Countly.TAG, "[CountlyWebViewClient] notifyPageLoaded, pageLoadTime: " + pageLoadTime + " ms, failed: " + failed);
if (afterPageFinished != null) {
afterPageFinished.onPageLoaded(timedOut);
afterPageFinished.onPageLoaded(failed);
afterPageFinished = null;
}
}
}

@Override
public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) {
if ((request.isForMainFrame() || isCriticalResource(request.getUrl())) && webViewClosed.compareAndSet(false, true)) {
String errorString;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
errorString = error.getDescription() + " (code: " + error.getErrorCode() + ")";
} else {
errorString = error.toString();
}
Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedError, error: [" + errorString + "]");
String errorString;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
errorString = error.getDescription() + " (code: " + error.getErrorCode() + ")";
} else {
errorString = error.toString();
}
boolean mainFrame = request.isForMainFrame();
boolean critical = isCriticalResource(request.getUrl());
// Log every failure to help trace a blank widget; only main-frame/critical failures close it.
Log.w(Countly.TAG, "[CountlyWebViewClient] onReceivedError, [" + (mainFrame ? "main" : "sub") + (critical ? "/critical" : "") + "] url=[" + request.getUrl() + "] error=[" + errorString + "]");

if ((mainFrame || critical) && webViewClosed.compareAndSet(false, true)) {
if (afterPageFinished != null) {
afterPageFinished.onPageLoaded(true);
afterPageFinished = null;
Expand All @@ -166,8 +176,11 @@ public void onReceivedError(WebView view, WebResourceRequest request, WebResourc

@Override
public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) {
if ((request.isForMainFrame() || isCriticalResource(request.getUrl())) && webViewClosed.compareAndSet(false, true)) {
Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedHttpError, url: [" + request.getUrl() + "], errorResponseCode: [" + errorResponse.getStatusCode() + "]");
boolean mainFrame = request.isForMainFrame();
boolean critical = isCriticalResource(request.getUrl());
Log.w(Countly.TAG, "[CountlyWebViewClient] onReceivedHttpError, [" + (mainFrame ? "main" : "sub") + (critical ? "/critical" : "") + "] url=[" + request.getUrl() + "] status=[" + errorResponse.getStatusCode() + "]");

if ((mainFrame || critical) && webViewClosed.compareAndSet(false, true)) {
if (afterPageFinished != null) {
afterPageFinished.onPageLoaded(true);
afterPageFinished = null;
Expand All @@ -177,7 +190,7 @@ public void onReceivedHttpError(WebView view, WebResourceRequest request, WebRes

@Override
public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) {
Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedSslError, SSL error. url:[" + error.getUrl() + "]");
Log.w(Countly.TAG, "[CountlyWebViewClient] onReceivedSslError, url:[" + error.getUrl() + "] primaryError=[" + error.getPrimaryError() + "]");

if (webViewClosed.compareAndSet(false, true) && afterPageFinished != null) {
afterPageFinished.onPageLoaded(true);
Expand Down
Loading