diff --git a/CHANGELOG.md b/CHANGELOG.md index c58ea896e..6e34612c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## 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. +* 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. +* 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, 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. +* 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. +* Mitigated an issue while sending health checks after SDK is halted. + ## 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/app/src/main/java/ly/count/android/demo/App.java b/app/src/main/java/ly/count/android/demo/App.java index 450f120f1..d80828c01 100644 --- a/app/src/main/java/ly/count/android/demo/App.java +++ b/app/src/main/java/ly/count/android/demo/App.java @@ -57,18 +57,13 @@ 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; + 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) { 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/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/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/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/CountlyWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java index a1d788a2e..2acd09d40 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,84 @@ 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 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() { + 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("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()); + } + + /** + * 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("myapp"))); + // https always loads regardless of the allow-list + Assert.assertNull(allowlisted.shouldInterceptRequest(null, fakeRequest("https://example.com/a.png", false))); + // 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/FeedbackDialogWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java new file mode 100644 index 000000000..192960a7d --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/FeedbackDialogWebViewClientTests.java @@ -0,0 +1,96 @@ +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"))); + // 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. */ + @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/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/ModuleContentTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java index 599d9008b..7bc48adf4 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 disableWebView() 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.disableWebView(); + 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 */ @@ -255,4 +277,102 @@ 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)); + } + + @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/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/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..663b4be7b --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleHealthCheckTests.java @@ -0,0 +1,276 @@ +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; + +@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 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 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 + } +} 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/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/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/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/androidTest/java/ly/count/android/sdk/UtilsTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsTests.java index df9f4aa7a..70087117d 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,80 @@ 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)); + // 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("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")))); + // 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)); + 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")); + } } 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..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 @@ -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,294 @@ 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 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() { + 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, even for the own package), 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("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")))); + } + + // ---- 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() { + 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 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 + 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..5e3730574 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,14 @@ package ly.count.android.sdk; +import java.util.HashSet; +import java.util.List; +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 +35,19 @@ 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 = Utils.normalizeSchemeSet(allowedIntentSchemes); + return this; + } } 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/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java index c55f4edcd..bc7e63fa9 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,21 @@ 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. Sub-resource scheme restrictions are enforced in + // CountlyWebViewClient. Shared with the feedback/ratings WebViews via Utils. + Utils.applyWebViewSecurityDefaults(settings); + 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 +1169,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/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java index 54c09e522..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 */ @@ -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; @@ -1016,9 +1023,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 +1048,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 +1134,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(); @@ -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..fcf100b94 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; @@ -212,6 +214,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; @@ -379,6 +382,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. @@ -1109,6 +1126,18 @@ public synchronized CountlyConfig setWebviewDisplayOption(WebViewDisplayOption d return this; } + /** + * 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. + * + * @return Returns the same config object for convenient linking + */ + public synchronized CountlyConfig disableWebView() { + this.webViewEnabled = false; + 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/CountlyWebViewClient.java b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java index c99edbe43..639949862 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -31,10 +31,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 +67,23 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request return false; } + @Override + public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) { + 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) 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: [" + url + "]"); + return Utils.blankWebResourceResponse(); + } + 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/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/ModuleContent.java b/sdk/src/main/java/ly/count/android/sdk/ModuleContent.java index b525c28c4..63f292b86 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; @@ -283,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; @@ -313,7 +328,8 @@ private void showContentOverlay(@NonNull Activity activity, @NonNull Map customCrashSegments = null; @@ -123,7 +126,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); } } @@ -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,11 +213,11 @@ 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); - if (!crashFilterCheck(crashData)) { + if (!crashFilterCheck(crashData, false)) { sendCrashReportToQueue(crashData, false); } } @@ -230,9 +238,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 +263,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 +331,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); @@ -353,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) { @@ -364,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..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,6 +83,14 @@ void exitTemporaryIdMode(@NonNull String deviceId) { // trigger fetching if the temp id given on init _cly.moduleConfiguration.fetchConfigFromServer(_cly.config_); + // 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.resumeContentZoneAfterTemporaryIdExit(); + } + //update stored request for ID change to use this new ID replaceTempIDWithRealIDinRQ(deviceId); @@ -135,8 +143,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 { feedbackOverlay = null; - } + }, + _cly.config_.content.allowedIntentSchemes ); feedbackOverlay.setOnWidgetCancelRunnable(() -> reportFeedbackWidgetCancelButton(widgetInfo)); 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..dc84ebb4b 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleHealthCheck.java @@ -36,7 +36,9 @@ void halt() { @Override void onActivityStopped(int updatedActivityCount) { - hCounter.saveState(); + if (hCounter != null) { + hCounter.saveState(); + } } void sendHealthCheck() { @@ -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); } } 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..a6a3b199a 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; @@ -450,6 +451,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"); @@ -457,6 +466,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; @@ -522,6 +539,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(_cly.config_.content.allowedIntentSchemes)); webView.loadUrl(ratingWidgetUrl); AlertDialog.Builder builder = new AlertDialog.Builder(activity); @@ -568,6 +587,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) { @@ -581,7 +611,15 @@ 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 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; + } + Intent intent = new Intent(Intent.ACTION_VIEW, link); view.getContext().startActivity(intent); return true; } @@ -590,13 +628,23 @@ 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(url == null ? null : 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. 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, allowedSchemes)) { + 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/ModuleSessions.java b/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java index 811341a1c..3439ab447 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java +++ b/sdk/src/main/java/ly/count/android/sdk/ModuleSessions.java @@ -124,6 +124,15 @@ void endSessionInternal() { endSessionInternal(true); } + /** + * Resolved "is automatic session tracking active" value. It is seeded from the developer config + * ('!manualSessionControlEnabled') and can be overridden by the SBS layers, so the server takes + * precedence over the developer's manual session control choice. + */ + boolean automaticSessionTrackingEnabled() { + return configProvider.getAutomaticSessionTrackingEnabled(); + } + /** * If a session has been started and is still running * @@ -156,8 +165,8 @@ void onConsentChanged(@NonNull final List 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(); } } 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..4d08c9f39 100644 --- a/sdk/src/main/java/ly/count/android/sdk/Utils.java +++ b/sdk/src/main/java/ly/count/android/sdk/Utils.java @@ -2,20 +2,28 @@ 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; +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; 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 +36,99 @@ 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. "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")); + + /** + * 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); + } + + /** + * 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", + * "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))) { + 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) { @@ -158,6 +259,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 * 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..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 @@ -6,12 +6,15 @@ import java.util.List; import java.util.Set; import ly.count.android.sdk.Countly; +import ly.count.android.sdk.Utils; public class CountlyConfigPush { Application application; Countly.CountlyMessagingProvider provider; Set allowedIntentClassNames = new HashSet<>(); Set allowedIntentPackageNames = new HashSet<>(); + Set allowedIntentSchemes = new HashSet<>(); + boolean useAdditionalIntentRedirectionChecks = false; CountlyNotificationButtonURLHandler notificationButtonURLHandler; @@ -62,6 +65,37 @@ 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. + *

