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.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/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/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..a3c5deb6f --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiGetAndTrackDeepLinkTest.kt @@ -0,0 +1,430 @@ +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 +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 + private var releaseDeepLinkResponse: 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() + releaseDeepLinkResponse?.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 `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() + 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 `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() + 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 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) + } + + @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 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) + .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( + waitTimeoutSeconds: Long = WAIT_TIMEOUT_SECONDS + ) { + val sdkQueueDrained = CountDownLatch(1) + IterableExecutors.deepLink().execute(sdkQueueDrained::countDown) + + assertTrue( + "SDK serial executor did not finish", + sdkQueueDrained.await(waitTimeoutSeconds, 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 + const val READ_TIMEOUT_WAIT_SECONDS = 10L + } +} 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()); 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() { + } + } +}