Skip to content
Open
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
### Added
- `IterableDataRegion` now exposes stable cross-SDK identifiers and lookup helpers: `getRegionCode()` (e.g. `"EU"`), `getCode()` (`0` for US, `1` for EU, matching the React Native and Flutter SDKs), and the static `IterableDataRegion.from(String)` / `IterableDataRegion.from(int)` factories. `from(String)` accepts a region code in any case and also accepts a full API endpoint URL, so the iOS SDK's string-based data region values resolve without translation. **No action required** β€” the existing `setDataRegion(IterableDataRegion.EU)` API is unchanged.

### Fixed
- An unrecognised data region no longer resolves silently. `IterableDataRegion.from(...)` and `setDataRegion(...)` log a warning naming the supported values before falling back to `US`, so a misconfigured region surfaces in the logs instead of quietly routing EU-destined data to the US data center. `setDataRegion(null)` now falls back to `US` with a warning instead of leaving the region unset.

### Deprecated
- `IterableConstants.BASE_URL_API` and `IterableConstants.BASE_URL_LINKS` β€” both are hardcoded to the US data region and are unused by the SDK, which resolves its endpoint from the configured `IterableDataRegion`. Use `IterableDataRegion.getEndpoint()` instead. They still resolve to the same values, so no action is required in this release. **They will be removed in 3.12.0** β€” if you reference either constant, switch to `IterableDataRegion.getEndpoint()` before upgrading to that version.

## [3.10.0]
### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -362,10 +362,21 @@ public Builder setAllowedProtocols(@NonNull String[] allowedProtocols) {

