From 3f4f2482787a2ddc4c3600032afdcef01504a50b Mon Sep 17 00:00:00 2001 From: James Newman Date: Wed, 13 May 2026 15:28:16 -0400 Subject: [PATCH 1/3] feat: add customBaseURL CNAME support to NetworkOptions Adds NetworkOptions.Builder.setCustomBaseURL(String) which routes all mParticle endpoint traffic (config, events, identity, alias, audience) through a single HTTPS CNAME host. When set, customBaseURL takes priority over individual domain mappings and rewrites paths to match CDN routing: /config/v4/, /nativeevents/v2/, /identity/v1/, /nativeevents/v1/identity/, /nativeevents/v1//audience. Also adds R8 keep rules for MParticle$Internal and ConfigManager.getNetworkOptions() so kits can read customBaseURL after minification. The Rokt kit reads NetworkOptions.customBaseURL and forwards it to the Rokt SDK: https://github.com/mparticle-integrations/mparticle-android-integration-rokt/pull/143 Mirrors iOS work from mparticle-apple-sdk#760. Co-Authored-By: Claude Opus 4.7 (1M context) --- android-core/proguard.pro | 4 + .../networking/MParticleBaseClientImplTest.kt | 148 ++++++++++++++++++ .../networking/MParticleBaseClientImpl.java | 70 ++++++--- .../mparticle/networking/NetworkOptions.java | 50 ++++++ 4 files changed, 248 insertions(+), 24 deletions(-) diff --git a/android-core/proguard.pro b/android-core/proguard.pro index 400222184..956b9a715 100644 --- a/android-core/proguard.pro +++ b/android-core/proguard.pro @@ -84,6 +84,10 @@ -keep class com.mparticle.MPEvent$* { *; } -keep class com.mparticle.MParticle { *; } +-keep class com.mparticle.MParticle$Internal { *; } +-keep class com.mparticle.internal.ConfigManager { + public com.mparticle.networking.NetworkOptions getNetworkOptions(); +} -keep class com.mparticle.MParticle$EventType { *; } -keep class com.mparticle.MParticle$InstallType { *; } -keep class com.mparticle.MParticle$IdentityType { *; } diff --git a/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt index 349a4faa4..3ca6a8358 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt @@ -422,4 +422,152 @@ class MParticleBaseClientImplTest : BaseCleanInstallEachTest() { ) assertEquals(null, result) } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLConfigEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val configUrl = baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.CONFIG) + Assert.assertTrue(configUrl.toString().contains("rkt.example.com/config/v4/")) + Assert.assertFalse(configUrl.toString().contains("config2.mparticle.com")) + } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLEventsEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val uploadSettings = UploadSettings(apiKey, "secret", options.networkOptions, "", "") + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val eventsUrl = + baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.EVENTS, null, null, uploadSettings) + Assert.assertTrue(eventsUrl.toString().contains("rkt.example.com/nativeevents/v2/")) + Assert.assertFalse(eventsUrl.toString().contains("nativesdks")) + } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLIdentityEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val identityUrl = + baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.IDENTITY, "login") + Assert.assertTrue(identityUrl.toString().contains("rkt.example.com/identity/v1/login")) + Assert.assertFalse(identityUrl.toString().contains("identity.us1.mparticle.com")) + } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLAliasEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val uploadSettings = UploadSettings(apiKey, "secret", options.networkOptions, "", "") + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val aliasUrl = + baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.ALIAS, null, null, uploadSettings) + Assert.assertTrue(aliasUrl.toString().contains("rkt.example.com/nativeevents/v1/identity/")) + Assert.assertTrue(aliasUrl.toString().endsWith("/alias")) + Assert.assertFalse(aliasUrl.toString().contains("nativesdks")) + } + + @Test + @Throws(MalformedURLException::class) + fun testCustomBaseURLAudienceEndpoint() { + val options = + MParticleOptions + .builder(mContext) + .credentials(apiKey, "secret") + .networkOptions( + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com") + .build(), + ).build() + MParticle.start(options) + val baseClientImpl = AccessUtils.getApiClient() as MParticleBaseClientImpl + val audienceUrl = + baseClientImpl.getUrl(MParticleBaseClientImpl.Endpoint.AUDIENCE, 12345L) + Assert.assertTrue(audienceUrl.toString().contains("rkt.example.com/nativeevents/v1/")) + Assert.assertTrue(audienceUrl.toString().contains("/audience")) + Assert.assertFalse(audienceUrl.toString().contains("nativesdks")) + } + + @Test + fun testCustomBaseURLRejectsNonHTTPS() { + val opts = + NetworkOptions + .builder() + .setCustomBaseURL("http://rkt.example.com") + .build() + assertEquals(null, opts.customBaseURL) + } + + @Test + fun testCustomBaseURLStripsPath() { + val opts = + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com/some/path?q=1") + .build() + assertEquals("rkt.example.com", opts.customBaseURL) + } + + @Test + fun testCustomBaseURLPreservesPort() { + val opts = + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com:8443") + .build() + assertEquals("rkt.example.com:8443", opts.customBaseURL) + } + + @Test + fun testCustomBaseURLRejectsMalformed() { + val opts = + NetworkOptions + .builder() + .setCustomBaseURL("not a url") + .build() + assertEquals(null, opts.customBaseURL) + } } diff --git a/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java b/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java index 2ed7817ba..0b0792793 100644 --- a/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java +++ b/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java @@ -116,36 +116,56 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap< DomainMapping domainMapping = networkOptions.getDomain(endpoint); String url = NetworkOptionsManager.getDefaultUrl(endpoint); String apiKey = uploadSettings == null ? mApiKey : uploadSettings.getApiKey(); + final String customBaseURL = networkOptions.getCustomBaseURL(); // `defaultDomain` variable is for URL generation when domain mapping is specified. String defaultDomain = url; boolean isDefaultDomain = true; - // Check if domain mapping is specified and update the URL based on domain mapping - String domainMappingUrl = domainMapping != null ? domainMapping.getUrl() : null; - if (!MPUtility.isEmpty(domainMappingUrl)) { - isDefaultDomain = url.equals(domainMappingUrl); - url = domainMappingUrl; - } - - if (endpoint != Endpoint.CONFIG) { - // Set URL with pod prefix if it’s the default domain and endpoint is not CONFIG - if (isDefaultDomain) { - url = getPodUrl(url, mConfigManager.getPodPrefix(apiKey), mConfigManager.isDirectUrlRoutingEnabled()); - } else { - // When domain mapping is specified, generate the default domain. Whether podRedirection is enabled or not, always use the original URL. + if (!MPUtility.isEmpty(customBaseURL)) { + // customBaseURL takes priority over all individual domain mappings. + if (domainMapping != null && !MPUtility.isEmpty(domainMapping.getUrl())) { + Logger.warning("NetworkOptions: customBaseURL is set; domain mapping for " + endpoint.name() + " is ignored."); + } + url = customBaseURL; + isDefaultDomain = false; + if (endpoint != Endpoint.CONFIG) { + // When custom CNAME is used, the default-domain URL still needs the pod prefix + // so MPConnectionTest matching and pinning fallbacks continue to work. defaultDomain = getPodUrl(defaultDomain, null, false); } + } else { + // Check if domain mapping is specified and update the URL based on domain mapping + String domainMappingUrl = domainMapping != null ? domainMapping.getUrl() : null; + if (!MPUtility.isEmpty(domainMappingUrl)) { + isDefaultDomain = url.equals(domainMappingUrl); + url = domainMappingUrl; + } + + if (endpoint != Endpoint.CONFIG) { + // Set URL with pod prefix if it’s the default domain and endpoint is not CONFIG + if (isDefaultDomain) { + url = getPodUrl(url, mConfigManager.getPodPrefix(apiKey), mConfigManager.isDirectUrlRoutingEnabled()); + } else { + // When domain mapping is specified, generate the default domain. Whether podRedirection is enabled or not, always use the original URL. + defaultDomain = getPodUrl(defaultDomain, null, false); + } + } } Uri uri; String subdirectory; String pathPrefix; String pathPostfix; + final boolean usingCustomBaseURL = !MPUtility.isEmpty(customBaseURL); boolean overridesSubdirectory = domainMapping != null && domainMapping.isOverridesSubdirectory(); + if (usingCustomBaseURL && overridesSubdirectory) { + Logger.warning("NetworkOptions: customBaseURL with overridesSubdirectory is unsupported for CDN routing; overridesSubdirectory will be ignored for " + endpoint.name() + "."); + overridesSubdirectory = false; + } switch (endpoint) { case CONFIG: - pathPrefix = SERVICE_VERSION_4 + "/"; + pathPrefix = usingCustomBaseURL ? "/config/v4/" : SERVICE_VERSION_4 + "/"; subdirectory = overridesSubdirectory ? "" : pathPrefix; pathPostfix = mApiKey + "/config"; Uri.Builder builder = new Uri.Builder() @@ -165,9 +185,9 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap< } } } - return MPUrl.getUrl(builder.build().toString(), generateDefaultURL(isDefaultDomain, builder.build(), defaultDomain, (pathPrefix + pathPostfix))); + return MPUrl.getUrl(builder.build().toString(), generateDefaultURL(isDefaultDomain, builder.build(), defaultDomain, (SERVICE_VERSION_4 + "/" + pathPostfix))); case EVENTS: - pathPrefix = SERVICE_VERSION_2 + "/"; + pathPrefix = usingCustomBaseURL ? "/nativeevents/v2/" : SERVICE_VERSION_2 + "/"; subdirectory = overridesSubdirectory ? "" : pathPrefix; pathPostfix = apiKey + "/events"; uri = new Uri.Builder() @@ -176,9 +196,9 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap< .path(subdirectory + pathPostfix) .build(); - return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (pathPrefix + pathPostfix))); + return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (SERVICE_VERSION_2 + "/" + pathPostfix))); case ALIAS: - pathPrefix = SERVICE_VERSION_1 + "/identity/"; + pathPrefix = usingCustomBaseURL ? "/nativeevents/v1/identity/" : SERVICE_VERSION_1 + "/identity/"; subdirectory = overridesSubdirectory ? "" : pathPrefix; pathPostfix = apiKey + "/alias"; uri = new Uri.Builder() @@ -186,26 +206,28 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap< .encodedAuthority(url) .path(subdirectory + pathPostfix) .build(); - return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (pathPrefix + pathPostfix))); + return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (SERVICE_VERSION_1 + "/identity/" + pathPostfix))); case IDENTITY: - pathPrefix = SERVICE_VERSION_1 + "/"; - subdirectory = overridesSubdirectory ? "" : SERVICE_VERSION_1 + "/"; + pathPrefix = usingCustomBaseURL ? "/identity/v1/" : SERVICE_VERSION_1 + "/"; + subdirectory = overridesSubdirectory ? "" : pathPrefix; pathPostfix = identityPath; uri = new Uri.Builder() .scheme(BuildConfig.SCHEME) .encodedAuthority(url) .path(subdirectory + pathPostfix) .build(); - return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (pathPrefix + pathPostfix))); + return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (SERVICE_VERSION_1 + "/" + pathPostfix))); case AUDIENCE: - pathPostfix = SERVICE_VERSION_1 + "/" + mApiKey + "/audience"; + pathPostfix = usingCustomBaseURL + ? "/nativeevents/v1/" + mApiKey + "/audience" + : SERVICE_VERSION_1 + "/" + mApiKey + "/audience"; uri = new Uri.Builder() .scheme(BuildConfig.SCHEME) .encodedAuthority(url) .path(pathPostfix) .appendQueryParameter("mpid", String.valueOf(mConfigManager.getMpid())) .build(); - return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, pathPostfix)); + return MPUrl.getUrl(uri.toString(), generateDefaultURL(isDefaultDomain, uri, defaultDomain, (SERVICE_VERSION_1 + "/" + mApiKey + "/audience"))); default: return null; } diff --git a/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java b/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java index 6faf9d521..57d104a26 100644 --- a/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java +++ b/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java @@ -28,6 +28,7 @@ public class NetworkOptions { Map domainMappings = new HashMap(); boolean pinningDisabledInDevelopment = false; boolean pinningDisabled = false; + private String customBaseURL = null; private static Set loggedDomainTypes = new HashSet<>(); private NetworkOptions() { @@ -44,6 +45,10 @@ private NetworkOptions(Builder builder) { if (builder.pinningDisabled != null) { pinningDisabled = builder.pinningDisabled; } + + if (builder.customBaseURL != null) { + customBaseURL = builder.customBaseURL; + } } @NonNull @@ -106,6 +111,16 @@ public boolean isPinningDisabled() { return pinningDisabled; } + /** + * Returns the configured custom CNAME host (without scheme), or {@code null} + * if not set. When non-null, this host overrides individual domain mappings + * for all endpoints. + */ + @Nullable + public String getCustomBaseURL() { + return customBaseURL; + } + DomainMapping getDomain(Endpoint endpoint) { return domainMappings.get(endpoint); } @@ -137,6 +152,7 @@ public static class Builder { private Map domainMappings = new HashMap(); private Boolean pinningDisabledInDevelopment; private Boolean pinningDisabled; + private String customBaseURL; private Builder() { } @@ -187,6 +203,40 @@ public Builder setPinningDisabled(boolean disabled) { return this; } + /** + * Routes all mParticle endpoint traffic (config, events, identity, alias, audience) + * through a single CNAME host. Must be an HTTPS URL (e.g. https://rkt.example.com). + * Non-HTTPS values are rejected with a warning log and the property is left unset. + * + *

