From 5f170d7c60621811ba28350d1765628039ac758d Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 17 Sep 2026 15:58:21 +0100 Subject: [PATCH 1/5] [SDK-706] Replace deep-link AsyncTask with SDK executors --- CHANGELOG.md | 2 +- .../com/iterable/iterableapi/IterableApi.java | 4 +- .../iterableapi/IterableDeeplinkManager.java | 125 ++----- .../IterableDeeplinkRedirectResolver.kt | 110 ++++++ .../IterableDeeplinkRedirectTask.kt | 29 ++ .../iterableapi/IterableExecutors.java | 11 + .../IterableApiGetAndTrackDeepLinkTest.kt | 324 ++++++++++++++++++ .../iterable/iterableapi/IterableApiTest.java | 22 -- 8 files changed, 501 insertions(+), 126 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.kt create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7918bafa1..22dc10702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Fixed -- Push token registration and disable operations, including their API requests and retries, now run on a dedicated SDK-owned serial executor instead of Android's process-wide `AsyncTask` queue. This prevents host app background work from delaying device registration, preserves operation order, and keeps client callbacks on the main thread. +- Push token registration and disable operations, including their API requests and retries, now run on a dedicated SDK-owned serial executor, while Iterable deep-link redirects use a separate SDK-owned serial executor. This removes their dependency on Android's process-wide `AsyncTask` queue, preserves ordering within each operation type, prevents slow redirects from delaying push work, and keeps client callbacks and attribution updates on the main thread. ## [3.11.0] ### Added diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 78451b0fd..a89a360cc 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -1420,7 +1420,7 @@ private boolean checkIfMessageIsNull(@Nullable IterableInAppMessage message, @Nu * or the original url if it is not an Iterable link. */ public void getAndTrackDeepLink(@NonNull String uri, @NonNull IterableHelper.IterableActionHandler onCallback) { - IterableDeeplinkManager.getAndTrackDeeplink(uri, onCallback); + IterableDeeplinkManager.getAndTrackDeepLink(uri, onCallback); } /** @@ -1452,7 +1452,7 @@ public boolean handleAppLink(@NonNull String uri) { IterableLogger.printInfo(); if (IterableDeeplinkManager.isIterableDeeplink(uri)) { - IterableDeeplinkManager.getAndTrackDeeplink(uri, new IterableHelper.IterableActionHandler() { + IterableDeeplinkManager.getAndTrackDeepLink(uri, new IterableHelper.IterableActionHandler() { @Override public void execute(String originalUrl) { IterableAction action = IterableAction.actionOpenUrl(originalUrl); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkManager.java index 4ac911818..ce23b1909 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkManager.java @@ -1,39 +1,44 @@ package com.iterable.iterableapi; -import android.os.AsyncTask; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import java.net.HttpCookie; -import java.net.HttpURLConnection; -import java.net.URL; -import java.util.ArrayList; -import java.util.List; import java.util.regex.Matcher; import java.util.regex.Pattern; class IterableDeeplinkManager { - private static Pattern deeplinkPattern = Pattern.compile(IterableConstants.ITBL_DEEPLINK_IDENTIFIER); + private static final Pattern DEEP_LINK_PATTERN = + Pattern.compile(IterableConstants.ITBL_DEEPLINK_IDENTIFIER); /** * Tracks a link click and passes the redirected URL to the callback * @param url The URL that was clicked * @param callback The callback to execute the original URL is retrieved */ - static void getAndTrackDeeplink(@Nullable String url, @NonNull IterableHelper.IterableActionHandler callback) { - if (url != null) { - if (!IterableUtil.isUrlOpenAllowed(url)) { - return; - } - if (isIterableDeeplink(url)) { - new RedirectTask(callback).execute(url); - } else { - callback.execute(url); - } - } else { + static void getAndTrackDeepLink(@Nullable String url, @NonNull IterableHelper.IterableActionHandler callback) { + if (url == null) { callback.execute(null); + return; + } + + if (!IterableUtil.isUrlOpenAllowed(url)) { + return; + } + + if (!isIterableDeeplink(url)) { + callback.execute(url); + return; } + + IterableDeeplinkRedirectTask redirectTask = + new IterableDeeplinkRedirectTask( + url, + callback, + new IterableDeeplinkRedirectResolver(), + IterableExecutors.main() + ); + IterableExecutors.deepLink().execute(redirectTask); } /** @@ -43,93 +48,11 @@ static void getAndTrackDeeplink(@Nullable String url, @NonNull IterableHelper.It */ static boolean isIterableDeeplink(@Nullable String url) { if (url != null) { - Matcher m = deeplinkPattern.matcher(url); + Matcher m = DEEP_LINK_PATTERN.matcher(url); if (m.find()) { return true; } } return false; } - - private static class RedirectTask extends AsyncTask { - static final String TAG = "RedirectTask"; - static final int DEFAULT_TIMEOUT_MS = 3000; //3 seconds - - private IterableHelper.IterableActionHandler callback; - - public int campaignId; - public int templateId; - public String messageId; - - RedirectTask(IterableHelper.IterableActionHandler callback) { - this.callback = callback; - } - - @Override - protected String doInBackground(String... params) { - if (params == null || params.length == 0) { - return null; - } - - String urlString = params[0]; - HttpURLConnection urlConnection = null; - - try { - URL url = new URL(urlString); - urlConnection = (HttpURLConnection) url.openConnection(); - urlConnection.setReadTimeout(DEFAULT_TIMEOUT_MS); - urlConnection.setInstanceFollowRedirects(false); - - int responseCode = urlConnection.getResponseCode(); - - if (responseCode >= 400) { - IterableLogger.d(TAG, "Invalid Request for: " + urlString + ", returned code " + responseCode); - } else if (responseCode >= 300) { - urlString = urlConnection.getHeaderField(IterableConstants.LOCATION_HEADER_FIELD); - try { - List cookieHeaders = urlConnection.getHeaderFields().get("Set-Cookie"); - if (cookieHeaders != null) { - ArrayList httpCookies = new ArrayList<>(cookieHeaders.size()); - for (String cookieString : cookieHeaders) { - List cookies = HttpCookie.parse(cookieString); - if (cookies != null) { - httpCookies.addAll(cookies); - } - } - for (HttpCookie cookie : httpCookies) { - if (cookie.getName().equals("iterableEmailCampaignId")) { - campaignId = Integer.parseInt(cookie.getValue()); - } else if (cookie.getName().equals("iterableTemplateId")) { - templateId = Integer.parseInt(cookie.getValue()); - } else if (cookie.getName().equals("iterableMessageId")) { - messageId = cookie.getValue(); - } - } - } - } catch (Exception e) { - IterableLogger.e(TAG, "Error while parsing cookies: " + e.getMessage()); - } - } - } catch (Exception e) { - IterableLogger.e(TAG, e.getMessage()); - } finally { - if (urlConnection != null) { - urlConnection.disconnect(); - } - } - return urlString; - } - - @Override - protected void onPostExecute(String s) { - if (callback != null) { - callback.execute(s); - } - - if (campaignId != 0) { - IterableAttributionInfo attributionInfo = new IterableAttributionInfo(campaignId, templateId, messageId); - IterableApi.sharedInstance.setAttributionInfo(attributionInfo); - } - } - } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt new file mode 100644 index 000000000..6c4e16885 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt @@ -0,0 +1,110 @@ +package com.iterable.iterableapi + +import java.net.HttpCookie +import java.net.HttpURLConnection +import java.net.URL + +internal class IterableDeeplinkRedirectResolver { + + fun resolve(url: String): IterableDeeplinkRedirectResult { + var connection: HttpURLConnection? = null + + return try { + connection = openConnection(url) + resolveResponse(url, connection) + } catch (e: Exception) { + IterableLogger.e(TAG, e.message) + IterableDeeplinkRedirectResult(url) + } finally { + connection?.disconnect() + } + } + + private fun openConnection(url: String): HttpURLConnection { + return (URL(url).openConnection() as HttpURLConnection).apply { + readTimeout = DEFAULT_TIMEOUT_MS + instanceFollowRedirects = false + } + } + + private fun resolveResponse( + originalUrl: String, + connection: HttpURLConnection + ): IterableDeeplinkRedirectResult { + val responseCode = connection.responseCode + + return when { + responseCode >= 400 -> { + IterableLogger.d( + TAG, + "Invalid Request for: $originalUrl, returned code $responseCode" + ) + IterableDeeplinkRedirectResult(originalUrl) + } + responseCode >= 300 -> resolveRedirectResponse(connection) + else -> IterableDeeplinkRedirectResult(originalUrl) + } + } + + private fun resolveRedirectResponse( + connection: HttpURLConnection + ): IterableDeeplinkRedirectResult { + val redirectUrl = connection.getHeaderField( + IterableConstants.LOCATION_HEADER_FIELD + ) + val attribution = parseAttributionCookies(connection) + + return IterableDeeplinkRedirectResult( + redirectUrl, + attribution.campaignId, + attribution.templateId, + attribution.messageId + ) + } + + private fun parseAttributionCookies( + connection: HttpURLConnection + ): RedirectAttribution { + var campaignId = 0 + var templateId = 0 + var messageId: String? = null + + try { + connection.headerFields[SET_COOKIE_HEADER] + ?.flatMap(HttpCookie::parse) + ?.forEach { cookie -> + when (cookie.name) { + CAMPAIGN_ID_COOKIE -> campaignId = cookie.value.toInt() + TEMPLATE_ID_COOKIE -> templateId = cookie.value.toInt() + MESSAGE_ID_COOKIE -> messageId = cookie.value + } + } + } catch (e: Exception) { + IterableLogger.e(TAG, "Error while parsing cookies: ${e.message}") + } + + return RedirectAttribution(campaignId, templateId, messageId) + } + + private data class RedirectAttribution( + val campaignId: Int, + val templateId: Int, + val messageId: String? + ) + + private companion object { + const val TAG = "RedirectTask" + const val DEFAULT_TIMEOUT_MS = 3000 + const val SET_COOKIE_HEADER = "Set-Cookie" + const val CAMPAIGN_ID_COOKIE = "iterableEmailCampaignId" + const val TEMPLATE_ID_COOKIE = "iterableTemplateId" + const val MESSAGE_ID_COOKIE = "iterableMessageId" + } +} + +internal data class IterableDeeplinkRedirectResult( + val url: String?, + val campaignId: Int = 0, + val templateId: Int = 0, + val messageId: String? = null +) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.kt new file mode 100644 index 000000000..f540731db --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.kt @@ -0,0 +1,29 @@ +package com.iterable.iterableapi + +import java.util.concurrent.Executor + +internal class IterableDeeplinkRedirectTask( + private val url: String, + private val callback: IterableHelper.IterableActionHandler?, + private val redirectResolver: IterableDeeplinkRedirectResolver, + private val callbackExecutor: Executor +) : Runnable { + + override fun run() { + val result = redirectResolver.resolve(url) + callbackExecutor.execute { deliver(result) } + } + + private fun deliver(result: IterableDeeplinkRedirectResult) { + callback?.execute(result.url) + + if (result.campaignId != 0) { + val attributionInfo = IterableAttributionInfo( + result.campaignId, + result.templateId, + result.messageId + ) + IterableApi.sharedInstance.setAttributionInfo(attributionInfo) + } + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java index 0a76e5247..632ed7591 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java @@ -13,6 +13,13 @@ final class IterableExecutors { thread.setPriority(Thread.NORM_PRIORITY); return thread; }); + private static final Executor DEEP_LINK_EXECUTOR = + Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "IterableDeepLinkExecutor"); + thread.setDaemon(true); + thread.setPriority(Thread.NORM_PRIORITY); + return thread; + }); private static final Executor MAIN_EXECUTOR = runnable -> new Handler(Looper.getMainLooper()).post(runnable); @@ -23,6 +30,10 @@ static Executor push() { return PUSH_EXECUTOR; } + static Executor deepLink() { + return DEEP_LINK_EXECUTOR; + } + static Executor main() { return MAIN_EXECUTOR; } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt new file mode 100644 index 000000000..0e145d48b --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt @@ -0,0 +1,324 @@ +package com.iterable.iterableapi + +import android.net.Uri +import android.os.AsyncTask +import android.os.Looper +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowPausedAsyncTask +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +class IterableApiGetAndTrackDeepLinkTest : BaseTest() { + private lateinit var server: MockWebServer + private var releaseHostTask: CountDownLatch? = null + private var hostTaskFinished: CountDownLatch? = null + + @Before + fun setUp() { + server = MockWebServer() + IterableApi.sharedInstance._applicationContext = getContext() + IterableApi.sharedInstance.config = IterableConfig.Builder() + .setAllowedProtocols(arrayOf("http")) + .build() + } + + @After + fun tearDown() { + releaseHostTask?.countDown() + hostTaskFinished?.let { + assertTrue( + "Host AsyncTask queue did not resume", + it.await(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ) + } + server.shutdown() + } + + @Test + fun `Iterable link returns destination and stores attribution on main thread`() { + val iterableLink = server.url("/a/abc123").toString() + val destinationUrl = "https://example.com/destination" + enqueueRedirectWithAttribution(destinationUrl) + + var callbackUrl: String? = null + var callbackLooper: Looper? = null + var attributionDuringCallback: IterableAttributionInfo? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + callbackLooper = Looper.myLooper() + attributionDuringCallback = IterableApi.getInstance().attributionInfo + } + awaitDeepLinkResolution() + + assertEquals(destinationUrl, callbackUrl) + assertSame(Looper.getMainLooper(), callbackLooper) + assertNull(attributionDuringCallback) + + val attributionInfo = IterableApi.getInstance().attributionInfo + assertNotNull(attributionInfo) + with(attributionInfo!!) { + assertEquals(123, campaignId) + assertEquals(456, templateId) + assertEquals("message-id", messageId) + } + } + + @Test + fun `Iterable link does not wait for host AsyncTask queue`() { + val iterableLink = server.url("/a/abc123").toString() + val destinationUrl = "https://example.com/destination" + enqueueRedirect(destinationUrl) + blockHostAsyncTaskQueue() + var callbackUrl: String? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + } + awaitDeepLinkResolution() + + assertEquals(destinationUrl, callbackUrl) + } + + @Test + fun `redirect without attribution returns destination without attribution`() { + val iterableLink = server.url("/a/abc123").toString() + val destinationUrl = "https://example.com/destination" + enqueueRedirect(destinationUrl) + var callbackUrl: String? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + } + awaitDeepLinkResolution() + + assertEquals(destinationUrl, callbackUrl) + assertNull(IterableApi.getInstance().attributionInfo) + } + + @Test + fun `successful response without redirect returns original URL`() { + val iterableLink = server.url("/a/abc123").toString() + server.enqueue(MockResponse().setResponseCode(200)) + var callbackUrl: String? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + } + awaitDeepLinkResolution() + + assertEquals(iterableLink, callbackUrl) + assertNull(IterableApi.getInstance().attributionInfo) + } + + @Test + fun `server error returns original URL without attribution`() { + val iterableLink = server.url("/a/abc123").toString() + server.enqueue(MockResponse().setResponseCode(500)) + var callbackUrl: String? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + } + awaitDeepLinkResolution() + + assertEquals(iterableLink, callbackUrl) + assertNull(IterableApi.getInstance().attributionInfo) + } + + @Test + fun `malformed Iterable link returns original URL without attribution`() { + val iterableLink = "http://[invalid/a/abc123" + var callbackUrl: String? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + } + awaitDeepLinkResolution() + + assertEquals(iterableLink, callbackUrl) + assertNull(IterableApi.getInstance().attributionInfo) + } + + @Test + fun `invalid attribution cookie still returns destination URL`() { + val iterableLink = server.url("/a/abc123").toString() + val destinationUrl = "https://example.com/destination" + enqueueRedirectWithInvalidAttribution(destinationUrl) + var callbackUrl: String? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + } + awaitDeepLinkResolution() + + assertEquals(destinationUrl, callbackUrl) + assertNull(IterableApi.getInstance().attributionInfo) + } + + @Test + fun `non-Iterable link returns original URL`() { + val originalUrl = server.url("/not-an-iterable-link").toString() + var callbackUrl: String? = null + + IterableApi.getInstance().getAndTrackDeepLink(originalUrl) { url -> + callbackUrl = url + } + awaitDeepLinkResolution() + + assertEquals(originalUrl, callbackUrl) + } + + @Test + fun `disallowed protocol does not invoke callback`() { + var callbackInvoked = false + + IterableApi.getInstance().getAndTrackDeepLink( + "ftp://links.example.com/a/abc123" + ) { + callbackInvoked = true + } + awaitDeepLinkResolution() + + assertFalse(callbackInvoked) + } + + @Test + fun `Iterable links are resolved in submission order`() { + val firstIterableLink = server.url("/a/first").toString() + val secondIterableLink = server.url("/a/second").toString() + val firstDestination = "https://example.com/first" + val secondDestination = "https://example.com/second" + server.enqueue(redirectResponse(firstDestination)) + server.enqueue(redirectResponse(secondDestination)) + val callbackUrls = mutableListOf() + + IterableApi.getInstance().getAndTrackDeepLink(firstIterableLink) { url -> + callbackUrls.add(url) + } + IterableApi.getInstance().getAndTrackDeepLink(secondIterableLink) { url -> + callbackUrls.add(url) + } + awaitDeepLinkResolution() + + assertEquals( + listOf(firstDestination, secondDestination), + callbackUrls + ) + } + + @Test + fun `handle app link opens resolved destination through client URL handler`() { + val iterableLink = server.url("/a/abc123").toString() + val destinationUrl = "https://example.com/destination" + enqueueRedirect(destinationUrl) + var openedUri: Uri? = null + var receivedContext: IterableActionContext? = null + IterableApi.initialize( + getContext(), + "fake-api-key", + IterableConfig.Builder() + .setAllowedProtocols(arrayOf("http")) + .setAutoPushRegistration(false) + .setUrlHandler { uri, context -> + openedUri = uri + receivedContext = context + true + } + .build() + ) + + val handled = IterableApi.getInstance().handleAppLink(iterableLink) + awaitDeepLinkResolution() + + assertTrue(handled) + assertEquals(destinationUrl, openedUri.toString()) + assertEquals(IterableActionSource.APP_LINK, receivedContext?.source) + assertTrue( + receivedContext!!.action.isOfType(IterableAction.ACTION_TYPE_OPEN_URL) + ) + assertEquals(destinationUrl, receivedContext!!.action.data) + } + + private fun enqueueRedirect(destinationUrl: String) { + server.enqueue(redirectResponse(destinationUrl)) + } + + private fun redirectResponse(destinationUrl: String): MockResponse { + return MockResponse() + .setResponseCode(302) + .addHeader(IterableConstants.LOCATION_HEADER_FIELD, destinationUrl) + } + + private fun enqueueRedirectWithAttribution(destinationUrl: String) { + server.enqueue( + MockResponse() + .setResponseCode(302) + .addHeader(IterableConstants.LOCATION_HEADER_FIELD, destinationUrl) + .addHeader("Set-Cookie", "iterableEmailCampaignId=123") + .addHeader("Set-Cookie", "iterableTemplateId=456") + .addHeader("Set-Cookie", "iterableMessageId=message-id") + ) + } + + private fun enqueueRedirectWithInvalidAttribution(destinationUrl: String) { + server.enqueue( + MockResponse() + .setResponseCode(302) + .addHeader(IterableConstants.LOCATION_HEADER_FIELD, destinationUrl) + .addHeader("Set-Cookie", "iterableEmailCampaignId=invalid") + ) + } + + private fun awaitDeepLinkResolution() { + val sdkQueueDrained = CountDownLatch(1) + IterableExecutors.deepLink().execute(sdkQueueDrained::countDown) + + assertTrue( + "SDK serial executor did not finish", + sdkQueueDrained.await(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ) + shadowOf(Looper.getMainLooper()).idle() + } + + @Suppress("DEPRECATION") + private fun blockHostAsyncTaskQueue() { + ShadowPausedAsyncTask.reset() + val hostTaskStarted = CountDownLatch(1) + val releaseTask = CountDownLatch(1) + val taskFinished = CountDownLatch(1) + releaseHostTask = releaseTask + hostTaskFinished = taskFinished + + AsyncTask.SERIAL_EXECUTOR.execute { + hostTaskStarted.countDown() + try { + releaseTask.await() + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } finally { + taskFinished.countDown() + } + } + + assertTrue( + "Host AsyncTask queue did not block", + hostTaskStarted.await(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ) + } + + private companion object { + const val WAIT_TIMEOUT_SECONDS = 5L + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java index edbae4237..e2d0aa7ee 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java @@ -52,7 +52,6 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoMoreInteractions; -import static org.mockito.Mockito.when; import static org.robolectric.Shadows.shadowOf; public class IterableApiTest extends BaseTest { @@ -238,27 +237,6 @@ public void testUpdateEmailWithUserId() throws Exception { assertEquals("testUserId", IterableApi.getInstance().getUserId()); } - @Ignore("handleAppLink performs real HTTP redirect - needs MockWebServer to stub the redirect endpoint") - @Test - public void testHandleUniversalLinkRewrite() throws Exception { - IterableUrlHandler urlHandlerMock = mock(IterableUrlHandler.class); - when(urlHandlerMock.handleIterableURL(any(Uri.class), any(IterableActionContext.class))).thenReturn(true); - IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setUrlHandler(urlHandlerMock).build()); - - String url = "https://iterable.com"; - IterableApi.getInstance().handleAppLink( - "https://links.iterable.com/a/60402396fbd5433eb35397b47ab2fb83?_e=joneng%40iterable.com&_m=93125f33ba814b13a882358f8e0852e0"); - - ArgumentCaptor capturedUri = ArgumentCaptor.forClass(Uri.class); - ArgumentCaptor capturedActionContext = ArgumentCaptor.forClass(IterableActionContext.class); - shadowOf(getMainLooper()).idle(); - verify(urlHandlerMock, timeout(5000)).handleIterableURL(capturedUri.capture(), capturedActionContext.capture()); - assertEquals(url, capturedUri.getValue().toString()); - assertEquals(IterableActionSource.APP_LINK, capturedActionContext.getValue().source); - assertTrue(capturedActionContext.getValue().action.isOfType(IterableAction.ACTION_TYPE_OPEN_URL)); - assertEquals(url, capturedActionContext.getValue().action.getData()); - } - @Test public void testSetEmailWithAutomaticPushRegistration() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); From c0035e773cfb79dcdf9224e45ee6ca6a03b3b584 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 23 Sep 2026 10:21:34 +0100 Subject: [PATCH 2/5] [SDK-706] Cover deep-link failure callback contract --- .../IterableApiGetAndTrackDeepLinkTest.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt index 0e145d48b..c2e7cfaa1 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt @@ -5,6 +5,7 @@ import android.os.AsyncTask import android.os.Looper import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.SocketPolicy import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -123,17 +124,38 @@ class IterableApiGetAndTrackDeepLinkTest : BaseTest() { } @Test - fun `server error returns original URL without attribution`() { + fun `server error returns original URL on main thread without attribution`() { val iterableLink = server.url("/a/abc123").toString() server.enqueue(MockResponse().setResponseCode(500)) var callbackUrl: String? = null + var callbackLooper: Looper? = null IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> callbackUrl = url + callbackLooper = Looper.myLooper() } awaitDeepLinkResolution() assertEquals(iterableLink, callbackUrl) + assertSame(Looper.getMainLooper(), callbackLooper) + assertNull(IterableApi.getInstance().attributionInfo) + } + + @Test + fun `read timeout returns original URL on main thread without attribution`() { + val iterableLink = server.url("/a/abc123").toString() + server.enqueue(MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE)) + var callbackUrl: String? = null + var callbackLooper: Looper? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + callbackLooper = Looper.myLooper() + } + awaitDeepLinkResolution(READ_TIMEOUT_WAIT_SECONDS) + + assertEquals(iterableLink, callbackUrl) + assertSame(Looper.getMainLooper(), callbackLooper) assertNull(IterableApi.getInstance().attributionInfo) } @@ -281,13 +303,15 @@ class IterableApiGetAndTrackDeepLinkTest : BaseTest() { ) } - private fun awaitDeepLinkResolution() { + private fun awaitDeepLinkResolution( + waitTimeoutSeconds: Long = WAIT_TIMEOUT_SECONDS + ) { val sdkQueueDrained = CountDownLatch(1) IterableExecutors.deepLink().execute(sdkQueueDrained::countDown) assertTrue( "SDK serial executor did not finish", - sdkQueueDrained.await(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + sdkQueueDrained.await(waitTimeoutSeconds, TimeUnit.SECONDS) ) shadowOf(Looper.getMainLooper()).idle() } @@ -320,5 +344,6 @@ class IterableApiGetAndTrackDeepLinkTest : BaseTest() { private companion object { const val WAIT_TIMEOUT_SECONDS = 5L + const val READ_TIMEOUT_WAIT_SECONDS = 10L } } From 86bc25f0e7b10856a637b28c54b9c0b6cfe05072 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Wed, 23 Sep 2026 13:36:53 +0100 Subject: [PATCH 3/5] [SDK-706] Cover executor lane isolation --- .../IterableApiGetAndTrackDeepLinkTest.kt | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt index c2e7cfaa1..6e098abdf 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt @@ -3,8 +3,10 @@ package com.iterable.iterableapi import android.net.Uri import android.os.AsyncTask import android.os.Looper +import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest import okhttp3.mockwebserver.SocketPolicy import org.junit.After import org.junit.Assert.assertEquals @@ -24,6 +26,7 @@ class IterableApiGetAndTrackDeepLinkTest : BaseTest() { private lateinit var server: MockWebServer private var releaseHostTask: CountDownLatch? = null private var hostTaskFinished: CountDownLatch? = null + private var releaseDeepLinkResponse: CountDownLatch? = null @Before fun setUp() { @@ -37,6 +40,7 @@ class IterableApiGetAndTrackDeepLinkTest : BaseTest() { @After fun tearDown() { releaseHostTask?.countDown() + releaseDeepLinkResponse?.countDown() hostTaskFinished?.let { assertTrue( "Host AsyncTask queue did not resume", @@ -92,6 +96,47 @@ class IterableApiGetAndTrackDeepLinkTest : BaseTest() { assertEquals(destinationUrl, callbackUrl) } + @Test + fun `slow Iterable link does not block push work`() { + val iterableLink = server.url("/a/abc123").toString() + val destinationUrl = "https://example.com/destination" + val redirectStarted = CountDownLatch(1) + val releaseRedirect = CountDownLatch(1) + releaseDeepLinkResponse = releaseRedirect + server.dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + redirectStarted.countDown() + try { + releaseRedirect.await() + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + return redirectResponse(destinationUrl) + } + } + var callbackUrl: String? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + } + assertTrue( + "Deep-link request did not start", + redirectStarted.await(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ) + + val pushWorkCompleted = CountDownLatch(1) + IterableExecutors.push().execute(pushWorkCompleted::countDown) + + assertTrue( + "Push work was blocked by the deep-link request", + pushWorkCompleted.await(WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ) + + releaseRedirect.countDown() + awaitDeepLinkResolution() + assertEquals(destinationUrl, callbackUrl) + } + @Test fun `redirect without attribution returns destination without attribution`() { val iterableLink = server.url("/a/abc123").toString() From 307ff28ca3d948c47f0f613be36bd574cd7766d6 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 2 Oct 2026 11:40:12 +0100 Subject: [PATCH 4/5] [SDK-706] Fall back when redirect location is missing --- .../IterableDeeplinkRedirectResolver.kt | 6 ++-- .../IterableApiGetAndTrackDeepLinkTest.kt | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt index 6c4e16885..7cf545ce9 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt @@ -41,17 +41,19 @@ internal class IterableDeeplinkRedirectResolver { ) IterableDeeplinkRedirectResult(originalUrl) } - responseCode >= 300 -> resolveRedirectResponse(connection) + responseCode >= 300 -> resolveRedirectResponse(originalUrl, connection) else -> IterableDeeplinkRedirectResult(originalUrl) } } private fun resolveRedirectResponse( + originalUrl: String, connection: HttpURLConnection ): IterableDeeplinkRedirectResult { val redirectUrl = connection.getHeaderField( IterableConstants.LOCATION_HEADER_FIELD - ) + )?.takeIf(String::isNotBlank) + ?: return IterableDeeplinkRedirectResult(originalUrl) val attribution = parseAttributionCookies(connection) return IterableDeeplinkRedirectResult( diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt index 6e098abdf..a3c5deb6f 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt @@ -153,6 +153,25 @@ class IterableApiGetAndTrackDeepLinkTest : BaseTest() { assertNull(IterableApi.getInstance().attributionInfo) } + @Test + fun `redirect without location returns original URL on main thread without attribution`() { + assertInvalidRedirectReturnsOriginalUrl( + MockResponse() + .setResponseCode(302) + .addHeader("Set-Cookie", "iterableEmailCampaignId=123") + ) + } + + @Test + fun `redirect with blank location returns original URL on main thread without attribution`() { + assertInvalidRedirectReturnsOriginalUrl( + MockResponse() + .setResponseCode(302) + .addHeader(IterableConstants.LOCATION_HEADER_FIELD, "") + .addHeader("Set-Cookie", "iterableEmailCampaignId=123") + ) + } + @Test fun `successful response without redirect returns original URL`() { val iterableLink = server.url("/a/abc123").toString() @@ -322,6 +341,23 @@ class IterableApiGetAndTrackDeepLinkTest : BaseTest() { server.enqueue(redirectResponse(destinationUrl)) } + private fun assertInvalidRedirectReturnsOriginalUrl(response: MockResponse) { + val iterableLink = server.url("/a/abc123").toString() + server.enqueue(response) + var callbackUrl: String? = null + var callbackLooper: Looper? = null + + IterableApi.getInstance().getAndTrackDeepLink(iterableLink) { url -> + callbackUrl = url + callbackLooper = Looper.myLooper() + } + awaitDeepLinkResolution() + + assertEquals(iterableLink, callbackUrl) + assertSame(Looper.getMainLooper(), callbackLooper) + assertNull(IterableApi.getInstance().attributionInfo) + } + private fun redirectResponse(destinationUrl: String): MockResponse { return MockResponse() .setResponseCode(302) From e248fc83cbec39dd31e4b0b5ef56548ce09a24fc Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 6 Oct 2026 09:54:01 +0100 Subject: [PATCH 5/5] [SDK-706] Harden deep-link redirect execution --- .../IterableDeeplinkRedirectResolver.java | 167 ++++++++++++++++++ .../IterableDeeplinkRedirectResolver.kt | 112 ------------ .../IterableDeeplinkRedirectTask.java | 46 +++++ .../IterableDeeplinkRedirectTask.kt | 29 --- .../IterableDeeplinkRedirectResolverTest.java | 67 +++++++ 5 files changed, 280 insertions(+), 141 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.java delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.java delete mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.kt create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolverTest.java diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.java new file mode 100644 index 000000000..df58e0d81 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.java @@ -0,0 +1,167 @@ +package com.iterable.iterableapi; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import java.net.HttpCookie; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.List; +import java.util.Map; + +final class IterableDeeplinkRedirectResolver { + static final int DEFAULT_TIMEOUT_MS = 3000; + + private static final String TAG = "RedirectTask"; + private static final String SET_COOKIE_HEADER = "Set-Cookie"; + private static final String CAMPAIGN_ID_COOKIE = "iterableEmailCampaignId"; + private static final String TEMPLATE_ID_COOKIE = "iterableTemplateId"; + private static final String MESSAGE_ID_COOKIE = "iterableMessageId"; + + interface ConnectionFactory { + HttpURLConnection open(String url) throws Exception; + } + + private final ConnectionFactory connectionFactory; + + IterableDeeplinkRedirectResolver() { + this(url -> (HttpURLConnection) new URL(url).openConnection()); + } + + IterableDeeplinkRedirectResolver(ConnectionFactory connectionFactory) { + this.connectionFactory = connectionFactory; + } + + @NonNull + IterableDeeplinkRedirectResult resolve(@NonNull String url) { + HttpURLConnection connection = null; + try { + connection = openConnection(url); + return resolveResponse(url, connection); + } catch (Exception e) { + IterableLogger.e(TAG, e.getMessage()); + return new IterableDeeplinkRedirectResult(url); + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + private HttpURLConnection openConnection(String url) throws Exception { + HttpURLConnection connection = connectionFactory.open(url); + connection.setConnectTimeout(DEFAULT_TIMEOUT_MS); + connection.setReadTimeout(DEFAULT_TIMEOUT_MS); + connection.setInstanceFollowRedirects(false); + return connection; + } + + private IterableDeeplinkRedirectResult resolveResponse( + String originalUrl, + HttpURLConnection connection + ) throws Exception { + int responseCode = connection.getResponseCode(); + if (responseCode >= 400) { + IterableLogger.d( + TAG, + "Invalid Request for: " + originalUrl + ", returned code " + responseCode + ); + return new IterableDeeplinkRedirectResult(originalUrl); + } + if (responseCode >= 300) { + return resolveRedirectResponse(originalUrl, connection); + } + return new IterableDeeplinkRedirectResult(originalUrl); + } + + private IterableDeeplinkRedirectResult resolveRedirectResponse( + String originalUrl, + HttpURLConnection connection + ) { + String redirectUrl = connection.getHeaderField( + IterableConstants.LOCATION_HEADER_FIELD + ); + if (redirectUrl == null || redirectUrl.trim().isEmpty()) { + return new IterableDeeplinkRedirectResult(originalUrl); + } + + RedirectAttribution attribution = parseAttributionCookies(connection); + return new IterableDeeplinkRedirectResult( + redirectUrl, + attribution.campaignId, + attribution.templateId, + attribution.messageId + ); + } + + private RedirectAttribution parseAttributionCookies(HttpURLConnection connection) { + int campaignId = 0; + int templateId = 0; + String messageId = null; + + try { + Map> headerFields = connection.getHeaderFields(); + List cookies = headerFields.get(SET_COOKIE_HEADER); + if (cookies != null) { + for (String header : cookies) { + for (HttpCookie cookie : HttpCookie.parse(header)) { + switch (cookie.getName()) { + case CAMPAIGN_ID_COOKIE: + campaignId = Integer.parseInt(cookie.getValue()); + break; + case TEMPLATE_ID_COOKIE: + templateId = Integer.parseInt(cookie.getValue()); + break; + case MESSAGE_ID_COOKIE: + messageId = cookie.getValue(); + break; + default: + break; + } + } + } + } + } catch (Exception e) { + IterableLogger.e(TAG, "Error while parsing cookies: " + e.getMessage()); + } + + return new RedirectAttribution(campaignId, templateId, messageId); + } + + private static final class RedirectAttribution { + final int campaignId; + final int templateId; + final String messageId; + + RedirectAttribution(int campaignId, int templateId, String messageId) { + this.campaignId = campaignId; + this.templateId = templateId; + this.messageId = messageId; + } + } +} + +final class IterableDeeplinkRedirectResult { + @Nullable + final String url; + final int campaignId; + final int templateId; + @Nullable + final String messageId; + + IterableDeeplinkRedirectResult(@Nullable String url) { + this(url, 0, 0, null); + } + + IterableDeeplinkRedirectResult( + @Nullable String url, + int campaignId, + int templateId, + @Nullable String messageId + ) { + this.url = url; + this.campaignId = campaignId; + this.templateId = templateId; + this.messageId = messageId; + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt deleted file mode 100644 index 7cf545ce9..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolver.kt +++ /dev/null @@ -1,112 +0,0 @@ -package com.iterable.iterableapi - -import java.net.HttpCookie -import java.net.HttpURLConnection -import java.net.URL - -internal class IterableDeeplinkRedirectResolver { - - fun resolve(url: String): IterableDeeplinkRedirectResult { - var connection: HttpURLConnection? = null - - return try { - connection = openConnection(url) - resolveResponse(url, connection) - } catch (e: Exception) { - IterableLogger.e(TAG, e.message) - IterableDeeplinkRedirectResult(url) - } finally { - connection?.disconnect() - } - } - - private fun openConnection(url: String): HttpURLConnection { - return (URL(url).openConnection() as HttpURLConnection).apply { - readTimeout = DEFAULT_TIMEOUT_MS - instanceFollowRedirects = false - } - } - - private fun resolveResponse( - originalUrl: String, - connection: HttpURLConnection - ): IterableDeeplinkRedirectResult { - val responseCode = connection.responseCode - - return when { - responseCode >= 400 -> { - IterableLogger.d( - TAG, - "Invalid Request for: $originalUrl, returned code $responseCode" - ) - IterableDeeplinkRedirectResult(originalUrl) - } - responseCode >= 300 -> resolveRedirectResponse(originalUrl, connection) - else -> IterableDeeplinkRedirectResult(originalUrl) - } - } - - private fun resolveRedirectResponse( - originalUrl: String, - connection: HttpURLConnection - ): IterableDeeplinkRedirectResult { - val redirectUrl = connection.getHeaderField( - IterableConstants.LOCATION_HEADER_FIELD - )?.takeIf(String::isNotBlank) - ?: return IterableDeeplinkRedirectResult(originalUrl) - val attribution = parseAttributionCookies(connection) - - return IterableDeeplinkRedirectResult( - redirectUrl, - attribution.campaignId, - attribution.templateId, - attribution.messageId - ) - } - - private fun parseAttributionCookies( - connection: HttpURLConnection - ): RedirectAttribution { - var campaignId = 0 - var templateId = 0 - var messageId: String? = null - - try { - connection.headerFields[SET_COOKIE_HEADER] - ?.flatMap(HttpCookie::parse) - ?.forEach { cookie -> - when (cookie.name) { - CAMPAIGN_ID_COOKIE -> campaignId = cookie.value.toInt() - TEMPLATE_ID_COOKIE -> templateId = cookie.value.toInt() - MESSAGE_ID_COOKIE -> messageId = cookie.value - } - } - } catch (e: Exception) { - IterableLogger.e(TAG, "Error while parsing cookies: ${e.message}") - } - - return RedirectAttribution(campaignId, templateId, messageId) - } - - private data class RedirectAttribution( - val campaignId: Int, - val templateId: Int, - val messageId: String? - ) - - private companion object { - const val TAG = "RedirectTask" - const val DEFAULT_TIMEOUT_MS = 3000 - const val SET_COOKIE_HEADER = "Set-Cookie" - const val CAMPAIGN_ID_COOKIE = "iterableEmailCampaignId" - const val TEMPLATE_ID_COOKIE = "iterableTemplateId" - const val MESSAGE_ID_COOKIE = "iterableMessageId" - } -} - -internal data class IterableDeeplinkRedirectResult( - val url: String?, - val campaignId: Int = 0, - val templateId: Int = 0, - val messageId: String? = null -) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.java new file mode 100644 index 000000000..d2ad07ddb --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.java @@ -0,0 +1,46 @@ +package com.iterable.iterableapi; + +import androidx.annotation.Nullable; + +import java.util.concurrent.Executor; + +final class IterableDeeplinkRedirectTask implements Runnable { + private final String url; + @Nullable + private final IterableHelper.IterableActionHandler callback; + private final IterableDeeplinkRedirectResolver redirectResolver; + private final Executor callbackExecutor; + + IterableDeeplinkRedirectTask( + String url, + @Nullable IterableHelper.IterableActionHandler callback, + IterableDeeplinkRedirectResolver redirectResolver, + Executor callbackExecutor + ) { + this.url = url; + this.callback = callback; + this.redirectResolver = redirectResolver; + this.callbackExecutor = callbackExecutor; + } + + @Override + public void run() { + IterableDeeplinkRedirectResult result = redirectResolver.resolve(url); + callbackExecutor.execute(() -> deliver(result)); + } + + private void deliver(IterableDeeplinkRedirectResult result) { + if (callback != null) { + callback.execute(result.url); + } + + if (result.campaignId != 0) { + IterableAttributionInfo attributionInfo = new IterableAttributionInfo( + result.campaignId, + result.templateId, + result.messageId + ); + IterableApi.sharedInstance.setAttributionInfo(attributionInfo); + } + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.kt deleted file mode 100644 index f540731db..000000000 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableDeeplinkRedirectTask.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.iterable.iterableapi - -import java.util.concurrent.Executor - -internal class IterableDeeplinkRedirectTask( - private val url: String, - private val callback: IterableHelper.IterableActionHandler?, - private val redirectResolver: IterableDeeplinkRedirectResolver, - private val callbackExecutor: Executor -) : Runnable { - - override fun run() { - val result = redirectResolver.resolve(url) - callbackExecutor.execute { deliver(result) } - } - - private fun deliver(result: IterableDeeplinkRedirectResult) { - callback?.execute(result.url) - - if (result.campaignId != 0) { - val attributionInfo = IterableAttributionInfo( - result.campaignId, - result.templateId, - result.messageId - ) - IterableApi.sharedInstance.setAttributionInfo(attributionInfo) - } - } -} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolverTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolverTest.java new file mode 100644 index 000000000..9a52337f8 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableDeeplinkRedirectResolverTest.java @@ -0,0 +1,67 @@ +package com.iterable.iterableapi; + +import com.iterable.iterableapi.unit.TestRunner; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.SocketTimeoutException; +import java.net.URL; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(TestRunner.class) +public class IterableDeeplinkRedirectResolverTest { + @Test + public void connectTimeoutReturnsOriginalUrlAndReleasesConnection() + throws Exception { + TimeoutConnection connection = + new TimeoutConnection(new URL("https://example.com/a/link")); + IterableDeeplinkRedirectResolver resolver = + new IterableDeeplinkRedirectResolver(url -> connection); + + IterableDeeplinkRedirectResult result = + resolver.resolve("https://example.com/a/link"); + + assertEquals("https://example.com/a/link", result.url); + assertEquals( + IterableDeeplinkRedirectResolver.DEFAULT_TIMEOUT_MS, + connection.getConnectTimeout() + ); + assertEquals( + IterableDeeplinkRedirectResolver.DEFAULT_TIMEOUT_MS, + connection.getReadTimeout() + ); + assertTrue(connection.disconnected); + } + + private static final class TimeoutConnection extends HttpURLConnection { + boolean disconnected; + + TimeoutConnection(URL url) { + super(url); + } + + @Override + public int getResponseCode() throws IOException { + throw new SocketTimeoutException("connect timed out"); + } + + @Override + public void disconnect() { + disconnected = true; + } + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public void connect() { + } + } +}