/**
* Set the data region used by the SDK
* <p>
* To resolve a region from a string or numeric identifier (for example when bridging from a
* cross-platform wrapper), use {@link IterableDataRegion#from(String)} or
* {@link IterableDataRegion#from(int)}, which fall back to
* {@link IterableDataRegion#US} and log on unrecognised values.
Comment thread
rtlsilva marked this conversation as resolved.
*
* @param dataRegion enum value that determines which endpoint to use, defaults to IterableDataRegion.US
*/
@NonNull
public Builder setDataRegion(@NonNull IterableDataRegion dataRegion) {
if (dataRegion == null) {
IterableLogger.w("IterableConfig", "setDataRegion received null, defaulting to " + IterableDataRegion.US.getRegionCode());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use the static final string TAG here instead of the "magic string"!

this.dataRegion = IterableDataRegion.US;
return this;
}
this.dataRegion = dataRegion;
return this;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@ public final class IterableConstants {
public static final String ACTION_PUSH_REGISTRATION = "com.iterable.push.ACTION_PUSH_REGISTRATION";

//Hosts
/**
* @deprecated These constants are hardcoded to the US data region and are unused by the SDK.
* Use {@link IterableDataRegion#getEndpoint()} instead, which respects the configured region.
* Scheduled for removal in 3.12.0.
*/
@Deprecated
public static final String BASE_URL_API = "https://api.iterable.com/api/";
/**
* @deprecated Unused by the SDK and hardcoded to the US data region.
* Scheduled for removal in 3.12.0.
*/
@Deprecated
public static final String BASE_URL_LINKS = "https://links.iterable.com/";

//API Fields
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,109 @@
package com.iterable.iterableapi;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;

/**
* Data region determining which data center and endpoints the SDK sends data to.
* Defaults to {@link #US}; EU-hosted projects must select {@link #EU}.
*/
public enum IterableDataRegion {
US("https://api.iterable.com/api/"),
EU("https://api.eu.iterable.com/api/");
US(0, "US", "https://api.iterable.com/api/"),
EU(1, "EU", "https://api.eu.iterable.com/api/");

private static final String TAG = "IterableDataRegion";

private final int code;
private final String regionCode;
private final String endpoint;

IterableDataRegion(String endpoint) {
IterableDataRegion(int code, String regionCode, String endpoint) {
this.code = code;
this.regionCode = regionCode;
this.endpoint = endpoint;
}

public String getEndpoint() {
return this.endpoint;
}

/**
* Stable numeric identifier for this region, matching the values used by the React Native and
* Flutter SDKs. Unlike {@link #ordinal()} this is guaranteed not to shift if regions are added.
*/
public int getCode() {
return this.code;
}

/**
* Stable short identifier for this region (for example {@code "EU"}), matching the values used
* by Iterable's other SDKs.
*/
@NonNull
public String getRegionCode() {
return this.regionCode;
}
Comment thread
rtlsilva marked this conversation as resolved.

/**
* Resolves a region from its short identifier (e.g. {@code "EU"}), accepting either case. Also
* accepts a full API endpoint URL, so values from the iOS SDK's string-based data region can be
* passed through unchanged.
* <p>
* Unrecognised or null values fall back to {@link #US} and are logged, matching the behaviour of
* Iterable's other SDKs.
*
* @param value region identifier, or {@code null}
* @return the matching region, or {@link #US} if the value is not recognised
*/
@NonNull
public static IterableDataRegion from(@Nullable String value) {
if (value == null || value.trim().isEmpty()) {
IterableLogger.w(TAG, "No data region specified, defaulting to " + US.regionCode);
return US;
}

String normalized = value.trim();
for (IterableDataRegion region : values()) {
if (normalized.equalsIgnoreCase(region.regionCode) || normalized.equalsIgnoreCase(region.endpoint)) {
return region;
}
}

IterableLogger.w(TAG, "Unsupported data region \"" + value + "\", defaulting to " + US.regionCode

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These warnings never reach logcat. IterableLogger.w checks Log.WARN >= getLogLevel(), and at the point from() and setDataRegion() run we are still building the config, so getLogLevel() reads the pre-init default IterableConfig whose logLevel is Log.ERROR.

That makes the check 5 >= 6, so the message is dropped.

Note this is not fixable by the integrator either: sharedInstance.config is only swapped for the customer's config in initialize() (IterableApi.java:817), so even .setLogLevel(Log.VERBOSE) in the same builder chain is too late.

I confirmed this with a probe test using Cursor, IterableLogger.w is gated out while IterableLogger.e reaches android.util.Log. Given the failure mode is EU-destined data silently going to the US data center, I would use IterableLogger.e for the unrecognised-region path and add a test asserting the log fires. Otherwise the CHANGELOG line about it no longer resolving silently is not true for anyone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for the catch, this was a good finding and is now fixed.

One exception, from(null) / from("") stays at warning. That's the documented default rather than a misconfiguration, and ERROR there would fire for every wrapper app that never sets a region.

+ ". Supported values: " + supportedRegionCodes());
return US;
}

/**
* Resolves a region from its numeric identifier, as used by the React Native and Flutter SDKs.
* <p>
* Unrecognised values fall back to {@link #US} and are logged, matching the behaviour of
* Iterable's other SDKs.
*
* @param code region identifier, see {@link #getCode()}
* @return the matching region, or {@link #US} if the code is not recognised
*/
@NonNull
public static IterableDataRegion from(int code) {
for (IterableDataRegion region : values()) {
if (region.code == code) {
return region;
}
}

IterableLogger.w(TAG, "Unsupported data region code " + code + ", defaulting to " + US.regionCode
+ ". Supported values: " + supportedRegionCodes());
return US;
}

private static String supportedRegionCodes() {
StringBuilder builder = new StringBuilder();
for (IterableDataRegion region : values()) {
if (builder.length() > 0) {
builder.append(", ");
}
builder.append(region.regionCode).append(" (").append(region.code).append(")");
}
return builder.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,6 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque
String baseUrl = getBaseUrl();

try {
if (overrideUrl != null && !overrideUrl.isEmpty()) {
baseUrl = overrideUrl;
}
if (iterableApiRequest.requestType == IterableApiRequest.GET) {
Uri.Builder builder = Uri.parse(baseUrl + iterableApiRequest.resourcePath).buildUpon();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ class IterableConfigTest {
val config: IterableConfig = configBuilder.build()
assertThat(config.dataRegion, `is`(IterableDataRegion.EU))
}

/** Only reachable from Java, where the `@NonNull` parameter can still be passed null. */
@Test
fun nullDataRegionFallsBackToUs() {
val builder = IterableConfig.Builder()
val setter = IterableConfig.Builder::class.java
.getMethod("setDataRegion", IterableDataRegion::class.java)
setter.invoke(builder, null)
assertThat(builder.build().dataRegion, `is`(IterableDataRegion.US))
}

@Test
fun defaultWebViewBaseUrl() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.iterable.iterableapi

import org.hamcrest.Matchers.`is`
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThat
import org.junit.Test

class IterableDataRegionTest {

@Test
fun endpointsMatchDataCenters() {
assertEquals("https://api.iterable.com/api/", IterableDataRegion.US.endpoint)
assertEquals("https://api.eu.iterable.com/api/", IterableDataRegion.EU.endpoint)
}

/** These identifiers are part of the cross-SDK contract; changing them breaks the wrappers. */
@Test
fun stableIdentifiersMatchOtherSdks() {
assertEquals(0, IterableDataRegion.US.code)
assertEquals(1, IterableDataRegion.EU.code)
assertEquals("US", IterableDataRegion.US.regionCode)
assertEquals("EU", IterableDataRegion.EU.regionCode)
}

@Test
fun fromRegionCode() {
assertThat(IterableDataRegion.from("US"), `is`(IterableDataRegion.US))
assertThat(IterableDataRegion.from("EU"), `is`(IterableDataRegion.EU))
}

@Test
fun fromRegionCodeIsCaseAndWhitespaceInsensitive() {
assertThat(IterableDataRegion.from("eu"), `is`(IterableDataRegion.EU))
assertThat(IterableDataRegion.from("Eu"), `is`(IterableDataRegion.EU))
assertThat(IterableDataRegion.from(" EU "), `is`(IterableDataRegion.EU))
}

/** iOS expresses the data region as the endpoint URL itself, so those values must resolve. */
@Test
fun fromIosStyleEndpointUrl() {
assertThat(
IterableDataRegion.from("https://api.eu.iterable.com/api/"),
`is`(IterableDataRegion.EU)
)
assertThat(
IterableDataRegion.from("https://api.iterable.com/api/"),
`is`(IterableDataRegion.US)
)
}

@Test
fun fromUnsupportedStringFallsBackToUs() {
assertThat(IterableDataRegion.from("APAC"), `is`(IterableDataRegion.US))
assertThat(IterableDataRegion.from(""), `is`(IterableDataRegion.US))
assertThat(IterableDataRegion.from(" "), `is`(IterableDataRegion.US))
assertThat(IterableDataRegion.from(null as String?), `is`(IterableDataRegion.US))
}

@Test
fun fromCode() {
assertThat(IterableDataRegion.from(0), `is`(IterableDataRegion.US))
assertThat(IterableDataRegion.from(1), `is`(IterableDataRegion.EU))
}

@Test
fun fromUnsupportedCodeFallsBackToUs() {
assertThat(IterableDataRegion.from(2), `is`(IterableDataRegion.US))
assertThat(IterableDataRegion.from(-1), `is`(IterableDataRegion.US))
assertThat(IterableDataRegion.from(Int.MAX_VALUE), `is`(IterableDataRegion.US))
}

/** Round-trips guard the wrappers, which serialize the region and resolve it back. */
@Test
fun identifiersRoundTrip() {
for (region in IterableDataRegion.values()) {
assertThat(IterableDataRegion.from(region.code), `is`(region))
assertThat(IterableDataRegion.from(region.regionCode), `is`(region))
assertThat(IterableDataRegion.from(region.endpoint), `is`(region))
}
}
}
Loading