From 7b6fb510844f686c541a4e0bd2505ee0ab675f2b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 18 Dec 2025 12:43:58 +0300 Subject: [PATCH 01/43] refactor: force flush RQ --- .../ly/count/android/sdk/ModuleRequestQueue.java | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java index d446bee4e..fa0886ab0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java @@ -197,7 +197,18 @@ public void flushQueuesInternal() { * attempt to process stored requests on demand */ public void attemptToSendStoredRequestsInternal() { - L.i("[ModuleRequestQueue] Calling attemptToSendStoredRequests"); + attemptToSendStoredRequestsInternal(false); + } + + /** + * This method sends all RQ synchronously if forceFlushRQ is true + * + * @param forceFlushRQ whether to force flush the request queue + * Be cautious when using this flag as it may cause ANRs if used on main thread + * Wrap calls in a separate thread to unsure non-Blocking UI or main thread + */ + protected void attemptToSendStoredRequestsInternal(boolean forceFlushRQ) { + L.i("[ModuleRequestQueue] attemptToSendStoredRequestsInternal, forceFlushRQ: [" + forceFlushRQ + "]"); //combine all available events into a request sendEventsIfNeeded(true); @@ -206,7 +217,7 @@ public void attemptToSendStoredRequestsInternal() { _cly.moduleUserProfile.saveInternal(); //trigger the processing of the request queue - requestQueueProvider.tick(); + requestQueueProvider.tick(forceFlushRQ); } /** From 0193057f81f25fa5da4317a52c94c0f4e47b9bf3 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 18 Dec 2025 12:45:10 +0300 Subject: [PATCH 02/43] feat: use it in refresh --- .../android/sdk/ModuleConfigurationTests.java | 1 - .../ly/count/android/sdk/ModuleContent.java | 18 +++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 625d43649..285467761 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -1174,7 +1174,6 @@ private int[] setupTest_allFeatures(JSONObject serverConfig) { countlyConfig.metricProviderOverride = new MockedMetricProvider(); Countly.sharedInstance().init(countlyConfig); Countly.sharedInstance().moduleContent.CONTENT_START_DELAY_MS = 0; // make it zero to catch content immediate request - Countly.sharedInstance().moduleContent.REFRESH_CONTENT_ZONE_DELAY_MS = 0; // make it zero to catch content immediate request return counts; } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index 0e5151bf2..54853b3d5 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -12,11 +12,14 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import org.jetbrains.annotations.NotNull; import org.json.JSONObject; public class ModuleContent extends ModuleBase { private final ImmediateRequestGenerator iRGenerator; + private ExecutorService refreshExecutor; // to not block main thread during refresh Content contentInterface; CountlyTimer countlyTimer; private boolean shouldFetchContents = false; @@ -25,12 +28,12 @@ public class ModuleContent extends ModuleBase { private final ContentCallback globalContentCallback; private int waitForDelay = 0; int CONTENT_START_DELAY_MS = 4000; // 4 seconds - int REFRESH_CONTENT_ZONE_DELAY_MS = 2500; // 2.5 seconds ModuleContent(@NonNull Countly cly, @NonNull CountlyConfig config) { super(cly, config); L.v("[ModuleContent] Initialising, zoneTimerInterval: [" + config.content.zoneTimerInterval + "], globalContentCallback: [" + config.content.globalContentCallback + "]"); iRGenerator = config.immediateRequestGenerator; + refreshExecutor = Executors.newSingleThreadExecutor(); contentInterface = new Content(); countlyTimer = new CountlyTimer(); @@ -295,6 +298,7 @@ void halt() { contentInterface = null; countlyTimer.stopTimer(L); countlyTimer = null; + refreshExecutor.shutdown(); } @Override @@ -329,13 +333,13 @@ private void refreshContentZoneInternal() { return; } - if (!shouldFetchContents) { - exitContentZoneInternal(); - } + exitContentZoneInternal(); - _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); - - enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS); + refreshExecutor.execute(() -> { + _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(true); + L.d("[ModuleContent] refreshContentZone, RQ flush done, re-entering content zone"); + enterContentZoneInternal(null, 0); + }); } public class Content { From b98a7ca72fc47cfe2a35992e234b492a38a450f4 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 18 Dec 2025 12:45:34 +0300 Subject: [PATCH 03/43] feat: force flush tick --- .../ly/count/android/sdk/ConnectionQueue.java | 22 +++++++++++++++++-- .../android/sdk/RequestQueueProvider.java | 2 ++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java index cfb6b315d..d128a23e0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -887,6 +887,15 @@ void ensureExecutor() { * Should only be called if SDK is initialized */ public void tick() { + tick(false); + } + + /** + * This function blocks caller until RQ flushes, so be cautious when using it. + * + * @param forceFlushRQ if true, will block until RQ is fully flushed + */ + public void tick(boolean forceFlushRQ) { //todo enable later //assert storageProvider != null; if (backoff_.get()) { @@ -906,8 +915,17 @@ public void tick() { if (!rqEmpty && (connectionProcessorFuture_ == null || cpDoneIfOngoing)) { L.d("[ConnectionQueue] tick, Starting ConnectionProcessor"); - ensureExecutor(); - connectionProcessorFuture_ = executor_.submit(createConnectionProcessor()); + Runnable cp = createConnectionProcessor(); + if (forceFlushRQ) { + try { + cp.run(); + } catch (Exception e) { + L.e("[ConnectionQueue] tick, forceFlushRQ encountered an error: " + e.getMessage()); + } + } else { + ensureExecutor(); + connectionProcessorFuture_ = executor_.submit(cp); + } } } diff --git a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java index 19fa17936..24fa38aab 100644 --- a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java @@ -52,6 +52,8 @@ interface RequestQueueProvider { void tick(); + void tick(boolean forceFlushRQ); + ConnectionProcessor createConnectionProcessor(); String prepareRemoteConfigRequestLegacy(@Nullable String keysInclude, @Nullable String keysExclude, @NonNull String preparedMetrics); From 1ef4de5e35b93a1dd44ddd500b4f5a66c1a7160f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 18 Dec 2025 12:58:42 +0300 Subject: [PATCH 04/43] feat: make it final --- sdk/src/main/java/ly/count/android/sdk/ModuleContent.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index 54853b3d5..a895c9400 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -19,7 +19,7 @@ public class ModuleContent extends ModuleBase { private final ImmediateRequestGenerator iRGenerator; - private ExecutorService refreshExecutor; // to not block main thread during refresh + private final ExecutorService refreshExecutor; // to not block main thread during refresh Content contentInterface; CountlyTimer countlyTimer; private boolean shouldFetchContents = false; From 4c20f37a3269135be56d7c6c050a64728d9f47ec Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 18 Dec 2025 13:11:17 +0300 Subject: [PATCH 05/43] feat: wait mech for testing --- .../java/ly/count/android/sdk/ModuleConfigurationTests.java | 6 ++++++ sdk/src/main/java/ly/count/android/sdk/ModuleContent.java | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 285467761..541be2fd7 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -7,6 +7,7 @@ import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -1223,6 +1224,11 @@ private void immediateFlow_allFeatures() throws InterruptedException { Thread.sleep(1000); Countly.sharedInstance().contents().refreshContentZone(); // will add one more content immediate request + try { + // wait for refresh to complete + Countly.sharedInstance().moduleContent.refreshContentZoneInternalFuture.get(5, TimeUnit.SECONDS); + } catch (Exception ignored) { + } } private void feedbackFlow_allFeatures() { diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index a895c9400..5dd2b4ad2 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -14,12 +14,14 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import org.jetbrains.annotations.NotNull; import org.json.JSONObject; public class ModuleContent extends ModuleBase { private final ImmediateRequestGenerator iRGenerator; private final ExecutorService refreshExecutor; // to not block main thread during refresh + Future refreshContentZoneInternalFuture; // for test access Content contentInterface; CountlyTimer countlyTimer; private boolean shouldFetchContents = false; @@ -335,7 +337,7 @@ private void refreshContentZoneInternal() { exitContentZoneInternal(); - refreshExecutor.execute(() -> { + refreshContentZoneInternalFuture = refreshExecutor.submit(() -> { _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(true); L.d("[ModuleContent] refreshContentZone, RQ flush done, re-entering content zone"); enterContentZoneInternal(null, 0); From 84bc8062e7a25e5b13d2055ff47b9a8855f90483 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 18 Dec 2025 13:11:59 +0300 Subject: [PATCH 06/43] feat: changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1af367ea2..c12de27d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Improved Content refresh mechanics. + ## 25.4.8 * Mitigated an issue where push notifications were not shown when consent was not required and app was killed. From d4d6dbaa957ab49eff27d0621e903330a5f96ea7 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 18 Dec 2025 15:10:47 +0300 Subject: [PATCH 07/43] fix: sync thingies --- .../java/ly/count/android/sdk/ConnectionQueue.java | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java index d128a23e0..24c306db4 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -913,6 +913,16 @@ public void tick(boolean forceFlushRQ) { return; } + if (forceFlushRQ && connectionProcessorFuture_ != null && !connectionProcessorFuture_.isDone()) { + L.d("[ConnectionQueue] tick, forceFlushRQ ongoing future closing it"); + try { + connectionProcessorFuture_.get(); + cpDoneIfOngoing = true; + } catch (Exception e) { + L.e("[ConnectionQueue] tick, forceFlushRQ ongoing future encountered an error: " + e.getMessage()); + } + } + if (!rqEmpty && (connectionProcessorFuture_ == null || cpDoneIfOngoing)) { L.d("[ConnectionQueue] tick, Starting ConnectionProcessor"); Runnable cp = createConnectionProcessor(); From 8ed6d19b4c275a633f0a0068e4a0aa0a900f2aa2 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 14 Jan 2026 09:26:38 +0300 Subject: [PATCH 08/43] feat: await all resources --- .../android/sdk/CountlyWebViewClient.java | 84 ++++++++++++++++--- 1 file changed, 71 insertions(+), 13 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java index cd88a02a8..2f59d410e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -1,7 +1,10 @@ package ly.count.android.sdk; +import android.net.Uri; +import android.net.http.SslError; import android.os.Build; import android.util.Log; +import android.webkit.SslErrorHandler; import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; import android.webkit.WebResourceResponse; @@ -9,12 +12,21 @@ import android.webkit.WebViewClient; import java.net.URLDecoder; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; class CountlyWebViewClient extends WebViewClient { private final List listeners; WebViewPageLoadedListener afterPageFinished; long pageLoadTime; + private final AtomicBoolean webViewClosed = new AtomicBoolean(false); + + private static final Set CRITICAL_RESOURCES = new HashSet<>(Arrays.asList( + "js", "css", "png", "jpg", "jpeg", "webp" + )); public CountlyWebViewClient() { super(); @@ -46,20 +58,35 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request @Override public void onPageFinished(WebView view, String url) { + // This function is only called when the main frame is loaded. + // However, the page might still be loading resources (images, scripts, etc.). + // To ensure the page is fully loaded, we use JavaScript to check the document's ready state. Log.v(Countly.TAG, "[CountlyWebViewClient] onPageFinished, url: [" + url + "]"); - if (afterPageFinished != null) { - pageLoadTime = System.currentTimeMillis() - pageLoadTime; - boolean timeOut = (pageLoadTime / 1000L) >= 60; - Log.d(Countly.TAG, "[CountlyWebViewClient] onPageFinished, pageLoadTime: " + pageLoadTime + " ms"); - - afterPageFinished.onPageLoaded(timeOut); - afterPageFinished = null; - } + view.evaluateJavascript("(function() {" + + " if (document.readyState === 'complete') {" + + " return 'READY';" + + " }" + + " return new Promise(function(resolve) {" + + " window.addEventListener('load', function() {" + + " resolve('READY');" + + " });" + + " });" + + "})();", result -> { + if (result.equals("\"READY\"") && webViewClosed.compareAndSet(false, true)) { + pageLoadTime = System.currentTimeMillis() - pageLoadTime; + boolean timeOut = (pageLoadTime / 1000L) >= 60; + Log.d(Countly.TAG, "[CountlyWebViewClient] onPageFinished, pageLoadTime: " + pageLoadTime + " ms"); + if (afterPageFinished != null) { + afterPageFinished.onPageLoaded(timeOut); + afterPageFinished = null; + } + } + }); } @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { - if (request.isForMainFrame() && afterPageFinished != null) { + 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() + ")"; @@ -68,18 +95,49 @@ public void onReceivedError(WebView view, WebResourceRequest request, WebResourc } Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedError, error: [" + errorString + "]"); - afterPageFinished.onPageLoaded(true); - afterPageFinished = null; + if (afterPageFinished != null) { + afterPageFinished.onPageLoaded(true); + afterPageFinished = null; + } } } @Override public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { - if (request.isForMainFrame() && afterPageFinished != null) { - Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedHttpError, errorResponseCode: [" + errorResponse.getStatusCode() + "]"); + if ((request.isForMainFrame() || isCriticalResource(request.getUrl())) && webViewClosed.compareAndSet(false, true)) { + Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedHttpError, url: [" + request.getUrl() + "], errorResponseCode: [" + errorResponse.getStatusCode() + "]"); + if (afterPageFinished != null) { + afterPageFinished.onPageLoaded(true); + afterPageFinished = null; + } + } + } + + @Override + public void onReceivedSslError(WebView view, SslErrorHandler handler, SslError error) { + Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedSslError, SSL error. url:[" + error.getUrl() + "]"); + + if (webViewClosed.compareAndSet(false, true) && afterPageFinished != null) { afterPageFinished.onPageLoaded(true); afterPageFinished = null; } + + handler.cancel(); + } + + private boolean isCriticalResource(Uri uri) { + String path = uri.getPath(); + if (path == null) { + return false; + } + + int dot = path.lastIndexOf('.'); + if (dot == -1) { + return false; + } + + String ext = path.substring(dot + 1).toLowerCase(); + return CRITICAL_RESOURCES.contains(ext); } public void registerWebViewUrlListener(WebViewUrlListener listener) { From 46176f68aa830e5cea53267c0c8ef4a81a7da728 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray <57103426+arifBurakDemiray@users.noreply.github.com> Date: Tue, 10 Feb 2026 08:52:37 +0300 Subject: [PATCH 09/43] Change API badge from 9+ to 21+ Updated API badge to reflect minimum SDK version. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 46f7bdc8b..c48233a73 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ [![Codacy Badge](https://app.codacy.com/project/badge/Grade/b26d1acc435c47af88b4e4b9eb94f59f)](https://app.codacy.com/gh/Countly/countly-sdk-android/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) -[![API](https://img.shields.io/badge/API-9%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=9) +![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat) # Countly Android SDK From 6a4705a659042fbb4b8d36b499a6c922f98f1bb6 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 26 Feb 2026 15:23:04 +0300 Subject: [PATCH 10/43] fix: top on 35 and bottom below 30 --- CHANGELOG.md | 3 ++ .../count/android/sdk/SafeAreaCalculator.java | 32 ++++++++----------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f4b4384..8de226225 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Improved content display positioning in safe area mode + ## 26.1.0 * Extended server configuration capabilities with server-controlled listing filters: * Event filters (blacklist/whitelist) to control which events are recorded. diff --git a/sdk/src/main/java/ly/count/android/sdk/SafeAreaCalculator.java b/sdk/src/main/java/ly/count/android/sdk/SafeAreaCalculator.java index 705b80bbf..fb2528a6a 100644 --- a/sdk/src/main/java/ly/count/android/sdk/SafeAreaCalculator.java +++ b/sdk/src/main/java/ly/count/android/sdk/SafeAreaCalculator.java @@ -11,7 +11,6 @@ import android.util.DisplayMetrics; import android.view.Display; import android.view.DisplayCutout; -import android.view.View; import android.view.WindowInsets; import android.view.WindowManager; import android.view.WindowMetrics; @@ -77,9 +76,9 @@ private static SafeAreaDimensions calculateSafeAreaDimensionsR(@NonNull Context L.d("[SafeAreaCalculator] calculateSafeAreaDimensionsR, mapped orientation dimensions (px) - Portrait: [" + portraitWidth + "x" + portraitHeight + "] Landscape: [" + landscapeWidth + "x" + landscapeHeight + "]"); SafeAreaInsets portraitInsets = calculateInsetsForOrientation( - context, windowInsets, true, density, portraitWidth, portraitHeight, L); + windowInsets, true, density, portraitWidth, portraitHeight, L); SafeAreaInsets landscapeInsets = calculateInsetsForOrientation( - context, windowInsets, false, density, landscapeWidth, landscapeHeight, L); + windowInsets, false, density, landscapeWidth, landscapeHeight, L); SafeAreaDimensions result = new SafeAreaDimensions( portraitInsets.width, @@ -144,8 +143,7 @@ private static SafeAreaDimensions calculateSafeAreaDimensionsLegacy(@NonNull Con } @TargetApi(Build.VERSION_CODES.R) - private static SafeAreaInsets calculateInsetsForOrientation(@NonNull Context context, - @NonNull WindowInsets windowInsets, boolean isPortrait, float density, + private static SafeAreaInsets calculateInsetsForOrientation(@NonNull WindowInsets windowInsets, boolean isPortrait, float density, int widthForOrientation, int heightForOrientation, @NonNull ModuleLog L) { String orientationStr = isPortrait ? "portrait" : "landscape"; @@ -159,15 +157,13 @@ private static SafeAreaInsets calculateInsetsForOrientation(@NonNull Context con int cutoutInset = 0; int navBarInset = 0; - boolean isActivity = context instanceof Activity; - boolean statusBarVisible = windowInsets.isVisible(WindowInsets.Type.statusBars()); boolean navBarVisible = windowInsets.isVisible(WindowInsets.Type.navigationBars()); boolean cutoutVisible = windowInsets.isVisible(WindowInsets.Type.displayCutout()); - L.d("[SafeAreaCalculator] calculateInsetsForOrientation [" + orientationStr + "], context type: [" + (isActivity ? "Activity" : "Non-Activity") + "], visibility - statusBar=[" + statusBarVisible + "], navBar=[" + navBarVisible + "], cutout=[" + cutoutVisible + "]"); + L.d("[SafeAreaCalculator] calculateInsetsForOrientation [" + orientationStr + "], visibility - statusBar=[" + statusBarVisible + "], navBar=[" + navBarVisible + "], cutout=[" + cutoutVisible + "]"); - if (statusBarVisible && isActivity) { + if (statusBarVisible) { Insets statusBarInsets = windowInsets.getInsets(WindowInsets.Type.statusBars()); statusBarInset = statusBarInsets.top; topInset = Math.max(topInset, statusBarInset); @@ -194,12 +190,12 @@ private static SafeAreaInsets calculateInsetsForOrientation(@NonNull Context con if (navBarVisible) { Insets navBarInsets = windowInsets.getInsets(WindowInsets.Type.navigationBars()); - + boolean isGestureNav = isGestureNavigation(navBarInsets, density); String navType = isGestureNav ? "gesture" : "button"; - + L.d("[SafeAreaCalculator] calculateInsetsForOrientation [" + orientationStr + "], nav bar type: [" + navType + "], raw insets (px) - top=[" + navBarInsets.top + "], bottom=[" + navBarInsets.bottom + "], left=[" + navBarInsets.left + "], right=[" + navBarInsets.right + "]"); - + if (isPortrait) { navBarInset = navBarInsets.bottom; if (navBarInset == 0) { @@ -237,7 +233,7 @@ private static SafeAreaInsets calculateInsetsForOrientation(@NonNull Context con if (!isPortrait) { Insets navBarInsets = navBarVisible ? windowInsets.getInsets(WindowInsets.Type.navigationBars()) : Insets.NONE; Insets cutoutInsets = cutoutVisible ? windowInsets.getInsets(WindowInsets.Type.displayCutout()) : Insets.NONE; - + if (navBarInsets.left > 0) { leftOffset = navBarInsets.left; L.d("[SafeAreaCalculator] calculateInsetsForOrientation [" + orientationStr + "], nav bar at left - leftOffset=[" + leftOffset + "] (navBar=" + navBarInsets.left + ")"); @@ -296,14 +292,14 @@ private static SafeAreaInsets calculateInsetsLegacy(@NonNull Context context, L.d("[SafeAreaCalculator] calculateInsetsLegacy [" + orientationStr + "], top inset (px) - using MAX(statusBar=" + statusBarInset + ", cutout=" + cutoutInset + ") = [" + topInset + "]"); int navBarHeightFromResource = getNavigationBarHeight(context, isPortrait); - + boolean navBarVisible = isNavigationBarVisible(context); L.d("[SafeAreaCalculator] calculateInsetsLegacy [" + orientationStr + "], nav bar visible: [" + navBarVisible + "], resource height (px): [" + navBarHeightFromResource + "]"); - + if (navBarVisible) { boolean isGestureNav = navBarHeightFromResource < (int) (density * 40); // < 40dp likely gesture String navType = isGestureNav ? "gesture" : "button"; - + navBarInset = navBarHeightFromResource; if (navBarInset == 0) { navBarInset = getDefaultNavBarInset(isGestureNav, density); @@ -311,11 +307,11 @@ private static SafeAreaInsets calculateInsetsLegacy(@NonNull Context context, } else { L.d("[SafeAreaCalculator] calculateInsetsLegacy [" + orientationStr + "], nav bar type: [" + navType + "], height (px): [" + navBarInset + "]"); } - + if (isPortrait) { bottomInset = Math.max(bottomInset, navBarInset); } else { - bottomInset = Math.max(bottomInset, navBarInset); + bottomInset = Math.min(bottomInset, navBarInset); } } From 3590e2f7a1947798b81bac1c599df346fd93184c Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 26 Feb 2026 15:30:14 +0300 Subject: [PATCH 11/43] feat: revert changes --- .../ly/count/android/sdk/ConnectionQueue.java | 163 ++++++++++++------ .../ly/count/android/sdk/ModuleContent.java | 129 ++++++++++---- .../count/android/sdk/ModuleRequestQueue.java | 38 ++-- .../android/sdk/RequestQueueProvider.java | 2 - 4 files changed, 229 insertions(+), 103 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java index 24c306db4..bc209e43b 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -25,7 +25,11 @@ of this software and associated documentation files (the "Software"), to deal import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.util.Arrays; +import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; @@ -48,6 +52,7 @@ of this software and associated documentation files (the "Software"), to deal * of this bug in dexmaker: https://code.google.com/p/dexmaker/issues/detail?id=34 */ class ConnectionQueue implements RequestQueueProvider { + static final String GLOBAL_RC_CALLBACK = "global_request_callback"; private ExecutorService executor_; private Context context_; private Future connectionProcessorFuture_; @@ -70,6 +75,9 @@ class ConnectionQueue implements RequestQueueProvider { StorageProvider storageProvider; ConfigurationProvider configProvider; RequestInfoProvider requestInfoProvider; + private final Map internalRequestCallbacks = new ConcurrentHashMap<>(); + // Using CopyOnWriteArrayList for thread safety - allows iteration while modifications may occur from other threads + private final List internalGlobalRequestCallbackActions = new CopyOnWriteArrayList<>(); void setBaseInfoProvider(BaseInfoProvider bip) { baseInfoProvider = bip; @@ -91,6 +99,24 @@ void setContext(final Context context) { context_ = context; } + public ConnectionQueue() { + // Register the global callback that executes all registered actions when the request queue finishes processing + internalRequestCallbacks.put(GLOBAL_RC_CALLBACK, new InternalRequestCallback() { + @Override public void onRQFinished() { + // Execute each registered action with try-catch to prevent one failing action from blocking others + for (Runnable r : internalGlobalRequestCallbackActions) { + try { + r.run(); + } catch (Exception e) { + if (L != null) { + L.e("[ConnectionQueue] Exception while executing global request callback action: " + e.getMessage()); + } + } + } + } + }); + } + void setupSSLContext() { if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { sslContext_ = null; @@ -208,7 +234,7 @@ public void beginSession(boolean locationDisabled, @Nullable String locationCoun Countly.sharedInstance().isBeginSessionSent = true; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -233,7 +259,7 @@ public void enrollToKeys(@NonNull String[] keys) { + "&keys=" + UtilsNetworking.encodedArrayBuilder(keys) + "&new_end_point=/o/sdk"; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -260,7 +286,7 @@ public void exitForKeys(@NonNull String[] keys) { data += "&keys=" + UtilsNetworking.encodedArrayBuilder(keys); } - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -286,7 +312,7 @@ public void updateSession(final int duration) { String data = prepareCommonRequestData(); data += "&session_duration=" + duration; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } } @@ -301,7 +327,7 @@ public void changeDeviceId(String deviceId, String oldDeviceId) { data += "&old_device_id=" + UtilsNetworking.urlEncodeString(oldDeviceId); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -330,7 +356,7 @@ public void tokenSession(String token, Countly.CountlyMessagingProvider provider @Override public void run() { L.d("[Connection Queue] Finished waiting 10 seconds adding token request"); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } }, 10, TimeUnit.SECONDS); @@ -356,7 +382,7 @@ public void endSession(final int duration) { data += "&session_duration=" + duration; } - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -373,7 +399,7 @@ public void sendLocation(boolean locationDisabled, String locationCountryCode, S data += prepareLocationData(locationDisabled, locationCountryCode, locationCity, locationGpsCoordinates, locationIpAddress); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -395,7 +421,7 @@ public void sendUserData(String userdata) { } String data = prepareCommonRequestData() + userdata; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -418,7 +444,7 @@ public void sendIndirectAttribution(@NonNull String attributionObj) { String param = "&aid=" + UtilsNetworking.urlEncodeString(attributionObj); String data = prepareCommonRequestData() + param; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -442,7 +468,7 @@ public void sendDirectAttributionTest(@NonNull String attributionData) { String res = "&attribution_data=" + UtilsNetworking.urlEncodeString(attributionData); String data = prepareCommonRequestData() + res; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -473,7 +499,7 @@ public void sendDirectAttributionLegacy(@NonNull String campaignID, @Nullable St } String data = prepareCommonRequestData() + res; - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -498,7 +524,7 @@ public void sendCrashReport(@NonNull final String crashData, final boolean nonFa + "&crash=" + UtilsNetworking.urlEncodeString(crashData); //in case of a fatal crash, write it in sync to shared preferences - addRequestToQueue(data, !nonFatalCrash); + addRequestToQueue(data, !nonFatalCrash, null); tick(); } @@ -535,7 +561,7 @@ public void sendDirectRequest(@NonNull final Map requestData) { )); } - addRequestToQueue(data.toString(), false); + addRequestToQueue(data.toString(), false, null); tick(); } @@ -546,7 +572,7 @@ public void sendMetricsRequest(@NonNull String preparedMetrics) { } L.d("[ConnectionQueue] sendMetricsRequest"); - addRequestToQueue(prepareCommonRequestData() + "&metrics=" + preparedMetrics, false); + addRequestToQueue(prepareCommonRequestData() + "&metrics=" + preparedMetrics, false, null); tick(); } @@ -557,6 +583,17 @@ public void sendMetricsRequest(@NonNull String preparedMetrics) { * @throws IllegalStateException if context, app key, store, or server URL have not been set */ public void recordEvents(final String events) { + recordEvents(events, null); + } + + /** + * Records the specified events and sends them to the server. + * + * @param events URL-encoded JSON string of event data + * @param callback InternalRequestCallback to be called when request is finished + * @throws IllegalStateException if context, app key, store, or server URL have not been set + */ + public void recordEvents(final String events, InternalRequestCallback callback) { if (!checkInternalState()) { return; } @@ -569,7 +606,7 @@ public void recordEvents(final String events) { final String data = prepareCommonRequestData() + "&events=" + events; - addRequestToQueue(data, false); + addRequestToQueue(data, false, callback); tick(); } @@ -582,7 +619,7 @@ public void sendConsentChanges(String formattedConsentChanges) { final String data = prepareCommonRequestData() + "&consent=" + UtilsNetworking.urlEncodeString(formattedConsentChanges); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -609,7 +646,7 @@ public void sendAPMCustomTrace(String key, Long durationMs, Long startMs, Long e + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -637,7 +674,7 @@ public void sendAPMNetworkTrace(String networkTraceKey, Long responseTimeMs, int + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -662,7 +699,7 @@ public void sendAPMAppStart(long durationMs, Long startMs, Long endMs) { + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -687,7 +724,7 @@ public void sendAPMScreenTime(boolean recordForegroundTime, long durationMs, Lon + "&count=1" + "&apm=" + UtilsNetworking.urlEncodeString(apmData); - addRequestToQueue(data, false); + addRequestToQueue(data, false, null); tick(); } @@ -887,15 +924,6 @@ void ensureExecutor() { * Should only be called if SDK is initialized */ public void tick() { - tick(false); - } - - /** - * This function blocks caller until RQ flushes, so be cautious when using it. - * - * @param forceFlushRQ if true, will block until RQ is fully flushed - */ - public void tick(boolean forceFlushRQ) { //todo enable later //assert storageProvider != null; if (backoff_.get()) { @@ -913,29 +941,10 @@ public void tick(boolean forceFlushRQ) { return; } - if (forceFlushRQ && connectionProcessorFuture_ != null && !connectionProcessorFuture_.isDone()) { - L.d("[ConnectionQueue] tick, forceFlushRQ ongoing future closing it"); - try { - connectionProcessorFuture_.get(); - cpDoneIfOngoing = true; - } catch (Exception e) { - L.e("[ConnectionQueue] tick, forceFlushRQ ongoing future encountered an error: " + e.getMessage()); - } - } - if (!rqEmpty && (connectionProcessorFuture_ == null || cpDoneIfOngoing)) { L.d("[ConnectionQueue] tick, Starting ConnectionProcessor"); - Runnable cp = createConnectionProcessor(); - if (forceFlushRQ) { - try { - cp.run(); - } catch (Exception e) { - L.e("[ConnectionQueue] tick, forceFlushRQ encountered an error: " + e.getMessage()); - } - } else { - ensureExecutor(); - connectionProcessorFuture_ = executor_.submit(cp); - } + ensureExecutor(); + connectionProcessorFuture_ = executor_.submit(createConnectionProcessor()); } } @@ -954,7 +963,7 @@ public void run() { } }, configProvider.getBOMDuration(), TimeUnit.SECONDS); } - }); + }, internalRequestCallbacks); cp.pcc = pcc; return cp; } @@ -972,8 +981,52 @@ public boolean queueContainsTemporaryIdItems() { return false; } - void addRequestToQueue(final @NonNull String requestData, final boolean writeInSync) { - storageProvider.addRequest(requestData, writeInSync); + /** + * Adds a request to the queue with an optional callback. + *

+ * When a callback is provided: + * - A unique UUID is generated and stored with the callback in internalRequestCallbacks + * - The callback_id is appended to the request data + * - When the request completes (success, failure, or dropped), the callback is invoked and removed + * + * @param requestData The request data to queue + * @param writeInSync Whether to write synchronously (used for crash reports) + * @param callback Optional callback to be notified when the request completes. May be null. + */ + void addRequestToQueue(final @NonNull String requestData, final boolean writeInSync, InternalRequestCallback callback) { + if (callback == null) { + storageProvider.addRequest(requestData, writeInSync); + } else { + String callbackID = UUID.randomUUID().toString(); + internalRequestCallbacks.put(callbackID, callback); + String callbackParam = "&callback_id=" + UtilsNetworking.urlEncodeString(callbackID); + storageProvider.addRequest(requestData + callbackParam, writeInSync); + } + } + + /** + * Registers an action to be executed when the request queue finishes processing (becomes empty). + *

+ * Important behaviors: + * - Actions persist across multiple queue completions (they are NOT automatically cleared) + * - Actions are executed in the order they were registered + * - If an action throws an exception, it is logged but does not prevent other actions from running + * - To clear all actions, call {@link #flushInternalGlobalRequestCallbackActions()} + * + * @param runnable The action to execute when the queue finishes + */ + void registerInternalGlobalRequestCallbackAction(Runnable runnable) { + internalGlobalRequestCallbackActions.add(runnable); + } + + /** + * Clears all registered global request callback actions. + *

+ * After calling this method, no actions will be executed on future queue completions + * until new actions are registered via {@link #registerInternalGlobalRequestCallbackAction(Runnable)}. + */ + void flushInternalGlobalRequestCallbackActions() { + internalGlobalRequestCallbackActions.clear(); } /** @@ -1000,4 +1053,4 @@ Future getConnectionProcessorFuture() { void setConnectionProcessorFuture(final Future connectionProcessorFuture) { connectionProcessorFuture_ = connectionProcessorFuture; } -} +} \ No newline at end of file diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index 5dd2b4ad2..cc632e026 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -4,6 +4,8 @@ import android.content.Intent; import android.content.res.Configuration; import android.content.res.Resources; +import android.os.Handler; +import android.os.Looper; import android.util.DisplayMetrics; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -12,30 +14,26 @@ import java.util.Locale; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.Future; import org.jetbrains.annotations.NotNull; import org.json.JSONObject; public class ModuleContent extends ModuleBase { private final ImmediateRequestGenerator iRGenerator; - private final ExecutorService refreshExecutor; // to not block main thread during refresh - Future refreshContentZoneInternalFuture; // for test access Content contentInterface; CountlyTimer countlyTimer; private boolean shouldFetchContents = false; private boolean isCurrentlyInContentZone = false; + private boolean isCurrentlyRetrying = false; private int zoneTimerInterval; private final ContentCallback globalContentCallback; private int waitForDelay = 0; int CONTENT_START_DELAY_MS = 4000; // 4 seconds + int REFRESH_CONTENT_ZONE_DELAY_MS = 2500; // 2.5 seconds ModuleContent(@NonNull Countly cly, @NonNull CountlyConfig config) { super(cly, config); L.v("[ModuleContent] Initialising, zoneTimerInterval: [" + config.content.zoneTimerInterval + "], globalContentCallback: [" + config.content.globalContentCallback + "]"); iRGenerator = config.immediateRequestGenerator; - refreshExecutor = Executors.newSingleThreadExecutor(); contentInterface = new Content(); countlyTimer = new CountlyTimer(); @@ -53,14 +51,14 @@ void onSdkConfigurationChanged(@NonNull CountlyConfig config) { exitContentZoneInternal(); } waitForDelay = 0; - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } } @Override void initFinished(@NotNull CountlyConfig config) { if (configProvider.getContentZoneEnabled()) { - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } } @@ -69,9 +67,20 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { if (UtilsDevice.cutout == null && activity != null) { UtilsDevice.getCutout(activity); } + if (isCurrentlyInContentZone + && activity != null + && !(activity instanceof TransparentActivity)) { + try { + Intent bringToFront = new Intent(activity, TransparentActivity.class); + bringToFront.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + activity.startActivity(bringToFront); + } catch (Exception ex) { + L.w("[ModuleContent] onActivityStarted, failed to reorder TransparentActivity to front", ex); + } + } } - void fetchContentsInternal(@NonNull String[] categories) { + void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable callbackOnFailure) { L.d("[ModuleContent] fetchContentsInternal, shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(categories) + "]"); DisplayMetrics displayMetrics = deviceInfo.mp.getDisplayMetrics(_cly.context_); @@ -112,16 +121,23 @@ void fetchContentsInternal(@NonNull String[] categories) { shouldFetchContents = false; // disable fetching contents until the next time, this will disable the timer fetching isCurrentlyInContentZone = true; + isCurrentlyRetrying = false; } else { L.w("[ModuleContent] fetchContentsInternal, response is not valid, skipping"); + if (callbackOnFailure != null) { + callbackOnFailure.run(); + } } } catch (Exception ex) { L.e("[ModuleContent] fetchContentsInternal, Encountered internal issue while trying to fetch contents, [" + ex + "]"); + if (callbackOnFailure != null) { + callbackOnFailure.run(); + } } }, L); } - private void enterContentZoneInternal(@Nullable String[] categories, final int initialDelayMS) { + private void enterContentZoneInternal(@Nullable String[] categories, final int initialDelayMS, @Nullable Runnable callbackOnFailure) { if (!consentProvider.getConsent(Countly.CountlyFeatureNames.content)) { L.w("[ModuleContent] enterContentZoneInternal, Consent is not granted, skipping"); return; @@ -156,22 +172,71 @@ private void enterContentZoneInternal(@Nullable String[] categories, final int i contentInitialDelay += CONTENT_START_DELAY_MS; } - countlyTimer.startTimer(zoneTimerInterval, contentInitialDelay, new Runnable() { - @Override public void run() { - L.d("[ModuleContent] enterContentZoneInternal, waitForDelay: [" + waitForDelay + "], shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(validCategories) + "]"); - if (waitForDelay > 0) { - waitForDelay--; - return; + if (countlyTimer != null) { // for tests, in normal conditions this should never be null here + countlyTimer.startTimer(zoneTimerInterval, contentInitialDelay, new Runnable() { + @Override public void run() { + L.d("[ModuleContent] enterContentZoneInternal, waitForDelay: [" + waitForDelay + "], shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(validCategories) + "]"); + if (waitForDelay > 0) { + waitForDelay--; + return; + } + + if (!shouldFetchContents) { + L.w("[ModuleContent] enterContentZoneInternal, shouldFetchContents is false, skipping"); + return; + } + + fetchContentsInternal(validCategories, callbackOnFailure); } + }, L); + } + } - if (!shouldFetchContents) { - L.w("[ModuleContent] enterContentZoneInternal, shouldFetchContents is false, skipping"); + private void enterContentZoneWithRetriesInternal() { + if (isCurrentlyRetrying) { + L.w("[ModuleContent] enterContentZoneWithRetriesInternal, already retrying, skipping"); + return; + } + isCurrentlyRetrying = true; + Handler handler = new Handler(Looper.getMainLooper()); + int maxRetries = 3; + int delayMillis = 1000; + + Runnable retryRunnable = new Runnable() { + int attempt = 0; + + @Override + public void run() { + if (isCurrentlyInContentZone) { + isCurrentlyRetrying = false; // Reset flag on success return; } - fetchContentsInternal(validCategories); + if (countlyTimer != null) { // for tests + countlyTimer.stopTimer(L); + } + + final Runnable self = this; // Capture reference to outer Runnable + + enterContentZoneInternal(null, 0, new Runnable() { + @Override public void run() { + if (isCurrentlyInContentZone) { + isCurrentlyRetrying = false; // Reset flag on success + return; + } + attempt++; + if (attempt < maxRetries) { + handler.postDelayed(self, delayMillis); + } else { + L.w("[ModuleContent] enterContentZoneWithRetriesInternal, " + maxRetries + " attempted"); + isCurrentlyRetrying = false; + } + } + }); } - }, L); + }; + + handler.post(retryRunnable); } void notifyAfterContentIsClosed() { @@ -300,7 +365,6 @@ void halt() { contentInterface = null; countlyTimer.stopTimer(L); countlyTimer = null; - refreshExecutor.shutdown(); } @Override @@ -325,7 +389,7 @@ private void exitContentZoneInternal() { waitForDelay = 0; } - private void refreshContentZoneInternal() { + void refreshContentZoneInternal(boolean callRQFlush) { if (!configProvider.getRefreshContentZoneEnabled()) { return; } @@ -335,13 +399,16 @@ private void refreshContentZoneInternal() { return; } - exitContentZoneInternal(); + if (!shouldFetchContents) { + exitContentZoneInternal(); + } - refreshContentZoneInternalFuture = refreshExecutor.submit(() -> { - _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(true); - L.d("[ModuleContent] refreshContentZone, RQ flush done, re-entering content zone"); - enterContentZoneInternal(null, 0); - }); + if (callRQFlush) { + _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); + enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS, null); + } else { + enterContentZoneWithRetriesInternal(); + } } public class Content { @@ -357,7 +424,7 @@ public void enterContentZone() { return; } - enterContentZoneInternal(null, 0); + enterContentZoneInternal(null, 0, null); } /** @@ -385,7 +452,7 @@ public void refreshContentZone() { return; } - refreshContentZoneInternal(); + refreshContentZoneInternal(true); } } -} +} \ No newline at end of file diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java index fa0886ab0..ee54c0d29 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRequestQueue.java @@ -152,11 +152,30 @@ synchronized List requestQueueRemoveWithoutAppKey(String[] storedRequest * They will be sent either if the exceed the Threshold size or if their sending is forced */ protected void sendEventsIfNeeded(boolean forceSendingEvents) { + sendEventsIfNeeded(forceSendingEvents, false); + } + + /** + * Check if events from event queue need to be added to the request queue + * They will be sent either if they exceed the Threshold size or if their sending is forced + */ + protected void sendEventsIfNeeded(boolean forceSendingEvents, boolean triggerRefreshContentZone) { int eventsInEventQueue = storageProvider.getEventQueueSize(); L.v("[ModuleRequestQueue] forceSendingEvents, forced:[" + forceSendingEvents + "], event count:[" + eventsInEventQueue + "]"); + InternalRequestCallback callback = null; + if (triggerRefreshContentZone) { + callback = new InternalRequestCallback() { + @Override public void onRequestCompleted(String response, boolean success) { + if (success) { + _cly.moduleContent.refreshContentZoneInternal(false); + } + } + }; + } + if ((forceSendingEvents && eventsInEventQueue > 0) || eventsInEventQueue >= _cly.EVENT_QUEUE_SIZE_THRESHOLD) { - requestQueueProvider.recordEvents(storageProvider.getEventsForRequestAndEmptyEventQueue()); + requestQueueProvider.recordEvents(storageProvider.getEventsForRequestAndEmptyEventQueue(), callback); } } @@ -197,18 +216,7 @@ public void flushQueuesInternal() { * attempt to process stored requests on demand */ public void attemptToSendStoredRequestsInternal() { - attemptToSendStoredRequestsInternal(false); - } - - /** - * This method sends all RQ synchronously if forceFlushRQ is true - * - * @param forceFlushRQ whether to force flush the request queue - * Be cautious when using this flag as it may cause ANRs if used on main thread - * Wrap calls in a separate thread to unsure non-Blocking UI or main thread - */ - protected void attemptToSendStoredRequestsInternal(boolean forceFlushRQ) { - L.i("[ModuleRequestQueue] attemptToSendStoredRequestsInternal, forceFlushRQ: [" + forceFlushRQ + "]"); + L.i("[ModuleRequestQueue] Calling attemptToSendStoredRequests"); //combine all available events into a request sendEventsIfNeeded(true); @@ -217,7 +225,7 @@ protected void attemptToSendStoredRequestsInternal(boolean forceFlushRQ) { _cly.moduleUserProfile.saveInternal(); //trigger the processing of the request queue - requestQueueProvider.tick(forceFlushRQ); + requestQueueProvider.tick(); } /** @@ -484,4 +492,4 @@ public void addCustomNetworkRequestHeaders(@Nullable Map customH } } } -} +} \ No newline at end of file diff --git a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java index 24fa38aab..19fa17936 100644 --- a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java @@ -52,8 +52,6 @@ interface RequestQueueProvider { void tick(); - void tick(boolean forceFlushRQ); - ConnectionProcessor createConnectionProcessor(); String prepareRemoteConfigRequestLegacy(@Nullable String keysInclude, @Nullable String keysExclude, @NonNull String preparedMetrics); From 53ff44917607ce605258c0e01b9c3e0cfe04aa3b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 26 Feb 2026 16:13:04 +0300 Subject: [PATCH 12/43] feat: new design for rq flush --- .../java/ly/count/android/sdk/ConnectionQueue.java | 9 ++++++++- .../main/java/ly/count/android/sdk/ModuleContent.java | 11 +++++++---- .../ly/count/android/sdk/RequestQueueProvider.java | 2 ++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java index bc209e43b..9cbeaaed6 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -113,6 +113,7 @@ public ConnectionQueue() { } } } + flushInternalGlobalRequestCallbackActions(); } }); } @@ -945,6 +946,12 @@ public void tick() { L.d("[ConnectionQueue] tick, Starting ConnectionProcessor"); ensureExecutor(); connectionProcessorFuture_ = executor_.submit(createConnectionProcessor()); + } else if (rqEmpty) { + // only fire callback when queue is genuinely empty + InternalRequestCallback globalCallback = internalRequestCallbacks.get(GLOBAL_RC_CALLBACK); + if (globalCallback != null) { + globalCallback.onRQFinished(); + } } } @@ -1015,7 +1022,7 @@ void addRequestToQueue(final @NonNull String requestData, final boolean writeInS * * @param runnable The action to execute when the queue finishes */ - void registerInternalGlobalRequestCallbackAction(Runnable runnable) { + public void registerInternalGlobalRequestCallbackAction(Runnable runnable) { internalGlobalRequestCallbackActions.add(runnable); } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index cc632e026..cb25f7950 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -28,7 +28,6 @@ public class ModuleContent extends ModuleBase { private final ContentCallback globalContentCallback; private int waitForDelay = 0; int CONTENT_START_DELAY_MS = 4000; // 4 seconds - int REFRESH_CONTENT_ZONE_DELAY_MS = 2500; // 2.5 seconds ModuleContent(@NonNull Countly cly, @NonNull CountlyConfig config) { super(cly, config); @@ -68,8 +67,8 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { UtilsDevice.getCutout(activity); } if (isCurrentlyInContentZone - && activity != null - && !(activity instanceof TransparentActivity)) { + && activity != null + && !(activity instanceof TransparentActivity)) { try { Intent bringToFront = new Intent(activity, TransparentActivity.class); bringToFront.addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); @@ -404,8 +403,12 @@ void refreshContentZoneInternal(boolean callRQFlush) { } if (callRQFlush) { + requestQueueProvider.registerInternalGlobalRequestCallbackAction(new Runnable() { + @Override public void run() { + enterContentZoneInternal(null, 0, null); + } + }); _cly.moduleRequestQueue.attemptToSendStoredRequestsInternal(); - enterContentZoneInternal(null, REFRESH_CONTENT_ZONE_DELAY_MS, null); } else { enterContentZoneWithRetriesInternal(); } diff --git a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java index f9bea9977..d93d86a2e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java @@ -75,4 +75,6 @@ interface RequestQueueProvider { String prepareHealthCheckRequest(String preparedMetrics); String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType); + + void registerInternalGlobalRequestCallbackAction(Runnable runnable); } From 2838cd82ed004d729c971d24e806d32897998836 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 26 Feb 2026 16:15:00 +0300 Subject: [PATCH 13/43] fix: move tests to new way --- .../ly/count/android/sdk/ModuleConfigurationTests.java | 7 ------- 1 file changed, 7 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index 27d624b0e..8790b0098 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -13,7 +13,6 @@ import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiConsumer; @@ -1308,11 +1307,6 @@ private void immediateFlow_allFeatures() throws InterruptedException { Thread.sleep(1000); Countly.sharedInstance().contents().refreshContentZone(); // will add one more content immediate request - try { - // wait for refresh to complete - Countly.sharedInstance().moduleContent.refreshContentZoneInternalFuture.get(5, TimeUnit.SECONDS); - } catch (Exception ignored) { - } } private void feedbackFlow_allFeatures() { @@ -2351,7 +2345,6 @@ private void testJTEWithMockedWebServer(BiConsumer Date: Fri, 27 Feb 2026 13:15:50 +0300 Subject: [PATCH 14/43] feat: more secure js bridge --- .../android/sdk/CountlyWebViewClient.java | 97 ++++++++++++++----- 1 file changed, 74 insertions(+), 23 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java index 2f59d410e..fe230dfd8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -1,9 +1,13 @@ package ly.count.android.sdk; +import android.graphics.Bitmap; import android.net.Uri; import android.net.http.SslError; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.util.Log; +import android.webkit.JavascriptInterface; import android.webkit.SslErrorHandler; import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; @@ -23,17 +27,40 @@ class CountlyWebViewClient extends WebViewClient { WebViewPageLoadedListener afterPageFinished; long pageLoadTime; private final AtomicBoolean webViewClosed = new AtomicBoolean(false); + private final AtomicBoolean pageFinishedCalled = new AtomicBoolean(false); + private boolean jsBridgeAdded = false; private static final Set CRITICAL_RESOURCES = new HashSet<>(Arrays.asList( - "js", "css", "png", "jpg", "jpeg", "webp" + "js", "css" )); + private static final String JS_BRIDGE_NAME = "CountlyPageReady"; + public CountlyWebViewClient() { super(); this.listeners = new ArrayList<>(); this.pageLoadTime = System.currentTimeMillis(); } + private class PageReadyBridge { + @JavascriptInterface + public void onReady() { + new Handler(Looper.getMainLooper()).post(() -> notifyPageReady()); + } + } + + private void notifyPageReady() { + if (webViewClosed.compareAndSet(false, true)) { + pageLoadTime = System.currentTimeMillis() - pageLoadTime; + boolean timeOut = (pageLoadTime / 1000L) >= 60; + Log.d(Countly.TAG, "[CountlyWebViewClient] page ready, pageLoadTime: " + pageLoadTime + " ms"); + if (afterPageFinished != null) { + afterPageFinished.onPageLoaded(timeOut); + afterPageFinished = null; + } + } + } + @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { String url = request.getUrl().toString(); @@ -56,37 +83,54 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request return false; } + @Override + public void onPageStarted(WebView view, String url, Bitmap favicon) { + super.onPageStarted(view, url, favicon); + Log.v(Countly.TAG, "[CountlyWebViewClient] onPageStarted, url: [" + url + "]"); + webViewClosed.set(false); + pageFinishedCalled.set(false); + pageLoadTime = System.currentTimeMillis(); + + if (!jsBridgeAdded) { + view.addJavascriptInterface(new PageReadyBridge(), JS_BRIDGE_NAME); + jsBridgeAdded = true; + } + } + @Override public void onPageFinished(WebView view, String url) { - // This function is only called when the main frame is loaded. - // However, the page might still be loading resources (images, scripts, etc.). - // To ensure the page is fully loaded, we use JavaScript to check the document's ready state. + // onPageFinished fires when the main frame is loaded, but resources may still be loading. + // We use a JavascriptInterface bridge to get a reliable callback when the document is fully ready. Log.v(Countly.TAG, "[CountlyWebViewClient] onPageFinished, url: [" + url + "]"); - view.evaluateJavascript("(function() {" + + pageFinishedCalled.set(true); + + if (!jsBridgeAdded) { + view.addJavascriptInterface(new PageReadyBridge(), JS_BRIDGE_NAME); + jsBridgeAdded = true; + } + + view.evaluateJavascript( + "(function() {" + " if (document.readyState === 'complete') {" + - " return 'READY';" + - " }" + - " return new Promise(function(resolve) {" + + " " + JS_BRIDGE_NAME + ".onReady();" + + " } else {" + " window.addEventListener('load', function() {" + - " resolve('READY');" + + " " + JS_BRIDGE_NAME + ".onReady();" + " });" + - " });" + - "})();", result -> { - if (result.equals("\"READY\"") && webViewClosed.compareAndSet(false, true)) { - pageLoadTime = System.currentTimeMillis() - pageLoadTime; - boolean timeOut = (pageLoadTime / 1000L) >= 60; - Log.d(Countly.TAG, "[CountlyWebViewClient] onPageFinished, pageLoadTime: " + pageLoadTime + " ms"); - if (afterPageFinished != null) { - afterPageFinished.onPageLoaded(timeOut); - afterPageFinished = null; - } - } - }); + " }" + + "})();", null); } @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { - if ((request.isForMainFrame() || isCriticalResource(request.getUrl())) && webViewClosed.compareAndSet(false, true)) { + boolean shouldAbort; + if (request.isForMainFrame()) { + shouldAbort = true; + } else { + shouldAbort = isCriticalResource(request.getUrl()) && !pageFinishedCalled.get(); + } + + if (shouldAbort && webViewClosed.compareAndSet(false, true)) { String errorString; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { errorString = error.getDescription() + " (code: " + error.getErrorCode() + ")"; @@ -104,7 +148,14 @@ 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)) { + boolean shouldAbort; + if (request.isForMainFrame()) { + shouldAbort = true; + } else { + shouldAbort = isCriticalResource(request.getUrl()) && !pageFinishedCalled.get(); + } + + if (shouldAbort && webViewClosed.compareAndSet(false, true)) { Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedHttpError, url: [" + request.getUrl() + "], errorResponseCode: [" + errorResponse.getStatusCode() + "]"); if (afterPageFinished != null) { afterPageFinished.onPageLoaded(true); From 03af7f801b4db994f1bfb035656c9536451fc77e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 27 Feb 2026 13:17:40 +0300 Subject: [PATCH 15/43] feat: preview content --- .../ly/count/android/sdk/ConnectionQueue.java | 10 +++- .../ly/count/android/sdk/ModuleContent.java | 49 ++++++++++++++++--- .../android/sdk/RequestQueueProvider.java | 2 +- 3 files changed, 52 insertions(+), 9 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java index 10266b4cf..46ab1859d 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -877,7 +877,7 @@ public String prepareHealthCheckRequest(String preparedMetrics) { return prepareCommonRequestData() + "&metrics=" + preparedMetrics; } - public String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType) { + public String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType, @Nullable String contentId) { JSONObject json = new JSONObject(); try { @@ -895,7 +895,13 @@ public String prepareFetchContents(int portraitWidth, int portraitHeight, int la L.e("Error while preparing fetch contents request"); } - return prepareCommonRequestData() + "&method=queue" + "&category=" + Arrays.asList(categories) + "&resolution=" + UtilsNetworking.urlEncodeString(json.toString()) + "&la=" + language + "&dt=" + deviceType; + String request = prepareCommonRequestData() + "&method=queue" + "&category=" + Arrays.asList(categories) + "&resolution=" + UtilsNetworking.urlEncodeString(json.toString()) + "&la=" + language + "&dt=" + deviceType; + + if (contentId != null) { + request += "&content_id=" + UtilsNetworking.urlEncodeString(contentId) + "&preview=true"; + } + + return request; } @Override diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index 7e6b9aab4..b7ea75b51 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -80,11 +80,11 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { } } - void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable callbackOnFailure) { - L.d("[ModuleContent] fetchContentsInternal, shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(categories) + "]"); + void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable callbackOnFailure, @Nullable String contentId) { + L.d("[ModuleContent] fetchContentsInternal, shouldFetchContents: [" + shouldFetchContents + "], categories: [" + Arrays.toString(categories) + "], contentId: [" + contentId + "]"); DisplayMetrics displayMetrics = deviceInfo.mp.getDisplayMetrics(_cly.context_); - String requestData = prepareContentFetchRequest(displayMetrics, categories); + String requestData = prepareContentFetchRequest(displayMetrics, categories, contentId); ConnectionProcessor cp = requestQueueProvider.createConnectionProcessor(); final boolean networkingIsEnabled = cp.configProvider_.getNetworkingEnabled(); @@ -186,7 +186,7 @@ private void enterContentZoneInternal(@Nullable String[] categories, final int i return; } - fetchContentsInternal(validCategories, callbackOnFailure); + fetchContentsInternal(validCategories, callbackOnFailure, null); } }, L); } @@ -247,7 +247,7 @@ void notifyAfterContentIsClosed() { } @NonNull - private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics, @NonNull String[] categories) { + private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics, @NonNull String[] categories, @Nullable String contentId) { Resources resources = _cly.context_.getResources(); int currentOrientation = resources.getConfiguration().orientation; boolean portrait = currentOrientation == Configuration.ORIENTATION_PORTRAIT; @@ -292,7 +292,7 @@ private String prepareContentFetchRequest(@NonNull DisplayMetrics displayMetrics String language = Locale.getDefault().getLanguage().toLowerCase(); String deviceType = deviceInfo.mp.getDeviceType(_cly.context_); - return requestQueueProvider.prepareFetchContents(portraitWidth, portraitHeight, landscapeWidth, landscapeHeight, categories, language, deviceType); + return requestQueueProvider.prepareFetchContents(portraitWidth, portraitHeight, landscapeWidth, landscapeHeight, categories, language, deviceType, contentId); } boolean validateResponse(@NonNull JSONObject response) { @@ -389,6 +389,27 @@ private void exitContentZoneInternal() { waitForDelay = 0; } + void previewContentInternal(@NonNull String contentId) { + L.d("[ModuleContent] previewContentInternal, contentId: [" + contentId + "]"); + + if (!consentProvider.getConsent(Countly.CountlyFeatureNames.content)) { + L.w("[ModuleContent] previewContentInternal, Consent is not granted, skipping"); + return; + } + + if (deviceIdProvider.isTemporaryIdEnabled()) { + L.w("[ModuleContent] previewContentInternal, temporary device ID is enabled, skipping"); + return; + } + + if (isCurrentlyInContentZone) { + L.w("[ModuleContent] previewContentInternal, content is already being displayed, skipping"); + return; + } + + fetchContentsInternal(new String[] {}, null, contentId); + } + void refreshContentZoneInternal(boolean callRQFlush) { if (!configProvider.getRefreshContentZoneEnabled()) { return; @@ -441,6 +462,22 @@ public void exitContentZone() { exitContentZoneInternal(); } + /** + * Previews a specific content by its ID. + * This performs a one-time fetch for the given content + * without starting periodic content updates. + * + * @param contentId the ID of the content to preview + */ + public void previewContent(@Nullable String contentId) { + if (Utils.isNullOrEmpty(contentId)) { + L.w("[ModuleContent] previewContent, contentId is null or empty, skipping"); + return; + } + + previewContentInternal(contentId); + } + /** * Triggers a manual refresh of the content zone. * This method forces an update by fetching the latest content, diff --git a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java index f9bea9977..2c6b399e9 100644 --- a/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/RequestQueueProvider.java @@ -74,5 +74,5 @@ interface RequestQueueProvider { String prepareHealthCheckRequest(String preparedMetrics); - String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType); + String prepareFetchContents(int portraitWidth, int portraitHeight, int landscapeWidth, int landscapeHeight, String[] categories, String language, String deviceType, @Nullable String contentId); } From 23fd875253c284769b1208a84a768e451ee2e6ed Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 27 Feb 2026 13:29:39 +0300 Subject: [PATCH 16/43] feat: add tests --- .../count/android/sdk/ModuleContentTests.java | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java new file mode 100644 index 000000000..521a48aee --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -0,0 +1,161 @@ +package ly.count.android.sdk; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.ArrayList; +import java.util.List; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class ModuleContentTests { + + Countly mCountly; + List capturedRequests; + List capturedEndpoints; + + @Before + public void setUp() { + TestUtils.getCountlyStore().clear(); + capturedRequests = new ArrayList<>(); + capturedEndpoints = new ArrayList<>(); + } + + @After + public void tearDown() { + } + + private ImmediateRequestGenerator createCapturingIRGenerator() { + return new ImmediateRequestGenerator() { + @Override public ImmediateRequestI CreateImmediateRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> { + capturedRequests.add(requestData); + capturedEndpoints.add(customEndpoint); + }; + } + + @Override public ImmediateRequestI CreatePreflightRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> { + }; + } + }; + } + + private Countly initWithConsent(boolean contentConsent) { + CountlyConfig config = TestUtils.createBaseConfig(); + config.setRequiresConsent(true); + if (contentConsent) { + config.setConsentEnabled(new String[] { Countly.CountlyFeatureNames.content }); + } + config.disableHealthCheck(); + config.immediateRequestGenerator = createCapturingIRGenerator(); + + mCountly = new Countly(); + mCountly.init(config); + mCountly.moduleContent.countlyTimer = null; + capturedRequests.clear(); + capturedEndpoints.clear(); + return mCountly; + } + + private void setIsCurrentlyInContentZone(ModuleContent module, boolean value) throws Exception { + java.lang.reflect.Field field = ModuleContent.class.getDeclaredField("isCurrentlyInContentZone"); + field.setAccessible(true); + field.set(module, value); + } + + // ======== previewContent public API tests ======== + + /** + * Null and empty contentId should be rejected at the public API level. + * No request should be made. + */ + @Test + public void previewContent_invalidContentId() { + Countly countly = initWithConsent(true); + + countly.contents().previewContent(null); + Assert.assertEquals(0, capturedRequests.size()); + + countly.contents().previewContent(""); + Assert.assertEquals(0, capturedRequests.size()); + } + + /** + * Valid contentId with consent should make a request to /o/sdk/content + * containing content_id and preview=true parameters + */ + @Test + public void previewContent_validContentId() { + Countly countly = initWithConsent(true); + + countly.contents().previewContent("test_content_123"); + + Assert.assertEquals(1, capturedRequests.size()); + Assert.assertEquals("/o/sdk/content", capturedEndpoints.get(0)); + + String request = capturedRequests.get(0); + Assert.assertTrue(request.contains("content_id=test_content_123")); + Assert.assertTrue(request.contains("preview=true")); + } + + /** + * Without content consent, no request should be made + */ + @Test + public void previewContent_noConsent() { + Countly countly = initWithConsent(false); + + countly.contents().previewContent("test_content_id"); + + Assert.assertEquals(0, capturedRequests.size()); + } + + /** + * When content is already being displayed, no new request should be made + */ + @Test + public void previewContent_alreadyInContentZone() throws Exception { + Countly countly = initWithConsent(true); + setIsCurrentlyInContentZone(countly.moduleContent, true); + + countly.contents().previewContent("test_content_id"); + + Assert.assertEquals(0, capturedRequests.size()); + } + + // ======== validateResponse tests ======== + + /** + * validateResponse returns true only when both "geo" and "html" are present, + * false for missing geo, missing html, or empty response + */ + @Test + public void validateResponse() throws JSONException { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + // empty + Assert.assertFalse(mc.validateResponse(new JSONObject())); + + // missing geo + JSONObject noGeo = new JSONObject(); + noGeo.put("html", ""); + Assert.assertFalse(mc.validateResponse(noGeo)); + + // missing html + JSONObject noHtml = new JSONObject(); + noHtml.put("geo", new JSONObject()); + Assert.assertFalse(mc.validateResponse(noHtml)); + + // valid + JSONObject valid = new JSONObject(); + valid.put("geo", new JSONObject()); + valid.put("html", ""); + Assert.assertTrue(mc.validateResponse(valid)); + } +} From 959e011d9e4c80abb4ad30f562b777fbb61cc92b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 27 Feb 2026 13:31:07 +0300 Subject: [PATCH 17/43] feat: changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f4b4384..27292716e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Added Content feature method `previewContent(String contentId)` (Experimental!). + ## 26.1.0 * Extended server configuration capabilities with server-controlled listing filters: * Event filters (blacklist/whitelist) to control which events are recorded. From b5634717d7e96634187d7de9748782014013a822 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Fri, 27 Feb 2026 15:05:09 +0300 Subject: [PATCH 18/43] feat: wait correctly --- .../sdk/CountlyWebViewClientTests.java | 317 ++++++++++++++++++ .../android/sdk/CountlyWebViewClient.java | 97 ++---- 2 files changed, 340 insertions(+), 74 deletions(-) create mode 100644 sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java new file mode 100644 index 000000000..97a24d1ba --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -0,0 +1,317 @@ +package ly.count.android.sdk; + +import android.annotation.SuppressLint; +import android.net.Uri; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import android.webkit.WebView; +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class CountlyWebViewClientTests { + + private CountlyWebViewClient client; + private final List callbackResults = new ArrayList<>(); + private WebView webView; + + @Before + public void setUp() { + client = new CountlyWebViewClient(); + callbackResults.clear(); + client.afterPageFinished = callbackResults::add; + } + + @After + public void tearDown() { + if (webView != null) { + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + webView.destroy(); + webView = null; + }); + } + } + + // ===================================== + // Helper methods + // ===================================== + + @SuppressLint("SetJavaScriptEnabled") + private WebView createWebView() { + final WebView[] holder = new WebView[1]; + InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> { + holder[0] = new WebView(ApplicationProvider.getApplicationContext()); + holder[0].getSettings().setJavaScriptEnabled(true); + }); + webView = holder[0]; + return webView; + } + + private void runOnMainSync(Runnable r) { + InstrumentationRegistry.getInstrumentation().runOnMainSync(r); + } + + private WebResourceRequest fakeRequest(String url, boolean isForMainFrame) { + Uri uri = Uri.parse(url); + return new WebResourceRequest() { + @Override public Uri getUrl() { + return uri; + } + + @Override public boolean isForMainFrame() { + return isForMainFrame; + } + + @Override public boolean isRedirect() { + return false; + } + + @Override public boolean hasGesture() { + return false; + } + + @Override public String getMethod() { + return "GET"; + } + + @Override public Map getRequestHeaders() { + return new HashMap<>(); + } + }; + } + + private WebResourceResponse fakeHttpErrorResponse(int statusCode) { + return new WebResourceResponse("text/html", "utf-8", null) { + @Override public int getStatusCode() { + return statusCode; + } + }; + } + + // ===================================== + // onReceivedHttpError - abort logic + // ===================================== + + /** + * "onReceivedHttpError" with main frame error + * should abort and fire callback with failed=true + */ + @Test + public void onReceivedHttpError_mainFrame_abortsPage() { + client.onReceivedHttpError(null, fakeRequest("https://example.com", true), fakeHttpErrorResponse(404)); + Assert.assertEquals(1, callbackResults.size()); + Assert.assertTrue(callbackResults.get(0)); + } + + /** + * "onReceivedHttpError" with critical sub-resource error (js, css, png, jpg, jpeg, webp) + * should abort immediately and fire callback with failed=true + */ + @Test + public void onReceivedHttpError_criticalSubResource_abortsImmediately() { + client.onReceivedHttpError(null, fakeRequest("https://example.com/app.js", false), fakeHttpErrorResponse(404)); + Assert.assertEquals(1, callbackResults.size()); + Assert.assertTrue(callbackResults.get(0)); + } + + /** + * "onReceivedHttpError" with non-critical sub-resource (no matching extension) + * should not abort + */ + @Test + public void onReceivedHttpError_nonCriticalSubResource_doesNotAbort() { + client.onReceivedHttpError(null, fakeRequest("https://example.com/api/data", false), fakeHttpErrorResponse(500)); + Assert.assertEquals(0, callbackResults.size()); + } + + // ===================================== + // Single-fire guarantee + // ===================================== + + /** + * "onReceivedHttpError" called twice (main frame + critical sub-resource) + * should fire callback only once + */ + @Test + public void singleFire_multipleErrors_onlyFirstFires() { + client.onReceivedHttpError(null, fakeRequest("https://example.com", true), fakeHttpErrorResponse(404)); + client.onReceivedHttpError(null, fakeRequest("https://example.com/app.js", false), fakeHttpErrorResponse(500)); + + Assert.assertEquals(1, callbackResults.size()); + Assert.assertTrue(callbackResults.get(0)); + } + + // ===================================== + // Null listener safety + // ===================================== + + /** + * "onReceivedHttpError" with null afterPageFinished listener + * should not crash + */ + @Test + public void onReceivedHttpError_nullListener_noCrash() { + client.afterPageFinished = null; + client.onReceivedHttpError(null, fakeRequest("https://example.com", true), fakeHttpErrorResponse(404)); + Assert.assertEquals(0, callbackResults.size()); + } + + // ===================================== + // onPageFinished callback behavior + // ===================================== + + /** + * "onPageFinished" should fire callback via evaluateJavascript with failed=false + * when page loads within timeout + */ + @Test + public void onPageFinished_firesCallback() throws InterruptedException { + WebView wv = createWebView(); + CountDownLatch latch = new CountDownLatch(1); + client.afterPageFinished = (failed) -> { + callbackResults.add(failed); + latch.countDown(); + }; + runOnMainSync(() -> { + client.onPageFinished(wv, "https://example.com"); + }); + Assert.assertTrue(latch.await(5, TimeUnit.SECONDS)); + + Assert.assertEquals(1, callbackResults.size()); + Assert.assertFalse(callbackResults.get(0)); + } + + /** + * "onPageFinished" called multiple times + * should fire callback only once + */ + @Test + public void onPageFinished_firesOnlyOnce() throws InterruptedException { + WebView wv = createWebView(); + CountDownLatch latch = new CountDownLatch(1); + client.afterPageFinished = (failed) -> { + callbackResults.add(failed); + latch.countDown(); + }; + runOnMainSync(() -> { + client.onPageFinished(wv, "https://example.com"); + }); + Assert.assertTrue(latch.await(5, TimeUnit.SECONDS)); + + // Second call - callback should not fire again (webViewClosed is true) + runOnMainSync(() -> { + client.onPageFinished(wv, "https://example.com"); + }); + Thread.sleep(500); + + Assert.assertEquals(1, callbackResults.size()); + } + + /** + * "onPageFinished" callback followed by main frame error + * should fire callback only once via onPageFinished + */ + @Test + public void onPageFinished_thenError_onlyOneFires() throws InterruptedException { + WebView wv = createWebView(); + CountDownLatch latch = new CountDownLatch(1); + client.afterPageFinished = (failed) -> { + callbackResults.add(failed); + latch.countDown(); + }; + runOnMainSync(() -> { + client.onPageFinished(wv, "https://example.com"); + }); + Assert.assertTrue(latch.await(5, TimeUnit.SECONDS)); + + // Error after page finished should not produce second callback + client.onReceivedHttpError(null, fakeRequest("https://example.com", true), fakeHttpErrorResponse(500)); + + Assert.assertEquals(1, callbackResults.size()); + Assert.assertFalse(callbackResults.get(0)); // from onPageFinished, not error + } + + // ===================================== + // Timeout detection + // ===================================== + + /** + * "onPageFinished" with page load exceeding 60 seconds + * should report timeout (failed=true) + */ + @Test + public void pageLoadTimeout_over60Seconds_reportsTimeout() throws InterruptedException { + WebView wv = createWebView(); + CountDownLatch latch = new CountDownLatch(1); + client.afterPageFinished = (failed) -> { + callbackResults.add(failed); + latch.countDown(); + }; + runOnMainSync(() -> { + // Simulate a page load that took 61 seconds by backdating pageLoadTime + client.pageLoadTime = System.currentTimeMillis() - 61_000; + client.onPageFinished(wv, "https://example.com"); + }); + Assert.assertTrue(latch.await(5, TimeUnit.SECONDS)); + + Assert.assertEquals(1, callbackResults.size()); + Assert.assertTrue(callbackResults.get(0)); + } + + // ===================================== + // Critical resource detection edge cases + // ===================================== + + /** + * "onReceivedHttpError" with URL that has query params after .js extension + * should still detect as critical JS resource + */ + @Test + public void criticalResource_jsWithQueryParams_detected() { + client.onReceivedHttpError(null, fakeRequest("https://example.com/app.js?v=123", false), fakeHttpErrorResponse(404)); + Assert.assertEquals(1, callbackResults.size()); + } + + /** + * "onReceivedHttpError" with URL that has uppercase extension + * should still detect as critical resource (case insensitive) + */ + @Test + public void criticalResource_uppercaseExtension_detected() { + client.onReceivedHttpError(null, fakeRequest("https://example.com/app.JS", false), fakeHttpErrorResponse(404)); + Assert.assertEquals(1, callbackResults.size()); + } + + /** + * "onReceivedHttpError" with URL that has no path + * should not crash and not abort + */ + @Test + public void criticalResource_noPath_doesNotCrash() { + client.onReceivedHttpError(null, fakeRequest("https://example.com", false), fakeHttpErrorResponse(404)); + Assert.assertEquals(0, callbackResults.size()); + } + + /** + * "onReceivedHttpError" with image sub-resource (png) + * should abort because png is a critical resource + */ + @Test + public void criticalResource_imageExtensions_detected() { + client.onReceivedHttpError(null, fakeRequest("https://example.com/photo.png", false), fakeHttpErrorResponse(404)); + Assert.assertEquals(1, callbackResults.size()); + Assert.assertTrue(callbackResults.get(0)); + } +} diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java index fe230dfd8..2f59d410e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -1,13 +1,9 @@ package ly.count.android.sdk; -import android.graphics.Bitmap; import android.net.Uri; import android.net.http.SslError; import android.os.Build; -import android.os.Handler; -import android.os.Looper; import android.util.Log; -import android.webkit.JavascriptInterface; import android.webkit.SslErrorHandler; import android.webkit.WebResourceError; import android.webkit.WebResourceRequest; @@ -27,40 +23,17 @@ class CountlyWebViewClient extends WebViewClient { WebViewPageLoadedListener afterPageFinished; long pageLoadTime; private final AtomicBoolean webViewClosed = new AtomicBoolean(false); - private final AtomicBoolean pageFinishedCalled = new AtomicBoolean(false); - private boolean jsBridgeAdded = false; private static final Set CRITICAL_RESOURCES = new HashSet<>(Arrays.asList( - "js", "css" + "js", "css", "png", "jpg", "jpeg", "webp" )); - private static final String JS_BRIDGE_NAME = "CountlyPageReady"; - public CountlyWebViewClient() { super(); this.listeners = new ArrayList<>(); this.pageLoadTime = System.currentTimeMillis(); } - private class PageReadyBridge { - @JavascriptInterface - public void onReady() { - new Handler(Looper.getMainLooper()).post(() -> notifyPageReady()); - } - } - - private void notifyPageReady() { - if (webViewClosed.compareAndSet(false, true)) { - pageLoadTime = System.currentTimeMillis() - pageLoadTime; - boolean timeOut = (pageLoadTime / 1000L) >= 60; - Log.d(Countly.TAG, "[CountlyWebViewClient] page ready, pageLoadTime: " + pageLoadTime + " ms"); - if (afterPageFinished != null) { - afterPageFinished.onPageLoaded(timeOut); - afterPageFinished = null; - } - } - } - @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { String url = request.getUrl().toString(); @@ -83,54 +56,37 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request return false; } - @Override - public void onPageStarted(WebView view, String url, Bitmap favicon) { - super.onPageStarted(view, url, favicon); - Log.v(Countly.TAG, "[CountlyWebViewClient] onPageStarted, url: [" + url + "]"); - webViewClosed.set(false); - pageFinishedCalled.set(false); - pageLoadTime = System.currentTimeMillis(); - - if (!jsBridgeAdded) { - view.addJavascriptInterface(new PageReadyBridge(), JS_BRIDGE_NAME); - jsBridgeAdded = true; - } - } - @Override public void onPageFinished(WebView view, String url) { - // onPageFinished fires when the main frame is loaded, but resources may still be loading. - // We use a JavascriptInterface bridge to get a reliable callback when the document is fully ready. + // This function is only called when the main frame is loaded. + // However, the page might still be loading resources (images, scripts, etc.). + // To ensure the page is fully loaded, we use JavaScript to check the document's ready state. Log.v(Countly.TAG, "[CountlyWebViewClient] onPageFinished, url: [" + url + "]"); - pageFinishedCalled.set(true); - - if (!jsBridgeAdded) { - view.addJavascriptInterface(new PageReadyBridge(), JS_BRIDGE_NAME); - jsBridgeAdded = true; - } - - view.evaluateJavascript( - "(function() {" + + view.evaluateJavascript("(function() {" + " if (document.readyState === 'complete') {" + - " " + JS_BRIDGE_NAME + ".onReady();" + - " } else {" + + " return 'READY';" + + " }" + + " return new Promise(function(resolve) {" + " window.addEventListener('load', function() {" + - " " + JS_BRIDGE_NAME + ".onReady();" + + " resolve('READY');" + " });" + - " }" + - "})();", null); + " });" + + "})();", result -> { + if (result.equals("\"READY\"") && webViewClosed.compareAndSet(false, true)) { + pageLoadTime = System.currentTimeMillis() - pageLoadTime; + boolean timeOut = (pageLoadTime / 1000L) >= 60; + Log.d(Countly.TAG, "[CountlyWebViewClient] onPageFinished, pageLoadTime: " + pageLoadTime + " ms"); + if (afterPageFinished != null) { + afterPageFinished.onPageLoaded(timeOut); + afterPageFinished = null; + } + } + }); } @Override public void onReceivedError(WebView view, WebResourceRequest request, WebResourceError error) { - boolean shouldAbort; - if (request.isForMainFrame()) { - shouldAbort = true; - } else { - shouldAbort = isCriticalResource(request.getUrl()) && !pageFinishedCalled.get(); - } - - if (shouldAbort && webViewClosed.compareAndSet(false, true)) { + 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() + ")"; @@ -148,14 +104,7 @@ public void onReceivedError(WebView view, WebResourceRequest request, WebResourc @Override public void onReceivedHttpError(WebView view, WebResourceRequest request, WebResourceResponse errorResponse) { - boolean shouldAbort; - if (request.isForMainFrame()) { - shouldAbort = true; - } else { - shouldAbort = isCriticalResource(request.getUrl()) && !pageFinishedCalled.get(); - } - - if (shouldAbort && webViewClosed.compareAndSet(false, true)) { + if ((request.isForMainFrame() || isCriticalResource(request.getUrl())) && webViewClosed.compareAndSet(false, true)) { Log.v(Countly.TAG, "[CountlyWebViewClient] onReceivedHttpError, url: [" + request.getUrl() + "], errorResponseCode: [" + errorResponse.getStatusCode() + "]"); if (afterPageFinished != null) { afterPageFinished.onPageLoaded(true); From 8b9bcb5d69b1853915c416828d7f50c921fa4b2b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 9 Mar 2026 15:26:46 +0300 Subject: [PATCH 19/43] feat: webview comm await via js bridge --- .../sdk/CountlyWebViewClientTests.java | 9 ++- .../android/sdk/CountlyWebViewClient.java | 81 ++++++++++++++----- 2 files changed, 66 insertions(+), 24 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java index 97a24d1ba..a1d788a2e 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -248,11 +248,11 @@ public void onPageFinished_thenError_onlyOneFires() throws InterruptedException // ===================================== /** - * "onPageFinished" with page load exceeding 60 seconds - * should report timeout (failed=true) + * "onPageFinished" with page load exceeding 60 seconds but no pending CSS + * should report success (failed=false) since all resources are ready */ @Test - public void pageLoadTimeout_over60Seconds_reportsTimeout() throws InterruptedException { + public void pageLoadTimeout_over60Seconds_noPendingCss_reportsSuccess() throws InterruptedException { WebView wv = createWebView(); CountDownLatch latch = new CountDownLatch(1); client.afterPageFinished = (failed) -> { @@ -267,7 +267,7 @@ public void pageLoadTimeout_over60Seconds_reportsTimeout() throws InterruptedExc Assert.assertTrue(latch.await(5, TimeUnit.SECONDS)); Assert.assertEquals(1, callbackResults.size()); - Assert.assertTrue(callbackResults.get(0)); + Assert.assertFalse(callbackResults.get(0)); } // ===================================== @@ -282,6 +282,7 @@ public void pageLoadTimeout_over60Seconds_reportsTimeout() throws InterruptedExc public void criticalResource_jsWithQueryParams_detected() { client.onReceivedHttpError(null, fakeRequest("https://example.com/app.js?v=123", false), fakeHttpErrorResponse(404)); Assert.assertEquals(1, callbackResults.size()); + Assert.assertTrue(callbackResults.get(0)); } /** diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java index 2f59d410e..d13cdc21b 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -3,6 +3,8 @@ import android.net.Uri; import android.net.http.SslError; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.util.Log; import android.webkit.SslErrorHandler; import android.webkit.WebResourceError; @@ -20,6 +22,7 @@ class CountlyWebViewClient extends WebViewClient { private final List listeners; + private final Handler pollHandler = new Handler(Looper.getMainLooper()); WebViewPageLoadedListener afterPageFinished; long pageLoadTime; private final AtomicBoolean webViewClosed = new AtomicBoolean(false); @@ -56,34 +59,66 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request return false; } + private static final long POLL_INTERVAL_MS = 100; + private static final long TIMEOUT_MS = 60_000; + + // Checks all and