diff --git a/CHANGELOG.md b/CHANGELOG.md index 0feb8cb95..de2ba1639 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,16 +5,19 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added - `IterableConfig.Builder.setExpiringAuthTokenRefreshPeriod(double)` accepts fractional seconds, matching the iOS, React Native and Flutter SDKs. Previously Android only accepted whole seconds, so a value like `0.5` behaved differently here than on other platforms. The existing `Long` overload is deprecated but still works, so no code changes are required. +- `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 - Fixed the keychain treating a transient crypto timeout as a permanent decryption failure. A slow AndroidKeyStore operation that exceeded the 500 ms timeout would wipe the stored email, userId, and auth token and disable encryption, forcing the user to re-authenticate (and request a new auth token) on the next launch. Crypto timeouts are now handled as transient without wiping credentials or disabling encryption for the device: a read that times out returns no value for that call (the stored ciphertext is left intact for the next attempt), and a write that times out stores that one value unencrypted (as the non-encrypted fallback already did) rather than clearing everything. The timed-out crypto operation is also cancelled so it no longer blocks subsequent reads/writes. - `setExpiringAuthTokenRefreshPeriod` now validates its input instead of silently producing a broken refresh schedule. Previously a negative value was converted to a negative millisecond period and then *subtracted* when computing the refresh time, scheduling the refresh after the token had already expired; a very large value overflowed to a negative period with the same effect; and `null` threw a `NullPointerException` on unboxing. Invalid values (`null`, `NaN`, negatives) are now logged and ignored, leaving the period at whatever it was before the call — the 60 second default unless an earlier call set something else. Values above ~10 years are clamped to that ceiling rather than ignored. Zero remains valid and means the token is refreshed only once it has expired. +- An unrecognised data region no longer resolves silently. `IterableDataRegion.from(...)` and `setDataRegion(...)` log an error 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 an error instead of leaving the region unset. Error level is deliberate: these run while the config is still being built, before `IterableApi.initialize()` applies your `setLogLevel(...)`, so anything lower would never reach logcat. ### Changed - Clarified that `setExpiringAuthTokenRefreshPeriod` takes **seconds**, with a default of 60. The unit and default are unchanged and match every other Iterable SDK. ### Deprecated - `IterableConfig.Builder.setExpiringAuthTokenRefreshPeriod(Long)` — use the `double` overload instead, which accepts fractional seconds. The `Long` overload delegates to it and remains fully supported. +- `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.1] ### Fixed diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index a7c532d0b..67e037b21 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -420,10 +420,27 @@ public Builder setAllowedProtocols(@NonNull String[] allowedProtocols) { /** * Set the data region used by the SDK - * @param dataRegion enum value that determines which endpoint to use, defaults to IterableDataRegion.US + *
+ * The parameter is annotated {@link NonNull}, but Java callers are not held to that at + * compile time: passing {@code null} falls back to {@link IterableDataRegion#US} and logs an + * error rather than throwing. + *
+ * 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. + * + * @param dataRegion enum value that determines which endpoint to use, defaults to + * IterableDataRegion.US; {@code null} is treated as + * {@link IterableDataRegion#US} */ @NonNull public Builder setDataRegion(@NonNull IterableDataRegion dataRegion) { + if (dataRegion == null) { + IterableLogger.e(TAG, "setDataRegion received null, defaulting to " + IterableDataRegion.US.getRegionCode()); + this.dataRegion = IterableDataRegion.US; + return this; + } this.dataRegion = dataRegion; return this; } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java index 6c896f6d7..403c12a05 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java @@ -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 diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataRegion.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataRegion.java index aec57c0f2..8175741a3 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataRegion.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDataRegion.java @@ -1,16 +1,114 @@ 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. It currently matches {@link #name()}, but is stored separately for + * the same reason {@link #getCode()} does not use {@link #ordinal()}: renaming an enum constant + * must not change an identifier that wrappers and persisted configuration already rely on. + */ + @NonNull + public String getRegionCode() { + return this.regionCode; + } + + /** + * 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. + *
+ * Unrecognised values fall back to {@link #US} and are logged as an error, matching the + * behaviour of Iterable's other SDKs. A null or blank value also falls back to {@link #US}, + * which is the documented default rather than a misconfiguration. + * + * @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; + } + } + + // Error level, not warning: this runs while the config is still being built, and + // IterableLogger reads the pre-init config (ERROR) until initialize() swaps it in. + IterableLogger.e(TAG, "Unsupported data region \"" + value + "\", defaulting to " + US.regionCode + + ". Supported values: " + supportedRegionCodes()); + return US; + } + + /** + * Resolves a region from its numeric identifier, as used by the React Native and Flutter SDKs. + *
+ * Unrecognised values fall back to {@link #US} and are logged as an error, 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.e(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();
+ }
}
diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java
index 8c66c885d..fb728bfdb 100644
--- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java
+++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java
@@ -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();
diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt
index 646105f1f..84d2ecf57 100644
--- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt
+++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt
@@ -1,12 +1,23 @@
package com.iterable.iterableapi
+import android.util.Log
+import com.iterable.iterableapi.unit.TestRunner
import org.hamcrest.Matchers.`is`
import org.hamcrest.Matchers.nullValue
import org.junit.Assert.*
+import org.junit.Before
import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.shadows.ShadowLog
+@RunWith(TestRunner::class)
class IterableConfigTest {
+ @Before
+ fun clearLogs() {
+ ShadowLog.clear()
+ }
+
@Test
fun defaultDataRegion() {
val configBuilder: IterableConfig.Builder = IterableConfig.Builder()
@@ -21,6 +32,24 @@ 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))
+
+ // Builder methods run before initialize() swaps in this config, so IterableLogger is still
+ // reading the pre-init ERROR level: anything below ERROR never reaches logcat.
+ val errors = ShadowLog.getLogsForTag("IterableConfig")
+ .filter { it.type == Log.ERROR }
+ .map { it.msg }
+ assertEquals(errors.toString(), 1, errors.size)
+ assertTrue(errors.single(), errors.single().contains("setDataRegion received null"))
+ }
@Test
fun defaultWebViewBaseUrl() {
diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataRegionTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataRegionTest.kt
new file mode 100644
index 000000000..255b75dfb
--- /dev/null
+++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDataRegionTest.kt
@@ -0,0 +1,130 @@
+package com.iterable.iterableapi
+
+import android.util.Log
+import com.iterable.iterableapi.unit.TestRunner
+import org.hamcrest.Matchers.`is`
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertThat
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.shadows.ShadowLog
+
+@RunWith(TestRunner::class)
+class IterableDataRegionTest {
+
+ @Before
+ fun clearLogs() {
+ ShadowLog.clear()
+ }
+
+ @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))
+ }
+ }
+
+ /**
+ * These factories run while the config is still being built, so IterableLogger is still reading
+ * the pre-init config's ERROR level. Anything below ERROR is dropped before it reaches logcat,
+ * which would leave a misconfigured region silently sending data to the US data center.
+ */
+ @Test
+ fun unsupportedStringIsLoggedAtDefaultLogLevel() {
+ IterableDataRegion.from("APAC")
+
+ val message = errorLogs().single()
+ assertTrue("log should name the rejected value: $message", message.contains("APAC"))
+ assertTrue("log should list supported values: $message", message.contains("US (0), EU (1)"))
+ }
+
+ @Test
+ fun unsupportedCodeIsLoggedAtDefaultLogLevel() {
+ IterableDataRegion.from(7)
+
+ val message = errorLogs().single()
+ assertTrue("log should name the rejected code: $message", message.contains("7"))
+ assertTrue("log should list supported values: $message", message.contains("US (0), EU (1)"))
+ }
+
+ @Test
+ fun recognisedValuesAreNotLoggedAsErrors() {
+ IterableDataRegion.from("EU")
+ IterableDataRegion.from("https://api.eu.iterable.com/api/")
+ IterableDataRegion.from(0)
+
+ assertTrue(errorLogs().toString(), errorLogs().isEmpty())
+ }
+
+ private fun errorLogs(): List