From 656727467a1472786a481bccd7fabcadf66eac3e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 2 Jul 2026 14:31:48 +0300 Subject: [PATCH 01/21] feat: report app theme for UI --- CHANGELOG.md | 3 + .../android/sdk/ConnectionQueueTests.java | 15 +++ .../count/android/sdk/ModuleContentTests.java | 50 ++++++++ .../count/android/sdk/UtilsDeviceTests.java | 109 ++++++++++++++++++ .../ly/count/android/sdk/ModuleContent.java | 3 + .../ly/count/android/sdk/ModuleFeedback.java | 2 +- .../ly/count/android/sdk/ModuleRatings.java | 4 +- .../ly/count/android/sdk/UtilsDevice.java | 49 ++++++++ 8 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e34612c3..0c6321683 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Added support for reporting the app's current theme (light or dark) when presenting feedback widgets, rating widgets, and content, so they are displayed in matching conditions. + ## 26.1.4 * ! Minor breaking change ! Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks". It is now a no-op; use "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()" instead, otherwise the stricter push intent redirection checks stay disabled. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java index 2341079d8..e457593b9 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java @@ -526,4 +526,19 @@ public void testPrepareCommonRequest() { } } } + + /** + * The theme ("th") parameter is reported on the URLs loaded into the WebView (feedback/rating + * widget and content URLs), not on the feedback-list or content-fetch data requests. These + * requests must therefore never carry "th" regardless of the device theme. The actual "th" + * append logic is validated in UtilsDeviceTests, its wiring into content in ModuleContentTests. + */ + @Test + public void testThemeParam_notOnFeedbackListNorFetchContents() { + final String feedbackRequest = connQ.prepareFeedbackListRequest(); + final String contentRequest = connQ.prepareFetchContents(100, 200, 200, 100, new String[] {}, "en", "mobile", null); + + Assert.assertFalse(feedbackRequest.contains("th=")); + Assert.assertFalse(contentRequest.contains("th=")); + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java index 7bc48adf4..fb4c3f80a 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -1,9 +1,13 @@ package ly.count.android.sdk; import android.app.Activity; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.util.DisplayMetrics; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.json.JSONException; import org.json.JSONObject; import org.junit.After; @@ -13,6 +17,7 @@ import org.junit.runner.RunWith; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @RunWith(AndroidJUnit4.class) public class ModuleContentTests { @@ -375,4 +380,49 @@ public void contentZone_doesNotResumeAfterExplicitExit() throws Exception { mCountly.deviceId().changeWithoutMerge("real_user_after_exit"); Assert.assertFalse(readShouldFetchContents(mCountly.moduleContent)); } + + // ======== theme ("th") param on the content URL ======== + + /** + * parseContent must append the app theme ("th") to the content URL that gets loaded into the + * WebView, so the content is rendered matching the theme. A dark foreground Activity is set so + * the resolved theme is deterministic ("d"); the URL already has a query, so "&th=d" is used. + * The l/d mapping and separator logic themselves are covered by UtilsDeviceTests. + */ + @Test + public void parseContent_appendsThemeParamToContentUrl() throws JSONException { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + Activity darkActivity = mock(Activity.class); + Resources darkResources = mock(Resources.class); + Configuration darkCfg = new Configuration(); + darkCfg.uiMode = Configuration.UI_MODE_NIGHT_YES; + when(darkActivity.getResources()).thenReturn(darkResources); + when(darkResources.getConfiguration()).thenReturn(darkCfg); + CountlyActivityHolder.getInstance().setActivity(darkActivity); + + try { + String html = "https://content.example/page?cid=1"; + JSONObject placement = new JSONObject(); + placement.put("x", 0); + placement.put("y", 0); + placement.put("w", 100); + placement.put("h", 100); + JSONObject geo = new JSONObject(); + geo.put("p", placement); + JSONObject response = new JSONObject(); + response.put("html", html); + response.put("geo", geo); + + DisplayMetrics dm = TestUtils.getContext().getResources().getDisplayMetrics(); + Map configs = mc.parseContent(response, dm); + + TransparentActivityConfig portrait = configs.get(Configuration.ORIENTATION_PORTRAIT); + Assert.assertNotNull(portrait); + Assert.assertEquals(html + "&th=d", portrait.url); + } finally { + CountlyActivityHolder.getInstance().clearActivity(darkActivity); + } + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java new file mode 100644 index 000000000..5669bd21d --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java @@ -0,0 +1,109 @@ +package ly.count.android.sdk; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(AndroidJUnit4.class) +public class UtilsDeviceTests { + + /** + * getThemeMode and appendThemeParam prefer the foreground Activity's configuration. These + * tests exercise the fallback-context path, so make sure no Activity is registered from a + * previously run test in the same process. + */ + @Before + public void setUp() { + clearForegroundActivity(); + } + + @After + public void tearDown() { + clearForegroundActivity(); + } + + private void clearForegroundActivity() { + Activity current = CountlyActivityHolder.getInstance().getActivity(); + if (current != null) { + CountlyActivityHolder.getInstance().clearActivity(current); + } + } + + /** + * Builds a context whose resources report exactly the given UI_MODE_NIGHT_* flag. A mock is + * used deliberately: a real createConfigurationContext falls back to the device's night mode + * for the UNDEFINED case, so it can not report "undefined" independently of the test device. + */ + private Context contextWithNightMode(int nightModeFlag) { + Context ctx = mock(Context.class); + Resources res = mock(Resources.class); + Configuration cfg = new Configuration(); + cfg.uiMode = nightModeFlag; + when(ctx.getResources()).thenReturn(res); + when(res.getConfiguration()).thenReturn(cfg); + return ctx; + } + + // ======== getThemeMode ======== + + /** A dark-configured context resolves to "d", a light one to "l", undefined to null. */ + @Test + public void getThemeMode_mapsNightModeFlags() { + Assert.assertEquals("d", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_YES))); + Assert.assertEquals("l", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_NO))); + Assert.assertNull(UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_UNDEFINED))); + } + + /** The foreground Activity's configuration wins over the fallback context. */ + @Test + public void getThemeMode_prefersForegroundActivity() { + Activity darkActivity = mock(Activity.class); + Resources darkResources = mock(Resources.class); + Configuration darkCfg = new Configuration(); + darkCfg.uiMode = Configuration.UI_MODE_NIGHT_YES; + when(darkActivity.getResources()).thenReturn(darkResources); + when(darkResources.getConfiguration()).thenReturn(darkCfg); + + CountlyActivityHolder.getInstance().setActivity(darkActivity); + try { + // fallback is light, but the dark Activity must take precedence + Assert.assertEquals("d", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_NO))); + } finally { + CountlyActivityHolder.getInstance().clearActivity(darkActivity); + } + } + + // ======== appendThemeParam ======== + + /** With an existing query string the theme is appended with "&". */ + @Test + public void appendThemeParam_appendsWithAmpersandWhenQueryPresent() { + String url = "https://widgets.example/feedback/nps?widget_id=abc&app_key=k"; + Assert.assertEquals(url + "&th=d", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_YES))); + Assert.assertEquals(url + "&th=l", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_NO))); + } + + /** Without a query string the theme is appended with "?". */ + @Test + public void appendThemeParam_appendsWithQuestionMarkWhenNoQuery() { + String url = "https://content.example/page"; + Assert.assertEquals(url + "?th=l", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_NO))); + } + + /** When the theme is undefined the URL is returned untouched. */ + @Test + public void appendThemeParam_returnsUrlUnchangedWhenThemeUndefined() { + String url = "https://content.example/page?a=1"; + Assert.assertEquals(url, UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_UNDEFINED))); + } +} 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 63f292b86..a0ad94692 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -404,6 +404,9 @@ Map parseContent(@NonNull JSONObject respons assert response != null; String content = response.optString("html"); + if (!content.isEmpty()) { + content = UtilsDevice.appendThemeParam(content, _cly.context_); + } JSONObject coordinates = response.optJSONObject("geo"); assert coordinates != null; diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java index 11d40c8b1..ddc19bc9e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java @@ -319,7 +319,7 @@ void presentFeedbackWidgetInternal(@Nullable final CountlyFeedbackWidget widgetI widgetListUrl.append("&custom="); widgetListUrl.append(customObjectToSendWithTheWidget); - String preparedWidgetUrl = widgetListUrl.toString(); + String preparedWidgetUrl = UtilsDevice.appendThemeParam(widgetListUrl.toString(), context); L.d("[ModuleFeedback] Using following url for widget:[" + preparedWidgetUrl + "]"); if (!Utils.isNullOrEmpty(widgetInfo.widgetVersion)) { diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java index a6a3b199a..eab9e96a1 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java @@ -490,9 +490,9 @@ synchronized void showFeedbackPopupInternal(@Nullable final String widgetId, @Nu } String requestData = requestQueueProvider.prepareRatingWidgetRequest(widgetId); - final String ratingWidgetUrl = baseInfoProvider.getServerURL() + "/feedback?widget_id=" + widgetId + + final String ratingWidgetUrl = UtilsDevice.appendThemeParam(baseInfoProvider.getServerURL() + "/feedback?widget_id=" + widgetId + "&device_id=" + UtilsNetworking.urlEncodeString(deviceIdProvider.getDeviceId()) + - "&app_key=" + UtilsNetworking.urlEncodeString(baseInfoProvider.getAppKey()); + "&app_key=" + UtilsNetworking.urlEncodeString(baseInfoProvider.getAppKey()), activity); L.d("[ModuleRatings] rating widget url :[" + ratingWidgetUrl + "]"); diff --git a/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java b/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java index 794a4f19e..7bec45608 100644 --- a/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java +++ b/sdk/src/main/java/ly/count/android/sdk/UtilsDevice.java @@ -16,6 +16,7 @@ import android.view.WindowManager; import android.view.WindowMetrics; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; class UtilsDevice { @@ -24,6 +25,54 @@ class UtilsDevice { private UtilsDevice() { } + /** + * Resolves the theme (dark/light) the app is currently rendering with. Reads from the current + * foreground Activity when available and falls back to the given context otherwise. The Activity + * is preferred because per-app night-mode overrides (e.g. AppCompatDelegate.setDefaultNightMode) + * are applied to the Activity's resources, not the application context, and because in-app + * messages render in the Activity's window - so its configuration is the effective theme. + * + * @param fallbackContext context used when no foreground Activity is available + * @return "d" for dark mode, "l" for light mode, or null when the mode is undefined/unavailable + */ + @Nullable + static String getThemeMode(@NonNull final Context fallbackContext) { + try { + final Activity activity = CountlyActivityHolder.getInstance().getActivity(); + final Context context = activity != null ? activity : fallbackContext; + int nightModeFlags = context.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK; + switch (nightModeFlags) { + case Configuration.UI_MODE_NIGHT_YES: + return "d"; + case Configuration.UI_MODE_NIGHT_NO: + return "l"; + default: + return null; + } + } catch (Throwable t) { + return null; + } + } + + /** + * Appends the app's current theme as the "th" query parameter (l = light, d = dark) to the + * given URL, so a feedback widget or content loaded in a WebView is rendered matching the + * theme the app is displaying with. Uses "?" as the separator when the URL has no query yet, + * "&" otherwise. When the theme can not be resolved the URL is returned unchanged. + * + * @param url URL that will be loaded in a WebView + * @param context context used to resolve the theme when no foreground Activity is available + * @return the URL with "th" appended, or the original URL when the theme is undefined + */ + @NonNull + static String appendThemeParam(@NonNull final String url, @NonNull final Context context) { + final String theme = getThemeMode(context); + if (theme == null) { + return url; + } + return url + (url.contains("?") ? "&" : "?") + "th=" + theme; + } + @NonNull static DisplayMetrics getDisplayMetrics(@NonNull final Context context) { final WindowManager wm = obtainWindowManager(context); From a0f99365953b62eb22e3e6986ad941bb7d7c15de Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 2 Jul 2026 14:37:57 +0300 Subject: [PATCH 02/21] refactor: improve link handling for contents and widgets --- CHANGELOG.md | 3 + .../android/sdk/ContentOverlayViewTests.java | 283 ++++++++++++++++++ .../sdk/CountlyWebViewClientTests.java | 39 +++ .../count/android/sdk/ContentOverlayView.java | 126 ++++++-- .../android/sdk/CountlyWebViewClient.java | 5 +- 5 files changed, 437 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e34612c3..d220f3e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly. + ## 26.1.4 * ! Minor breaking change ! Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks". It is now a no-op; use "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()" instead, otherwise the stricter push intent redirection checks stay disabled. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java index 329c83021..1d6c1a35f 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -121,6 +121,13 @@ private WindowManager.LayoutParams invokeCreateWindowParams( return (WindowManager.LayoutParams) method.invoke(overlay, activity, config); } + @SuppressWarnings("unchecked") + private Map invokeSplitQuery(String url) throws Exception { + Method method = ContentOverlayView.class.getDeclaredMethod("splitQuery", String.class); + method.setAccessible(true); + return (Map) method.invoke(overlay, url); + } + /** * Launches the test activity, runs the given action on the main thread, * and stores the scenario for cleanup. @@ -370,6 +377,282 @@ public void widgetUrlAction_nullWebView_returnsFalse() { }); } + // ===================== link parsing — query param preservation ===================== + + /** + * A link with a single query param is preserved intact (the raw, unencoded form the server + * sends). Regression guard: previously the naive '&' split truncated it at the first '&'. + */ + @Test + public void splitQuery_linkWithSingleQueryParam_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?foo=bar"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("link", q.get("action")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A link whose own query string has MULTIPLE '&'-separated params keeps all of them. This is + * the core fix: everything after "link=" is captured verbatim instead of being split on '&'. + */ + @Test + public void splitQuery_linkWithMultipleQueryParams_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?foo=bar&baz=qux&n=42"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + // The link's own params must NOT leak in as separate top-level entries. + Assert.assertFalse(q.containsKey("baz")); + Assert.assertFalse(q.containsKey("n")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A deeplink with query params (custom scheme) is preserved intact. + */ + @Test + public void splitQuery_deeplinkWithQueryParams_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "myapp://open?screen=home&id=42&ref=push"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * "event" appearing AFTER the link is separated out: the reserved marker is validated as JSON, + * so it is peeled from the tail while the link (with its own query params) stays intact. + */ + @Test + public void splitQuery_eventAfterLink_separatedFromLink() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://x.com/p?a=b&c=d"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link + + "&event=[{\"key\":\"e\",\"sg\":{\"x\":\"y\"}}]"; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertTrue(q.get("event") instanceof org.json.JSONArray); + Assert.assertEquals("e", ((org.json.JSONArray) q.get("event")).getJSONObject(0).getString("key")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A "&event=" that appears inside the link but does NOT validate as JSON is treated as ordinary + * link text (not a real param) and stays part of the link. + */ + @Test + public void splitQuery_invalidReservedMarkerInLink_staysInLink() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://x.com/p?a=b&event=notjson"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertFalse(q.containsKey("event")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A decoded "event" JSON whose segmentation value literally contains "&close=1" is parsed whole: + * the inner "&close=" fails close validation, so it is absorbed into the JSON value rather than + * being mistaken for a real close param. + */ + @Test + public void splitQuery_eventJsonContainingReservedText_parsedWhole() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event" + + "&event=[{\"key\":\"k\",\"sg\":{\"u\":\"a&close=1\"}}]"; + Map q = invokeSplitQuery(url); + Assert.assertTrue(q.get("event") instanceof org.json.JSONArray); + Assert.assertEquals("a&close=1", + ((org.json.JSONArray) q.get("event")).getJSONObject(0).getJSONObject("sg").getString("u")); + Assert.assertFalse(q.containsKey("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A link with no query params still works (no "link=" trailing content edge case). + */ + @Test + public void splitQuery_linkWithoutQueryParams_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/landing"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Non-regression: an "event" JSON value (already decoded by the WebView client) is parsed into a + * JSONArray. The event does not co-occur with a link, so the whole query splits cleanly. + */ + @Test + public void splitQuery_eventValue_parsed() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event" + + "&event=[{\"key\":\"test_key\",\"sg\":{\"color\":\"blue\"}}]"; + Map q = invokeSplitQuery(url); + Assert.assertTrue(q.get("event") instanceof org.json.JSONArray); + org.json.JSONArray arr = (org.json.JSONArray) q.get("event"); + Assert.assertEquals("test_key", arr.getJSONObject(0).getString("key")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * When a "close" flag is appended after a link (with the link carrying its own query params), + * the link is preserved intact AND the trailing close flag is peeled off and parsed separately + * rather than being swallowed into the link value. + */ + @Test + public void splitQuery_linkWithTrailingClose_separatesLinkAndClose() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?foo=bar&baz=qux"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link + "&close=1"; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("1", q.get("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * "close" may also precede the link. In that case it stays in the head and is parsed normally, + * while the link (with its own query params) is still captured intact. + */ + @Test + public void splitQuery_closeBeforeLink_separatesLinkAndClose() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?foo=bar&baz=qux"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&close=1&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("1", q.get("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Documented reserved-name limitation: because "close" is a reserved param, a link that itself + * ends with "&close=1" (a valid close value) has that segment consumed as the close flag. The + * link keeps everything up to it. Integrators are told these param names are reserved. + */ + @Test + public void splitQuery_linkEndingInReservedClose_consumedAsFlag() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com?a=b&c=d&close=1&close=1"; + Map q = invokeSplitQuery(url); + Assert.assertEquals("https://x.com?a=b&c=d", q.get("link")); + Assert.assertEquals("1", q.get("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A trailing "close=0" after a link is also peeled off (link kept open). + */ + @Test + public void splitQuery_linkWithTrailingCloseZero_separatesLinkAndClose() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?a=1&b=2"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link + "&close=0"; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("0", q.get("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * End-to-end: a link action with query params AND a trailing close is recognized (returns true), + * dispatches the external intent, and closes the overlay. + */ + @Test + public void contentUrlAction_linkWithQueryParamsAndClose_closesOverlay() { + AtomicBoolean closeCalled = new AtomicBoolean(false); + withActivity(activity -> { + overlay = createOverlay(activity, null, () -> closeCalled.set(true)); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link" + + "&link=https://example.com/path?foo=bar&baz=qux&close=1"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + Assert.assertTrue("overlay should close when close=1 trails the link", closeCalled.get()); + } + + /** + * End-to-end: a link action with query params (no close) is recognized and handled (returns + * true), and does not throw while dispatching the external intent. + */ + @Test + public void contentUrlAction_linkWithQueryParams_returnsTrue() { + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link" + + "&link=https://example.com/path?foo=bar&baz=qux"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + } + // ===================== Close & Destroy Lifecycle ===================== /** 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 2acd09d40..74f733229 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -102,6 +102,45 @@ private WebResourceResponse fakeHttpErrorResponse(int statusCode) { }; } + // ===================================== + // shouldOverrideUrlLoading - URL decoding + listener delivery + // ===================================== + + /** + * The URL is percent-decoded once before being handed to listeners, so encoded delimiters in + * an "event" JSON value (e.g. "%26" -> "&", "%5B" -> "[") arrive in plain form for parsing. + */ + @Test + public void shouldOverrideUrlLoading_decodesUrlForListener() { + final String[] received = new String[1]; + client.registerWebViewUrlListener((url, view) -> { + received[0] = url; + return true; + }); + + String encoded = Utils.COMM_URL + "/?cly_x_action_event=1&action=event&event=%5B%7B%22k%22%3A%22a%26b%22%7D%5D"; + String decoded = Utils.COMM_URL + "/?cly_x_action_event=1&action=event&event=[{\"k\":\"a&b\"}]"; + Assert.assertTrue(client.shouldOverrideUrlLoading(null, fakeRequest(encoded, true))); + Assert.assertEquals("listener must receive the decoded URL", decoded, received[0]); + } + + /** + * A malformed percent-escape (possible inside an unencoded link) must not drop the action: the + * listener still receives the URL (raw fallback) rather than the call returning false silently. + */ + @Test + public void shouldOverrideUrlLoading_malformedEscape_fallsBackToRaw() { + final String[] received = new String[1]; + client.registerWebViewUrlListener((url, view) -> { + received[0] = url; + return true; + }); + + String raw = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com?d=50%off"; + Assert.assertTrue(client.shouldOverrideUrlLoading(null, fakeRequest(raw, true))); + Assert.assertNotNull("listener must still be invoked on malformed escape", received[0]); + } + // ===================================== // onReceivedHttpError - abort logic // ===================================== diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java index bc7e63fa9..68ebfd308 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java +++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java @@ -917,6 +917,22 @@ private void resizeContentInternal(@NonNull Activity activity) { } } + // Reserved content-communication param keys. Their names are reserved: a value (a link, or a + // segmentation string) that literally contains "&=" may be + // mis-split. This is documented for integrators. + private static final String[] RESERVED_KEYS = {"action", "event", "resize_me", "close", "link"}; + + // The whole action URL is already percent-decoded (CountlyWebViewClient), so values are in plain + // form. Two params can carry a literal '&' in their value: "link" (sent unencoded, may hold its + // own query string) and a decoded "event"/"resize_me" JSON (segmentation strings can contain + // '&'). A plain '&' split would therefore mis-slice them, and the params can appear in any order. + // + // Instead we span the query from the END: at each step we take the right-most reserved marker + // ("&=") whose value VALIDATES for that key (event/resize_me = JSON, close = 0/1, action = + // a known verb, link = has a URI scheme), record it, and shrink the span to its left. A marker + // whose value does NOT validate is treated as ordinary text inside an enclosing value (so it is + // skipped and absorbed by an outer param). What remains at the front is the comm-url-adjacent + // identifier ("?cly_x_action_event=1" / "?cly_widget_command=1"), parsed verbatim with its '?'. private Map splitQuery(@NonNull String url) { Map query_pairs = new HashMap<>(); String[] pairs = url.split(Utils.COMM_URL + "/?"); @@ -924,31 +940,107 @@ private Map splitQuery(@NonNull String url) { return query_pairs; } - String[] pairs2 = pairs[1].split("&"); - for (String pair : pairs2) { - int idx = pair.indexOf('='); - if (idx < 0) { - continue; + String q = pairs[1]; + int end = q.length(); + + while (end > 0) { + int chosenIdx = -1; + String chosenKey = null; + Object chosenValue = null; + + // Right-to-left, pick the first reserved marker whose value validates. Scanning from the + // right lets an inner (invalid) marker be absorbed into an outer, valid value. + int searchFrom = end; + while (searchFrom > 0) { + int marker = -1; + String markerKey = null; + for (String key : RESERVED_KEYS) { + int idx = q.lastIndexOf("&" + key + "=", searchFrom - 1); + if (idx > marker && idx + key.length() + 2 <= end) { + marker = idx; + markerKey = key; + } + } + if (marker < 0) { + break; + } + String value = q.substring(marker + markerKey.length() + 2, end); + Object parsed = validateReservedValue(markerKey, value); + if (parsed != null) { + chosenIdx = marker; + chosenKey = markerKey; + chosenValue = parsed; + break; + } + // Not a real param -> ordinary text; keep looking further left. + searchFrom = marker; } - String key = pair.substring(0, idx); - String value = pair.substring(idx + 1); - try { - if ("event".equals(key)) { - query_pairs.put(key, new JSONArray(value)); - } else if ("resize_me".equals(key)) { - query_pairs.put(key, new JSONObject(value)); - } else { - query_pairs.put(key, value); - } - } catch (JSONException e) { - Log.e(Countly.TAG, "[ContentOverlayView] splitQuery, Failed to parse JSON", e); + if (chosenIdx < 0) { + break; + } + query_pairs.put(chosenKey, chosenValue); + end = chosenIdx; + } + + // Remaining prefix is the identifier param(s) adjacent to the comm URL (leading '?' kept). + for (String pair : q.substring(0, end).split("&")) { + int idx = pair.indexOf('='); + if (idx >= 0) { + query_pairs.put(pair.substring(0, idx), pair.substring(idx + 1)); } } return query_pairs; } + // Validates a reserved param value and returns what to store, or null if it does not validate + // (meaning the "&=" was actually text inside an enclosing value, not a real parameter). + @Nullable + private Object validateReservedValue(@NonNull String key, @NonNull String value) { + switch (key) { + case "event": + try { + return new JSONArray(value); + } catch (JSONException e) { + return null; + } + case "resize_me": + try { + return new JSONObject(value); + } catch (JSONException e) { + return null; + } + case "close": + return "0".equals(value) || "1".equals(value) ? value : null; + case "action": + return "event".equals(value) || "link".equals(value) || "resize_me".equals(value) ? value : null; + case "link": + return hasUriScheme(value) ? value : null; + default: + return null; + } + } + + // True if value begins with a URI scheme (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"), which + // covers http(s) URLs and custom-scheme deeplinks. The server prepends "https://" to schemeless + // links, so a valid link value always carries a scheme. + private static boolean hasUriScheme(@NonNull String value) { + int colon = value.indexOf(':'); + if (colon <= 0) { + return false; + } + for (int i = 0; i < colon; i++) { + char c = value.charAt(i); + boolean ok = (i == 0) ? Character.isLetter(c) + : (Character.isLetterOrDigit(c) || c == '+' || c == '-' || c == '.'); + if (!ok) { + return false; + } + } + return true; + } + private void recalculateSafeAreaOffsets(@NonNull Activity activity) { SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(activity, Countly.sharedInstance().L); 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 639949862..20c60ae51 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -52,8 +52,9 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request try { url = URLDecoder.decode(url, "UTF-8"); } catch (Exception e) { - Log.e(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, Failed to decode url", e); - return false; + // A malformed percent-escape (possible inside an unencoded link) must not drop the whole + // action: fall back to the raw URL so the listener can still handle it. + Log.w(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, failed to decode url, using raw", e); } Log.d(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, urlDecoded: [" + url + "]"); From f24f26ca8da05e2c5b4c0358d3f670a039db3fa3 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 2 Jul 2026 15:19:34 +0300 Subject: [PATCH 03/21] feat: new tests --- .../android/sdk/ContentOverlayViewTests.java | 100 ++++++++++++++++++ .../sdk/CountlyWebViewClientTests.java | 19 ++++ 2 files changed, 119 insertions(+) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java index 1d6c1a35f..8299139a8 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -653,6 +653,106 @@ public void contentUrlAction_linkWithQueryParams_returnsTrue() { }); } + /** + * A link fragment ("#...") is preserved verbatim. + */ + @Test + public void splitQuery_linkWithFragment_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?a=b#section"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A link containing more than one "?" is preserved verbatim (the "?" characters after the first + * are part of the link value, not new query separators). + */ + @Test + public void splitQuery_linkWithRepeatedQuestionMark_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/p?a=b?c=d"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * All three of link, event and close in one URL are each separated correctly, with the link + * (carrying its own query params) kept intact. + */ + @Test + public void splitQuery_linkEventAndClose_allSeparated() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://x.com/p?a=b&c=d"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link + + "&event=[{\"key\":\"e\"}]&close=1"; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("1", q.get("close")); + Assert.assertTrue(q.get("event") instanceof org.json.JSONArray); + Assert.assertEquals("e", ((org.json.JSONArray) q.get("event")).getJSONObject(0).getString("key")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A "close" whose value is not "0"/"1" (e.g. "close=2") fails validation, so it is treated as + * ordinary link text and absorbed into the link rather than parsed as the close flag. + */ + @Test + public void splitQuery_invalidCloseValue_staysInLink() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://x.com/p?a=b&close=2"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertFalse(q.containsKey("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Documents the fallback: a schemeless link fails link validation (no URI scheme), so the whole + * query falls back to the plain '&' split, which truncates a multi-param link. The server always + * prepends "https://", so this is an edge case; the test pins the current behavior. + */ + @Test + public void splitQuery_schemelessLink_fallbackTruncates() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=example.com/p?a=b&c=d"; + Map q = invokeSplitQuery(url); + Assert.assertEquals("example.com/p?a=b", q.get("link")); + Assert.assertEquals("d", q.get("c")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + // ===================== Close & Destroy Lifecycle ===================== /** 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 74f733229..4dda68ac0 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -124,6 +124,25 @@ public void shouldOverrideUrlLoading_decodesUrlForListener() { Assert.assertEquals("listener must receive the decoded URL", decoded, received[0]); } + /** + * Characterization: the whole-URL decode uses URLDecoder, which decodes a literal '+' to a space + * (form-encoding semantics). A link carrying a '+' therefore arrives with a space. This differs + * from iOS (stringByRemovingPercentEncoding leaves '+' untouched); pinned here to catch changes. + */ + @Test + public void shouldOverrideUrlLoading_plusInQuery_decodedToSpace() { + final String[] received = new String[1]; + client.registerWebViewUrlListener((url, view) -> { + received[0] = url; + return true; + }); + + String raw = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com/search?q=a+b"; + String expected = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com/search?q=a b"; + Assert.assertTrue(client.shouldOverrideUrlLoading(null, fakeRequest(raw, true))); + Assert.assertEquals(expected, received[0]); + } + /** * A malformed percent-escape (possible inside an unencoded link) must not drop the action: the * listener still receives the URL (raw fallback) rather than the call returning false silently. From 77020ea9914f9aa1163321944376ad2d1e81dab8 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 2 Jul 2026 15:43:35 +0300 Subject: [PATCH 04/21] fix: + to space issue --- .../android/sdk/CountlyWebViewClientTests.java | 14 ++++++-------- .../count/android/sdk/CountlyWebViewClient.java | 15 ++++++++------- 2 files changed, 14 insertions(+), 15 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 4dda68ac0..ef6a69c20 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -125,12 +125,11 @@ public void shouldOverrideUrlLoading_decodesUrlForListener() { } /** - * Characterization: the whole-URL decode uses URLDecoder, which decodes a literal '+' to a space - * (form-encoding semantics). A link carrying a '+' therefore arrives with a space. This differs - * from iOS (stringByRemovingPercentEncoding leaves '+' untouched); pinned here to catch changes. + * A literal '+' in a link is preserved (Uri.decode does not apply form '+'->space semantics), so + * deeplinks like "tel:+1..." and base64 query values are not corrupted. Matches iOS. */ @Test - public void shouldOverrideUrlLoading_plusInQuery_decodedToSpace() { + public void shouldOverrideUrlLoading_plusInQuery_preserved() { final String[] received = new String[1]; client.registerWebViewUrlListener((url, view) -> { received[0] = url; @@ -138,17 +137,16 @@ public void shouldOverrideUrlLoading_plusInQuery_decodedToSpace() { }); String raw = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com/search?q=a+b"; - String expected = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com/search?q=a b"; Assert.assertTrue(client.shouldOverrideUrlLoading(null, fakeRequest(raw, true))); - Assert.assertEquals(expected, received[0]); + Assert.assertEquals(raw, received[0]); } /** * A malformed percent-escape (possible inside an unencoded link) must not drop the action: the - * listener still receives the URL (raw fallback) rather than the call returning false silently. + * URL is decoded leniently and the listener is still invoked rather than the call returning false. */ @Test - public void shouldOverrideUrlLoading_malformedEscape_fallsBackToRaw() { + public void shouldOverrideUrlLoading_malformedEscape_stillHandled() { final String[] received = new String[1]; client.registerWebViewUrlListener((url, view) -> { received[0] = url; 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 20c60ae51..1735f12d0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -12,7 +12,6 @@ import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; -import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -49,12 +48,14 @@ public CountlyWebViewClient(Set allowedSchemes) { public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { String url = request.getUrl().toString(); Log.v(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, url: [" + url + "]"); - try { - url = URLDecoder.decode(url, "UTF-8"); - } catch (Exception e) { - // A malformed percent-escape (possible inside an unencoded link) must not drop the whole - // action: fall back to the raw URL so the listener can still handle it. - Log.w(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, failed to decode url, using raw", e); + + // Percent-decode with Uri.decode, NOT URLDecoder: URLDecoder applies form semantics and turns + // a literal '+' into a space, which corrupts links and deeplinks (e.g. "tel:+1..." or a base64 + // query value). Uri.decode decodes %XX, leaves '+' intact, and is lenient on a malformed + // escape (so the action is not dropped) - matching the iOS behavior. + String decoded = Uri.decode(url); + if (decoded != null) { + url = decoded; } Log.d(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, urlDecoded: [" + url + "]"); From b1d8a5da1bd7c81ca77d1ca5d3d00dc32cebbebe Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 13 Jul 2026 14:42:39 +0300 Subject: [PATCH 05/21] feat: content url handler --- .../android/sdk/ContentOverlayViewTests.java | 39 ++++++++++++++++++- .../ly/count/android/sdk/ConfigContent.java | 17 ++++++++ .../count/android/sdk/ContentOverlayView.java | 19 ++++++++- .../count/android/sdk/ContentUrlHandler.java | 13 +++++++ .../ly/count/android/sdk/ModuleContent.java | 3 +- .../ly/count/android/sdk/ModuleFeedback.java | 3 +- 6 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 sdk/src/main/java/ly/count/android/sdk/ContentUrlHandler.java diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java index 329c83021..29c83f248 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -89,6 +89,12 @@ private ContentOverlayView createOverlay(Activity activity) { private ContentOverlayView createOverlay(Activity activity, @Nullable ContentCallback callback, @Nullable Runnable onClose) { + return createOverlay(activity, callback, onClose, null); + } + + private ContentOverlayView createOverlay(Activity activity, + @Nullable ContentCallback callback, @Nullable Runnable onClose, + @Nullable ContentUrlHandler contentUrlHandler) { TransparentActivityConfig portrait = new TransparentActivityConfig(0, 0, 300, 500); portrait.url = "about:blank"; portrait.useSafeArea = false; @@ -103,7 +109,8 @@ private ContentOverlayView createOverlay(Activity activity, callback, onClose != null ? onClose : () -> { }, - null + null, + contentUrlHandler ); } @@ -135,6 +142,34 @@ interface ActivityAction { void run(Activity activity); } + // ===================== contentUrlHandler ===================== + + /** + * When a content URL handler is set and returns true, startSafeExternalIntent hands the URL to + * it and short-circuits before dispatching an ACTION_VIEW intent (so the app can route its own + * deep link). + */ + @Test + public void startSafeExternalIntent_contentUrlHandler_takesOverAndSkipsIntent() { + withActivity(activity -> { + final String[] captured = { null }; + ContentUrlHandler handler = url -> { + captured[0] = url; + return true; // app handled it -> SDK must not dispatch an intent + }; + overlay = createOverlay(activity, null, null, handler); + try { + Method m = ContentOverlayView.class.getDeclaredMethod("startSafeExternalIntent", String.class); + m.setAccessible(true); + m.invoke(overlay, "myapp://deeplink/screen?id=42"); + } catch (Exception e) { + Assert.fail("startSafeExternalIntent invoke failed: " + e); + } + // Handler received the URL; returning true short-circuits before the ACTION_VIEW intent. + Assert.assertEquals("myapp://deeplink/screen?id=42", captured[0]); + }); + } + // ===================== contentUrlAction — URL Parsing & Routing ===================== /** @@ -506,7 +541,7 @@ public void configs_storedCorrectly() { overlay = new ContentOverlayView( activity, portrait, landscape, Configuration.ORIENTATION_PORTRAIT, null, () -> { - }, null); + }, null, null); // Note: setupConfig may modify width/height if < 1, but ours are > 0 Assert.assertEquals(10, (int) overlay.configPortrait.x); diff --git a/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java b/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java index 5e3730574..72382923f 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java @@ -9,6 +9,7 @@ public class ConfigContent { int zoneTimerInterval = 30; ContentCallback globalContentCallback = null; Set allowedIntentSchemes = new HashSet<>(); + ContentUrlHandler contentUrlHandler = null; /** * Set the interval for the automatic content update calls @@ -50,4 +51,20 @@ public synchronized ConfigContent setAllowedIntentSchemes(List allowedIn this.allowedIntentSchemes = Utils.normalizeSchemeSet(allowedIntentSchemes); return this; } + + /** + * Set a handler that is called when a link is opened from the content (or feedback) web view, + * letting the app take over instead of the SDK opening the link via an ACTION_VIEW intent. This + * is how an app routes its own deep links (custom scheme or https) to the correct screen. The + * handler receives the URL and returns true if it handled it; returning false (or not setting a + * handler) makes the SDK open the URL as before. + * + * @param contentUrlHandler handler invoked for links opened from the content web view + * @return config content to chain calls + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public synchronized ConfigContent setContentUrlHandler(ContentUrlHandler contentUrlHandler) { + this.contentUrlHandler = contentUrlHandler; + return this; + } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java index bc7e63fa9..d885066fe 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java +++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java @@ -48,6 +48,7 @@ class ContentOverlayView extends FrameLayout { int currentOrientation; private ContentCallback contentCallback; private final Set allowedLinkSchemes; + private final ContentUrlHandler contentUrlHandler; private Runnable onCloseRunnable; private Runnable onWidgetCancelRunnable; private boolean isClosed = false; @@ -88,7 +89,8 @@ private static Context resolveOverlayContext(@NonNull Activity activity) { int orientation, @Nullable ContentCallback callback, @NonNull Runnable onClose, - @Nullable Set allowedLinkSchemes) { + @Nullable Set allowedLinkSchemes, + @Nullable ContentUrlHandler contentUrlHandler) { // View.mContext must not pin the constructing activity (overlay outlives activity // transitions; window attachment uses currentHostActivity). On API 31+ we additionally // need a UI context to satisfy StrictMode#detectIncorrectContextUse — see @@ -103,6 +105,7 @@ private static Context resolveOverlayContext(@NonNull Activity activity) { this.currentHostActivity = activity; // Defensive copy so a later config change cannot retroactively alter this overlay's policy. this.allowedLinkSchemes = allowedLinkSchemes == null ? null : new HashSet<>(allowedLinkSchemes); + this.contentUrlHandler = contentUrlHandler; setBackgroundColor(Color.TRANSPARENT); setClickable(false); @@ -842,6 +845,20 @@ private void startActivityFromOverlay(@NonNull Intent intent) { // schemes pass. Component/selector and flags are cleared so the intent cannot be redirected to a // specific (possibly internal) target. private void startSafeExternalIntent(@NonNull String url) { + // Give the app's content URL handler first refusal (e.g. to route its own deep link). If it + // reports it handled the URL, the SDK does not dispatch an intent. A handler exception is + // caught so it can never break link handling; on false/absent handler the SDK opens as usual. + if (contentUrlHandler != null) { + try { + if (contentUrlHandler.onContentUrl(url)) { + Log.d(Countly.TAG, "[ContentOverlayView] startSafeExternalIntent, url handled by content URL handler: [" + url + "]"); + return; + } + } catch (Throwable t) { + Log.e(Countly.TAG, "[ContentOverlayView] startSafeExternalIntent, content URL handler threw", t); + } + } + Uri uri = Uri.parse(url); String scheme = uri.getScheme(); if (!Utils.isExternalSchemeAllowed(scheme, allowedLinkSchemes)) { diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentUrlHandler.java b/sdk/src/main/java/ly/count/android/sdk/ContentUrlHandler.java new file mode 100644 index 000000000..4a447e308 --- /dev/null +++ b/sdk/src/main/java/ly/count/android/sdk/ContentUrlHandler.java @@ -0,0 +1,13 @@ +package ly.count.android.sdk; + +public interface ContentUrlHandler { + /** + * Called when a link is opened from the content (or feedback) web view, letting the host app + * take over instead of the SDK opening the link via an ACTION_VIEW intent. This is how an app + * routes its own deep links (custom scheme or https) to the correct screen. + * + * @param url the URL the web content is trying to open + * @return true if the app handled the URL; return false to let the SDK open it as usual + */ + boolean onContentUrl(String url); +} 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 63f292b86..8d4bfbe86 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -329,7 +329,8 @@ private void showContentOverlay(@NonNull Activity activity, @NonNull Map { feedbackOverlay = null; }, - _cly.config_.content.allowedIntentSchemes + _cly.config_.content.allowedIntentSchemes, + _cly.config_.content.contentUrlHandler ); feedbackOverlay.setOnWidgetCancelRunnable(() -> reportFeedbackWidgetCancelButton(widgetInfo)); From fcd75705a2490b0d635deea7cfbc15ff70d598e1 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 13 Jul 2026 14:45:58 +0300 Subject: [PATCH 06/21] feat: content url handler: changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e34612c3..f1154a50b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Added a content configuration option to provide a handler for links opened from the content web view, so the app can route its own deep links instead of the SDK opening the system browser, set via `setContentUrlHandler(ContentUrlHandler)`. + ## 26.1.4 * ! Minor breaking change ! Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks". It is now a no-op; use "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()" instead, otherwise the stricter push intent redirection checks stay disabled. From e272d0fa686b430983d1ec5f4e6f8409717e6f2e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 13 Jul 2026 16:12:01 +0300 Subject: [PATCH 07/21] feat: custom ssl factory --- CHANGELOG.md | 3 + .../android/sdk/ConnectionProcessorTests.java | 69 +++++++++ .../sdk/ConnectionQueueIntegrationTests.java | 131 ++++++++++++++++++ .../count/android/sdk/CountlyConfigTests.java | 6 + .../android/sdk/ConnectionProcessor.java | 24 ++-- .../ly/count/android/sdk/ConnectionQueue.java | 38 +++-- .../java/ly/count/android/sdk/Countly.java | 2 +- .../ly/count/android/sdk/CountlyConfig.java | 33 +++++ 8 files changed, 281 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e34612c3..3213f3175 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## XX.XX.XX +* Added a new configuration option `setCustomSSLSocketFactory(SSLSocketFactory)` to send the SDK's HTTPS requests through a custom SSLSocketFactory. + ## 26.1.4 * ! Minor breaking change ! Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks". It is now a no-op; use "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()" instead, otherwise the stricter push intent redirection checks stay disabled. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java index 3e6be1cfc..463766908 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java @@ -35,6 +35,8 @@ of this software and associated documentation files (the "Software"), to deal import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -43,6 +45,8 @@ of this software and associated documentation files (the "Software"), to deal import static ly.count.android.sdk.UtilsNetworking.sha256Hash; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -292,6 +296,71 @@ public void urlConnectionCustomHeaderValues() throws IOException { assertNull(urlConnection.getRequestProperty("33")); } + /** + * A custom SSL socket factory is applied to an https server request, even when no + * certificate/public-key pinning is configured. This is the key behavior the old pin-gated + * code lacked: the factory used to be applied only when the pinning statics were set. + */ + @Test + public void urlConnectionForServerRequest_appliesCustomSSLSocketFactoryOnHttps() throws IOException { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + ConnectionProcessor cp = new ConnectionProcessor("https://secureserver", mockStore, mockDeviceId, configurationProviderFake, rip, customFactory, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); + + final URLConnection urlConnection = cp.urlConnectionForServerRequest("eventData", null); + + assertTrue(urlConnection instanceof HttpsURLConnection); + assertSame(customFactory, ((HttpsURLConnection) urlConnection).getSSLSocketFactory()); + assertEquals(30_000, urlConnection.getConnectTimeout()); + assertFalse(urlConnection.getDoOutput()); + } + + /** + * The custom SSL socket factory is also applied to the preflight (HEAD) request path. + */ + @Test + public void urlConnectionForPreflightRequest_appliesCustomSSLSocketFactory() throws IOException { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + ConnectionProcessor cp = new ConnectionProcessor("https://secureserver", mockStore, mockDeviceId, configurationProviderFake, rip, customFactory, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); + + final HttpURLConnection conn = (HttpURLConnection) cp.urlConnectionForPreflightRequest("https://secureserver/o/sdk?method=fetch"); + + assertTrue(conn instanceof HttpsURLConnection); + assertSame(customFactory, ((HttpsURLConnection) conn).getSSLSocketFactory()); + assertEquals("HEAD", conn.getRequestMethod()); + } + + /** + * A plain http server URL has no TLS layer, so the custom factory cannot be applied. The + * request must still be built without throwing. + */ + @Test + public void urlConnectionForServerRequest_customFactoryNotAppliedOnHttp() throws IOException { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + ConnectionProcessor cp = new ConnectionProcessor("http://server", mockStore, mockDeviceId, configurationProviderFake, rip, customFactory, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); + + final URLConnection urlConnection = cp.urlConnectionForServerRequest("eventData", null); + + assertFalse(urlConnection instanceof HttpsURLConnection); + assertEquals("http", urlConnection.getURL().getProtocol()); + } + + /** + * With no custom factory (and no pinning), an https request falls back to the platform default + * socket factory, never to a Countly-injected one. + */ + @Test + public void urlConnectionForServerRequest_noFactoryUsesPlatformDefaultOnHttps() throws IOException { + SSLSocketFactory unusedFactory = mock(SSLSocketFactory.class); + ConnectionProcessor cp = new ConnectionProcessor("https://secureserver", mockStore, mockDeviceId, configurationProviderFake, rip, null, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); + + final URLConnection urlConnection = cp.urlConnectionForServerRequest("eventData", null); + + assertTrue(urlConnection instanceof HttpsURLConnection); + SSLSocketFactory used = ((HttpsURLConnection) urlConnection).getSSLSocketFactory(); + assertNotNull(used); + assertNotSame(unusedFactory, used); + } + @Test public void testRun_storeReturnsNullConnections() throws IOException { connectionProcessor = spy(connectionProcessor); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java index a6242d7a1..384656c63 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java @@ -1,10 +1,15 @@ package ly.count.android.sdk; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URLConnection; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -27,6 +32,45 @@ public class ConnectionQueueIntegrationTests { private final String appKey = "testAppKey123"; private final String serverUrl = "https://test.server.com"; + // A valid X.509 certificate (Sectigo, *.count.ly) used only to exercise the pinning code path; + // CertificateFactory parses it regardless of expiry, so the pinning SSLContext can be built. + private static final String PINNING_CERT = + "MIIGnjCCBYagAwIBAgIRAN73cVA7Y1nD+S8rToAqBpQwDQYJKoZIhvcNAQELBQAwgY8xCzAJ" + + "BgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcTB1" + + "NhbGZvcmQxGDAWBgNVBAoTD1NlY3RpZ28gTGltaXRlZDE3MDUGA1UEAxMuU2VjdGln" + + "byBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBDQTAeFw0yMDA2MD" + + "EwMDAwMDBaFw0yMjA5MDMwMDAwMDBaMBUxEzARBgNVBAMMCiouY291bnQubHkwggEi" + + "MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCl9zmATVRwrGRtRQJcmBmA+zc/ZL" + + "io3YfkwXO2w8u9lnw60J4JpPNn9OnGcxdM+sqbXKU3jTdjY4j3yaA6NlWibq2jU2x6" + + "HT2sS+I5gFFE/6tO53WqjoMk48i3FkyoJDittwtQrVaRGcP8RjJH0pfXaP+JLrLAgg" + + "HuW3tCFqYzkWi3uLGVjQbSIRNiXsM3FI0UMEa/x1I3U4hLjMjH28KagZbZLWnHOvks" + + "AvGLg3xQkS+GSQ+6ARZ2/bGh5O9q4hCCCk0/PpwAXmrOnWtwrNuwHcCDOvuB22JxLd" + + "t8jQDYrjwtJIvq4Yut8FQPv/75SKoETWWHyxe0x5NsB34UwA/BAgMBAAGjggNsMIID" + + "aDAfBgNVHSMEGDAWgBSNjF7EVK2K4Xfpm/mbBeG4AY1h4TAdBgNVHQ4EFgQU8uf/ND" + + "Rt8cu+AwARVIGXPMfxGbQwDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwHQYD" + + "VR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEkGA1UdIARCMEAwNAYLKwYBBAGyMQ" + + "ECAgcwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EM" + + "AQIBMIGEBggrBgEFBQcBAQR4MHYwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuc2VjdG" + + "lnby5jb20vU2VjdGlnb1JTQURvbWFpblZhbGlkYXRpb25TZWN1cmVTZXJ2ZXJDQS5j" + + "cnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMB8GA1UdEQQYMB" + + "aCCiouY291bnQubHmCCGNvdW50Lmx5MIIB9AYKKwYBBAHWeQIEAgSCAeQEggHgAd4A" + + "dQBGpVXrdfqRIDC1oolp9PN9ESxBdL79SbiFq/L8cP5tRwAAAXJwTJ0kAAAEAwBGME" + + "QCIEErTN/aGJ8LV9brGklKeGAXMg1EN/FUxXDu13kNfXhcAiBrKMYe+W4flPyuLNm5" + + "jp6FJwtUTZPNpZ+TmM40dRdwjQB0AN+lXqtogk8fbK3uuF9OPlrqzaISpGpejjsSwC" + + "BEXCpzAAABcnBMncsAAAQDAEUwQwIfEYSpsSDtKpmj9ZmRWsx73G622N74v09JDjzP" + + "bkg9RQIgUelIqSwqu69JanH7losrqTTsjwNv+3QJBNJ6GxJKkh0AdgBvU3asMfAxGd" + + "iZAKRRFf93FRwR2QLBACkGjbIImjfZEwAAAXJwTJ0YAAAEAwBHMEUCIQCMBaaQAoua" + + "97R+z2zONMUq1XsDP5aoAiutZG4XxuQ6wAIgW1p6XS3az4CCqjwbDKxL9qEnw8fWd+" + + "yLx2skviSsTS0AdwApeb7wnjk5IfBWc59jpXflvld9nGAK+PlNXSZcJV3HhAAAAXJw" + + "TJ1PAAAEAwBIMEYCIQDg1YFbJPPKDIyrFZJ9rtrUklkh2k/wpgwjDuIp7tPtOgIhAL" + + "dZl9s/qISsFm2E64ruYbdE4HKR1ZJ0zbIXOZcds7XXMA0GCSqGSIb3DQEBCwUAA4IB" + + "AQB2Ar1h2X/S4qsVlw0gEbXO//6Rj8mTB4BFW6c5r84n0vTwvA78h003eX00y0ymxO" + + "i5hkqB8gd1IUSWP1R1ijYtBVPdFi+SsMjUsB5NKquQNlWpo0GlFjRlcXnDC6R6toN2" + + "QixJb47VM40Vmn2g0ZuMGfy1XoQKvIyRosT92jGm1YcF+nLEHBDr+89apZ8sUpFfWo" + + "AnCom+8sBGwje6zP10eBbprHyzM8snvdwo/QNLAzLcvVNKP+Sr4H7HKzec3g1+THI0" + + "M72TzoguJcOZQEI6Pd+FIP5Xad53rq4jCtRGwYrsieH49a3orBnkkJvUKni+mtkxMb" + + "PTJ7eeMmX9g/0h"; + @Before public void setUp() { Countly.sharedInstance().halt(); @@ -302,6 +346,93 @@ public void integration_sdkOverride_reflectedInCommonRequest() { commonRequest.contains("sdk_version=" + customSdkVersion)); } + // ========================================== + // Integration Tests - Custom SSL socket factory + // ========================================== + + /** + * Integration test: a custom SSLSocketFactory set on CountlyConfig is resolved by + * ConnectionQueue and applied to both the server request and the preflight request that every + * ConnectionProcessor produces. + */ + @Test + public void integration_customSSLSocketFactory_appliedToServerAndPreflightRequests() throws Exception { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + CountlyConfig config = new CountlyConfig(TestUtils.getContext(), appKey, serverUrl) + .setCustomSSLSocketFactory(customFactory); + Countly.sharedInstance().init(config); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + + URLConnection serverConn = cq.createConnectionProcessor().urlConnectionForServerRequest("app_key=" + appKey, null); + HttpURLConnection preflightConn = (HttpURLConnection) cq.createConnectionProcessor().urlConnectionForPreflightRequest(serverUrl + "/o/sdk?method=fetch"); + + Assert.assertTrue(serverConn instanceof HttpsURLConnection); + Assert.assertSame(customFactory, ((HttpsURLConnection) serverConn).getSSLSocketFactory()); + Assert.assertTrue(preflightConn instanceof HttpsURLConnection); + Assert.assertSame(customFactory, ((HttpsURLConnection) preflightConn).getSSLSocketFactory()); + } + + /** + * Integration test: when both a custom SSLSocketFactory and public-key pinning are configured, + * the custom factory wins and the pinning certificates are never parsed (so intentionally + * invalid pinning certs do not break initialization). + */ + @Test + public void integration_customSSLSocketFactory_takesPrecedenceOverPinning() throws Exception { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + try { + CountlyConfig config = new CountlyConfig(TestUtils.getContext(), appKey, serverUrl) + .enablePublicKeyPinning(new String[] { "not-a-real-certificate" }) + .setCustomSSLSocketFactory(customFactory); + Countly.sharedInstance().init(config); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + + URLConnection serverConn = cq.createConnectionProcessor().urlConnectionForServerRequest("app_key=" + appKey, null); + + Assert.assertTrue(serverConn instanceof HttpsURLConnection); + Assert.assertSame("custom factory must win over pinning", customFactory, ((HttpsURLConnection) serverConn).getSSLSocketFactory()); + } finally { + Countly.publicKeyPinCertificates = null; + } + } + + /** + * Integration test: public-key pinning and certificate pinning both remain functional after the + * SSL socket factory refactor. Each installs its own (non-default) socket factory on the SDK's + * HTTPS connections, built from the CertificateTrustManager. + */ + @Test + public void integration_pinning_installsDistinctSocketFactory() throws Exception { + String[] certs = { PINNING_CERT }; + SSLSocketFactory platformDefault = HttpsURLConnection.getDefaultSSLSocketFactory(); + try { + // public key pinning + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl).enablePublicKeyPinning(certs)); + SSLSocketFactory publicKeyPinningFactory = appliedServerRequestFactory(); + Assert.assertNotNull(publicKeyPinningFactory); + Assert.assertNotSame("public key pinning must install its own socket factory", platformDefault, publicKeyPinningFactory); + + Countly.sharedInstance().halt(); + Countly.publicKeyPinCertificates = null; + + // certificate pinning + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl).enableCertificatePinning(certs)); + SSLSocketFactory certificatePinningFactory = appliedServerRequestFactory(); + Assert.assertNotNull(certificatePinningFactory); + Assert.assertNotSame("certificate pinning must install its own socket factory", platformDefault, certificatePinningFactory); + } finally { + Countly.publicKeyPinCertificates = null; + Countly.certificatePinCertificates = null; + } + } + + private SSLSocketFactory appliedServerRequestFactory() throws IOException { + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + URLConnection conn = cq.createConnectionProcessor().urlConnectionForServerRequest("app_key=" + appKey, null); + Assert.assertTrue(conn instanceof HttpsURLConnection); + return ((HttpsURLConnection) conn).getSSLSocketFactory(); + } + // ========================================== // Integration Tests - Update Session // ========================================== diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyConfigTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyConfigTests.java index 39d24c1be..2089e410b 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyConfigTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyConfigTests.java @@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.HashMap; import java.util.Map; +import javax.net.ssl.SSLSocketFactory; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -84,6 +85,8 @@ public boolean filterCrash(String crash) { String[] publicKeyCerts = { "ddd", "111", "ffd" }; String[] certificateCerts = { "ddsd", "vvcv", "mbnb" }; + SSLSocketFactory customSSLSocketFactory = mock(SSLSocketFactory.class); + Map crashSegments = new HashMap<>(); crashSegments.put("s2s", "fdf"); crashSegments.put("s224s", 2323); @@ -139,6 +142,7 @@ public boolean filterCrash(String crash) { config.setAppCrawlerNames(appCrawlerNames); config.enableCertificatePinning(certificateCerts); config.enablePublicKeyPinning(publicKeyCerts); + config.setCustomSSLSocketFactory(customSSLSocketFactory); config.setEnableAttribution(true); config.setCustomCrashSegment(crashSegments); config.setUpdateSessionTimerDelay(137); @@ -191,6 +195,7 @@ public boolean filterCrash(String crash) { Assert.assertArrayEquals(appCrawlerNames, config.appCrawlerNames); Assert.assertArrayEquals(certificateCerts, config.certificatePinningCertificates); Assert.assertArrayEquals(publicKeyCerts, config.publicKeyPinningCertificates); + Assert.assertSame(customSSLSocketFactory, config.customSSLSocketFactory); Assert.assertEquals(crashSegments, config.crashes.customCrashSegment); Assert.assertEquals(137, config.sessionUpdateTimerDelay.intValue()); Assert.assertTrue(config.starRatingDialogIsCancellable); @@ -293,6 +298,7 @@ void assertDefaultValues(CountlyConfig config, boolean includeConstructorValues) Assert.assertNull(config.appCrawlerNames); Assert.assertNull(config.publicKeyPinningCertificates); Assert.assertNull(config.certificatePinningCertificates); + Assert.assertNull(config.customSSLSocketFactory); Assert.assertNull(config.crashes.customCrashSegment); Assert.assertNull(config.sessionUpdateTimerDelay); Assert.assertFalse(config.starRatingDialogIsCancellable); diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java index 0b6646345..c631acfae 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java @@ -37,7 +37,7 @@ of this software and associated documentation files (the "Software"), to deal import java.nio.charset.StandardCharsets; import java.util.Map; import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; import org.json.JSONException; import org.json.JSONObject; @@ -59,7 +59,7 @@ public class ConnectionProcessor implements Runnable { final RequestInfoProvider requestInfoProvider_; private final String serverURL_; - private final SSLContext sslContext_; + private final SSLSocketFactory sslSocketFactory_; private final Map requestHeaderCustomValues_; private final Runnable backoffCallback_; @@ -76,13 +76,13 @@ private enum RequestResult { } ConnectionProcessor(final String serverURL, final StorageProvider storageProvider, final DeviceIdProvider deviceIdProvider, final ConfigurationProvider configProvider, - final RequestInfoProvider requestInfoProvider, final SSLContext sslContext, final Map requestHeaderCustomValues, ModuleLog logModule, + final RequestInfoProvider requestInfoProvider, final SSLSocketFactory sslSocketFactory, final Map requestHeaderCustomValues, ModuleLog logModule, HealthTracker healthTracker, Runnable backoffCallback, final Map internalRequestCallbacks) { serverURL_ = serverURL; storageProvider_ = storageProvider; deviceIdProvider_ = deviceIdProvider; configProvider_ = configProvider; - sslContext_ = sslContext; + sslSocketFactory_ = sslSocketFactory; requestHeaderCustomValues_ = requestHeaderCustomValues; requestInfoProvider_ = requestInfoProvider; backoffCallback_ = backoffCallback; @@ -130,13 +130,13 @@ private enum RequestResult { pccTsOpenURLConnection = UtilsTime.getNanoTime(); } - if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { - conn = (HttpURLConnection) url.openConnection(); - } else { - HttpsURLConnection c = (HttpsURLConnection) url.openConnection(); - c.setSSLSocketFactory(sslContext_.getSocketFactory()); - conn = c; + final URLConnection urlConnection = url.openConnection(); + // Apply the resolved SSL socket factory (a custom/FIPS factory or the pinning factory) to + // every HTTPS connection. A plain HTTP connection is left untouched. + if (sslSocketFactory_ != null && urlConnection instanceof HttpsURLConnection) { + ((HttpsURLConnection) urlConnection).setSSLSocketFactory(sslSocketFactory_); } + conn = (HttpURLConnection) urlConnection; if (pcc != null) { long openUrlConnectionTime = UtilsTime.getNanoTime() - pccTsOpenURLConnection; @@ -250,8 +250,8 @@ private enum RequestResult { long tOpen = pcc != null ? UtilsTime.getNanoTime() : 0; HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - if (conn instanceof HttpsURLConnection && (Countly.publicKeyPinCertificates != null || Countly.certificatePinCertificates != null)) { - ((HttpsURLConnection) conn).setSSLSocketFactory(sslContext_.getSocketFactory()); + if (sslSocketFactory_ != null && conn instanceof HttpsURLConnection) { + ((HttpsURLConnection) conn).setSSLSocketFactory(sslSocketFactory_); } if (pcc != null) { 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 4eb882fdf..9623c4acd 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -37,6 +37,7 @@ of this software and associated documentation files (the "Software"), to deal import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import org.json.JSONException; import org.json.JSONObject; @@ -57,7 +58,7 @@ class ConnectionQueue implements RequestQueueProvider { private Context context_; private Future connectionProcessorFuture_; private DeviceIdProvider deviceIdProvider_; - private SSLContext sslContext_; + private SSLSocketFactory sslSocketFactory_; private final ScheduledExecutorService backoffScheduler_ = Executors.newSingleThreadScheduledExecutor(); private final AtomicBoolean backoff_ = new AtomicBoolean(false); @@ -118,17 +119,30 @@ public ConnectionQueue() { }); } - void setupSSLContext() { - if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { - sslContext_ = null; - } else { - try { - TrustManager[] tm = { new CertificateTrustManager(Countly.publicKeyPinCertificates, Countly.certificatePinCertificates) }; - sslContext_ = SSLContext.getInstance("TLS"); - sslContext_.init(null, tm, null); - } catch (Throwable e) { - throw new IllegalStateException(e); + void setupSSLSocketFactory(SSLSocketFactory customSSLSocketFactory) { + // A customer-provided SSL socket factory (for example a FIPS-validated provider) takes + // precedence over the built-in pinning trust manager. The two cannot be combined here, so + // when both are set the custom factory wins and pinning is expected to be baked into it. + if (customSSLSocketFactory != null) { + sslSocketFactory_ = customSSLSocketFactory; + if (Countly.publicKeyPinCertificates != null || Countly.certificatePinCertificates != null) { + L.w("[ConnectionQueue] A custom SSL socket factory is set, the built-in certificate/public key pinning trust manager will not be applied"); } + return; + } + + if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { + sslSocketFactory_ = null; + return; + } + + try { + TrustManager[] tm = { new CertificateTrustManager(Countly.publicKeyPinCertificates, Countly.certificatePinCertificates) }; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tm, null); + sslSocketFactory_ = sslContext.getSocketFactory(); + } catch (Throwable e) { + throw new IllegalStateException(e); } } @@ -963,7 +977,7 @@ public void tick() { public ConnectionProcessor createConnectionProcessor() { - ConnectionProcessor cp = new ConnectionProcessor(baseInfoProvider.getServerURL(), storageProvider, deviceIdProvider_, configProvider, requestInfoProvider, sslContext_, requestHeaderCustomValues, L, healthTracker, new Runnable() { + ConnectionProcessor cp = new ConnectionProcessor(baseInfoProvider.getServerURL(), storageProvider, deviceIdProvider_, configProvider, requestInfoProvider, sslSocketFactory_, requestHeaderCustomValues, L, healthTracker, new Runnable() { @Override public void run() { L.d("[ConnectionQueue] createConnectionProcessor:run, backed off, countdown started for " + configProvider.getBOMDuration() + " seconds"); diff --git a/sdk/src/main/java/ly/count/android/sdk/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java index 8869a9752..13896a0db 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -686,7 +686,7 @@ public synchronized Countly init(CountlyConfig config) { connectionQueue_.deviceInfo = config.deviceInfo; connectionQueue_.pcc = config.pcc; connectionQueue_.setStorageProvider(config.storageProvider); - connectionQueue_.setupSSLContext(); + connectionQueue_.setupSSLSocketFactory(config.customSSLSocketFactory); connectionQueue_.setBaseInfoProvider(config.baseInfoProvider); connectionQueue_.setDeviceId(config.deviceIdProvider); connectionQueue_.setRequestHeaderCustomValues(requestHeaderCustomValues); diff --git a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java index fcf100b94..186ef3f8e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import javax.net.ssl.SSLSocketFactory; public class CountlyConfig { @@ -163,6 +164,8 @@ public class CountlyConfig { protected String[] certificatePinningCertificates = null; + protected SSLSocketFactory customSSLSocketFactory = null; + protected Integer sessionUpdateTimerDelay = null; /** @@ -730,6 +733,36 @@ public synchronized CountlyConfig enableCertificatePinning(String[] certificates return this; } + /** + * Provide a custom SSLSocketFactory that Countly uses for all of its HTTPS requests + * (session, event, remote-config, feedback/rating/content availability, health-check and + * preflight requests). + *

