diff --git a/CHANGELOG.md b/CHANGELOG.md index 372c3e524..a235be344 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,28 @@ ## XX.XX.XX +* Added a new configuration option `setCustomSSLSocketFactory(SSLSocketFactory)` to send the SDK's HTTPS requests through a custom SSLSocketFactory. +* Added support for reporting the app's current theme (light or dark) when presenting feedback widgets, rating widgets, and content, so they are displayed in matching conditions. +* Improved link handling for content and feedback widgets, so links that carry their own query parameters, such as deep links, are parsed correctly. +* Added a content configuration option to provide a handler for links opened from the content web view, so the app can route its own deep links instead of the SDK opening the system browser, set via `setContentUrlHandler(ContentUrlHandler)`. + +* Mitigated an issue where content could fail to be displayed on some devices, as the content web view could stay hidden even after its resources had finished loading. + +## 26.1.4 +* ! Minor breaking change ! Deprecated the static field "CountlyPush.useAdditionalIntentRedirectionChecks". It is now a no-op; use "CountlyConfigPush.enableAdditionalIntentRedirectionChecks()" instead, otherwise the stricter push intent redirection checks stay disabled. + +* 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 7f471cbdd..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.2 +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..463766908 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionProcessorTests.java @@ -35,6 +35,8 @@ of this software and associated documentation files (the "Software"), to deal import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; @@ -43,6 +45,8 @@ of this software and associated documentation files (the "Software"), to deal import static ly.count.android.sdk.UtilsNetworking.sha256Hash; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertTrue; @@ -103,6 +107,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 +174,10 @@ public void setUp() { @Override public Set getJourneyTriggerEvents() { return Collections.emptySet(); } + + @Override public Set getJourneyTriggerViews() { + return Collections.emptySet(); + } }; Countly.sharedInstance().setLoggingEnabled(true); @@ -276,6 +296,71 @@ public void urlConnectionCustomHeaderValues() throws IOException { assertNull(urlConnection.getRequestProperty("33")); } + /** + * A custom SSL socket factory is applied to an https server request, even when no + * certificate/public-key pinning is configured. This is the key behavior the old pin-gated + * code lacked: the factory used to be applied only when the pinning statics were set. + */ + @Test + public void urlConnectionForServerRequest_appliesCustomSSLSocketFactoryOnHttps() throws IOException { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + ConnectionProcessor cp = new ConnectionProcessor("https://secureserver", mockStore, mockDeviceId, configurationProviderFake, rip, customFactory, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); + + final URLConnection urlConnection = cp.urlConnectionForServerRequest("eventData", null); + + assertTrue(urlConnection instanceof HttpsURLConnection); + assertSame(customFactory, ((HttpsURLConnection) urlConnection).getSSLSocketFactory()); + assertEquals(30_000, urlConnection.getConnectTimeout()); + assertFalse(urlConnection.getDoOutput()); + } + + /** + * The custom SSL socket factory is also applied to the preflight (HEAD) request path. + */ + @Test + public void urlConnectionForPreflightRequest_appliesCustomSSLSocketFactory() throws IOException { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + ConnectionProcessor cp = new ConnectionProcessor("https://secureserver", mockStore, mockDeviceId, configurationProviderFake, rip, customFactory, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); + + final HttpURLConnection conn = (HttpURLConnection) cp.urlConnectionForPreflightRequest("https://secureserver/o/sdk?method=fetch"); + + assertTrue(conn instanceof HttpsURLConnection); + assertSame(customFactory, ((HttpsURLConnection) conn).getSSLSocketFactory()); + assertEquals("HEAD", conn.getRequestMethod()); + } + + /** + * A plain http server URL has no TLS layer, so the custom factory cannot be applied. The + * request must still be built without throwing. + */ + @Test + public void urlConnectionForServerRequest_customFactoryNotAppliedOnHttp() throws IOException { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + ConnectionProcessor cp = new ConnectionProcessor("http://server", mockStore, mockDeviceId, configurationProviderFake, rip, customFactory, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); + + final URLConnection urlConnection = cp.urlConnectionForServerRequest("eventData", null); + + assertFalse(urlConnection instanceof HttpsURLConnection); + assertEquals("http", urlConnection.getURL().getProtocol()); + } + + /** + * With no custom factory (and no pinning), an https request falls back to the platform default + * socket factory, never to a Countly-injected one. + */ + @Test + public void urlConnectionForServerRequest_noFactoryUsesPlatformDefaultOnHttps() throws IOException { + SSLSocketFactory unusedFactory = mock(SSLSocketFactory.class); + ConnectionProcessor cp = new ConnectionProcessor("https://secureserver", mockStore, mockDeviceId, configurationProviderFake, rip, null, null, moduleLog, healthTrackerMock, Mockito.mock(Runnable.class), new ConcurrentHashMap<>()); + + final URLConnection urlConnection = cp.urlConnectionForServerRequest("eventData", null); + + assertTrue(urlConnection instanceof HttpsURLConnection); + SSLSocketFactory used = ((HttpsURLConnection) urlConnection).getSSLSocketFactory(); + assertNotNull(used); + assertNotSame(unusedFactory, used); + } + @Test public void testRun_storeReturnsNullConnections() throws IOException { connectionProcessor = spy(connectionProcessor); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java index a6242d7a1..384656c63 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueIntegrationTests.java @@ -1,10 +1,15 @@ package ly.count.android.sdk; import androidx.test.ext.junit.runners.AndroidJUnit4; +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URLConnection; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLSocketFactory; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -27,6 +32,45 @@ public class ConnectionQueueIntegrationTests { private final String appKey = "testAppKey123"; private final String serverUrl = "https://test.server.com"; + // A valid X.509 certificate (Sectigo, *.count.ly) used only to exercise the pinning code path; + // CertificateFactory parses it regardless of expiry, so the pinning SSLContext can be built. + private static final String PINNING_CERT = + "MIIGnjCCBYagAwIBAgIRAN73cVA7Y1nD+S8rToAqBpQwDQYJKoZIhvcNAQELBQAwgY8xCzAJ" + + "BgNVBAYTAkdCMRswGQYDVQQIExJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcTB1" + + "NhbGZvcmQxGDAWBgNVBAoTD1NlY3RpZ28gTGltaXRlZDE3MDUGA1UEAxMuU2VjdGln" + + "byBSU0EgRG9tYWluIFZhbGlkYXRpb24gU2VjdXJlIFNlcnZlciBDQTAeFw0yMDA2MD" + + "EwMDAwMDBaFw0yMjA5MDMwMDAwMDBaMBUxEzARBgNVBAMMCiouY291bnQubHkwggEi" + + "MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCl9zmATVRwrGRtRQJcmBmA+zc/ZL" + + "io3YfkwXO2w8u9lnw60J4JpPNn9OnGcxdM+sqbXKU3jTdjY4j3yaA6NlWibq2jU2x6" + + "HT2sS+I5gFFE/6tO53WqjoMk48i3FkyoJDittwtQrVaRGcP8RjJH0pfXaP+JLrLAgg" + + "HuW3tCFqYzkWi3uLGVjQbSIRNiXsM3FI0UMEa/x1I3U4hLjMjH28KagZbZLWnHOvks" + + "AvGLg3xQkS+GSQ+6ARZ2/bGh5O9q4hCCCk0/PpwAXmrOnWtwrNuwHcCDOvuB22JxLd" + + "t8jQDYrjwtJIvq4Yut8FQPv/75SKoETWWHyxe0x5NsB34UwA/BAgMBAAGjggNsMIID" + + "aDAfBgNVHSMEGDAWgBSNjF7EVK2K4Xfpm/mbBeG4AY1h4TAdBgNVHQ4EFgQU8uf/ND" + + "Rt8cu+AwARVIGXPMfxGbQwDgYDVR0PAQH/BAQDAgWgMAwGA1UdEwEB/wQCMAAwHQYD" + + "VR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMCMEkGA1UdIARCMEAwNAYLKwYBBAGyMQ" + + "ECAgcwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EM" + + "AQIBMIGEBggrBgEFBQcBAQR4MHYwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuc2VjdG" + + "lnby5jb20vU2VjdGlnb1JTQURvbWFpblZhbGlkYXRpb25TZWN1cmVTZXJ2ZXJDQS5j" + + "cnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMB8GA1UdEQQYMB" + + "aCCiouY291bnQubHmCCGNvdW50Lmx5MIIB9AYKKwYBBAHWeQIEAgSCAeQEggHgAd4A" + + "dQBGpVXrdfqRIDC1oolp9PN9ESxBdL79SbiFq/L8cP5tRwAAAXJwTJ0kAAAEAwBGME" + + "QCIEErTN/aGJ8LV9brGklKeGAXMg1EN/FUxXDu13kNfXhcAiBrKMYe+W4flPyuLNm5" + + "jp6FJwtUTZPNpZ+TmM40dRdwjQB0AN+lXqtogk8fbK3uuF9OPlrqzaISpGpejjsSwC" + + "BEXCpzAAABcnBMncsAAAQDAEUwQwIfEYSpsSDtKpmj9ZmRWsx73G622N74v09JDjzP" + + "bkg9RQIgUelIqSwqu69JanH7losrqTTsjwNv+3QJBNJ6GxJKkh0AdgBvU3asMfAxGd" + + "iZAKRRFf93FRwR2QLBACkGjbIImjfZEwAAAXJwTJ0YAAAEAwBHMEUCIQCMBaaQAoua" + + "97R+z2zONMUq1XsDP5aoAiutZG4XxuQ6wAIgW1p6XS3az4CCqjwbDKxL9qEnw8fWd+" + + "yLx2skviSsTS0AdwApeb7wnjk5IfBWc59jpXflvld9nGAK+PlNXSZcJV3HhAAAAXJw" + + "TJ1PAAAEAwBIMEYCIQDg1YFbJPPKDIyrFZJ9rtrUklkh2k/wpgwjDuIp7tPtOgIhAL" + + "dZl9s/qISsFm2E64ruYbdE4HKR1ZJ0zbIXOZcds7XXMA0GCSqGSIb3DQEBCwUAA4IB" + + "AQB2Ar1h2X/S4qsVlw0gEbXO//6Rj8mTB4BFW6c5r84n0vTwvA78h003eX00y0ymxO" + + "i5hkqB8gd1IUSWP1R1ijYtBVPdFi+SsMjUsB5NKquQNlWpo0GlFjRlcXnDC6R6toN2" + + "QixJb47VM40Vmn2g0ZuMGfy1XoQKvIyRosT92jGm1YcF+nLEHBDr+89apZ8sUpFfWo" + + "AnCom+8sBGwje6zP10eBbprHyzM8snvdwo/QNLAzLcvVNKP+Sr4H7HKzec3g1+THI0" + + "M72TzoguJcOZQEI6Pd+FIP5Xad53rq4jCtRGwYrsieH49a3orBnkkJvUKni+mtkxMb" + + "PTJ7eeMmX9g/0h"; + @Before public void setUp() { Countly.sharedInstance().halt(); @@ -302,6 +346,93 @@ public void integration_sdkOverride_reflectedInCommonRequest() { commonRequest.contains("sdk_version=" + customSdkVersion)); } + // ========================================== + // Integration Tests - Custom SSL socket factory + // ========================================== + + /** + * Integration test: a custom SSLSocketFactory set on CountlyConfig is resolved by + * ConnectionQueue and applied to both the server request and the preflight request that every + * ConnectionProcessor produces. + */ + @Test + public void integration_customSSLSocketFactory_appliedToServerAndPreflightRequests() throws Exception { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + CountlyConfig config = new CountlyConfig(TestUtils.getContext(), appKey, serverUrl) + .setCustomSSLSocketFactory(customFactory); + Countly.sharedInstance().init(config); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + + URLConnection serverConn = cq.createConnectionProcessor().urlConnectionForServerRequest("app_key=" + appKey, null); + HttpURLConnection preflightConn = (HttpURLConnection) cq.createConnectionProcessor().urlConnectionForPreflightRequest(serverUrl + "/o/sdk?method=fetch"); + + Assert.assertTrue(serverConn instanceof HttpsURLConnection); + Assert.assertSame(customFactory, ((HttpsURLConnection) serverConn).getSSLSocketFactory()); + Assert.assertTrue(preflightConn instanceof HttpsURLConnection); + Assert.assertSame(customFactory, ((HttpsURLConnection) preflightConn).getSSLSocketFactory()); + } + + /** + * Integration test: when both a custom SSLSocketFactory and public-key pinning are configured, + * the custom factory wins and the pinning certificates are never parsed (so intentionally + * invalid pinning certs do not break initialization). + */ + @Test + public void integration_customSSLSocketFactory_takesPrecedenceOverPinning() throws Exception { + SSLSocketFactory customFactory = mock(SSLSocketFactory.class); + try { + CountlyConfig config = new CountlyConfig(TestUtils.getContext(), appKey, serverUrl) + .enablePublicKeyPinning(new String[] { "not-a-real-certificate" }) + .setCustomSSLSocketFactory(customFactory); + Countly.sharedInstance().init(config); + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + + URLConnection serverConn = cq.createConnectionProcessor().urlConnectionForServerRequest("app_key=" + appKey, null); + + Assert.assertTrue(serverConn instanceof HttpsURLConnection); + Assert.assertSame("custom factory must win over pinning", customFactory, ((HttpsURLConnection) serverConn).getSSLSocketFactory()); + } finally { + Countly.publicKeyPinCertificates = null; + } + } + + /** + * Integration test: public-key pinning and certificate pinning both remain functional after the + * SSL socket factory refactor. Each installs its own (non-default) socket factory on the SDK's + * HTTPS connections, built from the CertificateTrustManager. + */ + @Test + public void integration_pinning_installsDistinctSocketFactory() throws Exception { + String[] certs = { PINNING_CERT }; + SSLSocketFactory platformDefault = HttpsURLConnection.getDefaultSSLSocketFactory(); + try { + // public key pinning + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl).enablePublicKeyPinning(certs)); + SSLSocketFactory publicKeyPinningFactory = appliedServerRequestFactory(); + Assert.assertNotNull(publicKeyPinningFactory); + Assert.assertNotSame("public key pinning must install its own socket factory", platformDefault, publicKeyPinningFactory); + + Countly.sharedInstance().halt(); + Countly.publicKeyPinCertificates = null; + + // certificate pinning + Countly.sharedInstance().init(new CountlyConfig(TestUtils.getContext(), appKey, serverUrl).enableCertificatePinning(certs)); + SSLSocketFactory certificatePinningFactory = appliedServerRequestFactory(); + Assert.assertNotNull(certificatePinningFactory); + Assert.assertNotSame("certificate pinning must install its own socket factory", platformDefault, certificatePinningFactory); + } finally { + Countly.publicKeyPinCertificates = null; + Countly.certificatePinCertificates = null; + } + } + + private SSLSocketFactory appliedServerRequestFactory() throws IOException { + ConnectionQueue cq = Countly.sharedInstance().connectionQueue_; + URLConnection conn = cq.createConnectionProcessor().urlConnectionForServerRequest("app_key=" + appKey, null); + Assert.assertTrue(conn instanceof HttpsURLConnection); + return ((HttpsURLConnection) conn).getSSLSocketFactory(); + } + // ========================================== // Integration Tests - Update Session // ========================================== diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java index 2341079d8..e457593b9 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ConnectionQueueTests.java @@ -526,4 +526,19 @@ public void testPrepareCommonRequest() { } } } + + /** + * The theme ("th") parameter is reported on the URLs loaded into the WebView (feedback/rating + * widget and content URLs), not on the feedback-list or content-fetch data requests. These + * requests must therefore never carry "th" regardless of the device theme. The actual "th" + * append logic is validated in UtilsDeviceTests, its wiring into content in ModuleContentTests. + */ + @Test + public void testThemeParam_notOnFeedbackListNorFetchContents() { + final String feedbackRequest = connQ.prepareFeedbackListRequest(); + final String contentRequest = connQ.prepareFetchContents(100, 200, 200, 100, new String[] {}, "en", "mobile", null); + + Assert.assertFalse(feedbackRequest.contains("th=")); + Assert.assertFalse(contentRequest.contains("th=")); + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java index 720850dbe..b27c42279 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ContentOverlayViewTests.java @@ -22,6 +22,7 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; +import static org.mockito.Mockito.mock; /** * Instrumented tests for ContentOverlayView. @@ -89,6 +90,12 @@ private ContentOverlayView createOverlay(Activity activity) { private ContentOverlayView createOverlay(Activity activity, @Nullable ContentCallback callback, @Nullable Runnable onClose) { + return createOverlay(activity, callback, onClose, null); + } + + private ContentOverlayView createOverlay(Activity activity, + @Nullable ContentCallback callback, @Nullable Runnable onClose, + @Nullable ContentUrlHandler contentUrlHandler) { TransparentActivityConfig portrait = new TransparentActivityConfig(0, 0, 300, 500); portrait.url = "about:blank"; portrait.useSafeArea = false; @@ -102,7 +109,9 @@ private ContentOverlayView createOverlay(Activity activity, activity.getResources().getConfiguration().orientation, callback, onClose != null ? onClose : () -> { - } + }, + null, + contentUrlHandler ); } @@ -120,6 +129,13 @@ private WindowManager.LayoutParams invokeCreateWindowParams( return (WindowManager.LayoutParams) method.invoke(overlay, activity, config); } + @SuppressWarnings("unchecked") + private Map invokeSplitQuery(String url) throws Exception { + Method method = ContentOverlayView.class.getDeclaredMethod("splitQuery", String.class); + method.setAccessible(true); + return (Map) method.invoke(overlay, url); + } + /** * Launches the test activity, runs the given action on the main thread, * and stores the scenario for cleanup. @@ -134,6 +150,34 @@ interface ActivityAction { void run(Activity activity); } + // ===================== contentUrlHandler ===================== + + /** + * When a content URL handler is set and returns true, startSafeExternalIntent hands the URL to + * it and short-circuits before dispatching an ACTION_VIEW intent (so the app can route its own + * deep link). + */ + @Test + public void startSafeExternalIntent_contentUrlHandler_takesOverAndSkipsIntent() { + withActivity(activity -> { + final String[] captured = { null }; + ContentUrlHandler handler = url -> { + captured[0] = url; + return true; // app handled it -> SDK must not dispatch an intent + }; + overlay = createOverlay(activity, null, null, handler); + try { + Method m = ContentOverlayView.class.getDeclaredMethod("startSafeExternalIntent", String.class); + m.setAccessible(true); + m.invoke(overlay, "myapp://deeplink/screen?id=42"); + } catch (Exception e) { + Assert.fail("startSafeExternalIntent invoke failed: " + e); + } + // Handler received the URL; returning true short-circuits before the ACTION_VIEW intent. + Assert.assertEquals("myapp://deeplink/screen?id=42", captured[0]); + }); + } + // ===================== contentUrlAction — URL Parsing & Routing ===================== /** @@ -176,6 +220,37 @@ public void contentUrlAction_multipleEvents_returnsTrue() { }); } + /** + * Malformed "event" JSON must not crash. When the payload fails to validate, splitQuery stores + * it as a raw String (not a JSONArray) and still routes action=event to eventAction. eventAction + * must guard the cast instead of letting a ClassCastException propagate out of + * shouldOverrideUrlLoading. The URL is still a consumed countly action URL (returns true) and + * simply records nothing. Regression test for the unguarded (JSONArray) cast. + */ + @Test + public void contentUrlAction_malformedEventJson_doesNotThrow() { + withActivity(activity -> { + overlay = createOverlay(activity); + // Truncated JSON array: new JSONArray(...) throws, so "event" is not stored as a JSONArray. + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event&event=[{\"key\":\"oops\""; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + } + + /** + * Same guard with reversed param order: a valid action=event verb paired with a malformed event + * payload that appears before it. The event value lands in splitQuery's fallback prefix as a + * String, so eventAction must not cast-crash. + */ + @Test + public void contentUrlAction_malformedEventJson_reversedOrder_doesNotThrow() { + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&event=[oops&action=event"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + } + /** * resize_me action parses JSON and updates portrait/landscape configs. */ @@ -369,6 +444,382 @@ public void widgetUrlAction_nullWebView_returnsFalse() { }); } + // ===================== link parsing — query param preservation ===================== + + /** + * A link with a single query param is preserved intact (the raw, unencoded form the server + * sends). Regression guard: previously the naive '&' split truncated it at the first '&'. + */ + @Test + public void splitQuery_linkWithSingleQueryParam_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?foo=bar"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("link", q.get("action")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A link whose own query string has MULTIPLE '&'-separated params keeps all of them. This is + * the core fix: everything after "link=" is captured verbatim instead of being split on '&'. + */ + @Test + public void splitQuery_linkWithMultipleQueryParams_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?foo=bar&baz=qux&n=42"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + // The link's own params must NOT leak in as separate top-level entries. + Assert.assertFalse(q.containsKey("baz")); + Assert.assertFalse(q.containsKey("n")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A deeplink with query params (custom scheme) is preserved intact. + */ + @Test + public void splitQuery_deeplinkWithQueryParams_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "myapp://open?screen=home&id=42&ref=push"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * "event" appearing AFTER the link is separated out: the reserved marker is validated as JSON, + * so it is peeled from the tail while the link (with its own query params) stays intact. + */ + @Test + public void splitQuery_eventAfterLink_separatedFromLink() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://x.com/p?a=b&c=d"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link + + "&event=[{\"key\":\"e\",\"sg\":{\"x\":\"y\"}}]"; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertTrue(q.get("event") instanceof org.json.JSONArray); + Assert.assertEquals("e", ((org.json.JSONArray) q.get("event")).getJSONObject(0).getString("key")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A "&event=" that appears inside the link but does NOT validate as JSON is treated as ordinary + * link text (not a real param) and stays part of the link. + */ + @Test + public void splitQuery_invalidReservedMarkerInLink_staysInLink() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://x.com/p?a=b&event=notjson"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertFalse(q.containsKey("event")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A decoded "event" JSON whose segmentation value literally contains "&close=1" is parsed whole: + * the inner "&close=" fails close validation, so it is absorbed into the JSON value rather than + * being mistaken for a real close param. + */ + @Test + public void splitQuery_eventJsonContainingReservedText_parsedWhole() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event" + + "&event=[{\"key\":\"k\",\"sg\":{\"u\":\"a&close=1\"}}]"; + Map q = invokeSplitQuery(url); + Assert.assertTrue(q.get("event") instanceof org.json.JSONArray); + Assert.assertEquals("a&close=1", + ((org.json.JSONArray) q.get("event")).getJSONObject(0).getJSONObject("sg").getString("u")); + Assert.assertFalse(q.containsKey("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A link with no query params still works (no "link=" trailing content edge case). + */ + @Test + public void splitQuery_linkWithoutQueryParams_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/landing"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Non-regression: an "event" JSON value (already decoded by the WebView client) is parsed into a + * JSONArray. The event does not co-occur with a link, so the whole query splits cleanly. + */ + @Test + public void splitQuery_eventValue_parsed() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event" + + "&event=[{\"key\":\"test_key\",\"sg\":{\"color\":\"blue\"}}]"; + Map q = invokeSplitQuery(url); + Assert.assertTrue(q.get("event") instanceof org.json.JSONArray); + org.json.JSONArray arr = (org.json.JSONArray) q.get("event"); + Assert.assertEquals("test_key", arr.getJSONObject(0).getString("key")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * When a "close" flag is appended after a link (with the link carrying its own query params), + * the link is preserved intact AND the trailing close flag is peeled off and parsed separately + * rather than being swallowed into the link value. + */ + @Test + public void splitQuery_linkWithTrailingClose_separatesLinkAndClose() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?foo=bar&baz=qux"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link + "&close=1"; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("1", q.get("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * "close" may also precede the link. In that case it stays in the head and is parsed normally, + * while the link (with its own query params) is still captured intact. + */ + @Test + public void splitQuery_closeBeforeLink_separatesLinkAndClose() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?foo=bar&baz=qux"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&close=1&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("1", q.get("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Documented reserved-name limitation: because "close" is a reserved param, a link that itself + * ends with "&close=1" (a valid close value) has that segment consumed as the close flag. The + * link keeps everything up to it. Integrators are told these param names are reserved. + */ + @Test + public void splitQuery_linkEndingInReservedClose_consumedAsFlag() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com?a=b&c=d&close=1&close=1"; + Map q = invokeSplitQuery(url); + Assert.assertEquals("https://x.com?a=b&c=d", q.get("link")); + Assert.assertEquals("1", q.get("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A trailing "close=0" after a link is also peeled off (link kept open). + */ + @Test + public void splitQuery_linkWithTrailingCloseZero_separatesLinkAndClose() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?a=1&b=2"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link + "&close=0"; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("0", q.get("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * End-to-end: a link action with query params AND a trailing close is recognized (returns true), + * dispatches the external intent, and closes the overlay. + */ + @Test + public void contentUrlAction_linkWithQueryParamsAndClose_closesOverlay() { + AtomicBoolean closeCalled = new AtomicBoolean(false); + withActivity(activity -> { + overlay = createOverlay(activity, null, () -> closeCalled.set(true)); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link" + + "&link=https://example.com/path?foo=bar&baz=qux&close=1"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + Assert.assertTrue("overlay should close when close=1 trails the link", closeCalled.get()); + } + + /** + * End-to-end: a link action with query params (no close) is recognized and handled (returns + * true), and does not throw while dispatching the external intent. + */ + @Test + public void contentUrlAction_linkWithQueryParams_returnsTrue() { + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link" + + "&link=https://example.com/path?foo=bar&baz=qux"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + } + + /** + * A link fragment ("#...") is preserved verbatim. + */ + @Test + public void splitQuery_linkWithFragment_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/path?a=b#section"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A link containing more than one "?" is preserved verbatim (the "?" characters after the first + * are part of the link value, not new query separators). + */ + @Test + public void splitQuery_linkWithRepeatedQuestionMark_preserved() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://example.com/p?a=b?c=d"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * All three of link, event and close in one URL are each separated correctly, with the link + * (carrying its own query params) kept intact. + */ + @Test + public void splitQuery_linkEventAndClose_allSeparated() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://x.com/p?a=b&c=d"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link + + "&event=[{\"key\":\"e\"}]&close=1"; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertEquals("1", q.get("close")); + Assert.assertTrue(q.get("event") instanceof org.json.JSONArray); + Assert.assertEquals("e", ((org.json.JSONArray) q.get("event")).getJSONObject(0).getString("key")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * A "close" whose value is not "0"/"1" (e.g. "close=2") fails validation, so it is treated as + * ordinary link text and absorbed into the link rather than parsed as the close flag. + */ + @Test + public void splitQuery_invalidCloseValue_staysInLink() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String link = "https://x.com/p?a=b&close=2"; + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + link; + Map q = invokeSplitQuery(url); + Assert.assertEquals(link, q.get("link")); + Assert.assertFalse(q.containsKey("close")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** + * Documents the fallback: a schemeless link fails link validation (no URI scheme), so the whole + * query falls back to the plain '&' split, which truncates a multi-param link. The server always + * prepends "https://", so this is an edge case; the test pins the current behavior. + */ + @Test + public void splitQuery_schemelessLink_fallbackTruncates() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=example.com/p?a=b&c=d"; + Map q = invokeSplitQuery(url); + Assert.assertEquals("example.com/p?a=b", q.get("link")); + Assert.assertEquals("d", q.get("c")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + // ===================== Close & Destroy Lifecycle ===================== /** @@ -505,7 +956,7 @@ public void configs_storedCorrectly() { overlay = new ContentOverlayView( activity, portrait, landscape, Configuration.ORIENTATION_PORTRAIT, null, () -> { - }); + }, null, null); // Note: setupConfig may modify width/height if < 1, but ours are > 0 Assert.assertEquals(10, (int) overlay.configPortrait.x); @@ -859,4 +1310,179 @@ public void webView_usesApplicationContext_notActivity() { overlay.webView.getContext().getApplicationContext()); }); } + + // ===================== Plan verification (URL_EDGE_CASE_TESTS.md) — behavior gaps ===================== + // Empirically confirms plan behaviors that were not already covered by a dedicated test: actual + // event recording, malformed-payload safety, routing/parsing edge cases, and content-URL-handler + // fallthrough. (Scheme allow/deny decisions are covered in UtilsTests / CountlyWebViewClientTests / + // FeedbackDialogWebViewClientTests; percent-decoding in CountlyWebViewClientTests.) + + private void invokeStartSafeExternalIntent(String url) { + try { + Method m = ContentOverlayView.class.getDeclaredMethod("startSafeExternalIntent", String.class); + m.setAccessible(true); + m.invoke(overlay, url); + } catch (Exception e) { + Assert.fail("startSafeExternalIntent must not propagate an exception: " + e); + } + } + + /** §1.1 A valid event action actually records the event with its segmentation. */ + @Test + public void planEvent_validSingleEvent_isRecorded() { + EventProvider ep = TestUtils.setEventProviderToMock(Countly.sharedInstance(), mock(EventProvider.class)); + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event" + + "&event=[{\"key\":\"button_click\",\"sg\":{\"btn\":\"buy\"}}]"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + Map expected = new HashMap<>(); + expected.put("btn", "buy"); + TestUtils.validateRecordEventInternalMock(ep, "button_click", expected); + } + + /** §1.2 When an event carries both "sg" and "segmentation", "sg" wins. */ + @Test + public void planEvent_sgOverridesSegmentation_isRecorded() { + EventProvider ep = TestUtils.setEventProviderToMock(Countly.sharedInstance(), mock(EventProvider.class)); + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event" + + "&event=[{\"key\":\"e1\",\"sg\":{\"a\":\"1\"},\"segmentation\":{\"b\":\"2\"}}]"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + Map expected = new HashMap<>(); + expected.put("a", "1"); // taken from sg, "segmentation" ignored + TestUtils.validateRecordEventInternalMock(ep, "e1", expected); + } + + /** §1.4 An event object missing "key" records nothing (get("key") throws, caught) and does not crash. */ + @Test + public void planEvent_missingKey_recordsNothing() { + EventProvider ep = TestUtils.setEventProviderToMock(Countly.sharedInstance(), mock(EventProvider.class)); + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=event&event=[{\"sg\":{\"a\":\"1\"}}]"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + }); + TestUtils.validateRecordEventInternalMockInteractions(ep, 0); + } + + /** §5.4 The sentinel host appearing twice yields an empty parse (pairs != 2) -> not handled. */ + @Test + public void planRouting_sentinelTwice_emptyMapNotHandled() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=" + + Utils.COMM_URL + "/?x=1"; + Map q = invokeSplitQuery(url); + Assert.assertTrue("map must be empty when the sentinel appears twice", q.isEmpty()); + Assert.assertFalse(overlay.contentUrlAction(url, overlay.webView)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** §5/§6 An extra path segment before the query fuses into the key, so the marker is unrecognized. */ + @Test + public void planRouting_extraPathSegment_markerNotRecognized() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/foo?cly_widget_command=1&close=1"; + Map q = invokeSplitQuery(url); + Assert.assertNull("bare ?cly_widget_command must not be a key", q.get("?cly_widget_command")); + Assert.assertFalse(overlay.widgetUrlAction(url, overlay.webView)); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** §5.2 A query token with no '=' is skipped; surrounding params still parse. */ + @Test + public void planRouting_tokenWithoutEquals_skipped() { + withActivity(activity -> { + overlay = createOverlay(activity); + try { + String url = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&flagonly&link=https://example.com"; + Map q = invokeSplitQuery(url); + Assert.assertEquals("link", q.get("action")); + Assert.assertFalse("a bare token must not appear as a key", q.containsKey("flagonly")); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + /** §5.5 On the content path the widget cancel runnable is never fired (only widgetUrlAction fires it). */ + @Test + public void planRouting_contentPathDoesNotFireWidgetCancel() { + AtomicBoolean cancelCalled = new AtomicBoolean(false); + AtomicBoolean closed = new AtomicBoolean(false); + withActivity(activity -> { + overlay = createOverlay(activity); + overlay.setOnWidgetCancelRunnable(() -> cancelCalled.set(true)); + String url = Utils.COMM_URL + "/?cly_x_action_event=1&cly_widget_command=1&close=1"; + Assert.assertTrue(overlay.contentUrlAction(url, overlay.webView)); + try { + closed.set((Boolean) getField("isClosed")); + } catch (Exception e) { + Assert.fail("read isClosed: " + e); + } + }); + Assert.assertTrue("overlay should be closed", closed.get()); + Assert.assertFalse("widget cancel must NOT fire on the content path", cancelCalled.get()); + } + + /** §6.10 A widget command value other than "1" is not handled. */ + @Test + public void planWidget_commandValueNotOne_returnsFalse() { + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?cly_widget_command=2&close=1"; + Assert.assertFalse(overlay.widgetUrlAction(url, overlay.webView)); + }); + } + + /** §6.16 Marker matching is case-sensitive: an uppercase command key is not recognized. */ + @Test + public void planWidget_uppercaseKey_returnsFalse() { + withActivity(activity -> { + overlay = createOverlay(activity); + String url = Utils.COMM_URL + "/?CLY_WIDGET_COMMAND=1&close=1"; + Assert.assertFalse(overlay.widgetUrlAction(url, overlay.webView)); + }); + } + + /** §8/handler A content URL handler returning false lets the SDK proceed; a denylisted scheme means + * no dispatch and no crash, and the handler is consulted exactly once. */ + @Test + public void planHandler_returnsFalse_fallsThroughNoCrash() { + AtomicBoolean called = new AtomicBoolean(false); + withActivity(activity -> { + ContentUrlHandler handler = url -> { + called.set(true); + return false; + }; + overlay = createOverlay(activity, null, null, handler); + invokeStartSafeExternalIntent("file:///blocked"); + }); + Assert.assertTrue("handler must be consulted", called.get()); + } + + /** §8/handler A throwing content URL handler is caught; startSafeExternalIntent must not propagate it. */ + @Test + public void planHandler_throws_caughtNoCrash() { + withActivity(activity -> { + ContentUrlHandler handler = url -> { + throw new RuntimeException("handler boom"); + }; + overlay = createOverlay(activity, null, null, handler); + invokeStartSafeExternalIntent("file:///blocked"); // reaching the next line proves it was caught + }); + } } 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..2089e410b 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyConfigTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyConfigTests.java @@ -6,6 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.HashMap; import java.util.Map; +import javax.net.ssl.SSLSocketFactory; import org.junit.Assert; import org.junit.Before; import org.junit.Test; @@ -84,6 +85,8 @@ public boolean filterCrash(String crash) { String[] publicKeyCerts = { "ddd", "111", "ffd" }; String[] certificateCerts = { "ddsd", "vvcv", "mbnb" }; + SSLSocketFactory customSSLSocketFactory = mock(SSLSocketFactory.class); + Map crashSegments = new HashMap<>(); crashSegments.put("s2s", "fdf"); crashSegments.put("s224s", 2323); @@ -139,6 +142,7 @@ public boolean filterCrash(String crash) { config.setAppCrawlerNames(appCrawlerNames); config.enableCertificatePinning(certificateCerts); config.enablePublicKeyPinning(publicKeyCerts); + config.setCustomSSLSocketFactory(customSSLSocketFactory); config.setEnableAttribution(true); config.setCustomCrashSegment(crashSegments); config.setUpdateSessionTimerDelay(137); @@ -191,6 +195,7 @@ public boolean filterCrash(String crash) { Assert.assertArrayEquals(appCrawlerNames, config.appCrawlerNames); Assert.assertArrayEquals(certificateCerts, config.certificatePinningCertificates); Assert.assertArrayEquals(publicKeyCerts, config.publicKeyPinningCertificates); + Assert.assertSame(customSSLSocketFactory, config.customSSLSocketFactory); Assert.assertEquals(crashSegments, config.crashes.customCrashSegment); Assert.assertEquals(137, config.sessionUpdateTimerDelay.intValue()); Assert.assertTrue(config.starRatingDialogIsCancellable); @@ -269,6 +274,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); @@ -292,6 +298,7 @@ void assertDefaultValues(CountlyConfig config, boolean includeConstructorValues) Assert.assertNull(config.appCrawlerNames); Assert.assertNull(config.publicKeyPinningCertificates); Assert.assertNull(config.certificatePinningCertificates); + Assert.assertNull(config.customSSLSocketFactory); Assert.assertNull(config.crashes.customCrashSegment); Assert.assertNull(config.sessionUpdateTimerDelay); Assert.assertFalse(config.starRatingDialogIsCancellable); diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/CountlyWebViewClientTests.java index a1d788a2e..1a41d4c6c 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; @@ -100,6 +102,62 @@ private WebResourceResponse fakeHttpErrorResponse(int statusCode) { }; } + // ===================================== + // shouldOverrideUrlLoading - URL decoding + listener delivery + // ===================================== + + /** + * The URL is percent-decoded once before being handed to listeners, so encoded delimiters in + * an "event" JSON value (e.g. "%26" -> "&", "%5B" -> "[") arrive in plain form for parsing. + */ + @Test + public void shouldOverrideUrlLoading_decodesUrlForListener() { + final String[] received = new String[1]; + client.registerWebViewUrlListener((url, view) -> { + received[0] = url; + return true; + }); + + String encoded = Utils.COMM_URL + "/?cly_x_action_event=1&action=event&event=%5B%7B%22k%22%3A%22a%26b%22%7D%5D"; + String decoded = Utils.COMM_URL + "/?cly_x_action_event=1&action=event&event=[{\"k\":\"a&b\"}]"; + Assert.assertTrue(client.shouldOverrideUrlLoading(null, fakeRequest(encoded, true))); + Assert.assertEquals("listener must receive the decoded URL", decoded, received[0]); + } + + /** + * A literal '+' in a link is preserved (Uri.decode does not apply form '+'->space semantics), so + * deeplinks like "tel:+1..." and base64 query values are not corrupted. Matches iOS. + */ + @Test + public void shouldOverrideUrlLoading_plusInQuery_preserved() { + final String[] received = new String[1]; + client.registerWebViewUrlListener((url, view) -> { + received[0] = url; + return true; + }); + + String raw = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com/search?q=a+b"; + Assert.assertTrue(client.shouldOverrideUrlLoading(null, fakeRequest(raw, true))); + Assert.assertEquals(raw, received[0]); + } + + /** + * A malformed percent-escape (possible inside an unencoded link) must not drop the action: the + * URL is decoded leniently and the listener is still invoked rather than the call returning false. + */ + @Test + public void shouldOverrideUrlLoading_malformedEscape_stillHandled() { + final String[] received = new String[1]; + client.registerWebViewUrlListener((url, view) -> { + received[0] = url; + return true; + }); + + String raw = Utils.COMM_URL + "/?cly_x_action_event=1&action=link&link=https://x.com?d=50%off"; + Assert.assertTrue(client.shouldOverrideUrlLoading(null, fakeRequest(raw, true))); + Assert.assertNotNull("listener must still be invoked on malformed escape", received[0]); + } + // ===================================== // onReceivedHttpError - abort logic // ===================================== @@ -315,4 +373,117 @@ 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))); + } + + // ===================================== + // Readiness gate (regression) + // ===================================== + + /** + * Regression: a nomodule {@code " + + "content"; + runOnMainSync(() -> { + wv.setWebViewClient(client); + wv.loadDataWithBaseURL("https://example.com/", html, "text/html", "utf-8", null); + }); + + Assert.assertTrue("readiness callback did not fire under the 60s timeout — readyState gate regressed", + latch.await(20, TimeUnit.SECONDS)); + Assert.assertEquals(1, callbackResults.size()); + Assert.assertFalse("content must be shown (failed=false), not discarded", callbackResults.get(0)); + } } diff --git a/sdk/src/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..fb4c3f80a 100644 --- a/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java +++ b/sdk/src/androidTest/java/ly/count/android/sdk/ModuleContentTests.java @@ -1,9 +1,13 @@ package ly.count.android.sdk; import android.app.Activity; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.util.DisplayMetrics; import androidx.test.ext.junit.runners.AndroidJUnit4; import java.util.ArrayList; import java.util.List; +import java.util.Map; import org.json.JSONException; import org.json.JSONObject; import org.junit.After; @@ -13,6 +17,7 @@ import org.junit.runner.RunWith; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; @RunWith(AndroidJUnit4.class) public class ModuleContentTests { @@ -112,6 +117,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 +282,147 @@ 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)); + } + + // ======== theme ("th") param on the content URL ======== + + /** + * parseContent must append the app theme ("th") to the content URL that gets loaded into the + * WebView, so the content is rendered matching the theme. A dark foreground Activity is set so + * the resolved theme is deterministic ("d"); the URL already has a query, so "&th=d" is used. + * The l/d mapping and separator logic themselves are covered by UtilsDeviceTests. + */ + @Test + public void parseContent_appendsThemeParamToContentUrl() throws JSONException { + Countly countly = initWithConsent(true); + ModuleContent mc = countly.moduleContent; + + Activity darkActivity = mock(Activity.class); + Resources darkResources = mock(Resources.class); + Configuration darkCfg = new Configuration(); + darkCfg.uiMode = Configuration.UI_MODE_NIGHT_YES; + when(darkActivity.getResources()).thenReturn(darkResources); + when(darkResources.getConfiguration()).thenReturn(darkCfg); + CountlyActivityHolder.getInstance().setActivity(darkActivity); + + try { + String html = "https://content.example/page?cid=1"; + JSONObject placement = new JSONObject(); + placement.put("x", 0); + placement.put("y", 0); + placement.put("w", 100); + placement.put("h", 100); + JSONObject geo = new JSONObject(); + geo.put("p", placement); + JSONObject response = new JSONObject(); + response.put("html", html); + response.put("geo", geo); + + DisplayMetrics dm = TestUtils.getContext().getResources().getDisplayMetrics(); + Map configs = mc.parseContent(response, dm); + + TransparentActivityConfig portrait = configs.get(Configuration.ORIENTATION_PORTRAIT); + Assert.assertNotNull(portrait); + Assert.assertEquals(html + "&th=d", portrait.url); + } finally { + CountlyActivityHolder.getInstance().clearActivity(darkActivity); + } + } } diff --git a/sdk/src/androidTest/java/ly/count/android/sdk/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 82b516f1f..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.2"; + 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/UtilsDeviceTests.java b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java new file mode 100644 index 000000000..5669bd21d --- /dev/null +++ b/sdk/src/androidTest/java/ly/count/android/sdk/UtilsDeviceTests.java @@ -0,0 +1,109 @@ +package ly.count.android.sdk; + +import android.app.Activity; +import android.content.Context; +import android.content.res.Configuration; +import android.content.res.Resources; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import org.junit.After; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(AndroidJUnit4.class) +public class UtilsDeviceTests { + + /** + * getThemeMode and appendThemeParam prefer the foreground Activity's configuration. These + * tests exercise the fallback-context path, so make sure no Activity is registered from a + * previously run test in the same process. + */ + @Before + public void setUp() { + clearForegroundActivity(); + } + + @After + public void tearDown() { + clearForegroundActivity(); + } + + private void clearForegroundActivity() { + Activity current = CountlyActivityHolder.getInstance().getActivity(); + if (current != null) { + CountlyActivityHolder.getInstance().clearActivity(current); + } + } + + /** + * Builds a context whose resources report exactly the given UI_MODE_NIGHT_* flag. A mock is + * used deliberately: a real createConfigurationContext falls back to the device's night mode + * for the UNDEFINED case, so it can not report "undefined" independently of the test device. + */ + private Context contextWithNightMode(int nightModeFlag) { + Context ctx = mock(Context.class); + Resources res = mock(Resources.class); + Configuration cfg = new Configuration(); + cfg.uiMode = nightModeFlag; + when(ctx.getResources()).thenReturn(res); + when(res.getConfiguration()).thenReturn(cfg); + return ctx; + } + + // ======== getThemeMode ======== + + /** A dark-configured context resolves to "d", a light one to "l", undefined to null. */ + @Test + public void getThemeMode_mapsNightModeFlags() { + Assert.assertEquals("d", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_YES))); + Assert.assertEquals("l", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_NO))); + Assert.assertNull(UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_UNDEFINED))); + } + + /** The foreground Activity's configuration wins over the fallback context. */ + @Test + public void getThemeMode_prefersForegroundActivity() { + Activity darkActivity = mock(Activity.class); + Resources darkResources = mock(Resources.class); + Configuration darkCfg = new Configuration(); + darkCfg.uiMode = Configuration.UI_MODE_NIGHT_YES; + when(darkActivity.getResources()).thenReturn(darkResources); + when(darkResources.getConfiguration()).thenReturn(darkCfg); + + CountlyActivityHolder.getInstance().setActivity(darkActivity); + try { + // fallback is light, but the dark Activity must take precedence + Assert.assertEquals("d", UtilsDevice.getThemeMode(contextWithNightMode(Configuration.UI_MODE_NIGHT_NO))); + } finally { + CountlyActivityHolder.getInstance().clearActivity(darkActivity); + } + } + + // ======== appendThemeParam ======== + + /** With an existing query string the theme is appended with "&". */ + @Test + public void appendThemeParam_appendsWithAmpersandWhenQueryPresent() { + String url = "https://widgets.example/feedback/nps?widget_id=abc&app_key=k"; + Assert.assertEquals(url + "&th=d", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_YES))); + Assert.assertEquals(url + "&th=l", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_NO))); + } + + /** Without a query string the theme is appended with "?". */ + @Test + public void appendThemeParam_appendsWithQuestionMarkWhenNoQuery() { + String url = "https://content.example/page"; + Assert.assertEquals(url + "?th=l", UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_NO))); + } + + /** When the theme is undefined the URL is returned untouched. */ + @Test + public void appendThemeParam_returnsUrlUnchangedWhenThemeUndefined() { + String url = "https://content.example/page?a=1"; + Assert.assertEquals(url, UtilsDevice.appendThemeParam(url, contextWithNightMode(Configuration.UI_MODE_NIGHT_UNDEFINED))); + } +} diff --git a/sdk/src/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..72382923f 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConfigContent.java @@ -1,9 +1,15 @@ package ly.count.android.sdk; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + public class ConfigContent { int zoneTimerInterval = 30; ContentCallback globalContentCallback = null; + Set allowedIntentSchemes = new HashSet<>(); + ContentUrlHandler contentUrlHandler = null; /** * Set the interval for the automatic content update calls @@ -30,4 +36,35 @@ 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; + } + + /** + * Set a handler that is called when a link is opened from the content (or feedback) web view, + * letting the app take over instead of the SDK opening the link via an ACTION_VIEW intent. This + * is how an app routes its own deep links (custom scheme or https) to the correct screen. The + * handler receives the URL and returns true if it handled it; returning false (or not setting a + * handler) makes the SDK open the URL as before. + * + * @param contentUrlHandler handler invoked for links opened from the content web view + * @return config content to chain calls + * @apiNote This is an EXPERIMENTAL feature, and it can have breaking changes + */ + public synchronized ConfigContent setContentUrlHandler(ContentUrlHandler contentUrlHandler) { + this.contentUrlHandler = contentUrlHandler; + return this; + } } diff --git a/sdk/src/main/java/ly/count/android/sdk/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/ConnectionProcessor.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java index 0b6646345..c631acfae 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionProcessor.java @@ -37,7 +37,7 @@ of this software and associated documentation files (the "Software"), to deal import java.nio.charset.StandardCharsets; import java.util.Map; import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; import org.json.JSONException; import org.json.JSONObject; @@ -59,7 +59,7 @@ public class ConnectionProcessor implements Runnable { final RequestInfoProvider requestInfoProvider_; private final String serverURL_; - private final SSLContext sslContext_; + private final SSLSocketFactory sslSocketFactory_; private final Map requestHeaderCustomValues_; private final Runnable backoffCallback_; @@ -76,13 +76,13 @@ private enum RequestResult { } ConnectionProcessor(final String serverURL, final StorageProvider storageProvider, final DeviceIdProvider deviceIdProvider, final ConfigurationProvider configProvider, - final RequestInfoProvider requestInfoProvider, final SSLContext sslContext, final Map requestHeaderCustomValues, ModuleLog logModule, + final RequestInfoProvider requestInfoProvider, final SSLSocketFactory sslSocketFactory, final Map requestHeaderCustomValues, ModuleLog logModule, HealthTracker healthTracker, Runnable backoffCallback, final Map internalRequestCallbacks) { serverURL_ = serverURL; storageProvider_ = storageProvider; deviceIdProvider_ = deviceIdProvider; configProvider_ = configProvider; - sslContext_ = sslContext; + sslSocketFactory_ = sslSocketFactory; requestHeaderCustomValues_ = requestHeaderCustomValues; requestInfoProvider_ = requestInfoProvider; backoffCallback_ = backoffCallback; @@ -130,13 +130,13 @@ private enum RequestResult { pccTsOpenURLConnection = UtilsTime.getNanoTime(); } - if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { - conn = (HttpURLConnection) url.openConnection(); - } else { - HttpsURLConnection c = (HttpsURLConnection) url.openConnection(); - c.setSSLSocketFactory(sslContext_.getSocketFactory()); - conn = c; + final URLConnection urlConnection = url.openConnection(); + // Apply the resolved SSL socket factory (a custom/FIPS factory or the pinning factory) to + // every HTTPS connection. A plain HTTP connection is left untouched. + if (sslSocketFactory_ != null && urlConnection instanceof HttpsURLConnection) { + ((HttpsURLConnection) urlConnection).setSSLSocketFactory(sslSocketFactory_); } + conn = (HttpURLConnection) urlConnection; if (pcc != null) { long openUrlConnectionTime = UtilsTime.getNanoTime() - pccTsOpenURLConnection; @@ -250,8 +250,8 @@ private enum RequestResult { long tOpen = pcc != null ? UtilsTime.getNanoTime() : 0; HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - if (conn instanceof HttpsURLConnection && (Countly.publicKeyPinCertificates != null || Countly.certificatePinCertificates != null)) { - ((HttpsURLConnection) conn).setSSLSocketFactory(sslContext_.getSocketFactory()); + if (sslSocketFactory_ != null && conn instanceof HttpsURLConnection) { + ((HttpsURLConnection) conn).setSSLSocketFactory(sslSocketFactory_); } if (pcc != null) { diff --git a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java index 4eb882fdf..9623c4acd 100644 --- a/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java +++ b/sdk/src/main/java/ly/count/android/sdk/ConnectionQueue.java @@ -37,6 +37,7 @@ of this software and associated documentation files (the "Software"), to deal import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; import javax.net.ssl.TrustManager; import org.json.JSONException; import org.json.JSONObject; @@ -57,7 +58,7 @@ class ConnectionQueue implements RequestQueueProvider { private Context context_; private Future connectionProcessorFuture_; private DeviceIdProvider deviceIdProvider_; - private SSLContext sslContext_; + private SSLSocketFactory sslSocketFactory_; private final ScheduledExecutorService backoffScheduler_ = Executors.newSingleThreadScheduledExecutor(); private final AtomicBoolean backoff_ = new AtomicBoolean(false); @@ -118,17 +119,30 @@ public ConnectionQueue() { }); } - void setupSSLContext() { - if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { - sslContext_ = null; - } else { - try { - TrustManager[] tm = { new CertificateTrustManager(Countly.publicKeyPinCertificates, Countly.certificatePinCertificates) }; - sslContext_ = SSLContext.getInstance("TLS"); - sslContext_.init(null, tm, null); - } catch (Throwable e) { - throw new IllegalStateException(e); + void setupSSLSocketFactory(SSLSocketFactory customSSLSocketFactory) { + // A customer-provided SSL socket factory (for example a FIPS-validated provider) takes + // precedence over the built-in pinning trust manager. The two cannot be combined here, so + // when both are set the custom factory wins and pinning is expected to be baked into it. + if (customSSLSocketFactory != null) { + sslSocketFactory_ = customSSLSocketFactory; + if (Countly.publicKeyPinCertificates != null || Countly.certificatePinCertificates != null) { + L.w("[ConnectionQueue] A custom SSL socket factory is set, the built-in certificate/public key pinning trust manager will not be applied"); } + return; + } + + if (Countly.publicKeyPinCertificates == null && Countly.certificatePinCertificates == null) { + sslSocketFactory_ = null; + return; + } + + try { + TrustManager[] tm = { new CertificateTrustManager(Countly.publicKeyPinCertificates, Countly.certificatePinCertificates) }; + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, tm, null); + sslSocketFactory_ = sslContext.getSocketFactory(); + } catch (Throwable e) { + throw new IllegalStateException(e); } } @@ -963,7 +977,7 @@ public void tick() { public ConnectionProcessor createConnectionProcessor() { - ConnectionProcessor cp = new ConnectionProcessor(baseInfoProvider.getServerURL(), storageProvider, deviceIdProvider_, configProvider, requestInfoProvider, sslContext_, requestHeaderCustomValues, L, healthTracker, new Runnable() { + ConnectionProcessor cp = new ConnectionProcessor(baseInfoProvider.getServerURL(), storageProvider, deviceIdProvider_, configProvider, requestInfoProvider, sslSocketFactory_, requestHeaderCustomValues, L, healthTracker, new Runnable() { @Override public void run() { L.d("[ConnectionQueue] createConnectionProcessor:run, backed off, countdown started for " + configProvider.getBOMDuration() + " seconds"); diff --git a/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java b/sdk/src/main/java/ly/count/android/sdk/ContentOverlayView.java index c55f4edcd..66d88f620 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,8 @@ class ContentOverlayView extends FrameLayout { TransparentActivityConfig configLandscape; int currentOrientation; private ContentCallback contentCallback; + private final Set allowedLinkSchemes; + private final ContentUrlHandler contentUrlHandler; private Runnable onCloseRunnable; private Runnable onWidgetCancelRunnable; private boolean isClosed = false; @@ -84,7 +88,9 @@ private static Context resolveOverlayContext(@NonNull Activity activity) { @NonNull TransparentActivityConfig landscape, int orientation, @Nullable ContentCallback callback, - @NonNull Runnable onClose) { + @NonNull Runnable onClose, + @Nullable Set allowedLinkSchemes, + @Nullable ContentUrlHandler contentUrlHandler) { // View.mContext must not pin the constructing activity (overlay outlives activity // transitions; window attachment uses currentHostActivity). On API 31+ we additionally // need a UI context to satisfy StrictMode#detectIncorrectContextUse — see @@ -97,6 +103,9 @@ 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); + this.contentUrlHandler = contentUrlHandler; setBackgroundColor(Color.TRANSPARENT); setClickable(false); @@ -773,12 +782,12 @@ boolean widgetUrlAction(String url, WebView view) { private void eventAction(Map query) { Log.i(Countly.TAG, "[ContentOverlayView] eventAction, event action detected"); - if (query.containsKey("event")) { - JSONArray event = (JSONArray) query.get("event"); - if (event == null) { - Log.w(Countly.TAG, "[ContentOverlayView] eventAction, event is null"); - return; - } + // splitQuery only stores "event" as a JSONArray when its JSON validates; a malformed payload + // from web content falls back to a raw String, so guard the cast (as resizeMeAction guards + // resize_me) to keep a ClassCastException from propagating out of shouldOverrideUrlLoading. + Object eventObj = query.get("event"); + if (eventObj instanceof JSONArray) { + JSONArray event = (JSONArray) eventObj; for (int i = 0; i < event.length(); i++) { try { JSONObject eventJson = event.getJSONObject(i); @@ -830,6 +839,39 @@ 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) { + // Give the app's content URL handler first refusal (e.g. to route its own deep link). If it + // reports it handled the URL, the SDK does not dispatch an intent. A handler exception is + // caught so it can never break link handling; on false/absent handler the SDK opens as usual. + if (contentUrlHandler != null) { + try { + if (contentUrlHandler.onContentUrl(url)) { + Log.d(Countly.TAG, "[ContentOverlayView] startSafeExternalIntent, url handled by content URL handler: [" + url + "]"); + return; + } + } catch (Throwable t) { + Log.e(Countly.TAG, "[ContentOverlayView] startSafeExternalIntent, content URL handler threw", t); + } + } + + Uri uri = Uri.parse(url); + String scheme = uri.getScheme(); + if (!Utils.isExternalSchemeAllowed(scheme, allowedLinkSchemes)) { + 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 +882,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; } @@ -893,6 +934,22 @@ private void resizeContentInternal(@NonNull Activity activity) { } } + // Reserved content-communication param keys. Their names are reserved: a value (a link, or a + // segmentation string) that literally contains "&=" may be + // mis-split. This is documented for integrators. + private static final String[] RESERVED_KEYS = {"action", "event", "resize_me", "close", "link"}; + + // The whole action URL is already percent-decoded (CountlyWebViewClient), so values are in plain + // form. Two params can carry a literal '&' in their value: "link" (sent unencoded, may hold its + // own query string) and a decoded "event"/"resize_me" JSON (segmentation strings can contain + // '&'). A plain '&' split would therefore mis-slice them, and the params can appear in any order. + // + // Instead we span the query from the END: at each step we take the right-most reserved marker + // ("&=") whose value VALIDATES for that key (event/resize_me = JSON, close = 0/1, action = + // a known verb, link = has a URI scheme), record it, and shrink the span to its left. A marker + // whose value does NOT validate is treated as ordinary text inside an enclosing value (so it is + // skipped and absorbed by an outer param). What remains at the front is the comm-url-adjacent + // identifier ("?cly_x_action_event=1" / "?cly_widget_command=1"), parsed verbatim with its '?'. private Map splitQuery(@NonNull String url) { Map query_pairs = new HashMap<>(); String[] pairs = url.split(Utils.COMM_URL + "/?"); @@ -900,31 +957,107 @@ private Map splitQuery(@NonNull String url) { return query_pairs; } - String[] pairs2 = pairs[1].split("&"); - for (String pair : pairs2) { - int idx = pair.indexOf('='); - if (idx < 0) { - continue; + String q = pairs[1]; + int end = q.length(); + + while (end > 0) { + int chosenIdx = -1; + String chosenKey = null; + Object chosenValue = null; + + // Right-to-left, pick the first reserved marker whose value validates. Scanning from the + // right lets an inner (invalid) marker be absorbed into an outer, valid value. + int searchFrom = end; + while (searchFrom > 0) { + int marker = -1; + String markerKey = null; + for (String key : RESERVED_KEYS) { + int idx = q.lastIndexOf("&" + key + "=", searchFrom - 1); + if (idx > marker && idx + key.length() + 2 <= end) { + marker = idx; + markerKey = key; + } + } + if (marker < 0) { + break; + } + String value = q.substring(marker + markerKey.length() + 2, end); + Object parsed = validateReservedValue(markerKey, value); + if (parsed != null) { + chosenIdx = marker; + chosenKey = markerKey; + chosenValue = parsed; + break; + } + // Not a real param -> ordinary text; keep looking further left. + searchFrom = marker; } - String key = pair.substring(0, idx); - String value = pair.substring(idx + 1); - try { - if ("event".equals(key)) { - query_pairs.put(key, new JSONArray(value)); - } else if ("resize_me".equals(key)) { - query_pairs.put(key, new JSONObject(value)); - } else { - query_pairs.put(key, value); - } - } catch (JSONException e) { - Log.e(Countly.TAG, "[ContentOverlayView] splitQuery, Failed to parse JSON", e); + if (chosenIdx < 0) { + break; + } + query_pairs.put(chosenKey, chosenValue); + end = chosenIdx; + } + + // Remaining prefix is the identifier param(s) adjacent to the comm URL (leading '?' kept). + for (String pair : q.substring(0, end).split("&")) { + int idx = pair.indexOf('='); + if (idx >= 0) { + query_pairs.put(pair.substring(0, idx), pair.substring(idx + 1)); } } return query_pairs; } + // Validates a reserved param value and returns what to store, or null if it does not validate + // (meaning the "&=" was actually text inside an enclosing value, not a real parameter). + @Nullable + private Object validateReservedValue(@NonNull String key, @NonNull String value) { + switch (key) { + case "event": + try { + return new JSONArray(value); + } catch (JSONException e) { + return null; + } + case "resize_me": + try { + return new JSONObject(value); + } catch (JSONException e) { + return null; + } + case "close": + return "0".equals(value) || "1".equals(value) ? value : null; + case "action": + return "event".equals(value) || "link".equals(value) || "resize_me".equals(value) ? value : null; + case "link": + return hasUriScheme(value) ? value : null; + default: + return null; + } + } + + // True if value begins with a URI scheme (ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) ":"), which + // covers http(s) URLs and custom-scheme deeplinks. The server prepends "https://" to schemeless + // links, so a valid link value always carries a scheme. + private static boolean hasUriScheme(@NonNull String value) { + int colon = value.indexOf(':'); + if (colon <= 0) { + return false; + } + for (int i = 0; i < colon; i++) { + char c = value.charAt(i); + boolean ok = (i == 0) ? Character.isLetter(c) + : (Character.isLetterOrDigit(c) || c == '+' || c == '-' || c == '.'); + if (!ok) { + return false; + } + } + return true; + } + private void recalculateSafeAreaOffsets(@NonNull Activity activity) { SafeAreaDimensions safeArea = SafeAreaCalculator.calculateSafeAreaDimensions(activity, Countly.sharedInstance().L); @@ -1119,12 +1252,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 +1278,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/ContentUrlHandler.java b/sdk/src/main/java/ly/count/android/sdk/ContentUrlHandler.java new file mode 100644 index 000000000..4a447e308 --- /dev/null +++ b/sdk/src/main/java/ly/count/android/sdk/ContentUrlHandler.java @@ -0,0 +1,13 @@ +package ly.count.android.sdk; + +public interface ContentUrlHandler { + /** + * Called when a link is opened from the content (or feedback) web view, letting the host app + * take over instead of the SDK opening the link via an ACTION_VIEW intent. This is how an app + * routes its own deep links (custom scheme or https) to the correct screen. + * + * @param url the URL the web content is trying to open + * @return true if the app handled the URL; return false to let the SDK open it as usual + */ + boolean onContentUrl(String url); +} diff --git a/sdk/src/main/java/ly/count/android/sdk/Countly.java b/sdk/src/main/java/ly/count/android/sdk/Countly.java index f9f4b4a65..13896a0db 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.2"; + 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 @@ -680,7 +686,7 @@ public synchronized Countly init(CountlyConfig config) { connectionQueue_.deviceInfo = config.deviceInfo; connectionQueue_.pcc = config.pcc; connectionQueue_.setStorageProvider(config.storageProvider); - connectionQueue_.setupSSLContext(); + connectionQueue_.setupSSLSocketFactory(config.customSSLSocketFactory); connectionQueue_.setBaseInfoProvider(config.baseInfoProvider); connectionQueue_.setDeviceId(config.deviceIdProvider); connectionQueue_.setRequestHeaderCustomValues(requestHeaderCustomValues); @@ -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..186ef3f8e 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyConfig.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import javax.net.ssl.SSLSocketFactory; public class CountlyConfig { @@ -111,6 +112,8 @@ public class CountlyConfig { protected boolean loggingEnabled = false; + protected boolean disableSDKLoggingInProduction = false; + protected boolean enableAutomaticViewTracking = false; protected boolean autoTrackingUseShortName = false; @@ -161,6 +164,8 @@ public class CountlyConfig { protected String[] certificatePinningCertificates = null; + protected SSLSocketFactory customSSLSocketFactory = null; + protected Integer sessionUpdateTimerDelay = null; /** @@ -212,6 +217,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 +385,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. @@ -713,6 +733,36 @@ public synchronized CountlyConfig enableCertificatePinning(String[] certificates return this; } + /** + * Provide a custom SSLSocketFactory that Countly uses for all of its HTTPS requests + * (session, event, remote-config, feedback/rating/content availability, health-check and + * preflight requests). + *

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