When set, this property takes priority over any per-endpoint domain mapping. + * Any path, query, or fragment on the URL is ignored — only the scheme, host, and + * port are used. + * + *

Certificate pinning: if pinning is enabled (default), supply certificates for + * the CNAME domain via the relevant {@link DomainMapping}, or disable pinning via + * {@link #setPinningDisabled(boolean)} / {@link #setPinningDisabledInDevelopment(boolean)}. + * + * @param customBaseURL HTTPS URL containing the CNAME host + */ + @NonNull + public Builder setCustomBaseURL(@NonNull String customBaseURL) { + try { + java.net.URL parsed = new java.net.URL(customBaseURL); + if (!"https".equalsIgnoreCase(parsed.getProtocol()) || MPUtility.isEmpty(parsed.getHost())) { + Logger.warning("NetworkOptions: customBaseURL must use HTTPS and include a valid host — value ignored."); + return this; + } + StringBuilder host = new StringBuilder(parsed.getHost()); + if (parsed.getPort() != -1) { + host.append(":").append(parsed.getPort()); + } + this.customBaseURL = host.toString(); + } catch (java.net.MalformedURLException e) { + Logger.warning("NetworkOptions: customBaseURL is malformed — value ignored."); + } + return this; + } + @NonNull public NetworkOptions build() { return new NetworkOptions(this); From e75def6a8472d59cc60a80ce45d60d739d7ce1e6 Mon Sep 17 00:00:00 2001 From: James Newman Date: Wed, 13 May 2026 16:04:52 -0400 Subject: [PATCH 2/3] feat: pass mParticle CNAME through to Rokt SDK When the integrating app sets a custom CNAME on MParticleOptions via NetworkOptions.customBaseURL, forward it to the Rokt SDK so Rokt traffic routes through the same first-party domain. No-op when customBaseURL is unset, empty, or MParticle is uninitialized. Mirrors the iOS Rokt kit behavior added in mparticle-apple-sdk#760. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/kotlin/com/mparticle/kits/RoktKit.kt | 19 ++++ .../kotlin/com/mparticle/kits/RoktKitTests.kt | 102 ++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKit.kt b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKit.kt index 091ba6356..81d55668f 100644 --- a/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKit.kt +++ b/kits/rokt/rokt/src/main/kotlin/com/mparticle/kits/RoktKit.kt @@ -41,6 +41,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.lang.ref.WeakReference import java.math.BigDecimal +import java.net.URL const val ROKT_ATTRIBUTE_SANDBOX_MODE: String = "sandbox" @@ -90,6 +91,8 @@ class RoktKit : val mappedLogLevel = Logger.getMinLogLevel().toRoktLogLevel() Rokt.setLogLevel(mappedLogLevel) + applyCustomBaseURLIfSet() + Rokt.init( roktTagId = roktTagId, appVersion = info.versionName, @@ -176,6 +179,22 @@ class RoktKit : private fun throwOnKitCreateError(message: String): Unit = throw IllegalArgumentException(message) + /** + * Reads `customBaseURL` from the mParticle network options and forwards it to the + * Rokt SDK as a CNAME override. No-op if MParticle is uninitialized, the host is + * absent, or it's an empty string. + */ + private fun applyCustomBaseURLIfSet() { + val customBaseURL = MParticle.getInstance() + ?.Internal() + ?.configManager + ?.networkOptions + ?.customBaseURL + if (!customBaseURL.isNullOrEmpty()) { + Rokt.setCustomBaseURL(URL("https://$customBaseURL")) + } + } + /* For more details, visit the official documentation: https://docs.rokt.com/developers/integration-guides/android/how-to/adding-a-placement/ diff --git a/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktKitTests.kt b/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktKitTests.kt index 9717978a6..9c08b95a1 100644 --- a/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktKitTests.kt +++ b/kits/rokt/rokt/src/test/kotlin/com/mparticle/kits/RoktKitTests.kt @@ -25,9 +25,11 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.mockkStatic import io.mockk.runs import io.mockk.slot import io.mockk.unmockkObject +import io.mockk.unmockkStatic import io.mockk.verify import io.mockk.verifyOrder import kotlinx.coroutines.flow.first @@ -1503,4 +1505,104 @@ class RoktKitTests { method.isAccessible = true assertEquals(RoktLogLevel.NONE, method.invoke(roktKit, MParticle.LogLevel.NONE)) } + + // MARK: - applyCustomBaseURLIfSet + + private fun invokeApplyCustomBaseURLIfSet() { + val method = RoktKit::class.java.getDeclaredMethod("applyCustomBaseURLIfSet") + method.isAccessible = true + method.invoke(roktKit) + } + + private fun stubNetworkOptionsCustomBaseURL(customBaseURL: String?): com.mparticle.networking.NetworkOptions { + val mockInternal = mock(MParticle.Internal::class.java) + val mockConfigManager = mock(com.mparticle.internal.ConfigManager::class.java) + val mockNetworkOptions = mock(com.mparticle.networking.NetworkOptions::class.java) + Mockito.`when`(MParticle.getInstance()?.Internal()).thenReturn(mockInternal) + Mockito.`when`(mockInternal.configManager).thenReturn(mockConfigManager) + Mockito.`when`(mockConfigManager.networkOptions).thenReturn(mockNetworkOptions) + Mockito.`when`(mockNetworkOptions.customBaseURL).thenReturn(customBaseURL) + return mockNetworkOptions + } + + @Test + fun applyCustomBaseURLIfSet_whenHostIsSet_forwardsHttpsURLToRokt() { + stubNetworkOptionsCustomBaseURL("rkt.example.com") + mockkStatic(Rokt::class) + every { Rokt.setCustomBaseURL(any()) } just runs + + invokeApplyCustomBaseURLIfSet() + + verify(exactly = 1) { + Rokt.setCustomBaseURL(java.net.URL("https://rkt.example.com")) + } + unmockkStatic(Rokt::class) + } + + @Test + fun applyCustomBaseURLIfSet_whenHostHasPort_preservesPortInURL() { + stubNetworkOptionsCustomBaseURL("rkt.example.com:8443") + mockkStatic(Rokt::class) + every { Rokt.setCustomBaseURL(any()) } just runs + + invokeApplyCustomBaseURLIfSet() + + verify(exactly = 1) { + Rokt.setCustomBaseURL(java.net.URL("https://rkt.example.com:8443")) + } + unmockkStatic(Rokt::class) + } + + @Test + fun applyCustomBaseURLIfSet_whenHostIsNull_doesNotCallRokt() { + stubNetworkOptionsCustomBaseURL(null) + mockkStatic(Rokt::class) + every { Rokt.setCustomBaseURL(any()) } just runs + + invokeApplyCustomBaseURLIfSet() + + verify(exactly = 0) { Rokt.setCustomBaseURL(any()) } + unmockkStatic(Rokt::class) + } + + @Test + fun applyCustomBaseURLIfSet_whenHostIsEmpty_doesNotCallRokt() { + stubNetworkOptionsCustomBaseURL("") + mockkStatic(Rokt::class) + every { Rokt.setCustomBaseURL(any()) } just runs + + invokeApplyCustomBaseURLIfSet() + + verify(exactly = 0) { Rokt.setCustomBaseURL(any()) } + unmockkStatic(Rokt::class) + } + + @Test + fun applyCustomBaseURLIfSet_whenNetworkOptionsIsNull_doesNotCallRokt() { + val mockInternal = mock(MParticle.Internal::class.java) + val mockConfigManager = mock(com.mparticle.internal.ConfigManager::class.java) + Mockito.`when`(MParticle.getInstance()?.Internal()).thenReturn(mockInternal) + Mockito.`when`(mockInternal.configManager).thenReturn(mockConfigManager) + Mockito.`when`(mockConfigManager.networkOptions).thenReturn(null) + + mockkStatic(Rokt::class) + every { Rokt.setCustomBaseURL(any()) } just runs + + invokeApplyCustomBaseURLIfSet() + + verify(exactly = 0) { Rokt.setCustomBaseURL(any()) } + unmockkStatic(Rokt::class) + } + + @Test + fun applyCustomBaseURLIfSet_whenMParticleInstanceIsNull_doesNotCallRokt() { + MParticle.setInstance(null) + mockkStatic(Rokt::class) + every { Rokt.setCustomBaseURL(any()) } just runs + + invokeApplyCustomBaseURLIfSet() + + verify(exactly = 0) { Rokt.setCustomBaseURL(any()) } + unmockkStatic(Rokt::class) + } } From 8935e6b1bd9b8bac3088fe04423bd8f7740441aa Mon Sep 17 00:00:00 2001 From: James Newman Date: Wed, 13 May 2026 16:58:29 -0400 Subject: [PATCH 3/3] fix: persist customBaseURL across NetworkOptions JSON round-trip NetworkOptions.toJson() and withNetworkOptions(String) did not include customBaseURL, so any value was silently dropped when UploadSettings serialized NetworkOptions to the upload database. Events and alias uploads read back NetworkOptions without customBaseURL and routed to the default mParticle endpoints instead of the partner CNAME. Also: - Extract the customBaseURL/DomainMapping host-resolution branch out of getUrl() into a private resolveHost() helper plus a small ResolvedHost value type, lowering getUrl()'s cyclomatic complexity. - Switch java.net.URL / java.net.MalformedURLException to imports. - Add two androidTest cases covering the round-trip. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../networking/MParticleBaseClientImplTest.kt | 25 +++++ .../networking/MParticleBaseClientImpl.java | 93 +++++++++++-------- .../mparticle/networking/NetworkOptions.java | 18 +++- 3 files changed, 96 insertions(+), 40 deletions(-) diff --git a/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt index 3ca6a8358..d74f9f768 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/networking/MParticleBaseClientImplTest.kt @@ -570,4 +570,29 @@ class MParticleBaseClientImplTest : BaseCleanInstallEachTest() { .build() assertEquals(null, opts.customBaseURL) } + + @Test + fun testCustomBaseURLSurvivesJsonRoundTrip() { + val original = + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com:8443") + .build() + Assert.assertEquals("rkt.example.com:8443", original.customBaseURL) + + val json = original.toJson().toString() + val restored = NetworkOptions.withNetworkOptions(json) + Assert.assertNotNull(restored) + Assert.assertEquals("rkt.example.com:8443", restored!!.customBaseURL) + } + + @Test + fun testCustomBaseURLOmittedFromJsonWhenUnset() { + val opts = NetworkOptions.builder().build() + val json = opts.toJson() + Assert.assertFalse(json.has("customBaseURL")) + + val restored = NetworkOptions.withNetworkOptions(json.toString()) + Assert.assertNull(restored!!.customBaseURL) + } } diff --git a/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java b/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java index 0b0792793..cbb2153e5 100644 --- a/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java +++ b/android-core/src/main/java/com/mparticle/networking/MParticleBaseClientImpl.java @@ -114,50 +114,18 @@ protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath, HashMap protected MPUrl getUrl(Endpoint endpoint, @Nullable String identityPath,HashMap audienceQueryParams, @Nullable UploadSettings uploadSettings) throws MalformedURLException { NetworkOptions networkOptions = uploadSettings == null ? mConfigManager.getNetworkOptions() : uploadSettings.getNetworkOptions(); DomainMapping domainMapping = networkOptions.getDomain(endpoint); - String url = NetworkOptionsManager.getDefaultUrl(endpoint); String apiKey = uploadSettings == null ? mApiKey : uploadSettings.getApiKey(); - final String customBaseURL = networkOptions.getCustomBaseURL(); + final boolean usingCustomBaseURL = !MPUtility.isEmpty(networkOptions.getCustomBaseURL()); - // `defaultDomain` variable is for URL generation when domain mapping is specified. - String defaultDomain = url; - boolean isDefaultDomain = true; - - if (!MPUtility.isEmpty(customBaseURL)) { - // customBaseURL takes priority over all individual domain mappings. - if (domainMapping != null && !MPUtility.isEmpty(domainMapping.getUrl())) { - Logger.warning("NetworkOptions: customBaseURL is set; domain mapping for " + endpoint.name() + " is ignored."); - } - url = customBaseURL; - isDefaultDomain = false; - if (endpoint != Endpoint.CONFIG) { - // When custom CNAME is used, the default-domain URL still needs the pod prefix - // so MPConnectionTest matching and pinning fallbacks continue to work. - defaultDomain = getPodUrl(defaultDomain, null, false); - } - } else { - // Check if domain mapping is specified and update the URL based on domain mapping - String domainMappingUrl = domainMapping != null ? domainMapping.getUrl() : null; - if (!MPUtility.isEmpty(domainMappingUrl)) { - isDefaultDomain = url.equals(domainMappingUrl); - url = domainMappingUrl; - } - - if (endpoint != Endpoint.CONFIG) { - // Set URL with pod prefix if it’s the default domain and endpoint is not CONFIG - if (isDefaultDomain) { - url = getPodUrl(url, mConfigManager.getPodPrefix(apiKey), mConfigManager.isDirectUrlRoutingEnabled()); - } else { - // When domain mapping is specified, generate the default domain. Whether podRedirection is enabled or not, always use the original URL. - defaultDomain = getPodUrl(defaultDomain, null, false); - } - } - } + ResolvedHost host = resolveHost(endpoint, networkOptions, domainMapping, apiKey); + String url = host.url; + String defaultDomain = host.defaultDomain; + boolean isDefaultDomain = host.isDefaultDomain; Uri uri; String subdirectory; String pathPrefix; String pathPostfix; - final boolean usingCustomBaseURL = !MPUtility.isEmpty(customBaseURL); boolean overridesSubdirectory = domainMapping != null && domainMapping.isOverridesSubdirectory(); if (usingCustomBaseURL && overridesSubdirectory) { Logger.warning("NetworkOptions: customBaseURL with overridesSubdirectory is unsupported for CDN routing; overridesSubdirectory will be ignored for " + endpoint.name() + "."); @@ -268,6 +236,57 @@ String getPodUrl(String URLPrefix, String pod, boolean enablePodRedirection) { return null; } + /** + * Resolves the host(s) used to build the endpoint URL. Returns three values: + * {@code url} — host used for the request, {@code defaultDomain} — host used as the + * fallback for {@link #generateDefaultURL}, and {@code isDefaultDomain} — true when + * {@code url} is the unmodified mParticle default. + * + *

Priority: {@code customBaseURL} → per-endpoint {@code DomainMapping} → default. + */ + private ResolvedHost resolveHost(Endpoint endpoint, NetworkOptions networkOptions, DomainMapping domainMapping, String apiKey) { + String defaultUrl = NetworkOptionsManager.getDefaultUrl(endpoint); + String customBaseURL = networkOptions.getCustomBaseURL(); + + if (!MPUtility.isEmpty(customBaseURL)) { + if (domainMapping != null && !MPUtility.isEmpty(domainMapping.getUrl())) { + Logger.warning("NetworkOptions: customBaseURL is set; domain mapping for " + endpoint.name() + " is ignored."); + } + // When custom CNAME is used, the default-domain URL still needs the pod prefix + // so MPConnectionTest matching and pinning fallbacks continue to work. + String defaultDomain = endpoint == Endpoint.CONFIG ? defaultUrl : getPodUrl(defaultUrl, null, false); + return new ResolvedHost(customBaseURL, defaultDomain, false); + } + + String domainMappingUrl = domainMapping != null ? domainMapping.getUrl() : null; + boolean isDefaultDomain = MPUtility.isEmpty(domainMappingUrl) || defaultUrl.equals(domainMappingUrl); + String url = isDefaultDomain ? defaultUrl : domainMappingUrl; + String defaultDomain = defaultUrl; + + if (endpoint != Endpoint.CONFIG) { + if (isDefaultDomain) { + // Default domain gets the pod prefix. + url = getPodUrl(url, mConfigManager.getPodPrefix(apiKey), mConfigManager.isDirectUrlRoutingEnabled()); + } else { + // Domain-mapped: always generate the default with the original (un-pod-prefixed) host. + defaultDomain = getPodUrl(defaultDomain, null, false); + } + } + return new ResolvedHost(url, defaultDomain, isDefaultDomain); + } + + private static final class ResolvedHost { + final String url; + final String defaultDomain; + final boolean isDefaultDomain; + + ResolvedHost(String url, String defaultDomain, boolean isDefaultDomain) { + this.url = url; + this.defaultDomain = defaultDomain; + this.isDefaultDomain = isDefaultDomain; + } + } + public enum Endpoint { CONFIG(1), IDENTITY(2), diff --git a/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java b/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java index 57d104a26..708aae363 100644 --- a/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java +++ b/android-core/src/main/java/com/mparticle/networking/NetworkOptions.java @@ -16,11 +16,13 @@ import org.json.JSONException; import org.json.JSONObject; +import java.net.MalformedURLException; +import java.net.URL; import java.util.ArrayList; import java.util.HashMap; +import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.HashSet; import java.util.Set; public class NetworkOptions { @@ -66,6 +68,13 @@ public static NetworkOptions withNetworkOptions(@Nullable String jsonString) { JSONObject jsonObject = new JSONObject(jsonString); builder.setPinningDisabledInDevelopment(jsonObject.optBoolean("disableDevPinning", false)); builder.setPinningDisabled(jsonObject.optBoolean("disablePinning", false)); + String storedCustomBaseURL = jsonObject.optString("customBaseURL", null); + if (!MPUtility.isEmpty(storedCustomBaseURL)) { + // Stored value is already host(:port). Skip the Builder setter (which expects a full + // HTTPS URL with scheme) and assign directly so a previously-validated value survives + // the JSON round-trip. + builder.customBaseURL = storedCustomBaseURL; + } JSONArray domainMappingsJson = jsonObject.getJSONArray("domainMappings"); for (int i = 0; i < domainMappingsJson.length(); i++) { builder.addDomainMapping(DomainMapping @@ -138,6 +147,9 @@ public JSONObject toJson() { JSONArray domainMappingsJson = new JSONArray(); networkOptions.put("disableDevPinning", pinningDisabledInDevelopment); networkOptions.put("disablePinning", pinningDisabled); + if (!MPUtility.isEmpty(customBaseURL)) { + networkOptions.put("customBaseURL", customBaseURL); + } networkOptions.put("domainMappings", domainMappingsJson); for (DomainMapping domainMapping : domainMappings.values()) { domainMappingsJson.put(domainMapping.toString()); @@ -221,7 +233,7 @@ public Builder setPinningDisabled(boolean disabled) { @NonNull public Builder setCustomBaseURL(@NonNull String customBaseURL) { try { - java.net.URL parsed = new java.net.URL(customBaseURL); + URL parsed = new URL(customBaseURL); if (!"https".equalsIgnoreCase(parsed.getProtocol()) || MPUtility.isEmpty(parsed.getHost())) { Logger.warning("NetworkOptions: customBaseURL must use HTTPS and include a valid host — value ignored."); return this; @@ -231,7 +243,7 @@ public Builder setCustomBaseURL(@NonNull String customBaseURL) { host.append(":").append(parsed.getPort()); } this.customBaseURL = host.toString(); - } catch (java.net.MalformedURLException e) { + } catch (MalformedURLException e) { Logger.warning("NetworkOptions: customBaseURL is malformed — value ignored."); } return this;