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/internal/database/services/UploadServiceTest.kt b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt index 5b2ed9895..c0d3e5e38 100644 --- a/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt +++ b/android-core/src/androidTest/kotlin/com.mparticle/internal/database/services/UploadServiceTest.kt @@ -45,6 +45,27 @@ class UploadServiceTest : BaseMPServiceTest() { assertEquals(6, UploadService.getReadyUploads(database).size) } + @Test + @Throws(JSONException::class) + fun testUploadSettingsPreserveCustomBaseURL() { + val uploadSettings = UploadSettings( + "apiKey", + "secret", + NetworkOptions + .builder() + .setCustomBaseURL("https://rkt.example.com:8443") + .build(), + "", + "", + ) + UploadService.insertUpload(database, uploadJson(System.currentTimeMillis()), uploadSettings) + + val readyUploads = UploadService.getReadyUploads(database) + + assertEquals(1, readyUploads.size) + assertEquals("rkt.example.com:8443", readyUploads[0].uploadSettings.networkOptions.customBaseURL) + } + @Throws(JSONException::class) private fun uploadJson(timestampMillis: Long): JSONObject = JSONObject() .put(Constants.MessageKey.TIMESTAMP, timestampMillis) 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..d74f9f768 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,177 @@ 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) + } + + @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 2ed7817ba..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,38 +114,26 @@ 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 boolean usingCustomBaseURL = !MPUtility.isEmpty(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. - 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; 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 +153,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 +164,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 +174,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; } @@ -246,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 6faf9d521..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 { @@ -28,6 +30,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 +47,10 @@ private NetworkOptions(Builder builder) { if (builder.pinningDisabled != null) { pinningDisabled = builder.pinningDisabled; } + + if (builder.customBaseURL != null) { + customBaseURL = builder.customBaseURL; + } } @NonNull @@ -61,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 @@ -106,6 +120,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); } @@ -123,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()); @@ -137,6 +164,7 @@ public static class Builder { private Map domainMappings = new HashMap(); private Boolean pinningDisabledInDevelopment; private Boolean pinningDisabled; + private String customBaseURL; private Builder() { } @@ -187,6 +215,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 { + 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; + } + StringBuilder host = new StringBuilder(parsed.getHost()); + if (parsed.getPort() != -1) { + host.append(":").append(parsed.getPort()); + } + this.customBaseURL = host.toString(); + } catch (MalformedURLException e) { + Logger.warning("NetworkOptions: customBaseURL is malformed — value ignored."); + } + return this; + } + @NonNull public NetworkOptions build() { return new NetworkOptions(this);