+ * Use this to route Countly's network traffic through your own TLS provider — for example a + * FIPS 140-3 validated cryptographic module — or to enforce a specific TLS protocol version + * or cipher suite. Protocol and cipher-suite restrictions must be applied inside the supplied + * factory (for example by wrapping it and calling {@code setEnabledProtocols} / + * {@code setEnabledCipherSuites} on each created socket, or through {@code SSLParameters}); + * Countly applies the factory as it is. + *

+ * Notes: + *

    + *
  • Applies only to "https://" server URLs. It has no effect on a plain "http://" server URL.
  • + *
  • Takes precedence over {@link #enablePublicKeyPinning(String[])} and + * {@link #enableCertificatePinning(String[])}. When both are provided, this factory is used and + * the built-in pinning trust manager is not applied; add pinning to your own factory if you need it.
  • + *
  • Does not apply to WebView-rendered content, feedback and rating widgets (the Android WebView + * uses its own network stack) nor to push notification image downloads.
  • + *
+ * + * @param sslSocketFactory the factory to use; a null value leaves the default behavior unchanged + * @return Returns the same config object for convenient linking + */ + public synchronized CountlyConfig setCustomSSLSocketFactory(SSLSocketFactory sslSocketFactory) { + customSSLSocketFactory = sslSocketFactory; + return this; + } + /** * Set if Countly SDK should ignore app crawlers * From ee6b79fabeb9d2ac7dc62b0027ae6c47ea23035b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 14 Jul 2026 10:12:38 +0300 Subject: [PATCH 08/21] fix: event class check --- .../android/sdk/ContentOverlayViewTests.java | 31 +++++++++++++++++++ .../count/android/sdk/ContentOverlayView.java | 12 +++---- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java index c3a187f97..ffe2f18c4 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -219,6 +219,37 @@ public void contentUrlAction_multipleEvents_returnsTrue() { }); } + /** + * Malformed "event" JSON must not crash. When the payload fails to validate, splitQuery stores + * it as a raw String (not a JSONArray) and still routes action=event to eventAction. eventAction + * must guard the cast instead of letting a ClassCastException propagate out of + * shouldOverrideUrlLoading. The URL is still a consumed countly action URL (returns true) and + * simply records nothing. Regression test for the unguarded (JSONArray) cast. + */ + @Test + public void contentUrlAction_malformedEventJson_doesNotThrow() { + withActivity(activity -> { + overlay = createOverlay(activity); + // Truncated JSON array: new JSONArray(...) throws, so "event" is not stored as a JSONArray. + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event&event=[{\"key\":\"oops\""; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + } + + /** + * Same guard with reversed param order: a valid action=event verb paired with a malformed event + * payload that appears before it. The event value lands in splitQuery's fallback prefix as a + * String, so eventAction must not cast-crash. + */ + @Test + public void contentUrlAction_malformedEventJson_reversedOrder_doesNotThrow() { + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&event=[oops&action=event"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + } + /** * resize_me action parses JSON and updates portrait/landscape configs. */ diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java index d34405de1..66d88f620 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java +++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java @@ -782,12 +782,12 @@ boolean widgetUrlAction(String url, WebView view) { private void eventAction(Map query) { Log.i(Countly.TAG, "[ContentOverlayView] eventAction, event action detected"); - if (query.containsKey("event")) { - JSONArray event = (JSONArray) query.get("event"); - if (event == null) { - Log.w(Countly.TAG, "[ContentOverlayView] eventAction, event is null"); - return; - } + // splitQuery only stores "event" as a JSONArray when its JSON validates; a malformed payload + // from web content falls back to a raw String, so guard the cast (as resizeMeAction guards + // resize_me) to keep a ClassCastException from propagating out of shouldOverrideUrlLoading. + Object eventObj = query.get("event"); + if (eventObj instanceof JSONArray) { + JSONArray event = (JSONArray) eventObj; for (int i = 0; i < event.length(); i++) { try { JSONObject eventJson = event.getJSONObject(i); From f47f0d3a0043f5ce68d85c13127423ebd658a345 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 14 Jul 2026 10:54:26 +0300 Subject: [PATCH 09/21] feat: tests about urls --- .../android/sdk/ContentOverlayViewTests.java | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java index ffe2f18c4..b27c42279 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -22,6 +22,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import static org.mockito.Mockito.mock; /** * Instrumented tests for ContentOverlayView. @@ -1309,4 +1310,179 @@ public void webView_usesApplicationContext_notActivity() { overlay.webView.getContext().getApplicationContext()); }); } + + // ===================== Plan verification (URL_EDGE_CASE_TESTS.md) — behavior gaps ===================== + // Empirically confirms plan behaviors that were not already covered by a dedicated test: actual + // event recording, malformed-payload safety, routing/parsing edge cases, and content-URL-handler + // fallthrough. (Scheme allow/deny decisions are covered in UtilsTests / CountlyWebViewClientTests / + // FeedbackDialogWebViewClientTests; percent-decoding in CountlyWebViewClientTests.) + + private void invokeStartSafeExternalIntent(String url) { + try { + Method m = ContentOverlayView.class.getDeclaredMethod("startSafeExternalIntent", String.class); + m.setAccessible(true); + m.invoke(overlay, url); + } catch (Exception e) { + Assert.fail("startSafeExternalIntent must not propagate an exception: " + e); + } + } + + /** §1.1 A valid event action actually records the event with its segmentation. */ + @Test + public void planEvent_validSingleEvent_isRecorded() { + EventProvider ep = TestUtils.setEventProviderToMock(Countly.sharedInstance(), mock(EventProvider.class)); + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event" + + "&event=[{\"key\":\"button_click\",\"sg\":{\"btn\":\"buy\"}}]"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + Map expected = new HashMap<>(); + expected.put("btn", "buy"); + TestUtils.validateRecordEventInternalMock(ep, "button_click", expected); + } + + /** §1.2 When an event carries both "sg" and "segmentation", "sg" wins. */ + @Test + public void planEvent_sgOverridesSegmentation_isRecorded() { + EventProvider ep = TestUtils.setEventProviderToMock(Countly.sharedInstance(), mock(EventProvider.class)); + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event" + + "&event=[{\"key\":\"e1\",\"sg\":{\"a\":\"1\"},\"segmentation\":{\"b\":\"2\"}}]"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + Map expected = new HashMap<>(); + expected.put("a", "1"); // taken from sg, "segmentation" ignored + TestUtils.validateRecordEventInternalMock(ep, "e1", expected); + } + + /** §1.4 An event object missing "key" records nothing (get("key") throws, caught) and does not crash. */ + @Test + public void planEvent_missingKey_recordsNothing() { + EventProvider ep = TestUtils.setEventProviderToMock(Countly.sharedInstance(), mock(EventProvider.class)); + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event&event=[{\"sg\":{\"a\":\"1\"}}]"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + TestUtils.validateRecordEventInternalMockInteractions(ep, 0); + } + + /** §5.4 The sentinel host appearing twice yields an empty parse (pairs != 2) -> not handled. */ + @Test + public void planRouting_sentinelTwice_emptyMapNotHandled() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + + Utils.COMM_URL + "/?x=1"; + Map q = invokeSplitQuery(url); + Assert.assertTrue("map must be empty when the sentinel appears twice", q.isEmpty()); + Assert.assertFalse(overlay.contentUrlAction(url, overlay.webView)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** §5/§6 An extra path segment before the query fuses into the key, so the marker is unrecognized. */ + @Test + public void planRouting_extraPathSegment_markerNotRecognized() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/foo?cly_widget_command=1&close=1"; + Map q = invokeSplitQuery(url); + Assert.assertNull("bare ?cly_widget_command must not be a key", q.get("?cly_widget_command")); + Assert.assertFalse(overlay.widgetUrlAction(url, overlay.webView)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** §5.2 A query token with no '=' is skipped; surrounding params still parse. */ + @Test + public void planRouting_tokenWithoutEquals_skipped() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&flagonly&link=https://example.com"; + Map q = invokeSplitQuery(url); + Assert.assertEquals("link", q.get("action")); + Assert.assertFalse("a bare token must not appear as a key", q.containsKey("flagonly")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** §5.5 On the content path the widget cancel runnable is never fired (only widgetUrlAction fires it). */ + @Test + public void planRouting_contentPathDoesNotFireWidgetCancel() { + AtomicBoolean cancelCalled = new AtomicBoolean(false); + AtomicBoolean closed = new AtomicBoolean(false); + withActivity(activity -> { + overlay = createOverlay(activity); + overlay.setOnWidgetCancelRunnable(() -> cancelCalled.set(true)); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&cly_widget_command=1&close=1"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + try { + closed.set((Boolean) getField("isClosed")); + } catch (Exception e) { + Assert.fail("read isClosed: " + e); + } + }); + Assert.assertTrue("overlay should be closed", closed.get()); + Assert.assertFalse("widget cancel must NOT fire on the content path", cancelCalled.get()); + } + + /** §6.10 A widget command value other than "1" is not handled. */ + @Test + public void planWidget_commandValueNotOne_returnsFalse() { + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_widget_command=2&close=1"; + Assert.assertFalse(overlay.widgetUrlAction(url, overlay.webView)); + }); + } + + /** §6.16 Marker matching is case-sensitive: an uppercase command key is not recognized. */ + @Test + public void planWidget_uppercaseKey_returnsFalse() { + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?CLY_WIDGET_COMMAND=1&close=1"; + Assert.assertFalse(overlay.widgetUrlAction(url, overlay.webView)); + }); + } + + /** §8/handler A content URL handler returning false lets the SDK proceed; a denylisted scheme means + * no dispatch and no crash, and the handler is consulted exactly once. */ + @Test + public void planHandler_returnsFalse_fallsThroughNoCrash() { + AtomicBoolean called = new AtomicBoolean(false); + withActivity(activity -> { + ContentUrlHandler handler = url -> { + called.set(true); + return false; + }; + overlay = createOverlay(activity, null, null, handler); + invokeStartSafeExternalIntent("file:///blocked"); + }); + Assert.assertTrue("handler must be consulted", called.get()); + } + + /** §8/handler A throwing content URL handler is caught; startSafeExternalIntent must not propagate it. */ + @Test + public void planHandler_throws_caughtNoCrash() { + withActivity(activity -> { + ContentUrlHandler handler = url -> { + throw new RuntimeException("handler boom"); + }; + overlay = createOverlay(activity, null, null, handler); + invokeStartSafeExternalIntent("file:///blocked"); // reaching the next line proves it was caught + }); + } } From 1be9e40948deaa0087e98b6b3af3650b4c1f5432 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 16 Jul 2026 11:53:17 +0300 Subject: [PATCH 10/21] fix: nomodule entries break await resources in contents --- CHANGELOG.md | 2 + .../sdk/CountlyWebViewClientTests.java | 33 ++++++++++ .../android/sdk/CountlyWebViewClient.java | 61 +++++++++++-------- 3 files changed, 72 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 680ccee6e..1dce25fbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ * Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly. * Added a content configuration option to provide a handler for links opened from the content web view, so the app can route its own deep links instead of the SDK opening the system browser, set via `setContentUrlHandler(ContentUrlHandler)`. +* Mitigated an issue where content could fail to be displayed on some devices, as the content web view could stay hidden even after its resources had finished loading. + ## 26.1.4 * ! Minor breaking change ! Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks". It is now a no-op; use "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()" instead, otherwise the stricter push intent redirection checks stay disabled. 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 ef6a69c20..1a41d4c6c 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -453,4 +453,37 @@ public void shouldInterceptRequest_allowlistMode() { CountlyWebViewClient httpAllowed = new CountlyWebViewClient(new HashSet<>(Arrays.asList("http"))); Assert.assertNull(httpAllowed.shouldInterceptRequest(null, fakeRequest("http://example.com/a.png", false))); } + + // ===================================== + // Readiness gate (regression) + // ===================================== + + /** + * Regression: a nomodule {@code " + + "content"; + runOnMainSync(() -> { + wv.setWebViewClient(client); + wv.loadDataWithBaseURL("https://example.com/", html, "text/html", "utf-8", null); + }); + + Assert.assertTrue("readiness callback did not fire under the 60s timeout — readyState gate regressed", + latch.await(20, TimeUnit.SECONDS)); + Assert.assertEquals(1, callbackResults.size()); + Assert.assertFalse("content must be shown (failed=false), not discarded", callbackResults.get(0)); + } } 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 1735f12d0..a8f1671ed 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -89,20 +89,25 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque private static final long POLL_INTERVAL_MS = 100; private static final long TIMEOUT_MS = 60_000; - // Checks all and