From d521ace15b93cb5368ccf524ff5e1e21b89d9ec5 Mon Sep 17 00:00:00 2001 From: turtledreams <62231246+turtledreams@users.noreply.github.com> Date: Wed, 13 May 2026 16:33:16 +0900 Subject: [PATCH 01/24] app fix --- app/src/main/java/ly/count/android/demo/App.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/app/src/main/java/ly/count/android/demo/App.java b/app/src/main/java/ly/count/android/demo/App.java index 450f120f1..5587d2036 100644 --- a/app/src/main/java/ly/count/android/demo/App.java +++ b/app/src/main/java/ly/count/android/demo/App.java @@ -57,15 +57,6 @@ public void onCreate() { WebView.setWebContentsDebuggingEnabled(true); } - COUNTLY_SERVER_URL = - DEFAULT_URL.equals(BuildConfig.COUNTLY_SERVER_URL) - ? DEFAULT_URL - : BuildConfig.COUNTLY_SERVER_URL; - COUNTLY_APP_KEY = - DEFAULT_APP_KEY.equals(BuildConfig.COUNTLY_APP_KEY) - ? DEFAULT_APP_KEY - : BuildConfig.COUNTLY_APP_KEY; - if (DEFAULT_URL.equals(COUNTLY_SERVER_URL) || DEFAULT_APP_KEY.equals(COUNTLY_APP_KEY)) { Log.e("CountlyDemo", "Please provide correct COUNTLY_SERVER_URL and COUNTLY_APP_KEY"); return; From 4a78040e78b92da8200ad4d3ca91794c415e0948 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 14 May 2026 17:17:00 +0300 Subject: [PATCH 02/24] feat: remove build config to inside if --- app/src/main/java/ly/count/android/demo/App.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/ly/count/android/demo/App.java b/app/src/main/java/ly/count/android/demo/App.java index 5587d2036..d80828c01 100644 --- a/app/src/main/java/ly/count/android/demo/App.java +++ b/app/src/main/java/ly/count/android/demo/App.java @@ -58,8 +58,12 @@ public void onCreate() { } if (DEFAULT_URL.equals(COUNTLY_SERVER_URL) || DEFAULT_APP_KEY.equals(COUNTLY_APP_KEY)) { - Log.e("CountlyDemo", "Please provide correct COUNTLY_SERVER_URL and COUNTLY_APP_KEY"); - return; + COUNTLY_SERVER_URL = BuildConfig.COUNTLY_SERVER_URL; + COUNTLY_APP_KEY = BuildConfig.COUNTLY_APP_KEY; + if (DEFAULT_URL.equals(COUNTLY_SERVER_URL) || DEFAULT_APP_KEY.equals(COUNTLY_APP_KEY)) { + Log.e("CountlyDemo", "Please provide correct COUNTLY_SERVER_URL and COUNTLY_APP_KEY"); + return; + } } if (false) { From c17e04372c0f86826fbf3973ad51b52017211d2d Mon Sep 17 00:00:00 2001 From: turtledreams Date: Fri, 5 Jun 2026 20:51:39 +0900 Subject: [PATCH 03/24] Native crash fix --- CHANGELOG.md | 3 +++ .../count/android/sdk/ModuleCrashTests.java | 27 +++++++++++++++++++ .../ly/count/android/sdk/ModuleCrash.java | 17 +++++++----- 3 files changed, 41 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c58ea896e..7b73577bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## X.X.X +* Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. + ## 26.1.3 * Added gradle configuration cache support to upload symbols plugin. * Improved user properties auto-save conditions to flush event queue with every user property call. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleCrashTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleCrashTests.java index 88806761d..d8aeada41 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleCrashTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleCrashTests.java @@ -801,6 +801,33 @@ public void recordException_globalCrashFilter_nativeCrash() throws JSONException validateCrash(extractNativeCrash("dump2"), "", true, true, 2, 1, new ConcurrentHashMap<>(), 0, new ConcurrentHashMap<>(), new ArrayList<>()); } + /** + * A native crash dump is a single-line base64 string. When a global crash filter is set, + * the SDK must NOT apply the per-line stack trace length limit (maxStackTraceLineLength) to it, + * otherwise the whole dump is truncated to 'maxStackTraceLineLength' characters and corrupted. + * Regression test: the queued "_error" must equal the full, untruncated base64 dump. + */ + @Test + public void recordException_globalCrashFilter_nativeCrash_notTruncatedByLineLength() throws JSONException { + TestUtils.getCountlyStore().clear(); + String finalPath = TestUtils.getContext().getCacheDir().getAbsolutePath() + File.separator + "Countly" + File.separator + "CrashDumps"; + + // payload whose base64 length far exceeds the default maxStackTraceLineLength (200) + char[] payload = new char[400]; + Arrays.fill(payload, 'a'); + String longDump = new String(payload); + createFile(finalPath, File.separator + "dumpLong.dmp", longDump); + + CountlyConfig cConfig = TestUtils.createBaseConfig(); + cConfig.metricProviderOverride = mmp; + cConfig.crashes.setGlobalCrashFilterCallback(crash -> false); // keep the crash, do not modify it + + new Countly().init(cConfig); + + Assert.assertEquals(1, TestUtils.getCurrentRQ().length); + validateCrash(extractNativeCrash(longDump), "", true, true, new ConcurrentHashMap<>(), 0, new ConcurrentHashMap<>(), new ArrayList<>()); + } + /** * Validate that deprecated crash filter, filters out all native crash dumps * Validate RQ is empty after initialization of the SDK diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleCrash.java b/sdk/src/main/java/ly/count/android/sdk/ModuleCrash.java index 2df6e4f96..949247e1a 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleCrash.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleCrash.java @@ -123,7 +123,7 @@ private void recordNativeException(@NonNull File dumpFile) { String dumpString = Base64.encodeToString(bytes, Base64.NO_WRAP); CrashData crashData = prepareCrashData(dumpString, false, true, null); - if (!crashFilterCheck(crashData)) { + if (!crashFilterCheck(crashData, true)) { sendCrashReportToQueue(crashData, true); } } @@ -209,7 +209,7 @@ public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { String stackTrace = prepareStackTrace(e); CrashData crashData = prepareCrashData(stackTrace, false, false, null); - if (!crashFilterCheck(crashData)) { + if (!crashFilterCheck(crashData, false)) { sendCrashReportToQueue(crashData, false); } } @@ -230,9 +230,10 @@ public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { * If it does, the crash should be ignored * * @param crashData CrashData object to check + * @param isNativeCrash whether the crash is a native crash dump (base64 string, not a Java stack trace) * @return true if a match was found */ - boolean crashFilterCheck(@NonNull CrashData crashData) { + boolean crashFilterCheck(@NonNull CrashData crashData, final boolean isNativeCrash) { assert crashData != null; L.d("[ModuleCrash] Calling crashFilterCheck"); @@ -254,8 +255,12 @@ boolean crashFilterCheck(@NonNull CrashData crashData) { UtilsInternalLimits.applyInternalLimitsToBreadcrumbs(crashData.getBreadcrumbs(), _cly.config_.sdkInternalLimits, L, "[ModuleCrash] sendCrashReportToQueue"); UtilsInternalLimits.applySdkInternalLimitsToSegmentation(crashData.getCrashSegmentation(), _cly.config_.sdkInternalLimits, L, "[ModuleCrash] sendCrashReportToQueue"); - String truncatedStackTrace = UtilsInternalLimits.applyInternalLimitsToStackTraces(crashData.getStackTrace(), _cly.config_.sdkInternalLimits.maxStackTraceLineLength, "[ModuleCrash] sendCrashReportToQueue", L); - crashData.setStackTrace(truncatedStackTrace); + // Stack trace line limits must not be applied to native crashes: the "stack trace" of a + // native crash is a single-line base64 dump, so per-line truncation would corrupt the dump. + if (!isNativeCrash) { + String truncatedStackTrace = UtilsInternalLimits.applyInternalLimitsToStackTraces(crashData.getStackTrace(), _cly.config_.sdkInternalLimits.maxStackTraceLineLength, "[ModuleCrash] sendCrashReportToQueue", L); + crashData.setStackTrace(truncatedStackTrace); + } UtilsInternalLimits.removeUnsupportedDataTypes(crashData.getCrashSegmentation(), L); UtilsInternalLimits.removeUnsupportedDataTypes(crashData.getCrashMetrics(), L); @@ -318,7 +323,7 @@ Countly recordExceptionInternal(@Nullable final Throwable exception, final boole String exceptionString = prepareStackTrace(exception); CrashData crashData = prepareCrashData(exceptionString, itIsHandled, false, customSegmentation); - if (crashFilterCheck(crashData)) { + if (crashFilterCheck(crashData, false)) { L.d("[ModuleCrash] Crash filter found a match, exception will be ignored, [" + exceptionString.substring(0, Math.min(exceptionString.length(), 60)) + "]"); } else { sendCrashReportToQueue(crashData, false); From 63d559494efb710b4d6311a928c4b88b9375b0d8 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 10 Jun 2026 14:56:11 +0300 Subject: [PATCH 04/24] feat: automatic sbs settings and jtv --- CHANGELOG.md | 2 + .../android/sdk/ConnectionProcessorTests.java | 16 ++ .../sdk/InternalRequestCallbackTests.java | 16 ++ .../android/sdk/ModuleConfigurationTests.java | 268 +++++++++++++++++- .../android/sdk/ServerConfigBuilder.java | 28 ++ .../android/sdk/ConfigurationProvider.java | 8 + .../java/ly/count/android/sdk/Countly.java | 12 +- .../android/sdk/ModuleConfiguration.java | 50 +++- .../ly/count/android/sdk/ModuleCrash.java | 35 ++- .../ly/count/android/sdk/ModuleDeviceId.java | 4 +- .../ly/count/android/sdk/ModuleEvents.java | 6 +- .../ly/count/android/sdk/ModuleSessions.java | 35 ++- .../ly/count/android/sdk/ModuleViews.java | 10 +- 13 files changed, 455 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b73577bd..ddd9af3d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,6 @@ ## X.X.X +* Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. + * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. ## 26.1.3 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 89d9baa33..3e6be1cfc 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java @@ -103,6 +103,18 @@ public void setUp() { return true; } + @Override public boolean getAutomaticSessionTrackingEnabled() { + return true; + } + + @Override public boolean getAutomaticViewTrackingEnabled() { + return true; + } + + @Override public boolean getAutomaticCrashReportingEnabled() { + return true; + } + @Override public boolean getLocationTrackingEnabled() { return true; } @@ -158,6 +170,10 @@ public void setUp() { @Override public Set getJourneyTriggerEvents() { return Collections.emptySet(); } + + @Override public Set getJourneyTriggerViews() { + return Collections.emptySet(); + } }; Countly.sharedInstance().setLoggingEnabled(true); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java index d22c918e3..058345491 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/InternalRequestCallbackTests.java @@ -877,6 +877,18 @@ private ConfigurationProvider createConfigurationProvider() { return true; } + @Override public boolean getAutomaticSessionTrackingEnabled() { + return true; + } + + @Override public boolean getAutomaticViewTrackingEnabled() { + return true; + } + + @Override public boolean getAutomaticCrashReportingEnabled() { + return true; + } + @Override public boolean getLocationTrackingEnabled() { return true; } @@ -932,6 +944,10 @@ private ConfigurationProvider createConfigurationProvider() { @Override public Set getJourneyTriggerEvents() { return Collections.emptySet(); } + + @Override public Set getJourneyTriggerViews() { + return Collections.emptySet(); + } }; } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java index c31c47231..7ab4084fd 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleConfigurationTests.java @@ -1,5 +1,6 @@ package ly.count.android.sdk; +import android.app.Activity; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.io.IOException; import java.lang.reflect.Field; @@ -654,7 +655,7 @@ public void invalidConfigResponses_AreRejected() { */ @Test public void configurationParameterCount() { - int configParameterCount = 41; // plus config, timestamp and version parameters, UPDATE: list filters, user property cache limit, and journey trigger events + int configParameterCount = 45; // plus config, timestamp and version parameters, UPDATE: list filters, user property cache limit, journey trigger events, automatic session/view/crash tracking flags, and journey trigger views int count = 0; for (Field field : ModuleConfiguration.class.getDeclaredFields()) { if (field.getName().startsWith("keyR")) { @@ -1149,7 +1150,8 @@ private void initServerConfigWithValues(BiConsumer config .userPropertyFilterList(new HashSet<>(), false) .segmentationFilterList(new HashSet<>(), false) .eventSegmentationFilterMap(new ConcurrentHashMap<>(), false) - .journeyTriggerEvents(new HashSet<>()); + .journeyTriggerEvents(new HashSet<>()) + .journeyTriggerViews(new HashSet<>()); String serverConfig = builder.build(); CountlyConfig countlyConfig = TestUtils.createBaseConfig().setLoggingEnabled(false); @@ -2913,4 +2915,266 @@ public void edgeCase_multipleEventsWithDifferentFilters() throws JSONException { Assert.assertEquals(2, TestUtils.getCurrentRQ().length); validateEventInRQ("event2", TestUtils.map("key_a", "value"), 1, 2, 0, 1); } + + // ================ Automatic tracking flags (ast / avt / acr) ================ + + /** + * Tests that the automatic tracking SBS flags are parsed from the server config and exposed + * through the configuration provider getters with non-default values. + */ + @Test + public void automaticTrackingFlags_parsedAndExposed() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .automaticSessionTracking(false) + .automaticViewTracking(true) + .automaticCrashReporting(true) + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + ModuleConfiguration mc = Countly.sharedInstance().moduleConfiguration; + Assert.assertFalse(mc.getAutomaticSessionTrackingEnabled()); + Assert.assertTrue(mc.getAutomaticViewTrackingEnabled()); + Assert.assertTrue(mc.getAutomaticCrashReportingEnabled()); + } + + /** + * Tests the lowest-precedence layer: when the server is silent on the automatic tracking flags, + * the resolved values fall back to the developer config. createBaseConfig uses automatic sessions, + * enables crash reporting, and does not enable automatic view tracking, so the resolved flags are + * ast=true, avt=false, acr=true. + */ + @Test + public void automaticTrackingFlags_silentServer_fallsBackToLocalConfig() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig(); + // server config that does not carry ast/avt/acr at all + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().tracking(true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + ModuleConfiguration mc = Countly.sharedInstance().moduleConfiguration; + Assert.assertTrue(mc.getAutomaticSessionTrackingEnabled()); // manual session control not enabled + Assert.assertFalse(mc.getAutomaticViewTrackingEnabled()); // automatic view tracking not enabled locally + Assert.assertTrue(mc.getAutomaticCrashReportingEnabled()); // createBaseConfig enables crash reporting + } + + /** + * Tests that the server can disable automatic view tracking the developer enabled locally: + * a lifecycle activity start records no view. + */ + @Test + public void automaticViewTracking_disabledByServer_noAutoView() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig() + .enableManualSessionControl() + .enableAutomaticViewTracking(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().automaticViewTracking(false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getAutomaticViewTrackingEnabled()); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().moduleViews.onActivityStarted(Mockito.mock(Activity.class), 1); + + // server disabled automatic view tracking -> the activity view is not recorded + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + } + + /** + * Tests the precedence "Server SBS > Developer config" for views: the developer does NOT enable + * automatic view tracking, but the server sends avt=true, so the SDK records the activity view. + */ + @Test + public void automaticViewTracking_serverForceEnablesAutoView() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().automaticViewTracking(true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getAutomaticViewTrackingEnabled()); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + Countly.sharedInstance().moduleViews.onActivityStarted(Mockito.mock(Activity.class), 1); + + // server forced automatic view tracking on even though the developer never enabled it + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + } + + /** + * Tests the precedence "Server SBS > Developer config" for sessions: the developer enables manual + * session control, but the server sends ast=true. The SDK resolves to automatic session tracking - + * the manual session API is ignored and the lifecycle starts a session. + */ + @Test + public void automaticSessionTracking_serverOverridesManualSessionControl() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().automaticSessionTracking(true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // server precedence: automatic session tracking is active despite the local manual session control + Assert.assertTrue(Countly.sharedInstance().moduleSessions.automaticSessionTrackingEnabled()); + + // the manual session API is ignored while automatic tracking is active + Assert.assertFalse(Countly.sharedInstance().moduleSessions.sessionIsRunning()); + Countly.sharedInstance().sessions().beginSession(); + Assert.assertFalse(Countly.sharedInstance().moduleSessions.sessionIsRunning()); + + // the automatic lifecycle path starts a session + Countly.sharedInstance().onStartInternal(Mockito.mock(Activity.class)); + Assert.assertTrue(Countly.sharedInstance().moduleSessions.sessionIsRunning()); + } + + /** + * Tests that the server can disable automatic session tracking the developer relied on: + * with ast=false the lifecycle does not start a session. + */ + @Test + public void automaticSessionTracking_serverDisablesAutomaticSessions() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().automaticSessionTracking(false).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertFalse(Countly.sharedInstance().moduleSessions.automaticSessionTrackingEnabled()); + + Countly.sharedInstance().onStartInternal(Mockito.mock(Activity.class)); + + // server disabled automatic session tracking -> the lifecycle does not begin a session + Assert.assertFalse(Countly.sharedInstance().moduleSessions.sessionIsRunning()); + } + + /** + * Tests the precedence "Server SBS > Developer config" for crash: the developer did NOT enable + * unhandled crash reporting, but the server sends acr=true, so the SDK installs the uncaught + * exception handler reactively through the SBS config-change action. + */ + @Test + public void automaticCrashReporting_serverEnables_installsHandler() throws JSONException { + // developer does NOT enable unhandled crash reporting + CountlyConfig countlyConfig = new CountlyConfig(TestUtils.getContext(), TestUtils.commonAppKey, TestUtils.commonURL) + .setDeviceId(TestUtils.commonDeviceId) + .setLoggingEnabled(true) + .enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().automaticCrashReporting(true).build() + ); + Countly.sharedInstance().init(countlyConfig); + + // server enabled automatic crash reporting -> the handler is installed even though the dev did not opt in + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getAutomaticCrashReportingEnabled()); + Assert.assertTrue(Countly.sharedInstance().moduleCrash.unhandledCrashHandlerInstalled); + } + + /** + * Tests that when the developer did not enable crash reporting and the server is silent, the SDK + * does NOT install its uncaught exception handler, so it never wraps the global handler for apps + * that did not ask for crash reporting (avoids interfering with other crash tools). + */ + @Test + public void automaticCrashReporting_devDisabledServerSilent_noHandler() throws JSONException { + CountlyConfig countlyConfig = new CountlyConfig(TestUtils.getContext(), TestUtils.commonAppKey, TestUtils.commonURL) + .setDeviceId(TestUtils.commonDeviceId) + .setLoggingEnabled(true) + .enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().tracking(true).build() // no acr + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertFalse(Countly.sharedInstance().moduleConfiguration.getAutomaticCrashReportingEnabled()); + Assert.assertFalse(Countly.sharedInstance().moduleCrash.unhandledCrashHandlerInstalled); + } + + // ================ Journey Trigger Views (jtv) ================ + + /** + * Tests that journey trigger views are parsed from the server config and exposed by the getter. + */ + @Test + public void journeyTriggerViews_configuredCorrectly() throws JSONException { + Set triggerViews = new HashSet<>(); + triggerViews.add("home_view"); + triggerViews.add("checkout_view"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults().journeyTriggerViews(triggerViews).build() + ); + Countly.sharedInstance().init(countlyConfig); + + Set stored = Countly.sharedInstance().moduleConfiguration.getJourneyTriggerViews(); + Assert.assertEquals(2, stored.size()); + Assert.assertTrue(stored.contains("home_view")); + Assert.assertTrue(stored.contains("checkout_view")); + } + + /** + * Tests that recording a view whose name is in the journey trigger views set force-flushes the + * event queue and registers the content-zone refresh callback (callback_id present), while a + * non-trigger view stays queued. + */ + @Test + public void journeyTriggerViews_matchingViewForcesFlushAndRegistersCallback() throws JSONException { + Set triggerViews = new HashSet<>(); + triggerViews.add("jtv_view"); + + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerViews(triggerViews) + .eventQueueSize(100) // high threshold so only the trigger forces a flush + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + + // a non-trigger view stays in the event queue + Countly.sharedInstance().views().startView("regular_view"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + + // a trigger view force-flushes the whole event queue and registers a refresh callback + Countly.sharedInstance().views().startView("jtv_view"); + Assert.assertEquals(0, countlyStore.getEventQueueSize()); + + Map[] rq = TestUtils.getCurrentRQ(); + Assert.assertEquals(1, rq.length); + Assert.assertTrue(rq[0].containsKey("callback_id")); + String events = rq[0].get("events"); + Assert.assertNotNull(events); + Assert.assertTrue(events.contains("regular_view")); + Assert.assertTrue(events.contains("jtv_view")); + } + + /** + * Tests that with an empty journey trigger views set, recording any view does not force a flush + * and the view stays queued. + */ + @Test + public void journeyTriggerViews_emptySet_noForcedFlush() throws JSONException { + CountlyConfig countlyConfig = TestUtils.createBaseConfig().enableManualSessionControl(); + countlyConfig.immediateRequestGenerator = createIRGForSpecificResponse( + new ServerConfigBuilder().defaults() + .journeyTriggerViews(new HashSet<>()) + .eventQueueSize(100) + .build() + ); + Countly.sharedInstance().init(countlyConfig); + + Assert.assertTrue(Countly.sharedInstance().moduleConfiguration.getJourneyTriggerViews().isEmpty()); + + Countly.sharedInstance().views().startView("any_view"); + Assert.assertEquals(0, TestUtils.getCurrentRQ().length); + Assert.assertEquals(1, countlyStore.getEventQueueSize()); + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java index 16444f3a7..436c604ce 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ServerConfigBuilder.java @@ -11,6 +11,9 @@ import org.json.JSONObject; import org.junit.Assert; +import static ly.count.android.sdk.ModuleConfiguration.keyRAutomaticCrashReporting; +import static ly.count.android.sdk.ModuleConfiguration.keyRAutomaticSessionTracking; +import static ly.count.android.sdk.ModuleConfiguration.keyRAutomaticViewTracking; import static ly.count.android.sdk.ModuleConfiguration.keyRConfig; import static ly.count.android.sdk.ModuleConfiguration.keyRConsentRequired; import static ly.count.android.sdk.ModuleConfiguration.keyRContentZoneInterval; @@ -24,6 +27,7 @@ import static ly.count.android.sdk.ModuleConfiguration.keyREventSegmentationWhitelist; import static ly.count.android.sdk.ModuleConfiguration.keyREventWhitelist; import static ly.count.android.sdk.ModuleConfiguration.keyRJourneyTriggerEvents; +import static ly.count.android.sdk.ModuleConfiguration.keyRJourneyTriggerViews; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitBreadcrumb; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitKeyLength; import static ly.count.android.sdk.ModuleConfiguration.keyRLimitSegValues; @@ -99,6 +103,21 @@ ServerConfigBuilder customEventTracking(boolean enabled) { return this; } + ServerConfigBuilder automaticSessionTracking(boolean enabled) { + config.put(keyRAutomaticSessionTracking, enabled); + return this; + } + + ServerConfigBuilder automaticViewTracking(boolean enabled) { + config.put(keyRAutomaticViewTracking, enabled); + return this; + } + + ServerConfigBuilder automaticCrashReporting(boolean enabled) { + config.put(keyRAutomaticCrashReporting, enabled); + return this; + } + ServerConfigBuilder contentZone(boolean enabled) { config.put(keyREnterContentZone, enabled); return this; @@ -242,6 +261,11 @@ ServerConfigBuilder journeyTriggerEvents(Set journeyTriggerEvents) { return this; } + ServerConfigBuilder journeyTriggerViews(Set journeyTriggerViews) { + config.put(keyRJourneyTriggerViews, journeyTriggerViews); + return this; + } + boolean refreshContentZone() { Object val = config.get(keyRRefreshContentZone); return val == null || (boolean) val; @@ -288,6 +312,7 @@ ServerConfigBuilder defaults() { segmentationFilterList(new HashSet<>(), false); eventSegmentationFilterMap(new ConcurrentHashMap<>(), false); journeyTriggerEvents(new HashSet<>()); + journeyTriggerViews(new HashSet<>()); return this; } @@ -381,5 +406,8 @@ private void validateFilterSettings(Countly countly) { Set journeyTriggerEvents = (Set) config.get(keyRJourneyTriggerEvents); Assert.assertEquals(Objects.requireNonNull(journeyTriggerEvents).toString(), countly.moduleConfiguration.getJourneyTriggerEvents().toString()); + + Set journeyTriggerViews = (Set) config.get(keyRJourneyTriggerViews); + Assert.assertEquals(Objects.requireNonNull(journeyTriggerViews).toString(), countly.moduleConfiguration.getJourneyTriggerViews().toString()); } } \ No newline at end of file diff --git a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java index 75a13679c..d6551ab73 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigurationProvider.java @@ -18,6 +18,12 @@ interface ConfigurationProvider { boolean getCrashReportingEnabled(); + boolean getAutomaticSessionTrackingEnabled(); + + boolean getAutomaticViewTrackingEnabled(); + + boolean getAutomaticCrashReportingEnabled(); + boolean getLocationTrackingEnabled(); boolean getRefreshContentZoneEnabled(); @@ -49,6 +55,8 @@ interface ConfigurationProvider { Set getJourneyTriggerEvents(); + Set getJourneyTriggerViews(); + class FilterList { T filterList; boolean isWhitelist; 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 54c09e522..d659ad549 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -1016,9 +1016,9 @@ void onStartInternal(Activity activity) { moduleConfiguration.fetchIfTimeIsUpForFetchingServerConfig(); } //if we open the first activity - //and we are not using manual session control, + //and automatic session tracking is active (resolved through the SBS precedence chain), //begin a session - if (moduleSessions != null && !moduleSessions.manualSessionControlEnabled) { + if (moduleSessions != null && moduleSessions.automaticSessionTrackingEnabled()) { moduleSessions.beginSessionInternal(); } } @@ -1041,8 +1041,8 @@ void onStopInternal() { } --activityCount_; - if (activityCount_ == 0 && moduleSessions != null && !moduleSessions.manualSessionControlEnabled) { - // if we don't use manual session control + if (activityCount_ == 0 && moduleSessions != null && moduleSessions.automaticSessionTrackingEnabled()) { + // if automatic session tracking is active // Called when final Activity is stopped. // Sends an end session event to the server, also sends any unsent custom events. moduleSessions.endSessionInternal(); @@ -1127,10 +1127,10 @@ synchronized void onTimer() { if (isInitialized()) { final boolean appIsInForeground = activityCount_ > 0; - if (appIsInForeground && !moduleSessions.manualSessionControlEnabled) { + if (appIsInForeground && moduleSessions.automaticSessionTrackingEnabled()) { //if we have automatic session control and we are in the foreground, record an update moduleSessions.updateSessionInternal(); - } else if (moduleSessions.manualSessionControlEnabled && moduleSessions.manualSessionControlHybridModeEnabled && moduleSessions.sessionIsRunning()) { + } else if (!moduleSessions.automaticSessionTrackingEnabled() && moduleSessions.manualSessionControlHybridModeEnabled && moduleSessions.sessionIsRunning()) { // if we are in manual session control mode with hybrid sessions enabled (SDK takes care of update requests) and there is a session running, // let's create the update request moduleSessions.updateSessionInternal(); diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java index 0ff608396..d900a25af 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleConfiguration.java @@ -46,6 +46,9 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { final static String keyRConsentRequired = "cr"; final static String keyRDropOldRequestTime = "dort"; final static String keyRCrashReporting = "crt"; + final static String keyRAutomaticSessionTracking = "ast"; + final static String keyRAutomaticViewTracking = "avt"; + final static String keyRAutomaticCrashReporting = "acr"; final static String keyRServerConfigUpdateInterval = "scui"; final static String keyRBackoffMechanism = "bom"; final static String keyRBOMAcceptedTimeout = "bom_at"; @@ -62,6 +65,7 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { final static String keyRSegmentationWhitelist = "sw"; final static String keyREventSegmentationWhitelist = "esw"; // json final static String keyRJourneyTriggerEvents = "jte"; + final static String keyRJourneyTriggerViews = "jtv"; // FLAGS boolean currentVTracking = true; @@ -71,6 +75,10 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { boolean currentVCustomEventTracking = true; boolean currentVContentZone = false; boolean currentVCrashReporting = true; + // automatic tracking flags are seeded from the local config in the constructor and then overridden by the SBS layers; they are the single source of truth for whether automatic session/view/crash tracking is active + boolean currentVAutomaticSessionTracking = true; + boolean currentVAutomaticViewTracking = false; + boolean currentVAutomaticCrashReporting = false; boolean currentVLocationTracking = true; boolean currentVRefreshContentZone = true; boolean currentVBackoffMechanism = true; @@ -88,6 +96,7 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { FilterList> currentVSegmentationFilterList = new FilterList<>(new HashSet<>(), false); FilterList>> currentVEventSegmentationFilterList = new FilterList<>(new ConcurrentHashMap<>(), false); Set currentVJourneyTriggerEvents = new HashSet<>(); + Set currentVJourneyTriggerViews = new HashSet<>(); // SERVER CONFIGURATION PARAMS Integer serverConfigUpdateInterval; // in hours @@ -108,6 +117,13 @@ class ModuleConfiguration extends ModuleBase implements ConfigurationProvider { config.countlyStore.setConfigurationProvider(this); + //seed the automatic tracking flags from the local config: it is the lowest-precedence layer. + //the SBS layers (provided -> stored -> server) override these in updateConfigVariables, giving the precedence + //server SBS > stored SBS > provided SBS > developer config + currentVAutomaticSessionTracking = !config.manualSessionControlEnabled; + currentVAutomaticViewTracking = config.enableAutomaticViewTracking; + currentVAutomaticCrashReporting = config.crashes.enableUnhandledCrashReporting; + //load the previously saved configuration loadConfigFromStorage(config.sdkBehaviorSettings); @@ -220,6 +236,9 @@ private void updateConfigVariables(@NonNull final CountlyConfig clyConfig) { currentVTracking = extractValue(keyRTracking, sb, currentVTracking, currentVTracking); currentVSessionTracking = extractValue(keyRSessionTracking, sb, currentVSessionTracking, currentVSessionTracking); currentVCrashReporting = extractValue(keyRCrashReporting, sb, currentVCrashReporting, currentVCrashReporting); + currentVAutomaticSessionTracking = extractValue(keyRAutomaticSessionTracking, sb, currentVAutomaticSessionTracking, currentVAutomaticSessionTracking); + currentVAutomaticViewTracking = extractValue(keyRAutomaticViewTracking, sb, currentVAutomaticViewTracking, currentVAutomaticViewTracking); + currentVAutomaticCrashReporting = extractValue(keyRAutomaticCrashReporting, sb, currentVAutomaticCrashReporting, currentVAutomaticCrashReporting); currentVViewTracking = extractValue(keyRViewTracking, sb, currentVViewTracking, currentVViewTracking); currentVCustomEventTracking = extractValue(keyRCustomEventTracking, sb, currentVCustomEventTracking, currentVCustomEventTracking); currentVLocationTracking = extractValue(keyRLocationTracking, sb, currentVLocationTracking, currentVLocationTracking); @@ -262,7 +281,8 @@ private void updateListingFilters() { "User Property Filter List: " + currentVUserPropertyFilterList.filterList + ", isWhitelist: " + currentVUserPropertyFilterList.isWhitelist + "\n" + "Segmentation Filter List: " + currentVSegmentationFilterList.filterList + ", isWhitelist: " + currentVSegmentationFilterList.isWhitelist + "\n" + "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist + "\n" + - "Journey Trigger Events: " + currentVJourneyTriggerEvents); + "Journey Trigger Events: " + currentVJourneyTriggerEvents + "\n" + + "Journey Trigger Views: " + currentVJourneyTriggerViews); JSONArray eventBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventBlacklist); JSONArray eventWhitelistJSARR = latestRetrievedConfiguration.optJSONArray(keyREventWhitelist); JSONArray userPropertyBlacklistJSARR = latestRetrievedConfiguration.optJSONArray(keyRUserPropertyBlacklist); @@ -272,6 +292,7 @@ private void updateListingFilters() { JSONObject eventSegmentationBlacklistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationBlacklist); JSONObject eventSegmentationWhitelistJSOBJ = latestRetrievedConfiguration.optJSONObject(keyREventSegmentationWhitelist); JSONArray journeyTriggerEventsJSARR = latestRetrievedConfiguration.optJSONArray(keyRJourneyTriggerEvents); + JSONArray journeyTriggerViewsJSARR = latestRetrievedConfiguration.optJSONArray(keyRJourneyTriggerViews); if (eventBlacklistJSARR != null) { extractFilterSetFromJSONArray(eventBlacklistJSARR, currentVEventFilterList.filterList); @@ -329,12 +350,17 @@ private void updateListingFilters() { extractFilterSetFromJSONArray(journeyTriggerEventsJSARR, currentVJourneyTriggerEvents); } + if (journeyTriggerViewsJSARR != null) { + extractFilterSetFromJSONArray(journeyTriggerViewsJSARR, currentVJourneyTriggerViews); + } + L.d("[ModuleConfiguration] updateListingFilters, current listing filters after updating: \n" + "Event Filter List: " + currentVEventFilterList.filterList + ", isWhitelist: " + currentVEventFilterList.isWhitelist + "\n" + "User Property Filter List: " + currentVUserPropertyFilterList.filterList + ", isWhitelist: " + currentVUserPropertyFilterList.isWhitelist + "\n" + "Segmentation Filter List: " + currentVSegmentationFilterList.filterList + ", isWhitelist: " + currentVSegmentationFilterList.isWhitelist + "\n" + "Event Segmentation Filter List: " + currentVEventSegmentationFilterList.filterList + ", isWhitelist: " + currentVEventSegmentationFilterList.isWhitelist + "\n" + - "Journey Trigger Events: " + currentVJourneyTriggerEvents); + "Journey Trigger Events: " + currentVJourneyTriggerEvents + "\n" + + "Journey Trigger Views: " + currentVJourneyTriggerViews); } private void extractFilterSetFromJSONArray(@Nullable JSONArray jsonArray, @NonNull Set targetSet) { @@ -389,6 +415,9 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { case keyRTracking: case keyRSessionTracking: case keyRCrashReporting: + case keyRAutomaticSessionTracking: + case keyRAutomaticViewTracking: + case keyRAutomaticCrashReporting: case keyRViewTracking: case keyRCustomEventTracking: case keyRLocationTracking: @@ -441,6 +470,7 @@ private void removeUnsupportedKeys(@NonNull JSONObject newInner) { case keyRSegmentationWhitelist: case keyRUserPropertyWhitelist: case keyRJourneyTriggerEvents: + case keyRJourneyTriggerViews: isValid = value instanceof JSONArray; break; case keyREventSegmentationBlacklist: @@ -634,6 +664,18 @@ public boolean getTrackingEnabled() { return currentVCrashReporting; } + @Override public boolean getAutomaticSessionTrackingEnabled() { + return currentVAutomaticSessionTracking; + } + + @Override public boolean getAutomaticViewTrackingEnabled() { + return currentVAutomaticViewTracking; + } + + @Override public boolean getAutomaticCrashReportingEnabled() { + return currentVAutomaticCrashReporting; + } + @Override public boolean getLocationTrackingEnabled() { return currentVLocationTracking; } @@ -689,4 +731,8 @@ public boolean getTrackingEnabled() { @Override public Set getJourneyTriggerEvents() { return currentVJourneyTriggerEvents; } + + @Override public Set getJourneyTriggerViews() { + return currentVJourneyTriggerViews; + } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleCrash.java b/sdk/src/main/java/ly/count/android/sdk/ModuleCrash.java index 949247e1a..e11fc2ddd 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleCrash.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleCrash.java @@ -24,6 +24,9 @@ public class ModuleCrash extends ModuleBase { boolean recordAllThreads = false; + //tracks whether the unhandled crash handler has already been installed, so we only wrap the global handler once + boolean unhandledCrashHandlerInstalled = false; + @Nullable Map customCrashSegments = null; @@ -196,7 +199,12 @@ void setCustomCrashSegmentsInternal(@Nullable Map segments) { } void enableCrashReporting() { + if (unhandledCrashHandlerInstalled) { + //already installed, don't wrap the global handler again + return; + } L.d("[ModuleCrash] Enabling unhandled crash reporting"); + unhandledCrashHandlerInstalled = true; //get default handler final Thread.UncaughtExceptionHandler oldHandler = Thread.getDefaultUncaughtExceptionHandler(); @@ -205,7 +213,7 @@ void enableCrashReporting() { @Override public void uncaughtException(@NonNull Thread t, @NonNull Throwable e) { L.d("[ModuleCrash] Uncaught crash handler triggered"); - if (consentProvider.getConsent(Countly.CountlyFeatureNames.crashes)) { + if (consentProvider.getConsent(Countly.CountlyFeatureNames.crashes) && configProvider.getAutomaticCrashReportingEnabled()) { String stackTrace = prepareStackTrace(e); CrashData crashData = prepareCrashData(stackTrace, false, false, null); @@ -358,9 +366,12 @@ void initFinished(@NonNull CountlyConfig config) { return; } - if (config.crashes.enableUnhandledCrashReporting) { - enableCrashReporting(); - } + // Install the uncaught-exception handler when automatic crash reporting is enabled. The flag is + // resolved through the SBS precedence chain (seeded from the developer's enableUnhandledCrashReporting, + // overridden by the server), so the server can enable it even if the developer did not. We never wrap + // the global handler while automatic crash reporting is disabled, so apps that did not enable it (and + // whose server does not enable it) keep their own handler intact, avoiding interference with other crash tools. + installUnhandledCrashHandlerIfEnabled(); //check for previous native crash dumps if (config.crashes.checkForNativeCrashDumps) { @@ -369,6 +380,22 @@ void initFinished(@NonNull CountlyConfig config) { } } + /** + * Installs the unhandled crash handler if automatic crash reporting is enabled (resolved through the SBS + * precedence chain) and it has not been installed yet. Called at init and whenever the SDK configuration + * changes, so a server that enables 'acr' at runtime starts catching crashes. + */ + private void installUnhandledCrashHandlerIfEnabled() { + if (!unhandledCrashHandlerInstalled && configProvider.getCrashReportingEnabled() && configProvider.getAutomaticCrashReportingEnabled()) { + enableCrashReporting(); + } + } + + @Override + void onSdkConfigurationChanged(@NonNull CountlyConfig config) { + installUnhandledCrashHandlerIfEnabled(); + } + @Override void halt() { diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java b/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java index 6ecce4618..26970f55e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java @@ -135,8 +135,8 @@ void changeDeviceIdWithoutMergeInternal(@NonNull String deviceId) { //update remote config_ values after id change if automatic update is enabled _cly.moduleRemoteConfig.clearAndDownloadAfterIdChange(); - if (!_cly.moduleSessions.manualSessionControlEnabled) { - //if manual session control is not enabled, end the current session + if (_cly.moduleSessions.automaticSessionTrackingEnabled()) { + //if automatic session tracking is active, end the current session _cly.moduleSessions.endSessionInternal(); // this will check consent } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java index bea8ebb67..bc7c53356 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleEvents.java @@ -199,8 +199,12 @@ public void recordEventInternal(@Nullable final String key, @Nullable Map consentChangeDelta, final bool L.d("[ModuleSessions] onConsentChanged, consentChangeDelta:[" + consentChangeDelta + "], newConsent:[" + newConsent + "], changeSource:[" + changeSource + "]"); if (consentChangeDelta.contains(Countly.CountlyFeatureNames.sessions)) { if (newConsent) { - //if consent was just given and manual sessions sessions are not enabled, start a session if we are in the foreground - if (!manualSessionControlEnabled && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + //if consent was just given and automatic session tracking is active, start a session if we are in the foreground + if (automaticSessionTrackingEnabled() && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { beginSessionInternal(); } } else { @@ -179,7 +188,7 @@ void onConsentChanged(@NonNull final List consentChangeDelta, final bool @Override void initFinished(@NonNull CountlyConfig config) { - if (!manualSessionControlEnabled && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + if (automaticSessionTrackingEnabled() && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { //start a session if we initialized in the foreground beginSessionInternal(); } @@ -193,7 +202,7 @@ void halt() { @Override void deviceIdChanged(boolean withoutMerge) { - if (!manualSessionControlEnabled && withoutMerge && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { + if (automaticSessionTrackingEnabled() && withoutMerge && _cly.config_.lifecycleObserver.LifeCycleAtleastStarted()) { L.d("[ModuleSessions] deviceIdChanged, automatic session control enabled and device id changed without merge, starting a new session"); beginSessionInternal(); } @@ -202,10 +211,10 @@ void deviceIdChanged(boolean withoutMerge) { public class Sessions { public void beginSession() { synchronized (_cly) { - L.i("[Sessions] Calling 'beginSession', manual session control enabled:[" + manualSessionControlEnabled + "]"); + L.i("[Sessions] Calling 'beginSession', automatic session tracking active:[" + automaticSessionTrackingEnabled() + "]"); - if (!manualSessionControlEnabled) { - L.w("[Sessions] 'beginSession' will be ignored since manual session control is not enabled"); + if (automaticSessionTrackingEnabled()) { + L.w("[Sessions] 'beginSession' will be ignored since automatic session tracking is active"); return; } @@ -215,10 +224,10 @@ public void beginSession() { public void updateSession() { synchronized (_cly) { - L.i("[Sessions] Calling 'updateSession', manual session control enabled:[" + manualSessionControlEnabled + "]"); + L.i("[Sessions] Calling 'updateSession', automatic session tracking active:[" + automaticSessionTrackingEnabled() + "]"); - if (!manualSessionControlEnabled) { - L.w("[Sessions] 'updateSession' will be ignored since manual session control is not enabled"); + if (automaticSessionTrackingEnabled()) { + L.w("[Sessions] 'updateSession' will be ignored since automatic session tracking is active"); return; } @@ -233,10 +242,10 @@ public void updateSession() { public void endSession() { synchronized (_cly) { - L.i("[Sessions] Calling 'endSession', manual session control enabled:[" + manualSessionControlEnabled + "]"); + L.i("[Sessions] Calling 'endSession', automatic session tracking active:[" + automaticSessionTrackingEnabled() + "]"); - if (!manualSessionControlEnabled) { - L.w("[Sessions] 'endSession' will be ignored since manual session control is not enabled"); + if (automaticSessionTrackingEnabled()) { + L.w("[Sessions] 'endSession' will be ignored since automatic session tracking is active"); return; } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java b/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java index 69d3d9b40..ee7605882 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleViews.java @@ -545,7 +545,7 @@ void consentWillChange(@NonNull List consentThatWillChange, final boolea @Override void onActivityStopped(int updatedActivityCount) { - if (autoViewTracker) { + if (configProvider.getAutomaticViewTrackingEnabled()) { //main purpose of this is handling transitions when the app is getting closed/minimised //for cases when going from one view to another we would report the duration there if (updatedActivityCount <= 0) { @@ -554,7 +554,7 @@ void onActivityStopped(int updatedActivityCount) { } } - if (updatedActivityCount <= 0 && (autoViewTracker || restartManualViews)) { + if (updatedActivityCount <= 0 && (configProvider.getAutomaticViewTrackingEnabled() || restartManualViews)) { //if we go to the background, stop all running views stopRunningViewsAndSend(); } @@ -562,8 +562,8 @@ void onActivityStopped(int updatedActivityCount) { @Override void onActivityStarted(Activity activity, int updatedActivityCount) { - //automatic view tracking - if (autoViewTracker) { + //automatic view tracking, resolved through the SBS precedence chain (server can override the local setting) + if (configProvider.getAutomaticViewTrackingEnabled()) { if (!isActivityInExceptionList(activity)) { String usedActivityName = "NULL ACTIVITY"; @@ -589,7 +589,7 @@ void onActivityStarted(Activity activity, int updatedActivityCount) { } } - if (updatedActivityCount == 1 && (autoViewTracker || restartManualViews)) { + if (updatedActivityCount == 1 && (configProvider.getAutomaticViewTrackingEnabled() || restartManualViews)) { startStoppedViews(); } } From 0ef457e5a6464729383009d7ad273d5aca172031 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 23 Jun 2026 14:33:40 +0300 Subject: [PATCH 05/24] fix: temp id leak on ratings --- CHANGELOG.md | 1 + .../count/android/sdk/ModuleRatingsTests.java | 46 +++++++++++++++++++ .../ly/count/android/sdk/ModuleRatings.java | 8 ++++ 3 files changed, 55 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd9af3d0..e1d8cf11a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ * Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. +* Mitigated an issue where the rating feedback popup request could be sent while in temporary device ID mode, creating a `CLYTemporaryDeviceID` user on the server. ## 26.1.3 * Added gradle configuration cache support to upload symbols plugin. diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRatingsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRatingsTests.java index f216154dd..97fe9fc7c 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRatingsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleRatingsTests.java @@ -1,8 +1,11 @@ package ly.count.android.sdk; +import android.app.Activity; import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import org.json.JSONException; @@ -186,6 +189,49 @@ public void serverConfig_recordManualRating_maxValueSize() throws JSONException ModuleEventsTests.validateEventInRQ(ModuleFeedback.RATING_EVENT_KEY, ratingSegmentation, 1); } + /** + * Reproduction + regression test for the temporary-device-ID leak. + * The rating feedback popup performs an immediate request to "/o/feedback/widget" that carries + * "device_id". While in temporary device ID mode this must not be sent, otherwise a + * "CLYTemporaryDeviceID" user is created on the server. + * + * 1- Init with a capturing immediate-request generator and a real (custom) device ID + * 2- Enter temporary device ID mode + * 3- Call showFeedbackPopupInternal with valid widget id, consent and a (mock) Activity + * 4- Verify no immediate request is issued (no endpoint captured) + * 5- Verify the developer callback reports that temporary device ID mode blocked it + */ + @Test + public void showFeedbackPopup_blockedInTemporaryDeviceIDMode() { + final List capturedEndpoints = new ArrayList<>(); + + CountlyConfig config = new CountlyConfig(TestUtils.getContext(), "appkey", "http://test.count.ly").setDeviceId("1234").setLoggingEnabled(true); + config.disableHealthCheck(); + config.immediateRequestGenerator = new ImmediateRequestGenerator() { + @Override public ImmediateRequestI CreateImmediateRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> capturedEndpoints.add(customEndpoint); + } + + @Override public ImmediateRequestI CreatePreflightRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> capturedEndpoints.add(customEndpoint); + } + }; + + Countly countly = new Countly().init(config); + countly.deviceId().enableTemporaryIdMode(); + Assert.assertTrue(countly.moduleDeviceId.isTemporaryIdEnabled()); + + // ignore anything that init / mode switch may have produced + capturedEndpoints.clear(); + + final String[] callbackMessage = { null }; + countly.moduleRatings.showFeedbackPopupInternal("widget123", "Close", mock(Activity.class), error -> callbackMessage[0] = error); + + Assert.assertEquals(0, capturedEndpoints.size()); + Assert.assertNotNull(callbackMessage[0]); + Assert.assertTrue(callbackMessage[0].contains("temporary device ID mode")); + } + private Map prepareRatingSegmentation(String rating, String widgetId, String email, String comment, boolean userCanBeContacted) { Map segm = new ConcurrentHashMap<>(); segm.put("platform", "android"); 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 cc9ea10cc..28f82a713 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java @@ -457,6 +457,14 @@ synchronized void showFeedbackPopupInternal(@Nullable final String widgetId, @Nu return; } + if (deviceIdProvider.isTemporaryIdEnabled()) { + L.e("[ModuleRatings] feedback popup can't be shown when in temporary device ID mode"); + if (devCallback != null) { + devCallback.callback("[ModuleRatings] feedback popup can't be shown when in temporary device ID mode"); + } + return; + } + //check the device type final boolean deviceIsPhone; final boolean deviceIsTablet; From 8a855849571d5fa67ce3f53c154f1b3da6c3ceef Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 23 Jun 2026 15:18:20 +0300 Subject: [PATCH 06/24] feat: test for re-fetch contents after temp id --- .../count/android/sdk/ModuleContentTests.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) 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 599d9008b..ba2cdf809 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -255,4 +255,62 @@ public void onActivityDestroyed_clearsSeededActivity() throws Exception { mc.onActivityDestroyed(seeded); Assert.assertNull(getCurrentActivity(mc)); } + + private boolean readShouldFetchContents(ModuleContent module) throws Exception { + java.lang.reflect.Field field = ModuleContent.class.getDeclaredField("shouldFetchContents"); + field.setAccessible(true); + return (boolean) field.get(module); + } + + /** + * Validation: the server-driven content zone (ecz) keeps working across a temporary-device-ID + * toggle. When the SDK enters temporary mode the content zone is torn down, and when a real + * device ID is assigned the server config is re-fetched and the content zone resumes. + * + * 1- Init with an immediate-request generator that returns ecz=true for the server config (/o/sdk) + * 2- Verify the content zone is armed after the ecz=true server config is applied + * 3- Enter temporary device ID mode and verify the content zone is torn down + * 4- Leave temporary mode with a real device ID and verify the content zone resumes + * (the deferred-on-exit server config re-fetch re-applies ecz=true) + */ + @Test + public void contentZone_resumesAfterTemporaryDeviceIDToggle() throws Exception { + final String serverConfigWithEcz = new ServerConfigBuilder().contentZone(true).build(); + + CountlyConfig config = new CountlyConfig(TestUtils.getContext(), "appkey", "http://test.count.ly").setDeviceId("1234").setLoggingEnabled(true); + config.disableHealthCheck(); + config.immediateRequestGenerator = new ImmediateRequestGenerator() { + @Override public ImmediateRequestI CreateImmediateRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> { + if ("/o/sdk".equals(customEndpoint)) { + try { + callback.callback(new JSONObject(serverConfigWithEcz)); + } catch (JSONException e) { + callback.callback(null); + } + } else { + // content and any other immediate requests: no payload needed for this test + callback.callback(null); + } + }; + } + + @Override public ImmediateRequestI CreatePreflightRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> callback.callback(null); + } + }; + + mCountly = new Countly().init(config); + + // ecz=true was applied at init, so the content zone should be armed + Assert.assertTrue(readShouldFetchContents(mCountly.moduleContent)); + + // entering temporary device ID mode tears the content zone down + mCountly.deviceId().enableTemporaryIdMode(); + Assert.assertFalse(readShouldFetchContents(mCountly.moduleContent)); + + // leaving temporary mode with a real ID re-fetches the server config (ecz=true) and resumes the zone + mCountly.deviceId().changeWithoutMerge("real_user_after_temp"); + Assert.assertTrue(readShouldFetchContents(mCountly.moduleContent)); + } } From c6b91ad6bc12f0d7c5f49316042eb1dcaad809bf Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 23 Jun 2026 16:09:37 +0300 Subject: [PATCH 07/24] feat: a config switch to disable all webview based UI --- CHANGELOG.md | 1 + .../count/android/sdk/ModuleContentTests.java | 22 +++++++++++++++++++ .../ly/count/android/sdk/CountlyConfig.java | 14 ++++++++++++ .../ly/count/android/sdk/ModuleContent.java | 15 +++++++++++++ .../ly/count/android/sdk/ModuleFeedback.java | 8 +++++++ .../ly/count/android/sdk/ModuleRatings.java | 8 +++++++ 6 files changed, 68 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddd9af3d0..782a3dd20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## X.X.X * Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. +* Added a new configuration option "setWebViewEnabled(boolean)" to enable or disable all WebView-based UI in the SDK, including the content overlay, feedback widgets, and the rating popup. * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. 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 599d9008b..ea98a25d3 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -112,6 +112,28 @@ public void previewContent_validContentId() { Assert.assertTrue(request.contains("preview=true")); } + /** + * The global setWebViewEnabled(false) switch should disable the content feature, + * even when content consent is granted. + */ + @Test + public void previewContent_webViewDisabledByConfig() { + CountlyConfig config = TestUtils.createBaseConfig(); + config.setRequiresConsent(true); + config.setConsentEnabled(new String[] { Countly.CountlyFeatureNames.content }); + config.setWebViewEnabled(false); + config.disableHealthCheck(); + config.immediateRequestGenerator = createCapturingIRGenerator(); + + mCountly = new Countly(); + mCountly.init(config); + mCountly.moduleContent.countlyTimer = null; + capturedRequests.clear(); + + mCountly.contents().previewContent("test_content_123"); + Assert.assertEquals(0, capturedRequests.size()); + } + /** * Without content consent, no request should be made */ 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 9a8ab46d0..224558734 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java @@ -212,6 +212,7 @@ public class CountlyConfig { // If set to true, immediate requests will use serial AsyncTask executor instead of the thread pool boolean useSerialExecutor = false; WebViewDisplayOption webViewDisplayOption = WebViewDisplayOption.IMMERSIVE; + boolean webViewEnabled = true; // If set to true, request queue cleaner will remove all overflow at once instead of gradually (loop limited) removing boolean disableGradualRequestCleaner = false; @@ -1109,6 +1110,19 @@ public synchronized CountlyConfig setWebviewDisplayOption(WebViewDisplayOption d return this; } + /** + * Enable or disable all WebView-based UI in the SDK. When disabled, no WebView is ever + * created or shown for any feature. This covers the Content feature overlay, Feedback + * Widgets (surveys, NPS, and rating widgets), and the rating popup. Enabled by default. + * + * @param enabled false to disable all WebView triggers in the SDK + * @return Returns the same config object for convenient linking + */ + public synchronized CountlyConfig setWebViewEnabled(boolean enabled) { + this.webViewEnabled = enabled; + return this; + } + /** * To select the legacy AsyncTask.execute (serial executor) or * instead executeOnExecutor(THREAD_POOL_EXECUTOR) 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 b525c28c4..e882a5e89 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -25,6 +25,7 @@ public class ModuleContent extends ModuleBase { private boolean isCurrentlyInContentZone = false; private boolean isCurrentlyRetrying = false; private int zoneTimerInterval; + private final boolean webViewEnabled; private final ContentCallback globalContentCallback; private int waitForDelay = 0; int CONTENT_START_DELAY_MS = 4000; // 4 seconds @@ -42,7 +43,11 @@ public class ModuleContent extends ModuleBase { contentInterface = new Content(); countlyTimer = new CountlyTimer(); zoneTimerInterval = config.content.zoneTimerInterval; + webViewEnabled = config.webViewEnabled; globalContentCallback = config.content.globalContentCallback; + if (!webViewEnabled) { + L.i("[ModuleContent] WebView is disabled via configuration, content overlay will not be shown"); + } } @Override @@ -179,6 +184,11 @@ void fetchContentsInternal(@NonNull String[] categories, @Nullable Runnable call } private void enterContentZoneInternal(@Nullable String[] categories, final int initialDelayMS, @Nullable Runnable callbackOnFailure) { + if (!webViewEnabled) { + L.d("[ModuleContent] enterContentZoneInternal, WebView is disabled via configuration, skipping"); + return; + } + if (!consentProvider.getConsent(Countly.CountlyFeatureNames.content)) { L.w("[ModuleContent] enterContentZoneInternal, Consent is not granted, skipping"); return; @@ -496,6 +506,11 @@ private void exitContentZoneInternal() { void previewContentInternal(@NonNull String contentId) { L.d("[ModuleContent] previewContentInternal, contentId: [" + contentId + "]"); + if (!webViewEnabled) { + L.d("[ModuleContent] previewContentInternal, WebView is disabled via configuration, skipping"); + return; + } + if (!consentProvider.getConsent(Countly.CountlyFeatureNames.content)) { L.w("[ModuleContent] previewContentInternal, Consent is not granted, skipping"); return; 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 0c28d8e98..15386aae8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java @@ -248,6 +248,14 @@ void presentFeedbackWidgetInternal(@Nullable final CountlyFeedbackWidget widgetI return; } + if (!_cly.config_.webViewEnabled) { + L.w("[ModuleFeedback] presentFeedbackWidgetInternal, WebView is disabled via configuration, skipping"); + if (devCallback != null) { + devCallback.onFinished("WebView is disabled via configuration"); + } + return; + } + if (!consentProvider.getConsent(Countly.CountlyFeatureNames.feedback)) { if (devCallback != null) { devCallback.onFinished("Consent is not granted"); 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 cc9ea10cc..6d1dfd445 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java @@ -450,6 +450,14 @@ synchronized void showFeedbackPopupInternal(@Nullable final String widgetId, @Nu return; } + if (!_cly.config_.webViewEnabled) { + L.w("[ModuleRatings] showFeedbackPopupInternal, WebView is disabled via configuration, skipping"); + if (devCallback != null) { + devCallback.callback("WebView is disabled via configuration"); + } + return; + } + if (!consentProvider.getConsent(Countly.CountlyFeatureNames.starRating)) { if (devCallback != null) { devCallback.callback("Consent is not granted"); From 5755804110750b1b3251968c2f6888dbe0af7c0a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 23 Jun 2026 16:14:33 +0300 Subject: [PATCH 08/24] feat: more checks --- .../java/ly/count/android/sdk/ModuleContent.java | 5 +++++ .../java/ly/count/android/sdk/ModuleFeedback.java | 12 ++++++++++++ 2 files changed, 17 insertions(+) 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 e882a5e89..51ffdb767 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -293,6 +293,11 @@ public void run() { private void showContentOverlay(@NonNull Activity activity, @NonNull Map placementCoordinates) { L.d("[ModuleContent] showContentOverlay, showing content overlay on [" + activity.getClass().getSimpleName() + "]"); + if (!webViewEnabled) { + L.w("[ModuleContent] showContentOverlay, WebView is disabled via configuration, skipping"); + return; + } + // Do not show content if feedback widget is currently showing if (_cly.moduleFeedback != null && _cly.moduleFeedback.feedbackOverlay != null) { shouldFetchContents = true; 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 15386aae8..69d8f0a8b 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java @@ -361,6 +361,10 @@ public void run() { } private void showFeedbackWidget(Context context, CountlyFeedbackWidget widgetInfo, String closeButtonText, FeedbackCallback devCallback, String url) { + if (!_cly.config_.webViewEnabled) { + L.w("[ModuleFeedback] showFeedbackWidget, WebView is disabled via configuration, skipping"); + return; + } ModuleRatings.RatingDialogWebView webView = new ModuleRatings.RatingDialogWebView(context); webView.getSettings().setJavaScriptEnabled(true); webView.clearCache(true); @@ -390,6 +394,14 @@ private void showFeedbackWidget(Context context, CountlyFeedbackWidget widgetInf } private void showFeedbackWidget_newActivity(@NonNull Context context, String url, CountlyFeedbackWidget widgetInfo, FeedbackCallback devCallback) { + if (!_cly.config_.webViewEnabled) { + L.w("[ModuleFeedback] showFeedbackWidget_newActivity, WebView is disabled via configuration, skipping"); + if (devCallback != null) { + devCallback.onFinished("WebView is disabled via configuration"); + } + return; + } + Activity activity = null; if (context instanceof Activity && !((Activity) context).isFinishing()) { activity = (Activity) context; From 6c5e047156044e053e2012335d42a9f93a903d9f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 24 Jun 2026 16:18:37 +0300 Subject: [PATCH 09/24] feat: rename config --- CHANGELOG.md | 2 +- .../java/ly/count/android/sdk/ModuleContentTests.java | 4 ++-- .../main/java/ly/count/android/sdk/CountlyConfig.java | 11 +++++------ 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 782a3dd20..ec85bf27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ ## X.X.X * Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. -* Added a new configuration option "setWebViewEnabled(boolean)" to enable or disable all WebView-based UI in the SDK, including the content overlay, feedback widgets, and the rating popup. +* Added a new configuration option "disableWebView()" to disable all WebView-based UI in the SDK, including the content overlay, feedback widgets, and the rating popup. * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. 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 ea98a25d3..560811999 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -113,7 +113,7 @@ public void previewContent_validContentId() { } /** - * The global setWebViewEnabled(false) switch should disable the content feature, + * The global disableWebView() switch should disable the content feature, * even when content consent is granted. */ @Test @@ -121,7 +121,7 @@ public void previewContent_webViewDisabledByConfig() { CountlyConfig config = TestUtils.createBaseConfig(); config.setRequiresConsent(true); config.setConsentEnabled(new String[] { Countly.CountlyFeatureNames.content }); - config.setWebViewEnabled(false); + config.disableWebView(); config.disableHealthCheck(); config.immediateRequestGenerator = createCapturingIRGenerator(); 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 224558734..bb98bc5e4 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java @@ -1111,15 +1111,14 @@ public synchronized CountlyConfig setWebviewDisplayOption(WebViewDisplayOption d } /** - * Enable or disable all WebView-based UI in the SDK. When disabled, no WebView is ever - * created or shown for any feature. This covers the Content feature overlay, Feedback - * Widgets (surveys, NPS, and rating widgets), and the rating popup. Enabled by default. + * Disable all WebView-based UI in the SDK. When called, no WebView is ever created or shown + * for any feature. This covers the Content feature overlay, Feedback Widgets (surveys, NPS, + * and rating widgets), and the rating popup. WebView UI is enabled by default. * - * @param enabled false to disable all WebView triggers in the SDK * @return Returns the same config object for convenient linking */ - public synchronized CountlyConfig setWebViewEnabled(boolean enabled) { - this.webViewEnabled = enabled; + public synchronized CountlyConfig disableWebView() { + this.webViewEnabled = false; return this; } From 2c730104b03cfd28787497359bbeabaf1895071e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Thu, 25 Jun 2026 10:11:59 +0300 Subject: [PATCH 10/24] feat: config for disabling logging in production builds --- CHANGELOG.md | 1 + .../count/android/sdk/CountlyConfigTests.java | 1 + .../ly/count/android/sdk/ModuleLogTests.java | 134 ++++++++++++++++++ .../java/ly/count/android/sdk/Countly.java | 33 +++++ .../ly/count/android/sdk/CountlyConfig.java | 16 +++ .../main/java/ly/count/android/sdk/Utils.java | 17 +++ 6 files changed, 202 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d8cf11a..e8bf2837e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ## X.X.X * Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. +* Added a new config option "disableSDKLoggingInProduction()" that keeps the SDK's console logging disabled in production (non-debuggable) builds, even when logging is enabled. * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. * Mitigated an issue where the rating feedback popup request could be sent while in temporary device ID mode, creating a `CLYTemporaryDeviceID` user on the server. 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 63c58dfb3..39d24c1be 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyConfigTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyConfigTests.java @@ -269,6 +269,7 @@ void assertDefaultValues(CountlyConfig config, boolean includeConstructorValues) Assert.assertNull(config.starRatingTextMessage); Assert.assertNull(config.starRatingTextTitle); Assert.assertFalse(config.loggingEnabled); + Assert.assertFalse(config.disableSDKLoggingInProduction); Assert.assertFalse(config.crashes.enableUnhandledCrashReporting); Assert.assertFalse(config.enableAutomaticViewTracking); Assert.assertFalse(config.autoTrackingUseShortName); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleLogTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleLogTests.java index 8246ca131..70c9e3e47 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleLogTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleLogTests.java @@ -1,11 +1,18 @@ package ly.count.android.sdk; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.pm.ApplicationInfo; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.ArrayList; +import java.util.List; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.mock; @RunWith(AndroidJUnit4.class) @@ -56,4 +63,131 @@ public void runAllLogCallsWhileDisabled() { public void checkListenerSimple() { } + + /** + * Build a context whose reported debuggable state we control, so the production-build + * detection can be exercised without an actual release build. The wrapper only overrides + * the application-info flags; everything else (including getApplicationContext used during + * init) delegates to the real instrumentation context. + */ + private Context contextWithDebuggable(boolean debuggable) { + Context base = TestUtils.getContext(); + final ApplicationInfo info = new ApplicationInfo(base.getApplicationInfo()); + if (debuggable) { + info.flags |= ApplicationInfo.FLAG_DEBUGGABLE; + } else { + info.flags &= ~ApplicationInfo.FLAG_DEBUGGABLE; + } + + return new ContextWrapper(base) { + @Override + public ApplicationInfo getApplicationInfo() { + return info; + } + }; + } + + private CountlyConfig configFor(Context context, boolean disableInProduction, boolean loggingEnabled) { + CountlyConfig config = new CountlyConfig(context, "appkey", "http://test.count.ly") + .setDeviceId("1234") + .setLoggingEnabled(loggingEnabled); + if (disableInProduction) { + config.disableSDKLoggingInProduction(); + } + return config; + } + + /** + * The helper that backs the production detection reports the host build correctly: + * a debuggable context is reported debuggable, a release-flavor context is not. + */ + @Test + public void isAppInDebuggableMode_reflectsApplicationFlags() { + assertTrue(Utils.isAppInDebuggableMode(contextWithDebuggable(true))); + assertFalse(Utils.isAppInDebuggableMode(contextWithDebuggable(false))); + // the raw instrumentation context is a debuggable test build + assertTrue(Utils.isAppInDebuggableMode(TestUtils.getContext())); + } + + /** + * Production build + flag enabled: console logging is forced off even though it was + * requested at init, and a later runtime setLoggingEnabled(true) can not turn it back on. + */ + @Test + public void productionBuild_flagOn_forcesConsoleLoggingOff() { + Countly countly = new Countly(); + countly.init(configFor(contextWithDebuggable(false), true, true)); + + assertFalse("logging requested at init must stay off in production", countly.isLoggingEnabled()); + + // runtime attempt to re-enable is also blocked (single chokepoint) + countly.setLoggingEnabled(true); + assertFalse("runtime re-enable must stay off in production", countly.isLoggingEnabled()); + + // explicitly disabling still works and is idempotent + countly.setLoggingEnabled(false); + assertFalse(countly.isLoggingEnabled()); + } + + /** + * Production build but the flag is left at its safe default: logging behaves normally, + * proving the suppression is opt-in only and does not change existing behavior. + */ + @Test + public void productionBuild_flagOff_loggingUnaffected() { + Countly countly = new Countly(); + countly.init(configFor(contextWithDebuggable(false), false, true)); + + assertTrue("default flag must not suppress logging", countly.isLoggingEnabled()); + + countly.setLoggingEnabled(false); + assertFalse(countly.isLoggingEnabled()); + countly.setLoggingEnabled(true); + assertTrue("runtime re-enable must work when flag is off", countly.isLoggingEnabled()); + } + + /** + * Debug build + flag enabled: the flag only targets production, so a debuggable build + * keeps logging fully functional at init and at runtime. + */ + @Test + public void debugBuild_flagOn_loggingStaysEnabled() { + Countly countly = new Countly(); + countly.init(configFor(contextWithDebuggable(true), true, true)); + + assertTrue("debug build must keep logging on despite the flag", countly.isLoggingEnabled()); + + countly.setLoggingEnabled(false); + assertFalse(countly.isLoggingEnabled()); + countly.setLoggingEnabled(true); + assertTrue("runtime re-enable must work in debug builds", countly.isLoggingEnabled()); + } + + /** + * Production suppression targets console (logcat) output only: a developer-provided + * log listener keeps receiving SDK logs even while console logging is forced off. + */ + @Test + public void productionBuild_flagOn_logListenerStillReceivesLogs() { + final List received = new ArrayList<>(); + ModuleLog.LogCallback listener = (logMessage, logLevel) -> received.add(logMessage); + + Countly countly = new Countly(); + countly.init(configFor(contextWithDebuggable(false), true, true).setLogListener(listener)); + + assertFalse(countly.isLoggingEnabled()); + + // the SDK also logs asynchronously, so match a unique marker rather than an exact count + String marker = "a production error marker that must still reach the listener"; + countly.L.e(marker); + + boolean delivered = false; + for (String message : received) { + if (message.contains(marker)) { + delivered = true; + break; + } + } + assertTrue("listener must receive the log even with console output off", delivered); + } } 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 d659ad549..fafec43fc 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -157,6 +157,9 @@ private static class SingletonHolder { //d - regular SDK internals //v - spammy SDK internals private boolean enableLogging_; + // when true, console logging is kept off because the host app is a production build + // and the SDK was configured to disable logging in production + private boolean loggingForcedOffForProduction = false; Context context_; //Internal modules for functionality grouping @@ -276,6 +279,9 @@ public synchronized Countly init(CountlyConfig config) { throw new IllegalArgumentException("Can't init SDK with 'null' config"); } + //determine whether console logging must stay off for production builds before any logging call + loggingForcedOffForProduction = shouldForceLoggingOffForProduction(config); + //enable logging if (config.loggingEnabled) { //enable logging before any potential logging calls @@ -983,6 +989,7 @@ public synchronized void halt() { moduleContent = null; // Reset configuration values that may have been changed during runtime + loggingForcedOffForProduction = false; EVENT_QUEUE_SIZE_THRESHOLD = 100; COUNTLY_SDK_VERSION_STRING = DEFAULT_COUNTLY_SDK_VERSION_STRING; @@ -1180,10 +1187,36 @@ public void onRegistrationId(String registrationId, CountlyMessagingProvider pro } public void setLoggingEnabled(final boolean enableLogging) { + if (enableLogging && loggingForcedOffForProduction) { + //logging is suppressed for production builds, keep console output off + enableLogging_ = false; + return; + } enableLogging_ = enableLogging; L.d("Enabling logging"); } + /** + * Decide whether the SDK must keep console logging off because the host app is a + * production (non-debuggable) build and logging-in-production was disabled in config. + * + * @param config the provided init configuration + * @return true if console logging must be forced off + */ + private boolean shouldForceLoggingOffForProduction(@NonNull CountlyConfig config) { + if (!config.disableSDKLoggingInProduction) { + return false; + } + + Context context = config.context != null ? config.context : config.application; + if (context == null) { + //without a context we can not tell the build type, so do not force anything off + return false; + } + + return !Utils.isAppInDebuggableMode(context); + } + /** * To add new header key/value pairs or override existing ones. * A null or empty map is ignored. Null or empty keys, as well as null values, are ignored. 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 9a8ab46d0..030a6e9bd 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java @@ -111,6 +111,8 @@ public class CountlyConfig { protected boolean loggingEnabled = false; + protected boolean disableSDKLoggingInProduction = false; + protected boolean enableAutomaticViewTracking = false; protected boolean autoTrackingUseShortName = false; @@ -379,6 +381,20 @@ public synchronized CountlyConfig setLoggingEnabled(boolean enabled) { return this; } + /** + * Call this if you want the SDK to keep its console (logcat) logging disabled + * when the host app is built as a production (non-debuggable) build, even if + * logging was enabled through {@link #setLoggingEnabled(boolean)} or through the + * runtime call {@link Countly#setLoggingEnabled(boolean)}. + * A production build is detected as one that is not flagged debuggable in its + * application info. This only affects console output. A log listener provided + * through {@link #setLogListener(ModuleLog.LogCallback)} keeps receiving logs. + */ + public synchronized CountlyConfig disableSDKLoggingInProduction() { + this.disableSDKLoggingInProduction = true; + return this; + } + /** * Set a custom metric provider to override default device metrics. * Only the methods you override will replace the SDK defaults. diff --git a/sdk/src/main/java/ly/count/android/sdk/Utils.java b/sdk/src/main/java/ly/count/android/sdk/Utils.java index 506ed2c63..082ba9e36 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Utils.java +++ b/sdk/src/main/java/ly/count/android/sdk/Utils.java @@ -2,6 +2,7 @@ import android.app.UiModeManager; import android.content.Context; +import android.content.pm.ApplicationInfo; import android.content.res.Configuration; import android.os.Build; import android.util.Base64; @@ -158,6 +159,22 @@ public static boolean API(int version) { return Build.VERSION.SDK_INT >= version; } + /** + * Determine whether the host app is built as a debuggable build. + * A non-debuggable build is what a production/release flavor produces. + * + * @param context any context belonging to the host app + * @return true if the app is flagged debuggable, false if it is a production build + * or the debuggable state can not be determined + */ + public static boolean isAppInDebuggableMode(@NonNull Context context) { + try { + return (context.getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0; + } catch (Throwable t) { + return false; + } + } + /** * Read stream into a byte array * From d8402c0176aff0ebfeaf87d3ddec93c41e32783a Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 29 Jun 2026 16:33:46 +0300 Subject: [PATCH 11/24] feat: pn and content security --- CHANGELOG.md | 8 + .../android/sdk/ContentOverlayViewTests.java | 5 +- .../sdk/CountlyWebViewClientTests.java | 52 +++++ .../android/sdk/messaging/PushTests.java | 217 ++++++++++++++++++ .../ly/count/android/sdk/ConfigContent.java | 28 +++ .../count/android/sdk/ContentOverlayView.java | 57 ++++- .../android/sdk/CountlyWebViewClient.java | 22 ++ .../ly/count/android/sdk/ModuleContent.java | 11 +- .../ly/count/android/sdk/ModuleDeviceId.java | 7 + .../ly/count/android/sdk/ModuleFeedback.java | 3 +- .../main/java/ly/count/android/sdk/Utils.java | 28 +++ .../sdk/messaging/CountlyConfigPush.java | 37 +++ .../android/sdk/messaging/CountlyPush.java | 23 +- .../sdk/messaging/CountlyPushActivity.java | 188 +++++++++------ 14 files changed, 602 insertions(+), 84 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1d8cf11a..34b351260 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,16 @@ ## X.X.X * Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. +* Added a new push configuration option "enableAdditionalIntentRedirectionChecks()" to enable stricter validation of the notification intent's target package and class. +* Added a new content configuration option "setAllowedIntentSchemes(List)" to restrict which URI schemes content and feedback widget links may open. +* Added a new push configuration option "setAllowedIntentSchemes(List)" to restrict which URI schemes notification links may open. +* Improved the security of the content feature web view, disabled local file/content access, disallowed mixed content, enabled Safe Browsing, blocked non-HTTP(S) sub-resources, and blocked content links with dangerous schemes (file, content, javascript, jar, data). +* Improved the security of push notification click handling, null-safe and stricter package/class validation, reduced intent flags, no longer forwarding the payload to external apps, and blocking notification links with dangerous schemes (file, content, javascript, jar, data) by default. * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. * Mitigated an issue where the rating feedback popup request could be sent while in temporary device ID mode, creating a `CLYTemporaryDeviceID` user on the server. +* Mitigated an issue where the content zone did not resume after exiting temporary device ID mode even when it was enabled by the server configuration. + +* Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks", replaced with "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()". The static field is now a no-op. ## 26.1.3 * Added gradle configuration cache support to upload symbols plugin. 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 720850dbe..329c83021 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -102,7 +102,8 @@ private ContentOverlayView createOverlay(Activity activity, activity.getResources().getConfiguration().orientation, callback, onClose != null ? onClose : () -> { - } + }, + null ); } @@ -505,7 +506,7 @@ public void configs_storedCorrectly() { overlay = new ContentOverlayView( activity, portrait, landscape, Configuration.ORIENTATION_PORTRAIT, 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/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java index a1d788a2e..17db8969d 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -9,7 +9,9 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import androidx.test.platform.app.InstrumentationRegistry; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.concurrent.CountDownLatch; @@ -315,4 +317,54 @@ public void criticalResource_imageExtensions_detected() { Assert.assertEquals(1, callbackResults.size()); Assert.assertTrue(callbackResults.get(0)); } + + // ===================================== + // shouldInterceptRequest - sub-resource scheme blocking + // ===================================== + + /** + * "shouldInterceptRequest" with http(s) sub-resources should return null so they load normally. + */ + @Test + public void shouldInterceptRequest_httpAndHttps_allowed() { + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("https://example.com/photo.png", false))); + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("http://example.com/app.js", false))); + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("HTTPS://EXAMPLE.COM/x.css", true))); + } + + /** + * "shouldInterceptRequest" must block every non-http(s) scheme an attacker could use to read + * local data or run script from a content sub-resource (img/iframe/script/xhr): file://, + * content://, javascript:, data:, jar:file://, plus a file:// pointing at app-private storage. + */ + @Test + public void shouldInterceptRequest_nonWebSchemes_blocked() { + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("file:///data/data/ly.count.android.sdk/shared_prefs/secret.xml", false))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("file:///etc/hosts", false))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("content://com.app.provider/private", false))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("content://media/external/images/media/1", false))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("javascript:alert(document.cookie)", false))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("data:text/html,", false))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("jar:file:///data/app/x.apk!/a.html", false))); + // also blocked for a main-frame request, not just sub-resources + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("file:///data/data/ly.count.android.sdk/databases/countly.db", true))); + } + + private void assertBlocked(WebResourceResponse response) { + Assert.assertNotNull(response); + Assert.assertNotNull(response.getData()); + } + + /** + * With a configured scheme allow-list, sub-resources follow allow-list mode: only listed schemes + * load (the default denylist is omitted), everything else is blocked. + */ + @Test + public void shouldInterceptRequest_allowlistMode() { + CountlyWebViewClient allowlisted = new CountlyWebViewClient(new HashSet<>(Arrays.asList("https"))); + Assert.assertNull(allowlisted.shouldInterceptRequest(null, fakeRequest("https://example.com/a.png", false))); + assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("http://example.com/a.png", false))); // not listed + assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("market://details?id=x", false))); // not listed + assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("file:///etc/hosts", false))); // not listed + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java index b49b4e3a5..802a8466a 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java @@ -1,9 +1,18 @@ package ly.count.android.sdk.messaging; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import androidx.test.core.app.ApplicationProvider; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import ly.count.android.sdk.Countly; import org.junit.After; import org.junit.Assert; @@ -50,4 +59,212 @@ public void decodeMessage() { Assert.assertEquals("Button 2", buttons.get(1).title()); Assert.assertEquals("https://www.google222.com", buttons.get(1).link().toString()); } + + private static final String OWN_PKG = "com.example.app"; + private static final String OWN_CLASS = "com.example.app.MainActivity"; + + private ComponentName comp(String pkg, String cls) { + return new ComponentName(pkg, cls); + } + + private ArrayList list(String... values) { + return new ArrayList<>(Arrays.asList(values)); + } + + /** + * A null component (implicit intent) cannot be validated and must not be trusted. + * This also guards against the previous NPE on intent.getComponent(). + */ + @Test + public void isComponentTrusted_nullComponent_notTrusted() { + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(null, list(OWN_CLASS), list(OWN_CLASS), OWN_PKG)); + } + + /** + * An exact package match plus an exact (fully-qualified) class match is trusted. + */ + @Test + public void isComponentTrusted_exactPackageAndClass_trusted() { + Assert.assertTrue(CountlyPushActivity.isComponentTrusted(comp(OWN_PKG, OWN_CLASS), new ArrayList<>(), list(OWN_CLASS), OWN_PKG)); + } + + /** + * The own package is always allowed, but the class must still be listed exactly: an + * own-package class that is not in the class allow-list is not trusted. + */ + @Test + public void isComponentTrusted_ownPackageButUnlistedClass_notTrusted() { + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(comp(OWN_PKG, OWN_CLASS), new ArrayList<>(), new ArrayList<>(), OWN_PKG)); + } + + /** + * A foreign package is never trusted, regardless of the class. + */ + @Test + public void isComponentTrusted_foreignPackage_notTrusted() { + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(comp("com.attacker", OWN_CLASS), new ArrayList<>(), list(OWN_CLASS), OWN_PKG)); + } + + /** + * Matching is exact: neither a sub-package nor a deceptive sibling prefix of an allowed + * package is trusted. + */ + @Test + public void isComponentTrusted_subOrPrefixPackage_notTrusted() { + ArrayList pkgs = list("com.example.app"); + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(comp("com.example.app.sub", "com.example.app.sub.A"), pkgs, list("com.example.app.sub.A"), OWN_PKG)); + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(comp("com.example.appEvil", "com.example.appEvil.A"), pkgs, list("com.example.appEvil.A"), OWN_PKG)); + } + + /** + * Matching is exact: a short (non-qualified) class name or a deceptive suffix does not match + * the fully-qualified target class; only the exact FQN matches. + */ + @Test + public void isComponentTrusted_shortOrSuffixClass_notTrusted() { + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(comp(OWN_PKG, OWN_CLASS), new ArrayList<>(), list("MainActivity"), OWN_PKG)); + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(comp(OWN_PKG, "com.evil.NotMainActivity"), new ArrayList<>(), list("MainActivity"), OWN_PKG)); + Assert.assertTrue(CountlyPushActivity.isComponentTrusted(comp(OWN_PKG, OWN_CLASS), new ArrayList<>(), list(OWN_CLASS), OWN_PKG)); + } + + /** + * An allowed package other than the own package, with its class listed exactly, is trusted. + */ + @Test + public void isComponentTrusted_allowedForeignPackageExactClass_trusted() { + Assert.assertTrue(CountlyPushActivity.isComponentTrusted(comp("com.partner", "com.partner.Entry"), list("com.partner"), list("com.partner.Entry"), OWN_PKG)); + } + + /** + * Null allow-lists must not crash; with no class list nothing is trusted (a class match is + * always required), and a foreign package stays untrusted. + */ + @Test + public void isComponentTrusted_nullAllowLists_noCrash() { + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(comp(OWN_PKG, OWN_CLASS), null, null, OWN_PKG)); + Assert.assertFalse(CountlyPushActivity.isComponentTrusted(comp("com.attacker", "com.attacker.Evil"), null, null, OWN_PKG)); + } + + /** + * Default (no allow-list): http(s) and legitimate deep-link schemes are allowed. + */ + @Test + public void isLinkSchemeAllowed_defaultAllowsWebAndDeepLinks() { + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("https://countly.com/x"), null)); + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("http://example.com"), null)); + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("myapp://deep/link"), new HashSet<>())); + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("market://details?id=com.x"), null)); + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("tel:+1234567890"), null)); + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("mailto:a@b.com"), null)); + } + + /** + * Default (no allow-list): local-data and script schemes and a null/missing scheme are blocked. + */ + @Test + public void isLinkSchemeAllowed_defaultBlocksLocalAndScriptSchemes() { + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("file:///data/data/app/shared_prefs/secret.xml"), null)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("content://app.provider/secret"), null)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("javascript:alert(1)"), null)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("jar:file:///x.apk!/a.html"), null)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("data:text/html,"), null)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("FILE:///x"), null)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(null, null)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("/no/scheme/path"), null)); + } + + /** + * Allow-list mode: only listed schemes pass (others blocked even if normally allowed), and an + * explicitly listed otherwise-dangerous scheme is honored. + */ + @Test + public void isLinkSchemeAllowed_allowlistRestricts() { + Set allow = new HashSet<>(Arrays.asList("https", "myapp")); + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("https://x.com"), allow)); + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("myapp://x"), allow)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("http://x.com"), allow)); // not listed + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("market://x"), allow)); // not listed + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("file:///x"), allow)); // not listed + // an explicitly allow-listed scheme is honored even if it is otherwise dangerous + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("content://x"), new HashSet<>(Arrays.asList("content")))); + } + + // ---- validatePushIntent: the performPushAction guards (R1-R5) ---- + + private Context ctx() { + return ApplicationProvider.getApplicationContext(); + } + + // An explicit intent at a component declared in our own (merged) manifest -> resolves to own package. + private Intent ownTargetInner() { + return new Intent(ctx(), CountlyPushActivity.class); + } + + private Intent activityIntentWith(Intent inner) { + Intent act = new Intent(); + if (inner != null) { + act.putExtra(CountlyPush.EXTRA_INTENT, inner); + } + return act; + } + + /** R1: a push activity intent with no inner EXTRA_INTENT is rejected. */ + @Test + public void validatePushIntent_nullInner_returnsNull() { + Assert.assertNull(CountlyPushActivity.validatePushIntent(ctx(), new Intent(), null, ctx().getPackageName())); + } + + /** R4: an inner intent whose target is not our own package is rejected. */ + @Test + public void validatePushIntent_crossAppTarget_returnsNull() { + Intent act = activityIntentWith(new Intent(Intent.ACTION_VIEW, Uri.parse("https://example.com"))); + act.putExtra(CountlyPush.ADDITIONAL_INTENT_REDIRECTION_CHECKS, false); + Assert.assertNull(CountlyPushActivity.validatePushIntent(ctx(), act, null, ctx().getPackageName())); + } + + /** R2: URI-grant flags on the inner intent are stripped, and the intent is returned (API 26+). */ + @Test + public void validatePushIntent_uriGrantFlags_strippedAndReturned() { + Intent inner = ownTargetInner(); + inner.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + Intent act = activityIntentWith(inner); + act.putExtra(CountlyPush.ADDITIONAL_INTENT_REDIRECTION_CHECKS, false); + Intent result = CountlyPushActivity.validatePushIntent(ctx(), act, null, ctx().getPackageName()); + Assert.assertNotNull(result); + Assert.assertEquals(0, result.getFlags() & Intent.FLAG_GRANT_READ_URI_PERMISSION); + Assert.assertEquals(0, result.getFlags() & Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + } + + /** R3: a calling activity from a foreign package is rejected. */ + @Test + public void validatePushIntent_untrustedCaller_returnsNull() { + Intent act = activityIntentWith(ownTargetInner()); + act.putExtra(CountlyPush.ADDITIONAL_INTENT_REDIRECTION_CHECKS, false); + ComponentName foreignCaller = new ComponentName("com.attacker", "com.attacker.Evil"); + Assert.assertNull(CountlyPushActivity.validatePushIntent(ctx(), act, foreignCaller, ctx().getPackageName())); + } + + /** Happy path: own-package target with the additional checks disabled returns the inner intent. */ + @Test + public void validatePushIntent_ownTargetChecksDisabled_returnsIntent() { + Intent act = activityIntentWith(ownTargetInner()); + act.putExtra(CountlyPush.ADDITIONAL_INTENT_REDIRECTION_CHECKS, false); + Assert.assertNotNull(CountlyPushActivity.validatePushIntent(ctx(), act, null, ctx().getPackageName())); + } + + /** R5 default-true: missing flag -> checks run -> own-package target with no allow-list is rejected. */ + @Test + public void validatePushIntent_defaultChecksNoAllowList_returnsNull() { + Intent act = activityIntentWith(ownTargetInner()); // no ADDITIONAL_INTENT_REDIRECTION_CHECKS extra + Assert.assertNull(CountlyPushActivity.validatePushIntent(ctx(), act, null, ctx().getPackageName())); + } + + /** R5 pass: own-package target whose class is exactly allow-listed is returned. */ + @Test + public void validatePushIntent_additionalChecksAllowlisted_returnsIntent() { + Intent act = activityIntentWith(ownTargetInner()); + act.putStringArrayListExtra(CountlyPush.ALLOWED_CLASS_NAMES, new ArrayList<>(Arrays.asList(CountlyPushActivity.class.getName()))); + act.putStringArrayListExtra(CountlyPush.ALLOWED_PACKAGE_NAMES, new ArrayList<>(Arrays.asList(ctx().getPackageName()))); + Assert.assertNotNull(CountlyPushActivity.validatePushIntent(ctx(), act, null, ctx().getPackageName())); + } } 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 870f0f3c1..abb24729a 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java @@ -1,9 +1,15 @@ package ly.count.android.sdk; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; + public class ConfigContent { int zoneTimerInterval = 30; ContentCallback globalContentCallback = null; + Set allowedIntentSchemes = new HashSet<>(); /** * Set the interval for the automatic content update calls @@ -30,4 +36,26 @@ public synchronized ConfigContent setGlobalContentCallback(ContentCallback callb this.globalContentCallback = callback; return this; } + + /** + * Set the URI schemes that content (and feedback widget) overlay links are allowed to open via + * ACTION_VIEW. When a non-empty list is provided, only links whose scheme is in the list are + * opened. When left empty (the default), any scheme except known-dangerous ones ("file", + * "content", "javascript", "jar", "data") is allowed, so http(s) and deep links keep working. + * Schemes are matched case-insensitively. + * + * @param allowedIntentSchemes the URI schemes permitted for overlay links, for example ["https", "myapp"] + * @return config content to chain calls + */ + public synchronized ConfigContent setAllowedIntentSchemes(List allowedIntentSchemes) { + this.allowedIntentSchemes = new HashSet<>(); + if (allowedIntentSchemes != null) { + for (String scheme : allowedIntentSchemes) { + if (scheme != null) { + this.allowedIntentSchemes.add(scheme.toLowerCase(Locale.ROOT)); + } + } + } + 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 c55f4edcd..9020aecf8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java +++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java @@ -31,8 +31,10 @@ import androidx.annotation.Nullable; import java.lang.ref.WeakReference; import java.util.HashMap; +import java.util.HashSet; import java.util.Map; import java.util.Objects; +import java.util.Set; import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; @@ -45,6 +47,7 @@ class ContentOverlayView extends FrameLayout { TransparentActivityConfig configLandscape; int currentOrientation; private ContentCallback contentCallback; + private final Set allowedLinkSchemes; private Runnable onCloseRunnable; private Runnable onWidgetCancelRunnable; private boolean isClosed = false; @@ -84,7 +87,8 @@ private static Context resolveOverlayContext(@NonNull Activity activity) { @NonNull TransparentActivityConfig landscape, int orientation, @Nullable ContentCallback callback, - @NonNull Runnable onClose) { + @NonNull Runnable onClose, + @Nullable Set allowedLinkSchemes) { // 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 @@ -97,6 +101,8 @@ private static Context resolveOverlayContext(@NonNull Activity activity) { this.contentCallback = callback; this.onCloseRunnable = onClose; 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); setBackgroundColor(Color.TRANSPARENT); setClickable(false); @@ -830,6 +836,25 @@ private void startActivityFromOverlay(@NonNull Intent intent) { } } + // Dispatches an ACTION_VIEW intent for a URL originating from web content, gated by the shared + // scheme policy: with no allow-list the dangerous schemes (file/content/javascript/jar/data) are + // blocked while http(s) and deep links are allowed; with an allow-list configured, only those + // 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) { + Uri uri = Uri.parse(url); + String scheme = uri.getScheme(); + if (!Utils.isExternalSchemeAllowed(scheme, allowedLinkSchemes)) { + Log.w(Countly.TAG, "[ContentOverlayView] startSafeExternalIntent, blocked link with disallowed scheme: [" + scheme + "]"); + return; + } + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + intent.setComponent(null); + intent.setSelector(null); + intent.setFlags(0); + startActivityFromOverlay(intent); + } + private boolean linkAction(Map query, WebView view) { Log.i(Countly.TAG, "[ContentOverlayView] linkAction, link action detected"); if (!query.containsKey("link")) { @@ -840,8 +865,7 @@ private boolean linkAction(Map query, WebView view) { return false; } - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link.toString())); - startActivityFromOverlay(intent); + startSafeExternalIntent(link.toString()); return true; } @@ -1119,12 +1143,30 @@ private WebView createWebView(@NonNull Activity activity, @NonNull TransparentAc wv.setLayoutParams(webLayoutParams); wv.setBackgroundColor(Color.TRANSPARENT); - wv.getSettings().setJavaScriptEnabled(true); - wv.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); + + WebSettings settings = wv.getSettings(); + settings.setJavaScriptEnabled(true); // required for interactive content + settings.setCacheMode(WebSettings.LOAD_NO_CACHE); + + // Security hardening (defense-in-depth). The overlay only ever loads server-issued + // HTTPS content, so disabling local file/content access closes the local-file + // exfiltration vector without affecting legitimate content. Navigation and + // sub-resource scheme restrictions are enforced in CountlyWebViewClient. + settings.setAllowFileAccess(false); // default true on API <= 29 + settings.setAllowContentAccess(false); // OWASP MASTG-BEST-0013 + settings.setAllowFileAccessFromFileURLs(false); + settings.setAllowUniversalAccessFromFileURLs(false); + settings.setGeolocationEnabled(false); + settings.setJavaScriptCanOpenWindowsAutomatically(false); + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + settings.setSafeBrowsingEnabled(true); + } + wv.clearCache(true); wv.clearHistory(); - CountlyWebViewClient client = new CountlyWebViewClient(); + CountlyWebViewClient client = new CountlyWebViewClient(allowedLinkSchemes); webViewClient = client; client.registerWebViewUrlListener((url, webView) -> { if (url.startsWith(Utils.COMM_URL)) { @@ -1136,8 +1178,7 @@ private WebView createWebView(@NonNull Activity activity, @NonNull TransparentAc } if (url.endsWith("cly_x_int=1")) { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - startActivityFromOverlay(intent); + startSafeExternalIntent(url); return true; } 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 c99edbe43..fb661ce4f 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -12,6 +12,7 @@ import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; +import java.io.ByteArrayInputStream; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; @@ -31,10 +32,18 @@ class CountlyWebViewClient extends WebViewClient { "js", "css", "png", "jpg", "jpeg", "webp" )); + // Scheme policy for sub-resources: empty/null -> default denylist; non-empty -> allow-list mode. + private final Set allowedSchemes; + public CountlyWebViewClient() { + this(null); + } + + public CountlyWebViewClient(Set allowedSchemes) { super(); this.listeners = new ArrayList<>(); this.pageLoadTime = System.currentTimeMillis(); + this.allowedSchemes = allowedSchemes; } @Override @@ -59,6 +68,19 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request return false; } + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + String scheme = request.getUrl().getScheme(); + // Sub-resources (images, frames, scripts) follow the shared scheme policy: with no allow-list + // the dangerous local/script schemes (file, content, javascript, jar, data) are blocked; when + // an allow-list is configured, only those schemes load (the default denylist is omitted). + if (!Utils.isExternalSchemeAllowed(scheme, allowedSchemes)) { + Log.v(Countly.TAG, "[CountlyWebViewClient] shouldInterceptRequest, blocked sub-resource with disallowed scheme: [" + request.getUrl() + "]"); + return new WebResourceResponse("text/plain", "utf-8", new ByteArrayInputStream(new byte[0])); + } + return null; + } + private static final long POLL_INTERVAL_MS = 100; private static final long TIMEOUT_MS = 60_000; 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 b525c28c4..7f04be197 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -313,7 +313,8 @@ private void showContentOverlay(@NonNull Activity activity, @NonNull Map { feedbackOverlay = null; - } + }, + _cly.config_.content.allowedIntentSchemes ); feedbackOverlay.setOnWidgetCancelRunnable(() -> reportFeedbackWidgetCancelButton(widgetInfo)); diff --git a/sdk/src/main/java/ly/count/android/sdk/Utils.java b/sdk/src/main/java/ly/count/android/sdk/Utils.java index 506ed2c63..93a1c9383 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Utils.java +++ b/sdk/src/main/java/ly/count/android/sdk/Utils.java @@ -13,9 +13,13 @@ import java.io.InputStream; import java.io.InputStreamReader; import java.security.SecureRandom; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -28,6 +32,30 @@ public class Utils { * This is a communication url between web views and the SDK */ protected static final String COMM_URL = "https://countly_action_event"; + + /** + * Schemes that are never dispatched to ACTION_VIEW from server-controlled content, because they + * can read local data or run script. Used as the default denylist when no scheme allow-list is set. + */ + private static final Set DANGEROUS_INTENT_SCHEMES = new HashSet<>(Arrays.asList("file", "content", "javascript", "jar", "data")); + + /** + * Scheme policy for externally-dispatched links (content overlay links, push notification links). + * When {@code allowedSchemes} is non-empty, only those schemes are permitted (allow-list mode). + * Otherwise any scheme except the known-dangerous ones is permitted (deep links such as + * "myapp", "market", "tel", "mailto" stay allowed). A null scheme is never permitted. + * {@code allowedSchemes} entries are expected to be lower-case; the scheme is matched case-insensitively. + */ + public static boolean isExternalSchemeAllowed(String scheme, Set allowedSchemes) { + if (scheme == null) { + return false; + } + String normalized = scheme.toLowerCase(Locale.ROOT); + if (allowedSchemes != null && !allowedSchemes.isEmpty()) { + return allowedSchemes.contains(normalized); + } + return !DANGEROUS_INTENT_SCHEMES.contains(normalized); + } private static final ExecutorService bg = Executors.newSingleThreadExecutor(); public static Future runInBackground(Runnable runnable) { diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyConfigPush.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyConfigPush.java index 29134f2fb..54b6be116 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyConfigPush.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyConfigPush.java @@ -4,6 +4,7 @@ import androidx.annotation.NonNull; import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Set; import ly.count.android.sdk.Countly; @@ -12,6 +13,8 @@ public class CountlyConfigPush { Countly.CountlyMessagingProvider provider; Set allowedIntentClassNames = new HashSet<>(); Set allowedIntentPackageNames = new HashSet<>(); + Set allowedIntentSchemes = new HashSet<>(); + boolean useAdditionalIntentRedirectionChecks = false; CountlyNotificationButtonURLHandler notificationButtonURLHandler; @@ -62,6 +65,40 @@ public synchronized CountlyConfigPush setAllowedIntentPackageNames(@NonNull List return this; } + /** + * Enable additional intent redirection checks for notification clicks. When enabled, the + * intent's target component must match the allowed package and class names exactly (see + * {@link #setAllowedIntentClassNames(List)} and {@link #setAllowedIntentPackageNames(List)}) + * before the notification action is dispatched. Disabled by default. + * + * @return Returns the same push config object for convenient linking + */ + public synchronized CountlyConfigPush enableAdditionalIntentRedirectionChecks() { + this.useAdditionalIntentRedirectionChecks = true; + return this; + } + + /** + * Set the URI schemes that notification links are allowed to open via ACTION_VIEW. When a + * non-empty list is provided, only links whose scheme is in the list are opened. When left + * empty (the default), any scheme except known-dangerous ones ("file", "content", "javascript", + * "jar", "data") is allowed, so http(s) and deep links keep working. Matched case-insensitively. + * + * @param allowedIntentSchemes the URI schemes permitted for notification links, for example ["https", "myapp"] + * @return Returns the same push config object for convenient linking + */ + public synchronized CountlyConfigPush setAllowedIntentSchemes(List allowedIntentSchemes) { + this.allowedIntentSchemes = new HashSet<>(); + if (allowedIntentSchemes != null) { + for (String scheme : allowedIntentSchemes) { + if (scheme != null) { + this.allowedIntentSchemes.add(scheme.toLowerCase(Locale.ROOT)); + } + } + } + return this; + } + /** * set notification button URL handler * diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java index 2d03ac989..7070340e0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java @@ -57,6 +57,8 @@ public class CountlyPush { public static final String COUNTLY_BROADCAST_PERMISSION_POSTFIX = ".CountlyPush.BROADCAST_PERMISSION"; public static final String ALLOWED_PACKAGE_NAMES = "allowed_package_names"; public static final String ALLOWED_CLASS_NAMES = "allowed_class_names"; + static final String ADDITIONAL_INTENT_REDIRECTION_CHECKS = "additional_intent_redirection_checks"; + static final String ALLOWED_INTENT_SCHEMES = "allowed_intent_schemes"; private static Application.ActivityLifecycleCallbacks callbacks = null; private static Activity activity = null; @@ -75,6 +77,11 @@ public class CountlyPush { */ static int MEDIA_DOWNLOAD_ATTEMPTS = 3; + /** + * @deprecated No longer used. Setting this field has no effect. Enable the additional intent + * redirection checks through {@link CountlyConfigPush#enableAdditionalIntentRedirectionChecks()} instead. + */ + @Deprecated public static boolean useAdditionalIntentRedirectionChecks = false; static boolean initFinished = false; @@ -372,14 +379,20 @@ public static Boolean displayNotification(@Nullable final Context context, @Null Set allowedIntentClassNames; Set allowedIntentPackageNames; + Set allowedIntentSchemes; + boolean useAdditionalIntentRedirectionChecks; if (!initFinished) { Countly.sharedInstance().L.w("[CountlyPush, displayDialog] Push init has not been completed. Some things might not function."); allowedIntentClassNames = new HashSet<>(); allowedIntentPackageNames = new HashSet<>(); + allowedIntentSchemes = new HashSet<>(); + useAdditionalIntentRedirectionChecks = false; } else { allowedIntentClassNames = CountlyPush.countlyConfigPush.allowedIntentClassNames; allowedIntentPackageNames = CountlyPush.countlyConfigPush.allowedIntentPackageNames; + allowedIntentSchemes = CountlyPush.countlyConfigPush.allowedIntentSchemes; + useAdditionalIntentRedirectionChecks = CountlyPush.countlyConfigPush.useAdditionalIntentRedirectionChecks; } final NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); @@ -389,7 +402,7 @@ public static Boolean displayNotification(@Nullable final Context context, @Null return Boolean.FALSE; } - Intent pushActivityIntent = createPushActivityIntent(context, msg, notificationIntent, 0, allowedIntentClassNames, allowedIntentPackageNames); + Intent pushActivityIntent = createPushActivityIntent(context, msg, notificationIntent, 0, allowedIntentClassNames, allowedIntentPackageNames, useAdditionalIntentRedirectionChecks, allowedIntentSchemes); final Notification.Builder builder = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? new Notification.Builder(context.getApplicationContext(), CHANNEL_ID) : new Notification.Builder(context.getApplicationContext())) .setAutoCancel(true) @@ -416,7 +429,7 @@ public static Boolean displayNotification(@Nullable final Context context, @Null for (int i = 0; i < msg.buttons().size(); i++) { Button button = msg.buttons().get(i); - pushActivityIntent = createPushActivityIntent(context, msg, notificationIntent, i + 1, allowedIntentClassNames, allowedIntentPackageNames); + pushActivityIntent = createPushActivityIntent(context, msg, notificationIntent, i + 1, allowedIntentClassNames, allowedIntentPackageNames, useAdditionalIntentRedirectionChecks, allowedIntentSchemes); builder.addAction(button.icon(), button.title(), PendingIntent.getActivity(context, msg.hashCode() + i + 1, pushActivityIntent, Build.VERSION.SDK_INT >= 23 ? PendingIntent.FLAG_IMMUTABLE : 0)); } @@ -448,13 +461,17 @@ public void call(Bitmap bitmap) { return Boolean.TRUE; } - private static Intent createPushActivityIntent(@NonNull final Context context, @NonNull final Message msg, @Nullable final Intent notificationIntent, int index, @NonNull Set allowedIntentClassNames, @NonNull Set allowedIntentPackageNames) { + private static Intent createPushActivityIntent(@NonNull final Context context, @NonNull final Message msg, @Nullable final Intent notificationIntent, int index, @NonNull Set allowedIntentClassNames, @NonNull Set allowedIntentPackageNames, boolean useAdditionalIntentRedirectionChecks, @NonNull Set allowedIntentSchemes) { Intent pushActivityIntent = new Intent(context.getApplicationContext(), CountlyPushActivity.class) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); pushActivityIntent.setPackage(context.getApplicationContext().getPackageName()); pushActivityIntent.putExtra(EXTRA_INTENT, actionIntent(context, notificationIntent, msg, index)); pushActivityIntent.putStringArrayListExtra(ALLOWED_CLASS_NAMES, new ArrayList<>(allowedIntentClassNames)); pushActivityIntent.putStringArrayListExtra(ALLOWED_PACKAGE_NAMES, new ArrayList<>(allowedIntentPackageNames)); + pushActivityIntent.putStringArrayListExtra(ALLOWED_INTENT_SCHEMES, new ArrayList<>(allowedIntentSchemes)); + // Carried on the SDK-built intent so the activity does not read it from static state. The + // activity defaults to true (fail-secure) when this extra is absent. + pushActivityIntent.putExtra(ADDITIONAL_INTENT_REDIRECTION_CHECKS, useAdditionalIntentRedirectionChecks); return pushActivityIntent; } diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java index 3ae8f03d0..5d3d41ab2 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java @@ -5,17 +5,20 @@ import android.content.ComponentName; import android.content.Context; import android.content.Intent; +import android.net.Uri; import android.os.Build; import android.os.Bundle; import java.util.ArrayList; +import java.util.HashSet; +import java.util.Set; import ly.count.android.sdk.Countly; +import ly.count.android.sdk.Utils; import static ly.count.android.sdk.messaging.CountlyPush.ALLOWED_CLASS_NAMES; import static ly.count.android.sdk.messaging.CountlyPush.ALLOWED_PACKAGE_NAMES; import static ly.count.android.sdk.messaging.CountlyPush.EXTRA_ACTION_INDEX; import static ly.count.android.sdk.messaging.CountlyPush.EXTRA_INTENT; import static ly.count.android.sdk.messaging.CountlyPush.EXTRA_MESSAGE; -import static ly.count.android.sdk.messaging.CountlyPush.useAdditionalIntentRedirectionChecks; public class CountlyPushActivity extends Activity { @@ -26,17 +29,71 @@ protected void onCreate(Bundle savedInstanceState) { finish(); } - private void performPushAction(Intent activityIntent) { - Context context = this; - Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Push activity receiver receiving message"); + /** + * Validates that the intent's target component may be launched. Both the package and the class + * must match an allow-list entry exactly: the package must equal the app's own package or one + * of {@code allowedPackageNames}, and the class must equal one of {@code allowedClassNames}. + * Matching is exact (no prefix/suffix matching), so allow-list entries must be fully-qualified, + * e.g. "com.example.app.MainActivity". A null component (implicit intent that cannot be + * validated) is not trusted, and null allow-lists are treated as empty. + */ + static boolean isComponentTrusted(ComponentName component, ArrayList allowedPackageNames, ArrayList allowedClassNames, String ownPackageName) { + if (component == null) { + // Implicit intent has no component to validate against the allow-lists + return false; + } + + String intentPackageName = component.getPackageName(); + String intentClassName = component.getClassName(); + + ArrayList packages = allowedPackageNames == null ? new ArrayList() : new ArrayList<>(allowedPackageNames); + ArrayList classes = allowedClassNames == null ? new ArrayList() : new ArrayList<>(allowedClassNames); + packages.add(ownPackageName); + + boolean trustedPackage = false; + for (String packageName : packages) { + if (packageName != null && intentPackageName.equals(packageName)) { + trustedPackage = true; + break; + } + } + + if (!trustedPackage) { + return false; + } + + for (String className : classes) { + if (className != null && intentClassName.equals(className)) { + return true; + } + } + return false; + } + + /** + * Whether a notification link may be opened via ACTION_VIEW, per the shared scheme policy: + * with a non-empty allow-list only those schemes pass; otherwise any scheme except the known + * dangerous ones (file/content/javascript/jar/data) passes, so deep links keep working. + */ + static boolean isLinkSchemeAllowed(Uri link, Set allowedSchemes) { + return Utils.isExternalSchemeAllowed(link == null ? null : link.getScheme(), allowedSchemes); + } + + /** + * Validates the push activity intent and returns the inner intent that is safe to act on, or + * null if the push must be rejected. Encapsulates the redirection guards so they are testable: + * a missing inner intent, URI-grant flags (stripped on API 26+, rejected below), an untrusted + * caller package, a target outside the app's own package, and the optional additional + * component allow-list checks. The returned inner intent has any URI-grant flags removed. + */ + static Intent validatePushIntent(Context context, Intent activityIntent, ComponentName callingActivity, String packageNameCurrent) { activityIntent.setExtrasClassLoader(CountlyPush.class.getClassLoader()); Intent intent = activityIntent.getParcelableExtra(EXTRA_INTENT); - if (intent == null) { Countly.sharedInstance().L.e("[CountlyPush, CountlyPushActivity] Received a null Intent, stopping execution"); - return; + return null; } int flags = intent.getFlags(); @@ -49,78 +106,53 @@ private void performPushAction(Intent activityIntent) { intent.removeFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION); } else { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Can not remove URI permissions. Aborting"); - return; + return null; } } - ComponentName componentName = getCallingActivity(); - String packageNameCurrent = getPackageName(); - if (componentName != null) { - String callingPackage = componentName.getPackageName(); - if (!callingPackage.startsWith(packageNameCurrent) || !packageNameCurrent.equals(callingPackage)) { - Countly.sharedInstance().L.w("[CountlyPushActivity] performPushAction, Untrusted intent package"); - return; + if (callingActivity != null) { + String callingPackage = callingActivity.getPackageName(); + if (!packageNameCurrent.equals(callingPackage)) { + Countly.sharedInstance().L.w("[CountlyPushActivity] validatePushIntent, Untrusted intent package"); + return null; } } ComponentName targetComponent = intent.resolveActivity(context.getPackageManager()); - if (targetComponent == null || !targetComponent.getPackageName().startsWith(packageNameCurrent) || !targetComponent.getPackageName().equals(packageNameCurrent)) { - Countly.sharedInstance().L.w("[CountlyPushActivity] performPushAction, Untrusted target component"); - return; + if (targetComponent == null || !packageNameCurrent.equals(targetComponent.getPackageName())) { + Countly.sharedInstance().L.w("[CountlyPushActivity] validatePushIntent, Untrusted target component"); + return null; } + // Read from the intent the SDK built (not from static state). Defaults to true so an intent + // that lacks the flag (e.g. a forged/forwarded one) still goes through the stricter checks. + boolean useAdditionalIntentRedirectionChecks = activityIntent.getBooleanExtra(CountlyPush.ADDITIONAL_INTENT_REDIRECTION_CHECKS, true); if (useAdditionalIntentRedirectionChecks) { - componentName = intent.getComponent(); - String intentPackageName = componentName.getPackageName(); - String intentClassName = componentName.getClassName(); - String contextPackageName = context.getPackageName(); - ArrayList allowedIntentClassNames = activityIntent.getStringArrayListExtra(ALLOWED_CLASS_NAMES); ArrayList allowedIntentPackageNames = activityIntent.getStringArrayListExtra(ALLOWED_PACKAGE_NAMES); - if (intentPackageName != null) { - allowedIntentPackageNames.add(contextPackageName); + if (!isComponentTrusted(intent.getComponent(), allowedIntentPackageNames, allowedIntentClassNames, packageNameCurrent)) { + Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Untrusted intent component, aborting"); + return null; } + } - boolean isTrustedClass = false; - boolean isTrustedPackage = false; + return intent; + } - for (String packageName : allowedIntentPackageNames) { - // Checking is trusted package name, if intent package name contains in allowedPackagesNames then it is trusted package name - if (intentPackageName != null && intentPackageName.startsWith(packageName)) { - isTrustedPackage = true; - if (isTrustedClass) { - break; - } - } - // Checking is trusted class name, if intent class name starts with any allowedPackagesNames then it is trusted class name - if (intentClassName.startsWith(packageName)) { - isTrustedClass = true; - if (isTrustedPackage) { - break; - } - } - } + private void performPushAction(Intent activityIntent) { + Context context = this; + Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Push activity receiver receiving message"); - if (!isTrustedPackage) { - Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Untrusted intent package"); - return; - } - if (!isTrustedClass) { - // Checking is trusted class name, if class name contains in allowedIntentClassNames then it is trusted class name - for (String className : allowedIntentClassNames) { - if (intentClassName.endsWith(className)) { - isTrustedClass = true; - break; - } - } - if (!isTrustedClass) { - Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Untrusted intent class"); - return; - } - } + String packageNameCurrent = getPackageName(); + Intent intent = validatePushIntent(context, activityIntent, getCallingActivity(), packageNameCurrent); + if (intent == null) { + return; } + ArrayList allowedSchemesList = activityIntent.getStringArrayListExtra(CountlyPush.ALLOWED_INTENT_SCHEMES); + Set allowedLinkSchemes = allowedSchemesList == null ? null : new HashSet<>(allowedSchemesList); + Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Push activity, after filtering"); intent.setExtrasClassLoader(CountlyPush.class.getClassLoader()); @@ -166,13 +198,20 @@ private void performPushAction(Intent activityIntent) { return; } + if (!isLinkSchemeAllowed(message.link(), allowedLinkSchemes)) { + Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Blocked notification link with disallowed scheme: [" + message.link().getScheme() + "]"); + return; + } + Intent i = new Intent(Intent.ACTION_VIEW, message.link()); - i.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY | - Intent.FLAG_ACTIVITY_CLEAR_WHEN_TASK_RESET | - Intent.FLAG_ACTIVITY_MULTIPLE_TASK | - Intent.FLAG_ACTIVITY_NEW_TASK); - i.putExtra(EXTRA_MESSAGE, bundle); - i.putExtra(EXTRA_ACTION_INDEX, index); + i.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY | Intent.FLAG_ACTIVITY_NEW_TASK); + // Only forward the push payload when the link resolves to our own app, so the + // message data is not leaked to an external app that happens to handle the link. + ComponentName linkTarget = i.resolveActivity(context.getPackageManager()); + if (linkTarget != null && packageNameCurrent.equals(linkTarget.getPackageName())) { + i.putExtra(EXTRA_MESSAGE, bundle); + i.putExtra(EXTRA_ACTION_INDEX, index); + } context.startActivity(i); } else { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Starting activity without a link. Push body"); @@ -189,11 +228,22 @@ private void performPushAction(Intent activityIntent) { return; } - Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Starting activity with given button link. [" + (index - 1) + "] [" + message.buttons().get(index - 1).link() + "]"); - Intent i = new Intent(Intent.ACTION_VIEW, message.buttons().get(index - 1).link()); + Uri buttonLink = message.buttons().get(index - 1).link(); + if (!isLinkSchemeAllowed(buttonLink, allowedLinkSchemes)) { + Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Blocked notification button link with disallowed scheme: [" + (buttonLink == null ? null : buttonLink.getScheme()) + "]"); + return; + } + + Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Starting activity with given button link. [" + (index - 1) + "] [" + buttonLink + "]"); + Intent i = new Intent(Intent.ACTION_VIEW, buttonLink); i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - i.putExtra(EXTRA_MESSAGE, bundle); - i.putExtra(EXTRA_ACTION_INDEX, index); + // Only forward the push payload when the link resolves to our own app, so the + // message data is not leaked to an external app that happens to handle the link. + ComponentName linkTarget = i.resolveActivity(context.getPackageManager()); + if (linkTarget != null && packageNameCurrent.equals(linkTarget.getPackageName())) { + i.putExtra(EXTRA_MESSAGE, bundle); + i.putExtra(EXTRA_ACTION_INDEX, index); + } context.startActivity(i); } catch (Exception ex) { Countly.sharedInstance().L.e("[CountlyPush, displayDialog] Encountered issue while clicking on notification button [" + ex.toString() + "]"); From e1989070b8e331da7c313357b32c1518cccc7e2f Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Mon, 29 Jun 2026 18:27:58 +0300 Subject: [PATCH 12/24] fix: changes after review --- CHANGELOG.md | 6 +- .../sdk/CountlyWebViewClientTests.java | 22 ++++-- .../count/android/sdk/ModuleContentTests.java | 40 +++++++++++ .../android/sdk/messaging/PushTests.java | 9 +-- .../ly/count/android/sdk/ConfigContent.java | 10 +-- .../count/android/sdk/ContentOverlayView.java | 19 ++--- .../android/sdk/CountlyWebViewClient.java | 13 ++-- .../ly/count/android/sdk/ModuleContent.java | 22 +++--- .../ly/count/android/sdk/ModuleDeviceId.java | 9 +-- .../ly/count/android/sdk/ModuleFeedback.java | 1 + .../ly/count/android/sdk/ModuleRatings.java | 26 +++++-- .../main/java/ly/count/android/sdk/Utils.java | 69 +++++++++++++++++++ .../sdk/messaging/CountlyConfigPush.java | 15 ++-- .../android/sdk/messaging/CountlyPush.java | 31 +++++++-- .../sdk/messaging/CountlyPushActivity.java | 49 +++++++------ 15 files changed, 249 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index adb1879cc..8056fbc9a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,11 @@ ## X.X.X +* ! 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. + * Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. * Added a new push configuration option "enableAdditionalIntentRedirectionChecks()" to enable stricter validation of the notification intent's target package and class. * Added a new content configuration option "setAllowedIntentSchemes(List)" to restrict which URI schemes content and feedback widget links may open. * Added a new push configuration option "setAllowedIntentSchemes(List)" to restrict which URI schemes notification links may open. -* Improved the security of the content feature web view, disabled local file/content access, disallowed mixed content, enabled Safe Browsing, blocked non-HTTP(S) sub-resources, and blocked content links with dangerous schemes (file, content, javascript, jar, data). +* Improved the security of the content, feedback widget, and rating widget web views, disabled local file/content access, disallowed mixed content, enabled Safe Browsing, blocked sub-resources with dangerous schemes (file, content, javascript, jar, data) while always allowing https, and blocked links with dangerous schemes. * Improved the security of push notification click handling, null-safe and stricter package/class validation, reduced intent flags, no longer forwarding the payload to external apps, and blocking notification links with dangerous schemes (file, content, javascript, jar, data) by default. * Added a new config option "disableSDKLoggingInProduction()" that keeps the SDK's console logging disabled in production (non-debuggable) builds, even when logging is enabled. @@ -11,8 +13,6 @@ * Mitigated an issue where the rating feedback popup request could be sent while in temporary device ID mode, creating a `CLYTemporaryDeviceID` user on the server. * Mitigated an issue where the content zone did not resume after exiting temporary device ID mode even when it was enabled by the server configuration. -* Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks", replaced with "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()". The static field is now a no-op. - ## 26.1.3 * Added gradle configuration cache support to upload symbols plugin. * Improved user properties auto-save conditions to flush event queue with every user property call. 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 17db8969d..63685665e 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -356,15 +356,25 @@ private void assertBlocked(WebResourceResponse response) { } /** - * With a configured scheme allow-list, sub-resources follow allow-list mode: only listed schemes - * load (the default denylist is omitted), everything else is blocked. + * With a configured scheme allow-list, sub-resources follow allow-list mode: listed schemes load + * and everything else is blocked, EXCEPT https which always loads because it serves the content + * itself (so an outbound-link allow-list does not break the page's own https assets). Plain http + * is NOT auto-allowed — it must be listed explicitly, so the integrator decides whether to permit it. */ @Test public void shouldInterceptRequest_allowlistMode() { - CountlyWebViewClient allowlisted = new CountlyWebViewClient(new HashSet<>(Arrays.asList("https"))); + CountlyWebViewClient allowlisted = new CountlyWebViewClient(new HashSet<>(Arrays.asList("myapp"))); + // https always loads regardless of the allow-list Assert.assertNull(allowlisted.shouldInterceptRequest(null, fakeRequest("https://example.com/a.png", false))); - assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("http://example.com/a.png", false))); // not listed - assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("market://details?id=x", false))); // not listed - assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("file:///etc/hosts", false))); // not listed + // a listed non-web scheme loads + Assert.assertNull(allowlisted.shouldInterceptRequest(null, fakeRequest("myapp://x", false))); + // http is not auto-allowed: blocked unless explicitly listed + assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("http://example.com/a.png", false))); + // other unlisted non-web schemes are blocked + assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("market://details?id=x", false))); + assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("file:///etc/hosts", false))); + // http loads when explicitly listed + CountlyWebViewClient httpAllowed = new CountlyWebViewClient(new HashSet<>(Arrays.asList("http"))); + Assert.assertNull(httpAllowed.shouldInterceptRequest(null, fakeRequest("http://example.com/a.png", false))); } } 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 ba2cdf809..334757e04 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -313,4 +313,44 @@ public void contentZone_resumesAfterTemporaryDeviceIDToggle() throws Exception { mCountly.deviceId().changeWithoutMerge("real_user_after_temp"); Assert.assertTrue(readShouldFetchContents(mCountly.moduleContent)); } + + @Test + public void contentZone_doesNotResumeAfterExplicitExit() throws Exception { + final String serverConfigWithEcz = new ServerConfigBuilder().contentZone(true).build(); + + CountlyConfig config = new CountlyConfig(TestUtils.getContext(), "appkey", "http://test.count.ly").setDeviceId("1234").setLoggingEnabled(true); + config.disableHealthCheck(); + config.immediateRequestGenerator = new ImmediateRequestGenerator() { + @Override public ImmediateRequestI CreateImmediateRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> { + if ("/o/sdk".equals(customEndpoint)) { + try { + callback.callback(new JSONObject(serverConfigWithEcz)); + } catch (JSONException e) { + callback.callback(null); + } + } else { + callback.callback(null); + } + }; + } + + @Override public ImmediateRequestI CreatePreflightRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> callback.callback(null); + } + }; + + mCountly = new Countly().init(config); + + // ecz=true armed the content zone at init + Assert.assertTrue(readShouldFetchContents(mCountly.moduleContent)); + + // the developer explicitly exits the content zone + mCountly.contents().exitContentZone(); + Assert.assertFalse(readShouldFetchContents(mCountly.moduleContent)); + + // a device ID change must NOT silently resume a zone the developer turned off + mCountly.deviceId().changeWithoutMerge("real_user_after_exit"); + Assert.assertFalse(readShouldFetchContents(mCountly.moduleContent)); + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java index 802a8466a..83620b905 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java @@ -89,8 +89,9 @@ public void isComponentTrusted_exactPackageAndClass_trusted() { } /** - * The own package is always allowed, but the class must still be listed exactly: an - * own-package class that is not in the class allow-list is not trusted. + * The own package is always allowed for the package check, but the class must still be listed + * exactly: an own-package class that is not in the class allow-list is not trusted (the + * integrator must opt each target class in manually). */ @Test public void isComponentTrusted_ownPackageButUnlistedClass_notTrusted() { @@ -137,7 +138,7 @@ public void isComponentTrusted_allowedForeignPackageExactClass_trusted() { /** * Null allow-lists must not crash; with no class list nothing is trusted (a class match is - * always required), and a foreign package stays untrusted. + * always required, even for the own package), and a foreign package stays untrusted. */ @Test public void isComponentTrusted_nullAllowLists_noCrash() { @@ -252,7 +253,7 @@ public void validatePushIntent_ownTargetChecksDisabled_returnsIntent() { Assert.assertNotNull(CountlyPushActivity.validatePushIntent(ctx(), act, null, ctx().getPackageName())); } - /** R5 default-true: missing flag -> checks run -> own-package target with no allow-list is rejected. */ + /** R5 default-true: missing flag -> checks run -> own-package target with no class allow-list is rejected (the class must be opted in manually). */ @Test public void validatePushIntent_defaultChecksNoAllowList_returnsNull() { Intent act = activityIntentWith(ownTargetInner()); // no ADDITIONAL_INTENT_REDIRECTION_CHECKS extra 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 abb24729a..5e3730574 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java @@ -2,7 +2,6 @@ import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Set; public class ConfigContent { @@ -48,14 +47,7 @@ public synchronized ConfigContent setGlobalContentCallback(ContentCallback callb * @return config content to chain calls */ public synchronized ConfigContent setAllowedIntentSchemes(List allowedIntentSchemes) { - this.allowedIntentSchemes = new HashSet<>(); - if (allowedIntentSchemes != null) { - for (String scheme : allowedIntentSchemes) { - if (scheme != null) { - this.allowedIntentSchemes.add(scheme.toLowerCase(Locale.ROOT)); - } - } - } + this.allowedIntentSchemes = Utils.normalizeSchemeSet(allowedIntentSchemes); 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 9020aecf8..bc7e63fa9 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java +++ b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java @@ -1148,20 +1148,11 @@ private WebView createWebView(@NonNull Activity activity, @NonNull TransparentAc settings.setJavaScriptEnabled(true); // required for interactive content settings.setCacheMode(WebSettings.LOAD_NO_CACHE); - // Security hardening (defense-in-depth). The overlay only ever loads server-issued - // HTTPS content, so disabling local file/content access closes the local-file - // exfiltration vector without affecting legitimate content. Navigation and - // sub-resource scheme restrictions are enforced in CountlyWebViewClient. - settings.setAllowFileAccess(false); // default true on API <= 29 - settings.setAllowContentAccess(false); // OWASP MASTG-BEST-0013 - settings.setAllowFileAccessFromFileURLs(false); - settings.setAllowUniversalAccessFromFileURLs(false); - settings.setGeolocationEnabled(false); - settings.setJavaScriptCanOpenWindowsAutomatically(false); - settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - settings.setSafeBrowsingEnabled(true); - } + // Security hardening (defense-in-depth). The overlay only ever loads server-issued HTTPS + // content, so disabling local file/content access closes the local-file exfiltration vector + // without affecting legitimate content. Sub-resource scheme restrictions are enforced in + // CountlyWebViewClient. Shared with the feedback/ratings WebViews via Utils. + Utils.applyWebViewSecurityDefaults(settings); wv.clearCache(true); wv.clearHistory(); 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 fb661ce4f..229d493be 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.io.ByteArrayInputStream; import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; @@ -71,12 +70,14 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { String scheme = request.getUrl().getScheme(); - // Sub-resources (images, frames, scripts) follow the shared scheme policy: with no allow-list - // the dangerous local/script schemes (file, content, javascript, jar, data) are blocked; when - // an allow-list is configured, only those schemes load (the default denylist is omitted). - if (!Utils.isExternalSchemeAllowed(scheme, allowedSchemes)) { + // Sub-resources (images, frames, scripts) follow the shared scheme policy, except https + // always loads because it serves the content itself: with no allow-list the dangerous + // local/script schemes (file, content, javascript, jar, data) are blocked; when an allow-list + // is configured, only those schemes (plus https) load. This keeps an outbound-link allow-list + // from accidentally blocking the page's own https assets, while http stays integrator-decided. + if (!Utils.isWebContentSchemeAllowed(scheme, allowedSchemes)) { Log.v(Countly.TAG, "[CountlyWebViewClient] shouldInterceptRequest, blocked sub-resource with disallowed scheme: [" + request.getUrl() + "]"); - return new WebResourceResponse("text/plain", "utf-8", new ByteArrayInputStream(new byte[0])); + return Utils.blankWebResourceResponse(); } return null; } 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 7f04be197..4eda83d3c 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java @@ -474,14 +474,20 @@ void deviceIdChanged(boolean withoutMerge) { L.d("[ModuleContent] deviceIdChanged, withoutMerge: [" + withoutMerge + "]"); if (withoutMerge) { exitContentZoneInternal(); - // Re-arm the content zone if the (already known) server config enables it. The server - // config re-fetch triggered by a device ID change only notifies when a value actually - // changes, so a re-fetch returning the same content-zone value would otherwise leave - // the zone torn down. This makes the zone resume across a temporary-device-ID toggle. - if (configProvider.getContentZoneEnabled()) { - L.d("[ModuleContent] deviceIdChanged, content zone is enabled, re-entering after device ID change"); - enterContentZoneInternal(null, 0, null); - } + } + } + + /** + * Resumes the content zone after exiting temporary device ID mode. Called only from the + * temporary-ID-exit path (not from a generic device ID change), so a plain changeWithoutMerge + * does not silently re-arm a zone the developer turned off. The server config re-fetch on + * exiting temporary mode only notifies modules when a value changes, so an unchanged (still + * enabled) content-zone value would otherwise leave the zone torn down here. + */ + void resumeContentZoneAfterTemporaryIdExit() { + if (configProvider.getContentZoneEnabled()) { + L.d("[ModuleContent] resumeContentZoneAfterTemporaryIdExit, content zone is enabled, re-entering"); + enterContentZoneInternal(null, 0, null); } } diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java b/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java index e3feb8498..c95572827 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleDeviceId.java @@ -83,11 +83,12 @@ void exitTemporaryIdMode(@NonNull String deviceId) { // trigger fetching if the temp id given on init _cly.moduleConfiguration.fetchConfigFromServer(_cly.config_); - // Re-evaluate the content zone now that a real device ID is set again. The config re-fetch - // above only notifies modules when a value changes, so an unchanged (still enabled) - // content-zone value would otherwise leave the zone torn down after exiting temporary mode. + // Resume the content zone now that a real device ID is set again. The config re-fetch above + // only notifies modules when a value changes, so an unchanged (still enabled) content-zone + // value would otherwise leave the zone torn down after exiting temporary mode. This runs only + // here (not on a generic device ID change), so a plain changeWithoutMerge does not re-arm it. if (_cly.moduleContent != null) { - _cly.moduleContent.deviceIdChanged(true); + _cly.moduleContent.resumeContentZoneAfterTemporaryIdExit(); } //update stored request for ID change to use this new ID 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 038a6de3d..1eb9fa133 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java @@ -358,6 +358,7 @@ private void showFeedbackWidget(Context context, CountlyFeedbackWidget widgetInf webView.clearCache(true); webView.clearHistory(); webView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); + Utils.applyWebViewSecurityDefaults(webView.getSettings()); ModuleRatings.FeedbackDialogWebViewClient webViewClient = new ModuleRatings.FeedbackDialogWebViewClient(); webView.setWebViewClient(webViewClient); webView.loadUrl(url); 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 28f82a713..eac5e9785 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java @@ -530,6 +530,8 @@ public void run() { webView.clearHistory(); webView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); webView.getSettings().setJavaScriptEnabled(true); + Utils.applyWebViewSecurityDefaults(webView.getSettings()); + webView.setWebViewClient(new FeedbackDialogWebViewClient()); webView.loadUrl(ratingWidgetUrl); AlertDialog.Builder builder = new AlertDialog.Builder(activity); @@ -589,7 +591,14 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request // Filter out outgoing calls if (url.endsWith("cly_x_int=1")) { - Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); + Uri link = Uri.parse(url); + // Apply the shared scheme policy (default denylist) so dangerous schemes such as + // file/content/javascript are not dispatched to ACTION_VIEW from server content. + if (!Utils.isExternalSchemeAllowed(link.getScheme(), null)) { + Countly.sharedInstance().L.w("[FeedbackDialogWebViewClient] Blocked link with disallowed scheme: [" + link.getScheme() + "]"); + return true; + } + Intent intent = new Intent(Intent.ACTION_VIEW, link); view.getContext().startActivity(intent); return true; } @@ -598,13 +607,22 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request @Override public WebResourceResponse shouldInterceptRequest(WebView view, String url) { - // Countly.sharedInstance().L.i("attempting to load resource: " + url); - return null; + return interceptScheme(Uri.parse(url)); } @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { - // Countly.sharedInstance().L.i("attempting to load resource: " + request.getUrl()); + return interceptScheme(request.getUrl()); + } + + // Blocks dangerous local/script sub-resource schemes (file/content/javascript/jar/data), + // mirroring CountlyWebViewClient. http/https (the content itself) always load. + private WebResourceResponse interceptScheme(Uri uri) { + String scheme = uri == null ? null : uri.getScheme(); + if (!Utils.isWebContentSchemeAllowed(scheme, null)) { + Countly.sharedInstance().L.v("[FeedbackDialogWebViewClient] Blocked sub-resource with disallowed scheme: [" + uri + "]"); + return Utils.blankWebResourceResponse(); + } return null; } } diff --git a/sdk/src/main/java/ly/count/android/sdk/Utils.java b/sdk/src/main/java/ly/count/android/sdk/Utils.java index 8a53712cf..a5f72806f 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Utils.java +++ b/sdk/src/main/java/ly/count/android/sdk/Utils.java @@ -6,9 +6,12 @@ import android.content.res.Configuration; import android.os.Build; import android.util.Base64; +import android.webkit.WebResourceResponse; +import android.webkit.WebSettings; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import java.io.BufferedReader; +import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; @@ -57,6 +60,72 @@ public static boolean isExternalSchemeAllowed(String scheme, Set allowed } return !DANGEROUS_INTENT_SCHEMES.contains(normalized); } + + /** + * Whether a WebView sub-resource (image, script, stylesheet, frame) with this scheme may load. + * "https" always loads because it is how the content itself is served, so an outbound-link + * allow-list (which may list only a custom deep-link scheme such as "myapp") does not + * accidentally block the page's own https assets. Every other scheme — including plain "http" — + * follows the shared link policy, so the dangerous local/script schemes stay blocked and the + * integrator decides whether to permit http (allowed by the default denylist, opt-in when an + * allow-list is configured). + */ + public static boolean isWebContentSchemeAllowed(String scheme, Set allowedSchemes) { + if (scheme != null && "https".equals(scheme.toLowerCase(Locale.ROOT))) { + return true; + } + return isExternalSchemeAllowed(scheme, allowedSchemes); + } + + /** + * Builds a lower-cased, null-safe {@link Set} of intent schemes from a user-provided list. Null + * elements and a null list are tolerated (yielding an empty set). Shared by the content and push + * config setters so their normalization stays identical. + */ + @NonNull + public static Set normalizeSchemeSet(@Nullable List schemes) { + Set normalized = new HashSet<>(); + if (schemes != null) { + for (String scheme : schemes) { + if (scheme != null) { + normalized.add(scheme.toLowerCase(Locale.ROOT)); + } + } + } + return normalized; + } + + /** + * Applies the SDK's WebView security hardening (defense-in-depth) to the given settings. The + * SDK only ever loads server-issued HTTPS content in its WebViews, so disabling local + * file/content access closes the local-file exfiltration vector without affecting legitimate + * content. Call this from every WebView creation site so the policy lives in one place. + */ + public static void applyWebViewSecurityDefaults(@Nullable WebSettings settings) { + if (settings == null) { + return; + } + settings.setAllowFileAccess(false); // default true on API <= 29 + settings.setAllowContentAccess(false); // OWASP MASTG-BEST-0013 + settings.setAllowFileAccessFromFileURLs(false); + settings.setAllowUniversalAccessFromFileURLs(false); + settings.setGeolocationEnabled(false); + settings.setJavaScriptCanOpenWindowsAutomatically(false); + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + settings.setSafeBrowsingEnabled(true); + } + } + + /** + * An empty {@code text/plain} response used to neutralize a blocked WebView sub-resource. A + * fresh instance is returned per call because the backing stream is stateful. + */ + @NonNull + public static WebResourceResponse blankWebResourceResponse() { + return new WebResourceResponse("text/plain", "utf-8", new ByteArrayInputStream(new byte[0])); + } + private static final ExecutorService bg = Executors.newSingleThreadExecutor(); public static Future runInBackground(Runnable runnable) { diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyConfigPush.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyConfigPush.java index 54b6be116..cd2b3fd85 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyConfigPush.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyConfigPush.java @@ -4,9 +4,9 @@ import androidx.annotation.NonNull; import java.util.HashSet; import java.util.List; -import java.util.Locale; import java.util.Set; import ly.count.android.sdk.Countly; +import ly.count.android.sdk.Utils; public class CountlyConfigPush { Application application; @@ -70,6 +70,10 @@ public synchronized CountlyConfigPush setAllowedIntentPackageNames(@NonNull List * intent's target component must match the allowed package and class names exactly (see * {@link #setAllowedIntentClassNames(List)} and {@link #setAllowedIntentPackageNames(List)}) * before the notification action is dispatched. Disabled by default. + *

+ * The target class must be allow-listed explicitly even when it is in the app's own package, so + * when this is enabled you must add each launchable target class via + * {@link #setAllowedIntentClassNames(List)} (otherwise matching notification clicks are rejected). * * @return Returns the same push config object for convenient linking */ @@ -88,14 +92,7 @@ public synchronized CountlyConfigPush enableAdditionalIntentRedirectionChecks() * @return Returns the same push config object for convenient linking */ public synchronized CountlyConfigPush setAllowedIntentSchemes(List allowedIntentSchemes) { - this.allowedIntentSchemes = new HashSet<>(); - if (allowedIntentSchemes != null) { - for (String scheme : allowedIntentSchemes) { - if (scheme != null) { - this.allowedIntentSchemes.add(scheme.toLowerCase(Locale.ROOT)); - } - } - } + this.allowedIntentSchemes = Utils.normalizeSchemeSet(allowedIntentSchemes); return this; } diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java index 7070340e0..c8aa57fec 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java @@ -8,6 +8,7 @@ import android.app.NotificationManager; import android.app.PendingIntent; import android.content.BroadcastReceiver; +import android.content.ComponentName; import android.content.Context; import android.content.DialogInterface; import android.content.Intent; @@ -576,6 +577,12 @@ public void onClick(DialogInterface dialog, int which) { return; } + Set allowedDialogSchemes = countlyConfigPush != null ? countlyConfigPush.allowedIntentSchemes : null; + if (!Utils.isExternalSchemeAllowed(msg.link().getScheme(), allowedDialogSchemes)) { + Countly.sharedInstance().L.w("[CountlyPush, displayDialog] Blocked dialog link with disallowed scheme: [" + msg.link().getScheme() + "]"); + return; + } + try { Intent i = new Intent(Intent.ACTION_VIEW, msg.link()); i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); @@ -620,17 +627,31 @@ public void onClick(DialogInterface dialog, int which) { dialog.dismiss(); boolean isPositiveButtonPressed = (which == DialogInterface.BUTTON_POSITIVE); - if (countlyConfigPush.notificationButtonURLHandler != null && countlyConfigPush.notificationButtonURLHandler.onClick(msg.buttons().get(isPositiveButtonPressed ? 1 : 0).link().toString(), context)) { + Uri buttonLink = msg.buttons().get(isPositiveButtonPressed ? 1 : 0).link(); + if (countlyConfigPush.notificationButtonURLHandler != null && countlyConfigPush.notificationButtonURLHandler.onClick(buttonLink == null ? null : buttonLink.toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, dialog button onClick] Link handled by custom URL handler, skipping default link opening."); return; } try { msg.recordAction(context, isPositiveButtonPressed ? 2 : 1); - Intent intent = new Intent(Intent.ACTION_VIEW, msg.buttons().get(isPositiveButtonPressed ? 1 : 0).link()); - Bundle bundle = new Bundle(); - bundle.putParcelable(EXTRA_MESSAGE, msg); - intent.putExtra(EXTRA_MESSAGE, bundle); + + Set allowedDialogSchemes = countlyConfigPush != null ? countlyConfigPush.allowedIntentSchemes : null; + if (!Utils.isExternalSchemeAllowed(buttonLink == null ? null : buttonLink.getScheme(), allowedDialogSchemes)) { + Countly.sharedInstance().L.w("[CountlyPush, dialog button onClick] Blocked dialog button link with disallowed scheme: [" + (buttonLink == null ? null : buttonLink.getScheme()) + "]"); + return; + } + + Intent intent = new Intent(Intent.ACTION_VIEW, buttonLink); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + // Only forward the push payload when the link resolves to our own app, so the + // message data is not leaked to an external app that happens to handle the link. + ComponentName linkTarget = intent.resolveActivity(context.getPackageManager()); + if (linkTarget != null && context.getPackageName().equals(linkTarget.getPackageName())) { + Bundle bundle = new Bundle(); + bundle.putParcelable(EXTRA_MESSAGE, msg); + intent.putExtra(EXTRA_MESSAGE, bundle); + } intent.putExtra(EXTRA_ACTION_INDEX, isPositiveButtonPressed ? 2 : 1); context.startActivity(intent); } catch (Exception ex) { diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java index 5d3d41ab2..f2812f7ba 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java @@ -31,11 +31,13 @@ protected void onCreate(Bundle savedInstanceState) { /** * Validates that the intent's target component may be launched. Both the package and the class - * must match an allow-list entry exactly: the package must equal the app's own package or one - * of {@code allowedPackageNames}, and the class must equal one of {@code allowedClassNames}. - * Matching is exact (no prefix/suffix matching), so allow-list entries must be fully-qualified, - * e.g. "com.example.app.MainActivity". A null component (implicit intent that cannot be - * validated) is not trusted, and null allow-lists are treated as empty. + * must be allow-listed: the package must equal the app's own package or one of + * {@code allowedPackageNames}, and the class must equal one of {@code allowedClassNames}. The + * class must be listed explicitly even when it is in the app's own package, so the integrator + * opts each launchable target in by its fully-qualified class name (via + * setAllowedIntentClassNames). Matching is exact (no prefix/suffix matching), e.g. + * "com.example.app.MainActivity". A null component (implicit intent that cannot be validated) is + * not trusted, and null allow-lists are treated as empty. */ static boolean isComponentTrusted(ComponentName component, ArrayList allowedPackageNames, ArrayList allowedClassNames, String ownPackageName) { if (component == null) { @@ -46,15 +48,14 @@ static boolean isComponentTrusted(ComponentName component, ArrayList all String intentPackageName = component.getPackageName(); String intentClassName = component.getClassName(); - ArrayList packages = allowedPackageNames == null ? new ArrayList() : new ArrayList<>(allowedPackageNames); - ArrayList classes = allowedClassNames == null ? new ArrayList() : new ArrayList<>(allowedClassNames); - packages.add(ownPackageName); - - boolean trustedPackage = false; - for (String packageName : packages) { - if (packageName != null && intentPackageName.equals(packageName)) { - trustedPackage = true; - break; + // The package must be the app's own package or an explicitly allow-listed one. + boolean trustedPackage = ownPackageName != null && ownPackageName.equals(intentPackageName); + if (!trustedPackage && allowedPackageNames != null) { + for (String packageName : allowedPackageNames) { + if (packageName != null && intentPackageName.equals(packageName)) { + trustedPackage = true; + break; + } } } @@ -62,9 +63,12 @@ static boolean isComponentTrusted(ComponentName component, ArrayList all return false; } - for (String className : classes) { - if (className != null && intentClassName.equals(className)) { - return true; + // The class must be explicitly allow-listed, even for an own-package target. + if (allowedClassNames != null) { + for (String className : allowedClassNames) { + if (className != null && intentClassName.equals(className)) { + return true; + } } } @@ -223,14 +227,19 @@ private void performPushAction(Intent activityIntent) { } } else { try { - if (CountlyPush.countlyConfigPush.notificationButtonURLHandler != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(message.buttons().get(index - 1).link().toString(), context)) { + Uri buttonLink = message.buttons().get(index - 1).link(); + if (buttonLink == null) { + Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Notification button link is null, nothing to open"); + return; + } + + if (CountlyPush.countlyConfigPush.notificationButtonURLHandler != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(buttonLink.toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Link handled by custom URL handler, skipping default link opening."); return; } - Uri buttonLink = message.buttons().get(index - 1).link(); if (!isLinkSchemeAllowed(buttonLink, allowedLinkSchemes)) { - Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Blocked notification button link with disallowed scheme: [" + (buttonLink == null ? null : buttonLink.getScheme()) + "]"); + Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Blocked notification button link with disallowed scheme: [" + buttonLink.getScheme() + "]"); return; } From c8f77cf72dfb895325475e3ae76d2cab46dec09e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 30 Jun 2026 08:35:53 +0300 Subject: [PATCH 13/24] fix: ratings too --- .../ly/count/android/sdk/ModuleFeedback.java | 2 +- .../ly/count/android/sdk/ModuleRatings.java | 28 ++++++++++++++----- .../android/sdk/messaging/CountlyPush.java | 4 +-- 3 files changed, 24 insertions(+), 10 deletions(-) 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 1eb9fa133..80e16c011 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleFeedback.java @@ -359,7 +359,7 @@ private void showFeedbackWidget(Context context, CountlyFeedbackWidget widgetInf webView.clearHistory(); webView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); Utils.applyWebViewSecurityDefaults(webView.getSettings()); - ModuleRatings.FeedbackDialogWebViewClient webViewClient = new ModuleRatings.FeedbackDialogWebViewClient(); + ModuleRatings.FeedbackDialogWebViewClient webViewClient = new ModuleRatings.FeedbackDialogWebViewClient(_cly.config_.content.allowedIntentSchemes); webView.setWebViewClient(webViewClient); webView.loadUrl(url); webView.requestFocus(); 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 eac5e9785..bac467399 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleRatings.java @@ -20,6 +20,7 @@ import androidx.annotation.Nullable; import java.util.HashMap; import java.util.Map; +import java.util.Set; import org.json.JSONException; import org.json.JSONObject; @@ -531,7 +532,7 @@ public void run() { webView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE); webView.getSettings().setJavaScriptEnabled(true); Utils.applyWebViewSecurityDefaults(webView.getSettings()); - webView.setWebViewClient(new FeedbackDialogWebViewClient()); + webView.setWebViewClient(new FeedbackDialogWebViewClient(_cly.config_.content.allowedIntentSchemes)); webView.loadUrl(ratingWidgetUrl); AlertDialog.Builder builder = new AlertDialog.Builder(activity); @@ -578,6 +579,17 @@ public boolean onCheckIsTextEditor() { static class FeedbackDialogWebViewClient extends WebViewClient { WebViewUrlListener listener; + // Scheme policy for outbound links / sub-resources, shared with the content overlay. Empty or + // null -> default denylist; non-empty -> allow-list mode (sourced from config.content). + private final Set allowedSchemes; + + FeedbackDialogWebViewClient() { + this(null); + } + + FeedbackDialogWebViewClient(Set allowedSchemes) { + this.allowedSchemes = allowedSchemes; + } @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { @@ -592,9 +604,10 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request // Filter out outgoing calls if (url.endsWith("cly_x_int=1")) { Uri link = Uri.parse(url); - // Apply the shared scheme policy (default denylist) so dangerous schemes such as - // file/content/javascript are not dispatched to ACTION_VIEW from server content. - if (!Utils.isExternalSchemeAllowed(link.getScheme(), null)) { + // Apply the shared scheme policy so dangerous schemes such as file/content/javascript + // are not dispatched to ACTION_VIEW from server content, honoring any configured + // allow-list the same way the content overlay does. + if (!Utils.isExternalSchemeAllowed(link.getScheme(), allowedSchemes)) { Countly.sharedInstance().L.w("[FeedbackDialogWebViewClient] Blocked link with disallowed scheme: [" + link.getScheme() + "]"); return true; } @@ -607,7 +620,7 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request @Override public WebResourceResponse shouldInterceptRequest(WebView view, String url) { - return interceptScheme(Uri.parse(url)); + return interceptScheme(url == null ? null : Uri.parse(url)); } @Override @@ -616,10 +629,11 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque } // Blocks dangerous local/script sub-resource schemes (file/content/javascript/jar/data), - // mirroring CountlyWebViewClient. http/https (the content itself) always load. + // mirroring CountlyWebViewClient. https (the content itself) always loads; other schemes + // follow the configured allow-list / default denylist. private WebResourceResponse interceptScheme(Uri uri) { String scheme = uri == null ? null : uri.getScheme(); - if (!Utils.isWebContentSchemeAllowed(scheme, null)) { + if (!Utils.isWebContentSchemeAllowed(scheme, allowedSchemes)) { Countly.sharedInstance().L.v("[FeedbackDialogWebViewClient] Blocked sub-resource with disallowed scheme: [" + uri + "]"); return Utils.blankWebResourceResponse(); } diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java index c8aa57fec..abfebe6d8 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java @@ -577,7 +577,7 @@ public void onClick(DialogInterface dialog, int which) { return; } - Set allowedDialogSchemes = countlyConfigPush != null ? countlyConfigPush.allowedIntentSchemes : null; + Set allowedDialogSchemes = countlyConfigPush.allowedIntentSchemes; if (!Utils.isExternalSchemeAllowed(msg.link().getScheme(), allowedDialogSchemes)) { Countly.sharedInstance().L.w("[CountlyPush, displayDialog] Blocked dialog link with disallowed scheme: [" + msg.link().getScheme() + "]"); return; @@ -636,7 +636,7 @@ public void onClick(DialogInterface dialog, int which) { try { msg.recordAction(context, isPositiveButtonPressed ? 2 : 1); - Set allowedDialogSchemes = countlyConfigPush != null ? countlyConfigPush.allowedIntentSchemes : null; + Set allowedDialogSchemes = countlyConfigPush.allowedIntentSchemes; if (!Utils.isExternalSchemeAllowed(buttonLink == null ? null : buttonLink.getScheme(), allowedDialogSchemes)) { Countly.sharedInstance().L.w("[CountlyPush, dialog button onClick] Blocked dialog button link with disallowed scheme: [" + (buttonLink == null ? null : buttonLink.getScheme()) + "]"); return; From c8f340db588b67f3fcc3b7ce3d785aa46345ce69 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 30 Jun 2026 09:51:24 +0300 Subject: [PATCH 14/24] feat: more tests --- .../sdk/FeedbackDialogWebViewClientTests.java | 94 +++++++++++++++++++ .../java/ly/count/android/sdk/UtilsTests.java | 70 ++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java new file mode 100644 index 000000000..20fbbb297 --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java @@ -0,0 +1,94 @@ +package ly.count.android.sdk; + +import android.net.Uri; +import android.webkit.WebResourceRequest; +import android.webkit.WebResourceResponse; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * Locks in the security hardening of the rating/feedback-dialog WebView client (the twin of + * {@link CountlyWebViewClient}, which is covered by CountlyWebViewClientTests). Without these, a + * regression that drops the scheme blocking or the allow-list threading would ship silently. + */ +@RunWith(AndroidJUnit4.class) +public class FeedbackDialogWebViewClientTests { + + private WebResourceRequest fakeRequest(String url) { + Uri uri = Uri.parse(url); + return new WebResourceRequest() { + @Override public Uri getUrl() { + return uri; + } + + @Override public boolean isForMainFrame() { + return false; + } + + @Override public boolean isRedirect() { + return false; + } + + @Override public boolean hasGesture() { + return false; + } + + @Override public String getMethod() { + return "GET"; + } + + @Override public Map getRequestHeaders() { + return new HashMap<>(); + } + }; + } + + private void assertBlocked(WebResourceResponse response) { + Assert.assertNotNull(response); + Assert.assertNotNull(response.getData()); + } + + /** Dangerous local/script sub-resource schemes are blocked; https/http load (default denylist). */ + @Test + public void shouldInterceptRequest_defaultDenylist() { + ModuleRatings.FeedbackDialogWebViewClient client = new ModuleRatings.FeedbackDialogWebViewClient(null); + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("https://example.com/a.png"))); + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("http://example.com/a.js"))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("file:///data/data/ly.count.android.sdk/shared_prefs/secret.xml"))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("content://com.app.provider/private"))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("javascript:alert(document.cookie)"))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("jar:file:///x.apk!/a.html"))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("data:text/html,"))); + } + + /** With a configured allow-list, https still loads but http and other unlisted schemes are blocked. */ + @Test + public void shouldInterceptRequest_allowlistThreaded() { + ModuleRatings.FeedbackDialogWebViewClient client = + new ModuleRatings.FeedbackDialogWebViewClient(new HashSet<>(Arrays.asList("myapp"))); + // https always loads (serves the widget itself) + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("https://example.com/a.png"))); + // a listed non-web scheme loads + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("myapp://x"))); + // http is not auto-allowed in allow-list mode unless listed + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("http://example.com/a.png"))); + // unlisted non-web schemes blocked + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("market://details?id=x"))); + assertBlocked(client.shouldInterceptRequest(null, fakeRequest("file:///etc/hosts"))); + } + + /** The deprecated String overload must not NPE on a null url (a null scheme is blocked, fail-secure). */ + @Test + public void shouldInterceptRequest_stringOverload_nullSafe() { + ModuleRatings.FeedbackDialogWebViewClient client = new ModuleRatings.FeedbackDialogWebViewClient(null); + assertBlocked(client.shouldInterceptRequest(null, (String) null)); // null url -> null scheme -> blocked, no NPE + assertBlocked(client.shouldInterceptRequest(null, "file:///etc/hosts")); + Assert.assertNull(client.shouldInterceptRequest(null, "https://example.com/a.png")); + } +} diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java index df9f4aa7a..17d6e6820 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java @@ -3,9 +3,12 @@ import android.os.Build; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -231,4 +234,71 @@ public void combineParamsIntoRequest_validValues() { params.put("ccc", "ddd"); Assert.assertEquals("aaa=bbb&ccc=ddd", Utils.combineParamsIntoRequest(params)); } + + // ===== Scheme policy (shared chokepoint for content / feedback / push link + sub-resource security) ===== + + /** Default (no allow-list) mode: dangerous local/script schemes are denied, everything else allowed. */ + @Test + public void isExternalSchemeAllowed_defaultDenylist() { + // allowed (not dangerous) + Assert.assertTrue(Utils.isExternalSchemeAllowed("https", null)); + Assert.assertTrue(Utils.isExternalSchemeAllowed("http", null)); + Assert.assertTrue(Utils.isExternalSchemeAllowed("myapp", null)); + Assert.assertTrue(Utils.isExternalSchemeAllowed("market", new HashSet<>())); + Assert.assertTrue(Utils.isExternalSchemeAllowed("tel", null)); + Assert.assertTrue(Utils.isExternalSchemeAllowed("mailto", null)); + // denied (dangerous), case-insensitive + Assert.assertFalse(Utils.isExternalSchemeAllowed("file", null)); + Assert.assertFalse(Utils.isExternalSchemeAllowed("content", null)); + Assert.assertFalse(Utils.isExternalSchemeAllowed("javascript", null)); + Assert.assertFalse(Utils.isExternalSchemeAllowed("jar", null)); + Assert.assertFalse(Utils.isExternalSchemeAllowed("data", null)); + Assert.assertFalse(Utils.isExternalSchemeAllowed("FILE", null)); + Assert.assertFalse(Utils.isExternalSchemeAllowed("JavaScript", null)); + // null scheme is never allowed + Assert.assertFalse(Utils.isExternalSchemeAllowed(null, null)); + } + + /** Allow-list mode: only listed schemes pass (case-insensitive), the default denylist is omitted. */ + @Test + public void isExternalSchemeAllowed_allowlistMode() { + Set allow = new HashSet<>(Arrays.asList("https", "myapp")); + Assert.assertTrue(Utils.isExternalSchemeAllowed("https", allow)); + Assert.assertTrue(Utils.isExternalSchemeAllowed("HTTPS", allow)); + Assert.assertTrue(Utils.isExternalSchemeAllowed("myapp", allow)); + Assert.assertFalse(Utils.isExternalSchemeAllowed("http", allow)); // not listed + Assert.assertFalse(Utils.isExternalSchemeAllowed("market", allow)); // not listed + Assert.assertFalse(Utils.isExternalSchemeAllowed("file", allow)); // not listed + // an explicit allow-list can even opt a normally-dangerous scheme back in + Assert.assertTrue(Utils.isExternalSchemeAllowed("content", new HashSet<>(Arrays.asList("content")))); + } + + /** Sub-resource policy: https always loads (serves the content); other schemes follow the link policy. */ + @Test + public void isWebContentSchemeAllowed_httpsAlwaysAllowed_httpOptIn() { + // https always allowed, even in allow-list mode that omits it + Assert.assertTrue(Utils.isWebContentSchemeAllowed("https", null)); + Assert.assertTrue(Utils.isWebContentSchemeAllowed("HTTPS", new HashSet<>(Arrays.asList("myapp")))); + // http: allowed in default denylist mode, but NOT auto-allowed in allow-list mode (opt-in) + Assert.assertTrue(Utils.isWebContentSchemeAllowed("http", null)); + Assert.assertFalse(Utils.isWebContentSchemeAllowed("http", new HashSet<>(Arrays.asList("myapp")))); + Assert.assertTrue(Utils.isWebContentSchemeAllowed("http", new HashSet<>(Arrays.asList("http")))); + // dangerous schemes still blocked + Assert.assertFalse(Utils.isWebContentSchemeAllowed("file", null)); + Assert.assertFalse(Utils.isWebContentSchemeAllowed("javascript", null)); + Assert.assertFalse(Utils.isWebContentSchemeAllowed("content", null)); + Assert.assertFalse(Utils.isWebContentSchemeAllowed(null, null)); + } + + /** normalizeSchemeSet lower-cases, tolerates nulls, and never returns null. */ + @Test + public void normalizeSchemeSet_nullSafeLowercased() { + Assert.assertTrue(Utils.normalizeSchemeSet(null).isEmpty()); + Set out = Utils.normalizeSchemeSet(Arrays.asList("HTTPS", "MyApp", null, "tel")); + Assert.assertEquals(3, out.size()); + Assert.assertTrue(out.contains("https")); + Assert.assertTrue(out.contains("myapp")); + Assert.assertTrue(out.contains("tel")); + Assert.assertFalse(out.contains("HTTPS")); + } } From 82c093a018fadd9072caf60f6a03477141c162de Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 30 Jun 2026 14:42:57 +0300 Subject: [PATCH 15/24] fix: null check --- .../java/ly/count/android/sdk/messaging/CountlyPush.java | 8 ++++---- .../count/android/sdk/messaging/CountlyPushActivity.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java index abfebe6d8..1e59c11a0 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java @@ -572,12 +572,12 @@ public void onClick(DialogInterface dialog, int which) { msg.recordAction(activity, 0); dialog.dismiss(); - if (countlyConfigPush.notificationButtonURLHandler != null && countlyConfigPush.notificationButtonURLHandler.onClick(msg.link().toString(), activity)) { + if (countlyConfigPush != null && countlyConfigPush.notificationButtonURLHandler != null && countlyConfigPush.notificationButtonURLHandler.onClick(msg.link().toString(), activity)) { Countly.sharedInstance().L.d("[CountlyPush, displayDialog] Link handled by custom URL handler, skipping default link opening."); return; } - Set allowedDialogSchemes = countlyConfigPush.allowedIntentSchemes; + Set allowedDialogSchemes = countlyConfigPush != null ? countlyConfigPush.allowedIntentSchemes : null; if (!Utils.isExternalSchemeAllowed(msg.link().getScheme(), allowedDialogSchemes)) { Countly.sharedInstance().L.w("[CountlyPush, displayDialog] Blocked dialog link with disallowed scheme: [" + msg.link().getScheme() + "]"); return; @@ -628,7 +628,7 @@ public void onClick(DialogInterface dialog, int which) { boolean isPositiveButtonPressed = (which == DialogInterface.BUTTON_POSITIVE); Uri buttonLink = msg.buttons().get(isPositiveButtonPressed ? 1 : 0).link(); - if (countlyConfigPush.notificationButtonURLHandler != null && countlyConfigPush.notificationButtonURLHandler.onClick(buttonLink == null ? null : buttonLink.toString(), context)) { + if (countlyConfigPush != null && countlyConfigPush.notificationButtonURLHandler != null && countlyConfigPush.notificationButtonURLHandler.onClick(buttonLink == null ? null : buttonLink.toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, dialog button onClick] Link handled by custom URL handler, skipping default link opening."); return; } @@ -636,7 +636,7 @@ public void onClick(DialogInterface dialog, int which) { try { msg.recordAction(context, isPositiveButtonPressed ? 2 : 1); - Set allowedDialogSchemes = countlyConfigPush.allowedIntentSchemes; + Set allowedDialogSchemes = countlyConfigPush != null ? countlyConfigPush.allowedIntentSchemes : null; if (!Utils.isExternalSchemeAllowed(buttonLink == null ? null : buttonLink.getScheme(), allowedDialogSchemes)) { Countly.sharedInstance().L.w("[CountlyPush, dialog button onClick] Blocked dialog button link with disallowed scheme: [" + (buttonLink == null ? null : buttonLink.getScheme()) + "]"); return; diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java index f2812f7ba..a1b8e79a7 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java @@ -197,7 +197,7 @@ private void performPushAction(Intent activityIntent) { if (message.link() != null) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Starting activity with given link. Push body. [" + message.link() + "]"); - if (CountlyPush.countlyConfigPush.notificationButtonURLHandler != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(message.link().toString(), context)) { + if (CountlyPush.countlyConfigPush != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(message.link().toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Link handled by custom URL handler, skipping default link opening."); return; } @@ -233,7 +233,7 @@ private void performPushAction(Intent activityIntent) { return; } - if (CountlyPush.countlyConfigPush.notificationButtonURLHandler != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(buttonLink.toString(), context)) { + if (CountlyPush.countlyConfigPush != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(buttonLink.toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Link handled by custom URL handler, skipping default link opening."); return; } From 1b343e13e6f4a7389226841f10fada743c7ba331 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 30 Jun 2026 15:14:40 +0300 Subject: [PATCH 16/24] fix: add null check --- .../sdk/messaging/CountlyPushActivity.java | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java index a1b8e79a7..e1daf90cb 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java @@ -84,6 +84,19 @@ static boolean isLinkSchemeAllowed(Uri link, Set allowedSchemes) { return Utils.isExternalSchemeAllowed(link == null ? null : link.getScheme(), allowedSchemes); } + /** + * Whether the integrator's custom URL handler consumed the link. Null-safe against an + * un-initialized push config: {@code CountlyPush.countlyConfigPush} can be null when the + * activity runs in a fresh process (e.g. the notification is tapped after the app process was + * killed and push was not re-initialized), so this must not dereference it blindly. Returns + * false (handler did not consume the link) when there is no config or no handler. + */ + static boolean linkHandledByCustomHandler(String url, Context context) { + return CountlyPush.countlyConfigPush != null + && CountlyPush.countlyConfigPush.notificationButtonURLHandler != null + && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(url, context); + } + /** * Validates the push activity intent and returns the inner intent that is safe to act on, or * null if the push must be rejected. Encapsulates the redirection guards so they are testable: @@ -197,7 +210,7 @@ private void performPushAction(Intent activityIntent) { if (message.link() != null) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Starting activity with given link. Push body. [" + message.link() + "]"); - if (CountlyPush.countlyConfigPush != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(message.link().toString(), context)) { + if (linkHandledByCustomHandler(message.link().toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Link handled by custom URL handler, skipping default link opening."); return; } @@ -233,7 +246,7 @@ private void performPushAction(Intent activityIntent) { return; } - if (CountlyPush.countlyConfigPush != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler != null && CountlyPush.countlyConfigPush.notificationButtonURLHandler.onClick(buttonLink.toString(), context)) { + if (linkHandledByCustomHandler(buttonLink.toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Link handled by custom URL handler, skipping default link opening."); return; } From 8356fa8adc3af1edb973856207ec2b252dc7573e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Tue, 30 Jun 2026 15:44:33 +0300 Subject: [PATCH 17/24] feat: last test additions --- .../android/sdk/messaging/PushTests.java | 82 +++++++++++++++++++ .../android/sdk/messaging/CountlyPush.java | 3 +- .../sdk/messaging/CountlyPushActivity.java | 5 ++ 3 files changed, 89 insertions(+), 1 deletion(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java index 83620b905..c19837613 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java @@ -190,6 +190,88 @@ public void isLinkSchemeAllowed_allowlistRestricts() { Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("content://x"), new HashSet<>(Arrays.asList("content")))); } + // ---- linkHandledByCustomHandler: null-safety against an un-initialized push config ---- + + /** + * Regression: tapping a push link must not NPE when CountlyPush has not been initialized in this + * process (countlyConfigPush == null) — e.g. the notification is tapped after the app process + * was killed. The handler check must return false (not crash), so the scheme guard then runs. + */ + @Test + public void linkHandledByCustomHandler_nullConfig_noCrashReturnsFalse() { + CountlyConfigPush saved = CountlyPush.countlyConfigPush; + try { + CountlyPush.countlyConfigPush = null; + Assert.assertFalse(CountlyPushActivity.linkHandledByCustomHandler("https://x.com", ctx())); + Assert.assertFalse(CountlyPushActivity.linkHandledByCustomHandler("file:///etc/hosts", ctx())); + } finally { + CountlyPush.countlyConfigPush = saved; + } + } + + /** With a config but no handler set, the link is not consumed (and no crash). */ + @Test + public void linkHandledByCustomHandler_configWithoutHandler_returnsFalse() { + CountlyConfigPush saved = CountlyPush.countlyConfigPush; + try { + CountlyPush.countlyConfigPush = new CountlyConfigPush((android.app.Application) ctx().getApplicationContext()); + Assert.assertFalse(CountlyPushActivity.linkHandledByCustomHandler("https://x.com", ctx())); + } finally { + CountlyPush.countlyConfigPush = saved; + } + } + + /** A handler that claims the link returns true; one that declines returns false. */ + @Test + public void linkHandledByCustomHandler_handlerDecision_isHonored() { + CountlyConfigPush saved = CountlyPush.countlyConfigPush; + try { + CountlyPush.countlyConfigPush = new CountlyConfigPush((android.app.Application) ctx().getApplicationContext()) + .setNotificationButtonURLHandler((url, context) -> true); + Assert.assertTrue(CountlyPushActivity.linkHandledByCustomHandler("https://x.com", ctx())); + + CountlyPush.countlyConfigPush = new CountlyConfigPush((android.app.Application) ctx().getApplicationContext()) + .setNotificationButtonURLHandler((url, context) -> false); + Assert.assertFalse(CountlyPushActivity.linkHandledByCustomHandler("https://x.com", ctx())); + } finally { + CountlyPush.countlyConfigPush = saved; + } + } + + // ---- config -> intent-extra wiring: enableAdditionalIntentRedirectionChecks() / setAllowedIntentSchemes() reach the guards ---- + + /** + * Pins the customer-facing wiring: the values createPushActivityIntent is given (sourced from + * CountlyConfigPush at display time) must land on the built intent under the exact extra keys the + * activity reads, and feed the guards. A key rename or value flip here would silently disable a + * configured protection while every direct-arg guard test still passed. + */ + @Test + public void createPushActivityIntent_writesConfigExtras_consumedByGuards() { + Map data = new HashMap<>(); + data.put("c.i", "wiring_test"); + data.put("message", "m"); + CountlyPush.Message msg = CountlyPush.decodeMessage(data); + + // Explicit inner intent (the SDK test app has no launcher, so getLaunchIntentForPackage is null). + Intent built = CountlyPush.createPushActivityIntent(ctx(), msg, ownTargetInner(), 0, + new HashSet<>(Arrays.asList("com.example.app.MainActivity")), + new HashSet<>(Arrays.asList("com.example.app")), + true, + new HashSet<>(Arrays.asList("https"))); + + // enableAdditionalIntentRedirectionChecks() -> ADDITIONAL_INTENT_REDIRECTION_CHECKS=true on the intent + Assert.assertTrue(built.getBooleanExtra(CountlyPush.ADDITIONAL_INTENT_REDIRECTION_CHECKS, false)); + Assert.assertTrue(built.getStringArrayListExtra(CountlyPush.ALLOWED_CLASS_NAMES).contains("com.example.app.MainActivity")); + Assert.assertTrue(built.getStringArrayListExtra(CountlyPush.ALLOWED_PACKAGE_NAMES).contains("com.example.app")); + Assert.assertNotNull(built.getParcelableExtra(CountlyPush.EXTRA_INTENT)); + + // setAllowedIntentSchemes(["https"]) -> the scheme allow-list reaches isLinkSchemeAllowed as an allow-list + Set schemes = new HashSet<>(built.getStringArrayListExtra(CountlyPush.ALLOWED_INTENT_SCHEMES)); + Assert.assertTrue(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("https://x"), schemes)); + Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("countly://x"), schemes)); + } + // ---- validatePushIntent: the performPushAction guards (R1-R5) ---- private Context ctx() { diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java index 1e59c11a0..3a5e6e896 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java @@ -462,7 +462,8 @@ public void call(Bitmap bitmap) { return Boolean.TRUE; } - private static Intent createPushActivityIntent(@NonNull final Context context, @NonNull final Message msg, @Nullable final Intent notificationIntent, int index, @NonNull Set allowedIntentClassNames, @NonNull Set allowedIntentPackageNames, boolean useAdditionalIntentRedirectionChecks, @NonNull Set allowedIntentSchemes) { + // package-private (not private) so the config -> intent-extra wiring is unit-testable. + static Intent createPushActivityIntent(@NonNull final Context context, @NonNull final Message msg, @Nullable final Intent notificationIntent, int index, @NonNull Set allowedIntentClassNames, @NonNull Set allowedIntentPackageNames, boolean useAdditionalIntentRedirectionChecks, @NonNull Set allowedIntentSchemes) { Intent pushActivityIntent = new Intent(context.getApplicationContext(), CountlyPushActivity.class) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); pushActivityIntent.setPackage(context.getApplicationContext().getPackageName()); diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java index e1daf90cb..6f9eaeffc 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java @@ -127,6 +127,11 @@ static Intent validatePushIntent(Context context, Intent activityIntent, Compone } } + // Caller-package check. On the normal push tap this fires via PendingIntent.getActivity, so + // getCallingActivity() is null (the system, not a startActivityForResult caller) and this + // guard is skipped — that path is safe because a PendingIntent is unforgeable and the target + // is still constrained by the own-package resolveActivity check below. The check only adds + // protection if some app does startActivityForResult into this activity directly. if (callingActivity != null) { String callingPackage = callingActivity.getPackageName(); if (!packageNameCurrent.equals(callingPackage)) { From 9b1d810a4edb4277d78716af36e87ab48824bb96 Mon Sep 17 00:00:00 2001 From: Hlib Kulykovskyi Date: Tue, 30 Jun 2026 16:59:21 +0300 Subject: [PATCH 18/24] fix: HealthCheck NPE --- .../android/sdk/ModuleHealthCheckTests.java | 67 +++++++++++++++++++ .../count/android/sdk/ModuleHealthCheck.java | 10 ++- 2 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java new file mode 100644 index 000000000..5e56fa474 --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java @@ -0,0 +1,67 @@ +package ly.count.android.sdk; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(AndroidJUnit4.class) +public class ModuleHealthCheckTests { + + @Before + public void setUp() { + TestUtils.getCountlyStore().clear(); + Countly.sharedInstance().halt(); + } + + @After + public void tearDown() { + TestUtils.getCountlyStore().clear(); + Countly.sharedInstance().halt(); + } + + /** + * A health check response that arrives after the SDK has been halted (e.g. an + * init/halt/init reinit cycle on a slow network) must not crash with an NPE + * when it tries to reset the now-null health counter. + */ + @Test + public void healthCheckCallback_afterHalt_doesNotCrash() throws JSONException { + // Capture the callback the health check registers, instead of doing real networking. + ImmediateRequestI requestMaker = mock(ImmediateRequestI.class); + ImmediateRequestGenerator generator = mock(ImmediateRequestGenerator.class); + when(generator.CreateImmediateRequestMaker()).thenReturn(requestMaker); + + CountlyConfig config = TestUtils.createBaseConfig(); + config.immediateRequestGenerator = generator; + + Countly countly = new Countly().init(config); // initFinished() -> sendHealthCheck() + + ArgumentCaptor cb = + ArgumentCaptor.forClass(ImmediateRequestMaker.InternalImmediateRequestCallback.class); + Mockito.verify(requestMaker).doWork( + ArgumentMatchers.anyString(), + ArgumentMatchers.eq("/i"), + ArgumentMatchers.any(), + ArgumentMatchers.anyBoolean(), + ArgumentMatchers.anyBoolean(), + cb.capture(), + ArgumentMatchers.any() + ); + + // Simulate the reinit race: SDK halted while the request was in flight. + countly.halt(); + + // Delivering a successful response must not throw (regression: NPE on hCounter). + cb.getValue().callback(new JSONObject("{\"result\":\"Success\"}")); + } +} \ No newline at end of file diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java b/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java index f44a8f261..e75c4abbe 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java @@ -36,8 +36,10 @@ void halt() { @Override void onActivityStopped(int updatedActivityCount) { + if (hCounter != null) { hCounter.saveState(); } + } void sendHealthCheck() { L.v("[ModuleHealthCheck] sendHealthCheck, attempting to send health information"); @@ -83,9 +85,15 @@ void sendHealthCheck() { return; } + HealthCheckCounter counter = hCounter; + if (counter == null) { + L.d("[ModuleHealthCheck] sendHealthCheck, SDK was halted before the response returned, skipping counter reset"); + return; + } + //at this point we can expect that the request succeed and we can clear the counters L.d("[ModuleHealthCheck] sendHealthCheck, SDK health information sent successfully"); - hCounter.clearAndSave(); + counter.clearAndSave(); }, L); } } From 9c066fc0228d61213ad27b86ea457aeedbefae01 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 1 Jul 2026 09:45:48 +0300 Subject: [PATCH 19/24] feat: final review changes --- CHANGELOG.md | 4 +- .../sdk/CountlyWebViewClientTests.java | 28 +++++++++++-- .../sdk/FeedbackDialogWebViewClientTests.java | 4 +- .../java/ly/count/android/sdk/UtilsTests.java | 11 ++++- .../android/sdk/messaging/PushTests.java | 1 - .../android/sdk/CountlyWebViewClient.java | 14 ++++--- .../main/java/ly/count/android/sdk/Utils.java | 15 ++++--- .../android/sdk/messaging/CountlyPush.java | 40 +++++++++++++------ .../sdk/messaging/CountlyPushActivity.java | 16 +------- 9 files changed, 85 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62101ef80..3cd38579a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,8 @@ * Added a new push configuration option "setAllowedIntentSchemes(List)" to restrict which URI schemes notification links may open. * Added a new configuration option "disableWebView()" to disable all WebView-based UI in the SDK, including the content overlay, feedback widgets, and the rating popup. * Added a new config option "disableSDKLoggingInProduction()" that keeps the SDK's console logging disabled in production (non-debuggable) builds, even when logging is enabled. -* Improved the security of the content, feedback widget, and rating widget web views, disabled local file/content access, disallowed mixed content, enabled Safe Browsing, blocked sub-resources with dangerous schemes (file, content, javascript, jar, data) while always allowing https, and blocked links with dangerous schemes. -* Improved the security of push notification click handling, null-safe and stricter package/class validation, reduced intent flags, no longer forwarding the payload to external apps, and blocking notification links with dangerous schemes (file, content, javascript, jar, data) by default. +* Improved the security of the content, feedback widget, and rating widget web views, disabled local file/content access, disallowed mixed content, enabled Safe Browsing, blocked sub-resources with dangerous schemes (file, content, javascript, jar) while always allowing https, and blocked links with dangerous schemes. +* Improved the security of push notification click handling, null-safe and stricter package/class validation, reduced intent flags, no longer forwarding the payload to external apps, and blocking notification links with dangerous schemes (file, content, javascript, jar) by default. * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. * Mitigated an issue where the rating feedback popup request could be sent while in temporary device ID mode, creating a `CLYTemporaryDeviceID` user on the server. 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 63685665e..2acd09d40 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java @@ -333,9 +333,11 @@ public void shouldInterceptRequest_httpAndHttps_allowed() { } /** - * "shouldInterceptRequest" must block every non-http(s) scheme an attacker could use to read - * local data or run script from a content sub-resource (img/iframe/script/xhr): file://, - * content://, javascript:, data:, jar:file://, plus a file:// pointing at app-private storage. + * "shouldInterceptRequest" must block every local-data / script scheme an attacker could use to + * read local data or run script from a content sub-resource (img/iframe/script/xhr): file://, + * content://, javascript:, jar:file://, plus a file:// pointing at app-private storage. "data:" + * and "blob:" are NOT blocked here — they are inline / runtime-generated assets widgets embed + * (covered by shouldInterceptRequest_inlineAssetSchemes_allowed). */ @Test public void shouldInterceptRequest_nonWebSchemes_blocked() { @@ -344,12 +346,30 @@ public void shouldInterceptRequest_nonWebSchemes_blocked() { assertBlocked(client.shouldInterceptRequest(null, fakeRequest("content://com.app.provider/private", false))); assertBlocked(client.shouldInterceptRequest(null, fakeRequest("content://media/external/images/media/1", false))); assertBlocked(client.shouldInterceptRequest(null, fakeRequest("javascript:alert(document.cookie)", false))); - assertBlocked(client.shouldInterceptRequest(null, fakeRequest("data:text/html,", false))); assertBlocked(client.shouldInterceptRequest(null, fakeRequest("jar:file:///data/app/x.apk!/a.html", false))); // also blocked for a main-frame request, not just sub-resources assertBlocked(client.shouldInterceptRequest(null, fakeRequest("file:///data/data/ly.count.android.sdk/databases/countly.db", true))); } + /** + * "data:" and "blob:" sub-resources (inline images/fonts/CSS and runtime-generated assets that + * widgets legitimately embed) load in the default (no allow-list) mode, but like any other + * non-https scheme they are blocked in allow-list mode unless the integrator lists them. + */ + @Test + public void shouldInterceptRequest_inlineAssetSchemes_defaultAllowedAllowlistGoverned() { + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("data:image/png;base64,iVBORw0KGgo=", false))); + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("blob:https://example.com/uuid", false))); + // allow-list mode governs data/blob (not force-allowed): blocked unless listed + CountlyWebViewClient allowlisted = new CountlyWebViewClient(new HashSet<>(Arrays.asList("myapp"))); + assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("data:image/png;base64,iVBORw0KGgo=", false))); + assertBlocked(allowlisted.shouldInterceptRequest(null, fakeRequest("blob:https://example.com/uuid", false))); + // https still always loads, and an explicitly listed inline scheme loads + Assert.assertNull(allowlisted.shouldInterceptRequest(null, fakeRequest("https://example.com/a.png", false))); + CountlyWebViewClient dataAllowed = new CountlyWebViewClient(new HashSet<>(Arrays.asList("data"))); + Assert.assertNull(dataAllowed.shouldInterceptRequest(null, fakeRequest("data:image/png;base64,iVBORw0KGgo=", false))); + } + private void assertBlocked(WebResourceResponse response) { Assert.assertNotNull(response); Assert.assertNotNull(response.getData()); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java index 20fbbb297..192960a7d 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java @@ -64,7 +64,9 @@ public void shouldInterceptRequest_defaultDenylist() { assertBlocked(client.shouldInterceptRequest(null, fakeRequest("content://com.app.provider/private"))); assertBlocked(client.shouldInterceptRequest(null, fakeRequest("javascript:alert(document.cookie)"))); assertBlocked(client.shouldInterceptRequest(null, fakeRequest("jar:file:///x.apk!/a.html"))); - assertBlocked(client.shouldInterceptRequest(null, fakeRequest("data:text/html,"))); + // data:/blob: are inline / runtime-generated assets widgets embed -> load normally + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("data:image/png;base64,iVBORw0KGgo="))); + Assert.assertNull(client.shouldInterceptRequest(null, fakeRequest("blob:https://example.com/uuid"))); } /** With a configured allow-list, https still loads but http and other unlisted schemes are blocked. */ diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java index 17d6e6820..70087117d 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java @@ -247,12 +247,14 @@ public void isExternalSchemeAllowed_defaultDenylist() { Assert.assertTrue(Utils.isExternalSchemeAllowed("market", new HashSet<>())); Assert.assertTrue(Utils.isExternalSchemeAllowed("tel", null)); Assert.assertTrue(Utils.isExternalSchemeAllowed("mailto", null)); + // data/blob are inline assets, not local-data/script -> not in the denylist, allowed by default + Assert.assertTrue(Utils.isExternalSchemeAllowed("data", null)); + Assert.assertTrue(Utils.isExternalSchemeAllowed("blob", null)); // denied (dangerous), case-insensitive Assert.assertFalse(Utils.isExternalSchemeAllowed("file", null)); Assert.assertFalse(Utils.isExternalSchemeAllowed("content", null)); Assert.assertFalse(Utils.isExternalSchemeAllowed("javascript", null)); Assert.assertFalse(Utils.isExternalSchemeAllowed("jar", null)); - Assert.assertFalse(Utils.isExternalSchemeAllowed("data", null)); Assert.assertFalse(Utils.isExternalSchemeAllowed("FILE", null)); Assert.assertFalse(Utils.isExternalSchemeAllowed("JavaScript", null)); // null scheme is never allowed @@ -283,6 +285,13 @@ public void isWebContentSchemeAllowed_httpsAlwaysAllowed_httpOptIn() { Assert.assertTrue(Utils.isWebContentSchemeAllowed("http", null)); Assert.assertFalse(Utils.isWebContentSchemeAllowed("http", new HashSet<>(Arrays.asList("myapp")))); Assert.assertTrue(Utils.isWebContentSchemeAllowed("http", new HashSet<>(Arrays.asList("http")))); + // data/blob are inline / runtime-generated assets -> allowed as sub-resources in default + // mode, but (unlike https) governed by the allow-list when one is configured + Assert.assertTrue(Utils.isWebContentSchemeAllowed("data", null)); + Assert.assertTrue(Utils.isWebContentSchemeAllowed("blob", null)); + Assert.assertFalse(Utils.isWebContentSchemeAllowed("DATA", new HashSet<>(Arrays.asList("myapp")))); + Assert.assertFalse(Utils.isWebContentSchemeAllowed("blob", new HashSet<>(Arrays.asList("myapp")))); + Assert.assertTrue(Utils.isWebContentSchemeAllowed("data", new HashSet<>(Arrays.asList("data")))); // dangerous schemes still blocked Assert.assertFalse(Utils.isWebContentSchemeAllowed("file", null)); Assert.assertFalse(Utils.isWebContentSchemeAllowed("javascript", null)); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java index c19837613..ee78fecd1 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/messaging/PushTests.java @@ -168,7 +168,6 @@ public void isLinkSchemeAllowed_defaultBlocksLocalAndScriptSchemes() { Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("content://app.provider/secret"), null)); Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("javascript:alert(1)"), null)); Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("jar:file:///x.apk!/a.html"), null)); - Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("data:text/html,"), null)); Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("FILE:///x"), null)); Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(null, null)); Assert.assertFalse(CountlyPushActivity.isLinkSchemeAllowed(Uri.parse("/no/scheme/path"), null)); 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 229d493be..639949862 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -69,14 +69,16 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request @Override public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { - String scheme = request.getUrl().getScheme(); - // Sub-resources (images, frames, scripts) follow the shared scheme policy, except https + Uri url = request == null ? null : request.getUrl(); + String scheme = url == null ? null : url.getScheme(); + // Sub-resources (images, frames, scripts) follow the shared scheme policy, except https which // always loads because it serves the content itself: with no allow-list the dangerous - // local/script schemes (file, content, javascript, jar, data) are blocked; when an allow-list - // is configured, only those schemes (plus https) load. This keeps an outbound-link allow-list - // from accidentally blocking the page's own https assets, while http stays integrator-decided. + // local/script schemes (file, content, javascript, jar) are blocked while inline data/blob + // assets load; when an allow-list is configured, only those schemes (plus https) load. This + // keeps an outbound-link allow-list from blocking the page's own https assets, while http and + // data/blob stay integrator-decided. A null scheme (e.g. a malformed request) is blocked, fail-secure. if (!Utils.isWebContentSchemeAllowed(scheme, allowedSchemes)) { - Log.v(Countly.TAG, "[CountlyWebViewClient] shouldInterceptRequest, blocked sub-resource with disallowed scheme: [" + request.getUrl() + "]"); + Log.v(Countly.TAG, "[CountlyWebViewClient] shouldInterceptRequest, blocked sub-resource with disallowed scheme: [" + url + "]"); return Utils.blankWebResourceResponse(); } return null; diff --git a/sdk/src/main/java/ly/count/android/sdk/Utils.java b/sdk/src/main/java/ly/count/android/sdk/Utils.java index a5f72806f..4d08c9f39 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Utils.java +++ b/sdk/src/main/java/ly/count/android/sdk/Utils.java @@ -39,9 +39,12 @@ public class Utils { /** * Schemes that are never dispatched to ACTION_VIEW from server-controlled content, because they - * can read local data or run script. Used as the default denylist when no scheme allow-list is set. + * can read local data or run script. Used as the default denylist when no scheme allow-list is + * set. "data" and "blob" are intentionally NOT here — they are inline / runtime-generated assets + * (images, fonts, CSS) widgets legitimately embed and cannot read app-local data, so they load + * in the default mode and, like any other scheme, follow an allow-list when one is configured. */ - private static final Set DANGEROUS_INTENT_SCHEMES = new HashSet<>(Arrays.asList("file", "content", "javascript", "jar", "data")); + private static final Set DANGEROUS_INTENT_SCHEMES = new HashSet<>(Arrays.asList("file", "content", "javascript", "jar")); /** * Scheme policy for externally-dispatched links (content overlay links, push notification links). @@ -65,10 +68,10 @@ public static boolean isExternalSchemeAllowed(String scheme, Set allowed * Whether a WebView sub-resource (image, script, stylesheet, frame) with this scheme may load. * "https" always loads because it is how the content itself is served, so an outbound-link * allow-list (which may list only a custom deep-link scheme such as "myapp") does not - * accidentally block the page's own https assets. Every other scheme — including plain "http" — - * follows the shared link policy, so the dangerous local/script schemes stay blocked and the - * integrator decides whether to permit http (allowed by the default denylist, opt-in when an - * allow-list is configured). + * accidentally block the page's own https assets. Every other scheme — including plain "http", + * "data" and "blob" — follows the shared link policy: the dangerous local/script schemes stay + * blocked, inline data/blob assets load in the default mode, and an allow-list (when configured) + * governs everything except https. */ public static boolean isWebContentSchemeAllowed(String scheme, Set allowedSchemes) { if (scheme != null && "https".equals(scheme.toLowerCase(Locale.ROOT))) { diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java index 3a5e6e896..ce6f548bf 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPush.java @@ -477,6 +477,20 @@ static Intent createPushActivityIntent(@NonNull final Context context, @NonNull return pushActivityIntent; } + /** + * Attaches the push payload extras ({@link #EXTRA_MESSAGE} / {@link #EXTRA_ACTION_INDEX}) to an + * outgoing ACTION_VIEW intent only when the link resolves to our own app, so the message data is + * not leaked to an external app that happens to handle the link. Shared by the push-activity link + * paths and the dialog button path so the leak-prevention logic lives in one place. + */ + static void forwardPayloadIfOwnApp(@NonNull Context context, @NonNull Intent intent, @NonNull Bundle messageBundle, int actionIndex) { + ComponentName linkTarget = intent.resolveActivity(context.getPackageManager()); + if (linkTarget != null && context.getPackageName().equals(linkTarget.getPackageName())) { + intent.putExtra(EXTRA_MESSAGE, messageBundle); + intent.putExtra(EXTRA_ACTION_INDEX, actionIndex); + } + } + private static Intent actionIntent(Context context, Intent notificationIntent, Message message, int index) { Intent intent; if (notificationIntent == null) { @@ -573,19 +587,25 @@ public void onClick(DialogInterface dialog, int which) { msg.recordAction(activity, 0); dialog.dismiss(); - if (countlyConfigPush != null && countlyConfigPush.notificationButtonURLHandler != null && countlyConfigPush.notificationButtonURLHandler.onClick(msg.link().toString(), activity)) { + Uri link = msg.link(); + if (link == null) { + Countly.sharedInstance().L.w("[CountlyPush, displayDialog] Dialog link is null, nothing to open"); + return; + } + + if (countlyConfigPush != null && countlyConfigPush.notificationButtonURLHandler != null && countlyConfigPush.notificationButtonURLHandler.onClick(link.toString(), activity)) { Countly.sharedInstance().L.d("[CountlyPush, displayDialog] Link handled by custom URL handler, skipping default link opening."); return; } Set allowedDialogSchemes = countlyConfigPush != null ? countlyConfigPush.allowedIntentSchemes : null; - if (!Utils.isExternalSchemeAllowed(msg.link().getScheme(), allowedDialogSchemes)) { - Countly.sharedInstance().L.w("[CountlyPush, displayDialog] Blocked dialog link with disallowed scheme: [" + msg.link().getScheme() + "]"); + if (!Utils.isExternalSchemeAllowed(link.getScheme(), allowedDialogSchemes)) { + Countly.sharedInstance().L.w("[CountlyPush, displayDialog] Blocked dialog link with disallowed scheme: [" + link.getScheme() + "]"); return; } try { - Intent i = new Intent(Intent.ACTION_VIEW, msg.link()); + Intent i = new Intent(Intent.ACTION_VIEW, link); i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); i.putExtra(EXTRA_ACTION_INDEX, 0);// put zero because non 'button' action activity.startActivity(i); @@ -645,15 +665,9 @@ public void onClick(DialogInterface dialog, int which) { Intent intent = new Intent(Intent.ACTION_VIEW, buttonLink); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - // Only forward the push payload when the link resolves to our own app, so the - // message data is not leaked to an external app that happens to handle the link. - ComponentName linkTarget = intent.resolveActivity(context.getPackageManager()); - if (linkTarget != null && context.getPackageName().equals(linkTarget.getPackageName())) { - Bundle bundle = new Bundle(); - bundle.putParcelable(EXTRA_MESSAGE, msg); - intent.putExtra(EXTRA_MESSAGE, bundle); - } - intent.putExtra(EXTRA_ACTION_INDEX, isPositiveButtonPressed ? 2 : 1); + Bundle bundle = new Bundle(); + bundle.putParcelable(EXTRA_MESSAGE, msg); + forwardPayloadIfOwnApp(context, intent, bundle, isPositiveButtonPressed ? 2 : 1); context.startActivity(intent); } catch (Exception ex) { Countly.sharedInstance().L.e("[CountlyPush, dialog button onClick] Encountered issue while clicking on button #[" + which + "] [" + ex + "]"); diff --git a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java index 6f9eaeffc..8279b24c6 100644 --- a/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java +++ b/sdk/src/main/java/ly/count/android/sdk/messaging/CountlyPushActivity.java @@ -227,13 +227,7 @@ private void performPushAction(Intent activityIntent) { Intent i = new Intent(Intent.ACTION_VIEW, message.link()); i.setFlags(Intent.FLAG_ACTIVITY_NO_HISTORY | Intent.FLAG_ACTIVITY_NEW_TASK); - // Only forward the push payload when the link resolves to our own app, so the - // message data is not leaked to an external app that happens to handle the link. - ComponentName linkTarget = i.resolveActivity(context.getPackageManager()); - if (linkTarget != null && packageNameCurrent.equals(linkTarget.getPackageName())) { - i.putExtra(EXTRA_MESSAGE, bundle); - i.putExtra(EXTRA_ACTION_INDEX, index); - } + CountlyPush.forwardPayloadIfOwnApp(context, i, bundle, index); context.startActivity(i); } else { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Starting activity without a link. Push body"); @@ -264,13 +258,7 @@ private void performPushAction(Intent activityIntent) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Starting activity with given button link. [" + (index - 1) + "] [" + buttonLink + "]"); Intent i = new Intent(Intent.ACTION_VIEW, buttonLink); i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - // Only forward the push payload when the link resolves to our own app, so the - // message data is not leaked to an external app that happens to handle the link. - ComponentName linkTarget = i.resolveActivity(context.getPackageManager()); - if (linkTarget != null && packageNameCurrent.equals(linkTarget.getPackageName())) { - i.putExtra(EXTRA_MESSAGE, bundle); - i.putExtra(EXTRA_ACTION_INDEX, index); - } + CountlyPush.forwardPayloadIfOwnApp(context, i, bundle, index); context.startActivity(i); } catch (Exception ex) { Countly.sharedInstance().L.e("[CountlyPush, displayDialog] Encountered issue while clicking on notification button [" + ex.toString() + "]"); From 0d45fbfdab39c6892cf7a584362e10ff90b10680 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray <57103426+arifBurakDemiray@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:53:37 +0300 Subject: [PATCH 20/24] fix: curly brackets tabbing --- sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java b/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java index e75c4abbe..dc84ebb4b 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java @@ -37,8 +37,8 @@ void halt() { @Override void onActivityStopped(int updatedActivityCount) { if (hCounter != null) { - hCounter.saveState(); - } + hCounter.saveState(); + } } void sendHealthCheck() { From f3cefdb4472139f5611ffd276c303744806dc600 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray <57103426+arifBurakDemiray@users.noreply.github.com> Date: Wed, 1 Jul 2026 09:56:36 +0300 Subject: [PATCH 21/24] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d66e30427..75c9301fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. * Mitigated an issue where the rating feedback popup request could be sent while in temporary device ID mode, creating a `CLYTemporaryDeviceID` user on the server. +* Mitigated an issue while sending health checks after SDK is halted. ## 26.1.3 * Added gradle configuration cache support to upload symbols plugin. From ffe22e618caecb5b5212a7e5df11c8568df8781b Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray <57103426+arifBurakDemiray@users.noreply.github.com> Date: Wed, 1 Jul 2026 10:14:46 +0300 Subject: [PATCH 22/24] feat: more tests --- .../android/sdk/ModuleHealthCheckTests.java | 287 +++++++++++++++--- 1 file changed, 248 insertions(+), 39 deletions(-) diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java index 5e56fa474..663b4be7b 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java @@ -1,18 +1,14 @@ package ly.count.android.sdk; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.net.URLDecoder; import org.json.JSONException; import org.json.JSONObject; import org.junit.After; +import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; -import org.mockito.ArgumentCaptor; -import org.mockito.ArgumentMatchers; -import org.mockito.Mockito; - -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; @RunWith(AndroidJUnit4.class) public class ModuleHealthCheckTests { @@ -30,38 +26,251 @@ public void tearDown() { } /** - * A health check response that arrives after the SDK has been halted (e.g. an - * init/halt/init reinit cycle on a slow network) must not crash with an NPE - * when it tries to reset the now-null health counter. + * A programmable {@link ImmediateRequestGenerator} that stands in for real networking. + * It records every health check request (the one carrying the "&hc=" param), the callback + * the module registered, and how many health checks were attempted. Non health-check + * requests (e.g. server config) are ignored so they cannot interfere with assertions. + */ + private static class CapturingIRG implements ImmediateRequestGenerator { + int hcCallCount = 0; + String lastHcRequestData = null; + String lastHcEndpoint = null; + ImmediateRequestMaker.InternalImmediateRequestCallback hcCallback = null; + + @Override public ImmediateRequestI CreateImmediateRequestMaker() { + return (requestData, customEndpoint, cp, requestShouldBeDelayed, networkingIsEnabled, callback, log) -> { + if (requestData != null && requestData.contains("&hc=")) { + hcCallCount++; + lastHcRequestData = requestData; + lastHcEndpoint = customEndpoint; + hcCallback = callback; + } + }; + } + + @Override public ImmediateRequestI CreatePreflightRequestMaker() { + return null; + } + } + + private Countly initWith(CapturingIRG irg, CountlyConfig config) { + config.immediateRequestGenerator = irg; + return new Countly().init(config); // initFinished() -> sendHealthCheck() + } + + /** + * Decodes the "&hc=" request parameter (URL-encoded JSON) back into a JSONObject + * so the encoded counters can be asserted on. + */ + private JSONObject decodeHcParam(String requestData) throws Exception { + int idx = requestData.indexOf("&hc="); + Assert.assertTrue("request must carry the &hc= param", idx >= 0); + String encoded = requestData.substring(idx + "&hc=".length()); + return new JSONObject(URLDecoder.decode(encoded, "UTF-8")); + } + + /** + * A default init with health check enabled must send exactly one health check request, + * to the "/i" endpoint, carrying the "&hc=" param, and mark the module as having sent it. + */ + @Test + public void init_healthCheckEnabled_sendsSingleRequestToIEndpoint() { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig()); + + Assert.assertEquals(1, irg.hcCallCount); + Assert.assertEquals("/i", irg.lastHcEndpoint); + Assert.assertTrue(irg.lastHcRequestData.contains("&hc=")); + Assert.assertTrue(countly.moduleHealthCheck.healthCheckSent); + } + + /** + * When the health check is disabled via config, init must not attempt any health check + * request and the module must reflect the disabled state. + */ + @Test + public void init_healthCheckDisabled_doesNotSend() { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig().disableHealthCheck()); + + Assert.assertEquals(0, irg.hcCallCount); + Assert.assertFalse(countly.moduleHealthCheck.healthCheckEnabled); + Assert.assertFalse(countly.moduleHealthCheck.healthCheckSent); + } + + /** + * In temporary device ID mode the health check must be aborted before sending, and + * because it never passes the guards the "sent" flag must stay false. + */ + @Test + public void init_temporaryDeviceIdMode_doesNotSend() { + // No explicit device ID: an explicit ID would take precedence over temporary mode. + CountlyConfig config = new CountlyConfig(TestUtils.getApplication(), TestUtils.commonAppKey, TestUtils.commonURL) + .setLoggingEnabled(true) + .enableTemporaryDeviceIdMode(); + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, config); + + Assert.assertEquals(0, irg.hcCallCount); + Assert.assertTrue(countly.moduleHealthCheck.healthCheckEnabled); + Assert.assertFalse(countly.moduleHealthCheck.healthCheckSent); + } + + /** + * The "already sent" guard must make repeated sendHealthCheck() calls no-ops so the SDK + * never sends more than one health check per session. + */ + @Test + public void sendHealthCheck_calledAgain_isNoOp() { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig()); + Assert.assertEquals(1, irg.hcCallCount); + + countly.moduleHealthCheck.sendHealthCheck(); + + Assert.assertEquals(1, irg.hcCallCount); + } + + /** + * The persisted counter state must be loaded on init and encoded into the outgoing "&hc=" + * param. Warning/error counts also pick up entries the SDK logs during init, so those are + * asserted as lower bounds; the network-driven fields (status code, error message, backoff) + * are untouched by a mocked init and must round-trip exactly. + */ + @Test + public void requestParam_encodesPersistedCounters() throws Exception { + JSONObject seed = new JSONObject(); + seed.put("LWar", 3); + seed.put("LErr", 2); + seed.put("RStatC", 404); + seed.put("REMsg", "boom"); + seed.put("BReq", 5); + seed.put("CBReq", 1); + TestUtils.getCountlyStore().setHealthCheckCounterState(seed.toString()); + + CapturingIRG irg = new CapturingIRG(); + initWith(irg, TestUtils.createBaseConfig()); + + JSONObject hc = decodeHcParam(irg.lastHcRequestData); + Assert.assertTrue(hc.getInt("el") >= 2); // error count (seed + any logged on init) + Assert.assertTrue(hc.getInt("wl") >= 3); // warning count (seed + any logged on init) + Assert.assertEquals(404, hc.getInt("sc")); // status code + Assert.assertEquals("boom", hc.getString("em")); // error message + Assert.assertEquals(5, hc.getInt("bom")); // backoff request count + Assert.assertEquals(1, hc.getInt("cbom")); // consecutive backoff count + } + + /** + * A successful response ("result" present) must clear the in-memory counters and wipe the + * persisted state, so the next session starts from a clean slate. + */ + @Test + public void successResponse_clearsAndSavesCounters() throws JSONException { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig()); + + HealthCheckCounter counter = countly.moduleHealthCheck.hCounter; + counter.logWarning(); + counter.logError(); + counter.saveState(); + Assert.assertFalse(TestUtils.getCountlyStore().getHealthCheckCounterState().isEmpty()); + + irg.hcCallback.callback(new JSONObject("{\"result\":\"Success\"}")); + + Assert.assertEquals(0, counter.countLogWarning); + Assert.assertEquals(0, counter.countLogError); + Assert.assertTrue(TestUtils.getCountlyStore().getHealthCheckCounterState().isEmpty()); + } + + /** + * A null response (no connection) means the send failed, so counters must be preserved + * to be retried on the next session rather than silently dropped. + */ + @Test + public void nullResponse_keepsCounters() { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig()); + + // use errors: the null-response branch itself logs a warning, which would skew a warning delta + HealthCheckCounter counter = countly.moduleHealthCheck.hCounter; + long baseline = counter.countLogError; + counter.logError(); + counter.logError(); + + irg.hcCallback.callback(null); + + // a failed send must not clear the counters + Assert.assertEquals(baseline + 2, counter.countLogError); + } + + /** + * A malformed response (no "result" field) must not be treated as success, so the + * counters must be kept rather than cleared. */ @Test - public void healthCheckCallback_afterHalt_doesNotCrash() throws JSONException { - // Capture the callback the health check registers, instead of doing real networking. - ImmediateRequestI requestMaker = mock(ImmediateRequestI.class); - ImmediateRequestGenerator generator = mock(ImmediateRequestGenerator.class); - when(generator.CreateImmediateRequestMaker()).thenReturn(requestMaker); - - CountlyConfig config = TestUtils.createBaseConfig(); - config.immediateRequestGenerator = generator; - - Countly countly = new Countly().init(config); // initFinished() -> sendHealthCheck() - - ArgumentCaptor cb = - ArgumentCaptor.forClass(ImmediateRequestMaker.InternalImmediateRequestCallback.class); - Mockito.verify(requestMaker).doWork( - ArgumentMatchers.anyString(), - ArgumentMatchers.eq("/i"), - ArgumentMatchers.any(), - ArgumentMatchers.anyBoolean(), - ArgumentMatchers.anyBoolean(), - cb.capture(), - ArgumentMatchers.any() - ); - - // Simulate the reinit race: SDK halted while the request was in flight. - countly.halt(); - - // Delivering a successful response must not throw (regression: NPE on hCounter). - cb.getValue().callback(new JSONObject("{\"result\":\"Success\"}")); - } -} \ No newline at end of file + public void responseWithoutResult_keepsCounters() throws JSONException { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig()); + + HealthCheckCounter counter = countly.moduleHealthCheck.hCounter; + counter.logError(); + + irg.hcCallback.callback(new JSONObject("{\"foo\":\"bar\"}")); + + Assert.assertEquals(1, counter.countLogError); + } + + /** + * Regression: a successful response that arrives after the SDK has been halted (an + * init/halt/init reinit cycle on a slow network) must not crash with an NPE when it tries + * to reset the now-null health counter. + */ + @Test + public void successResponse_afterHalt_doesNotCrash() throws JSONException { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig()); + ModuleHealthCheck module = countly.moduleHealthCheck; + Assert.assertNotNull(irg.hcCallback); + + module.halt(); // simulate the reinit race: halted while the request was in flight + Assert.assertNull(module.hCounter); + + irg.hcCallback.callback(new JSONObject("{\"result\":\"Success\"}")); // must not throw + } + + /** + * Regression: an activity-stopped lifecycle callback that fires after the module has been + * halted must not crash when it tries to persist the now-null counter. + */ + @Test + public void onActivityStopped_afterHalt_doesNotCrash() { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig()); + ModuleHealthCheck module = countly.moduleHealthCheck; + + module.halt(); + Assert.assertNull(module.hCounter); + + module.onActivityStopped(0); // must not throw + } + + /** + * When not halted, an activity-stopped callback must persist the current counters so they + * survive a process death between sessions. + */ + @Test + public void onActivityStopped_persistsCounterState() throws Exception { + CapturingIRG irg = new CapturingIRG(); + Countly countly = initWith(irg, TestUtils.createBaseConfig()); + + HealthCheckCounter counter = countly.moduleHealthCheck.hCounter; + counter.logWarning(); + long expected = counter.countLogWarning; + countly.moduleHealthCheck.onActivityStopped(0); + + String stored = TestUtils.getCountlyStore().getHealthCheckCounterState(); + Assert.assertFalse(stored.isEmpty()); + Assert.assertTrue(expected >= 1); + Assert.assertEquals(expected, new JSONObject(stored).getLong("LWar")); // persists the live value + } +} From 46e2b570ba3f400b764245611770625ad4860a35 Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 1 Jul 2026 12:26:21 +0300 Subject: [PATCH 23/24] chore: make changelog basic --- CHANGELOG.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cd38579a..b1e67f900 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,9 @@ * Added a new push configuration option "enableAdditionalIntentRedirectionChecks()" to enable stricter validation of the notification intent's target package and class. * Added a new content configuration option "setAllowedIntentSchemes(List)" to restrict which URI schemes content and feedback widget links may open. * Added a new push configuration option "setAllowedIntentSchemes(List)" to restrict which URI schemes notification links may open. -* Added a new configuration option "disableWebView()" to disable all WebView-based UI in the SDK, including the content overlay, feedback widgets, and the rating popup. +* Added a new configuration option "disableWebView()" to disable all WebView-based UI in the SDK. * Added a new config option "disableSDKLoggingInProduction()" that keeps the SDK's console logging disabled in production (non-debuggable) builds, even when logging is enabled. -* Improved the security of the content, feedback widget, and rating widget web views, disabled local file/content access, disallowed mixed content, enabled Safe Browsing, blocked sub-resources with dangerous schemes (file, content, javascript, jar) while always allowing https, and blocked links with dangerous schemes. -* Improved the security of push notification click handling, null-safe and stricter package/class validation, reduced intent flags, no longer forwarding the payload to external apps, and blocking notification links with dangerous schemes (file, content, javascript, jar) by default. +* Improved the security of the content, feedback widget, rating widget and push notifications. * Mitigated an issue where a native crash dump was truncated by the stack trace line length limit when a global crash filter was set. * Mitigated an issue where the rating feedback popup request could be sent while in temporary device ID mode, creating a `CLYTemporaryDeviceID` user on the server. From ccb5ae5c5d4ec8c669b36dfcdc062682bfa1538e Mon Sep 17 00:00:00 2001 From: Arif Burak Demiray Date: Wed, 1 Jul 2026 12:41:22 +0300 Subject: [PATCH 24/24] feat: 26.1.4 --- CHANGELOG.md | 2 +- gradle.properties | 2 +- sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java | 2 +- sdk/src/main/java/ly/count/android/sdk/Countly.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6db300099..6e34612c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## X.X.X +## 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. * Added support for SDK behavior settings that control the SDK's automatic session tracking, automatic view tracking, automatic crash reporting, and Journey Trigger Views. diff --git a/gradle.properties b/gradle.properties index 19eb87c0c..5447f21f4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -22,7 +22,7 @@ org.gradle.configureondemand=true android.useAndroidX=true android.enableJetifier=true # RELEASE FIELD SECTION -VERSION_NAME=26.1.3 +VERSION_NAME=26.1.4 GROUP=ly.count.android POM_URL=https://github.com/Countly/countly-sdk-android POM_SCM_URL=https://github.com/Countly/countly-sdk-android diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java index 768baf222..9553a8746 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/TestUtils.java @@ -44,7 +44,7 @@ public class TestUtils { public final static String commonAppKey = "appkey"; public final static String commonDeviceId = "1234"; public final static String SDK_NAME = "java-native-android"; - public final static String SDK_VERSION = "26.1.3"; + public final static String SDK_VERSION = "26.1.4"; public static final int MAX_THREAD_COUNT_PER_STACK_TRACE = 50; public static class Activity2 extends Activity { 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 fafec43fc..8869a9752 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Countly.java +++ b/sdk/src/main/java/ly/count/android/sdk/Countly.java @@ -47,7 +47,7 @@ of this software and associated documentation files (the "Software"), to deal */ public class Countly { - private final String DEFAULT_COUNTLY_SDK_VERSION_STRING = "26.1.3"; + private final String DEFAULT_COUNTLY_SDK_VERSION_STRING = "26.1.4"; /** * Used as request meta data on every request */