+ * Notes: + *

    + *
  • Applies only to "https://" server URLs. It has no effect on a plain "http://" server URL.
  • + *
  • Takes precedence over {@link #enablePublicKeyPinning(String[])} and + * {@link #enableCertificatePinning(String[])}. When both are provided, this factory is used and + * the built-in pinning trust manager is not applied; add pinning to your own factory if you need it.
  • + *
  • Does not apply to WebView-rendered content, feedback and rating widgets (the Android WebView + * uses its own network stack) nor to push notification image downloads.
  • + *
+ * + * @param sslSocketFactory the factory to use; a null value leaves the default behavior unchanged + * @return Returns the same config object for convenient linking + */ + public synchronized CountlyConfig setCustomSSLSocketFactory(SSLSocketFactory sslSocketFactory) { + customSSLSocketFactory = sslSocketFactory; + return this; + } + /** * Set if Countly SDK should ignore app crawlers * @@ -1109,6 +1159,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..a8f1671ed 100644 --- a/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java +++ b/sdk/src/main/java/ly/count/android/sdk/CountlyWebViewClient.java @@ -12,7 +12,6 @@ import android.webkit.WebResourceResponse; import android.webkit.WebView; import android.webkit.WebViewClient; -import java.net.URLDecoder; import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; @@ -31,21 +30,32 @@ 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 public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { String url = request.getUrl().toString(); Log.v(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, url: [" + url + "]"); - try { - url = URLDecoder.decode(url, "UTF-8"); - } catch (Exception e) { - Log.e(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, Failed to decode url", e); - return false; + + // Percent-decode with Uri.decode, NOT URLDecoder: URLDecoder applies form semantics and turns + // a literal '+' into a space, which corrupts links and deeplinks (e.g. "tel:+1..." or a base64 + // query value). Uri.decode decodes %XX, leaves '+' intact, and is lenient on a malformed + // escape (so the action is not dropped) - matching the iOS behavior. + String decoded = Uri.decode(url); + if (decoded != null) { + url = decoded; } Log.d(Countly.TAG, "[CountlyWebViewClient] shouldOverrideUrlLoading, urlDecoded: [" + url + "]"); @@ -59,23 +69,45 @@ 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; - // Checks all and