+ * 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 + */ + 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 = Utils.normalizeSchemeSet(allowedIntentSchemes); + 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..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 @@ -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; @@ -57,6 +58,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 +78,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 +380,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 +403,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 +430,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,16 +462,35 @@ 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) { + // 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()); 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; } + /** + * 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) { @@ -554,13 +587,25 @@ public void onClick(DialogInterface dialog, int which) { msg.recordAction(activity, 0); dialog.dismiss(); - if (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(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); @@ -603,18 +648,26 @@ 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 != 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; } try { msg.recordAction(context, isPositiveButtonPressed ? 2 : 1); - Intent intent = new Intent(Intent.ACTION_VIEW, msg.buttons().get(isPositiveButtonPressed ? 1 : 0).link()); + + 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); Bundle bundle = new Bundle(); bundle.putParcelable(EXTRA_MESSAGE, msg); - intent.putExtra(EXTRA_MESSAGE, bundle); - intent.putExtra(EXTRA_ACTION_INDEX, isPositiveButtonPressed ? 2 : 1); + 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 3ae8f03d0..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 @@ -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,88 @@ 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 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) { + // Implicit intent has no component to validate against the allow-lists + return false; + } + + String intentPackageName = component.getPackageName(); + String intentClassName = component.getClassName(); + + // 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; + } + } + } + + if (!trustedPackage) { + return false; + } + + // 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; + } + } + } + + 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); + } + + /** + * 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: + * 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 +123,58 @@ 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; + // 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)) { + 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()); @@ -161,18 +215,19 @@ 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 (linkHandledByCustomHandler(message.link().toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Link handled by custom URL handler, skipping default link opening."); 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); + CountlyPush.forwardPayloadIfOwnApp(context, i, bundle, index); context.startActivity(i); } else { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Starting activity without a link. Push body"); @@ -184,16 +239,26 @@ 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 (linkHandledByCustomHandler(buttonLink.toString(), context)) { Countly.sharedInstance().L.d("[CountlyPush, CountlyPushActivity] Link handled by custom URL handler, skipping default link opening."); 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()); + if (!isLinkSchemeAllowed(buttonLink, allowedLinkSchemes)) { + Countly.sharedInstance().L.w("[CountlyPush, CountlyPushActivity] Blocked notification button link with disallowed scheme: [" + 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); + 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() + "]");