diff --git a/CHANGELOG.md b/CHANGELOG.md index 945c93e0b..7918bafa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ 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. + ## [3.11.0] ### 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. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index e7f9d9d34..78451b0fd 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -8,7 +8,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RestrictTo; -import androidx.annotation.VisibleForTesting; import androidx.core.app.NotificationManagerCompat; import com.iterable.iterableapi.util.DeviceInfoUtils; import org.json.JSONException; @@ -18,6 +17,7 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; /** * Created by David Truong dt@iterable.com @@ -56,6 +56,7 @@ public class IterableApi { private IterableAuthManager authManager; private ConcurrentHashMap deviceAttributes = new ConcurrentHashMap<>(); private IterableKeychain keychain; + private final IterablePushRegistration pushRegistration; //region Background Initialization - Delegated to IterableBackgroundInitializer //--------------------------------------------------------------------------------------- @@ -694,22 +695,26 @@ void setAuthToken(String authToken, boolean bypassAuth) { } protected void registerDeviceToken(final @Nullable String email, final @Nullable String userId, final @Nullable String authToken, final @NonNull String applicationName, final @NonNull String deviceToken, final Map deviceAttributes) { - if (deviceToken != null) { - if (!checkSDKInitialization() && _userIdUnknown == null) { - if (sharedInstance.config.enableUnknownUserActivation) { - unknownUserManager.trackUnknownTokenRegistration(deviceToken); - } - return; + registerDeviceToken(email, userId, authToken, applicationName, deviceToken, deviceAttributes, IterableExecutors.push()); + } + + void registerDeviceToken(final @Nullable String email, final @Nullable String userId, final @Nullable String authToken, final @NonNull String applicationName, final @NonNull String deviceToken, final Map deviceAttributes, Executor executor) { + if (shouldSubmitDeviceTokenRegistration(deviceToken)) { + executor.execute(() -> registerDeviceToken(email, userId, authToken, applicationName, deviceToken, null, deviceAttributes)); + } + } + private boolean shouldSubmitDeviceTokenRegistration(@Nullable String deviceToken) { + if (deviceToken == null) { + return false; + } + if (!checkSDKInitialization() && _userIdUnknown == null) { + if (sharedInstance.config.enableUnknownUserActivation) { + unknownUserManager.trackUnknownTokenRegistration(deviceToken); } - final Thread registrationThread = new Thread(new Runnable() { - public void run() { - registerDeviceToken(email, userId, authToken, applicationName, deviceToken, null, deviceAttributes); - } - }); - registrationThread.start(); + return false; } + return true; } - protected void disableToken(@Nullable String email, @Nullable String userId, @NonNull String token) { disableToken(email, userId, null, token, null, null); } @@ -970,26 +975,22 @@ static void initializeForPush(@Nullable Context context) { IterableApi() { config = new IterableConfig.Builder().build(); + pushRegistration = new IterablePushRegistration(); } - - @VisibleForTesting IterableApi(IterableInAppManager inAppManager) { - config = new IterableConfig.Builder().build(); + this(); this.inAppManager = inAppManager; } - - @VisibleForTesting - IterableApi(IterableInAppManager inAppManager, IterableEmbeddedManager embeddedManager) { - config = new IterableConfig.Builder().build(); - this.inAppManager = inAppManager; - this.embeddedManager = embeddedManager; - } - - @VisibleForTesting IterableApi(IterableApiClient apiClient, IterableInAppManager inAppManager) { - config = new IterableConfig.Builder().build(); + this(inAppManager); this.apiClient = apiClient; + } + IterableApi(IterableInAppManager inAppManager, IterableEmbeddedManager embeddedManager, + IterablePushRegistration pushRegistration) { + config = new IterableConfig.Builder().build(); this.inAppManager = inAppManager; + this.embeddedManager = embeddedManager; + this.pushRegistration = Objects.requireNonNull(pushRegistration); } //endregion @@ -1673,7 +1674,7 @@ public void updateUser(@NonNull JSONObject dataFields, Boolean mergeNestedObject public void registerForPush() { if (checkSDKInitialization()) { IterablePushRegistrationData data = new IterablePushRegistrationData(_email, _userId, _authToken, getPushIntegrationName(), IterablePushRegistrationData.PushRegistrationAction.ENABLE); - IterablePushRegistration.executePushRegistrationTask(data); + pushRegistration.executePushRegistrationTask(data); } } @@ -1683,7 +1684,7 @@ public void registerForPush() { public void disablePush() { if (checkSDKInitialization()) { IterablePushRegistrationData data = new IterablePushRegistrationData(_email, _userId, _authToken, getPushIntegrationName(), IterablePushRegistrationData.PushRegistrationAction.DISABLE); - IterablePushRegistration.executePushRegistrationTask(data); + pushRegistration.executePushRegistrationTask(data); } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java index d0d3ee60d..fb331b12c 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java @@ -16,10 +16,14 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; class IterableApiClient { private static final String TAG = "IterableApiClient"; private final @NonNull AuthProvider authProvider; + private final IterablePushRegistrationRequestProcessor pushRegistrationRequestProcessor; + // A newer push action invalidates retries from earlier registration or disable requests. + private final AtomicLong pushRegistrationRequestGeneration = new AtomicLong(); private RequestProcessor requestProcessor; interface AuthProvider { @@ -42,6 +46,8 @@ interface AuthProvider { IterableApiClient(@NonNull AuthProvider authProvider) { this.authProvider = authProvider; + pushRegistrationRequestProcessor = + new IterablePushRegistrationRequestProcessor(); } private RequestProcessor getRequestProcessor() { @@ -618,7 +624,13 @@ protected void disableToken(@Nullable String email, @Nullable String userId, @Nu requestJSON.put(IterableConstants.KEY_USER_ID, userId); } - sendPostRequest(IterableConstants.ENDPOINT_DISABLE_DEVICE, requestJSON, authToken, onSuccess, onFailure); + sendPushRegistrationPostRequest( + IterableConstants.ENDPOINT_DISABLE_DEVICE, + requestJSON, + authToken, + onSuccess, + onFailure + ); } catch (JSONException e) { e.printStackTrace(); } @@ -669,7 +681,13 @@ protected void registerDeviceToken(@Nullable String email, @Nullable String user requestJSON.put(IterableConstants.KEY_PREFER_USER_ID, true); } - sendPostRequest(IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, requestJSON, authToken, successHandler, failureHandler); + sendPushRegistrationPostRequest( + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + requestJSON, + authToken, + successHandler, + failureHandler + ); } catch (JSONException e) { IterableLogger.e(TAG, "registerDeviceToken: exception", e); } @@ -768,6 +786,25 @@ void sendPostRequest(@NonNull String resourcePath, @NonNull JSONObject json, @Nu getRequestProcessor().processPostRequest(authProvider.getApiKey(), resourcePath, json, authToken, onSuccess, onFailure); } + private void sendPushRegistrationPostRequest( + @NonNull String resourcePath, + @NonNull JSONObject json, + @Nullable String authToken, + @Nullable IterableHelper.SuccessHandler onSuccess, + @Nullable IterableHelper.FailureHandler onFailure + ) { + long requestGeneration = pushRegistrationRequestGeneration.incrementAndGet(); + pushRegistrationRequestProcessor.processPostRequest( + authProvider.getApiKey(), + resourcePath, + json, + authToken, + onSuccess, + onFailure, + () -> requestGeneration == pushRegistrationRequestGeneration.get() + ); + } + /** * Sends a GET request to Iterable. * Performs network operations on an async thread instead of the main thread. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java new file mode 100644 index 000000000..0a76e5247 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableExecutors.java @@ -0,0 +1,29 @@ +package com.iterable.iterableapi; + +import android.os.Handler; +import android.os.Looper; + +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; + +final class IterableExecutors { + private static final Executor PUSH_EXECUTOR = Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "IterablePushExecutor"); + thread.setDaemon(true); + thread.setPriority(Thread.NORM_PRIORITY); + return thread; + }); + private static final Executor MAIN_EXECUTOR = + runnable -> new Handler(Looper.getMainLooper()).post(runnable); + + private IterableExecutors() { + } + + static Executor push() { + return PUSH_EXECUTOR; + } + + static Executor main() { + return MAIN_EXECUTOR; + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java index 3d10c648b..822c70937 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistration.java @@ -1,19 +1,19 @@ package com.iterable.iterableapi; -import androidx.annotation.VisibleForTesting; +import java.util.concurrent.Executor; -class IterablePushRegistration { +final class IterablePushRegistration { + private final Executor executor; - @VisibleForTesting - static IterablePushRegistrationImpl instance = new IterablePushRegistrationImpl(); + IterablePushRegistration() { + this(IterableExecutors.push()); + } - static void executePushRegistrationTask(IterablePushRegistrationData data) { - instance.executePushRegistrationTask(data); + IterablePushRegistration(Executor executor) { + this.executor = executor; } - static class IterablePushRegistrationImpl { - void executePushRegistrationTask(IterablePushRegistrationData data) { - new IterablePushRegistrationTask().execute(data); - } + void executePushRegistrationTask(IterablePushRegistrationData data) { + executor.execute(new IterablePushRegistrationTask(data)); } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java new file mode 100644 index 000000000..89b3906cc --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessor.java @@ -0,0 +1,249 @@ +package com.iterable.iterableapi; + +import static com.iterable.iterableapi.IterableConstants.ENDPOINT_DISABLE_DEVICE; + +import android.os.Handler; +import android.os.Looper; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.Date; +import java.util.Objects; +import java.util.concurrent.Executor; + +final class IterablePushRegistrationRequestProcessor { + private static final String TAG = "IterablePushRegistrationRequestProcessor"; + + interface RetryScheduler { + void schedule(Runnable runnable, long delayMs); + } + + private static final RetryScheduler SDK_RETRY_SCHEDULER = + (runnable, delayMs) -> + new Handler(Looper.getMainLooper()).postDelayed(runnable, delayMs); + + private final Executor requestExecutor; + private final Executor callbackExecutor; + private final RetryScheduler retryScheduler; + + IterablePushRegistrationRequestProcessor() { + this( + IterableExecutors.push(), + IterableExecutors.main(), + SDK_RETRY_SCHEDULER + ); + } + + IterablePushRegistrationRequestProcessor( + Executor requestExecutor, + Executor callbackExecutor, + RetryScheduler retryScheduler + ) { + this.requestExecutor = requestExecutor; + this.callbackExecutor = callbackExecutor; + this.retryScheduler = retryScheduler; + } + + void processPostRequest( + @Nullable String apiKey, + @NonNull String resourcePath, + @NonNull JSONObject json, + @Nullable String authToken, + @Nullable IterableHelper.SuccessHandler onSuccess, + @Nullable IterableHelper.FailureHandler onFailure, + @NonNull PushRegistrationRetryState retryState + ) { + IterableApiRequest request = new IterableApiRequest( + apiKey, + resourcePath, + addCreatedAtToJson(json), + IterableApiRequest.POST, + authToken, + onSuccess, + onFailure + ); + execute(request, retryState, 0, false); + } + + void scheduleRetry( + IterableApiRequest request, + @NonNull PushRegistrationRetryState retryState, + int retryCount, + long delayMs + ) { + retryScheduler.schedule( + () -> execute(request, retryState, retryCount, true), + delayMs + ); + } + + void retryWithNewAuthToken( + String newAuthToken, + IterableApiRequest request, + @NonNull PushRegistrationRetryState retryState + ) { + IterableApiRequest retryRequest = new IterableApiRequest( + request.apiKey, + request.resourcePath, + request.json, + request.requestType, + newAuthToken, + request.legacyCallback + ); + execute(retryRequest, retryState, 0, true); + } + + void deliverResult(Runnable runnable) { + callbackExecutor.execute(runnable); + } + + private void execute( + IterableApiRequest request, + @NonNull PushRegistrationRetryState retryState, + int retryCount, + boolean retry + ) { + requestExecutor.execute(new IterablePushRegistrationRequestTask( + request, + retryState, + retryCount, + retry, + this + )); + } + + private JSONObject addCreatedAtToJson(JSONObject json) { + try { + long createdAt; + if (json.has(IterableConstants.KEY_CREATED_AT)) { + createdAt = Long.parseLong( + json.getString(IterableConstants.KEY_CREATED_AT) + ); + } else { + createdAt = new Date().getTime() / 1000; + } + json.put(IterableConstants.KEY_CREATED_AT, createdAt); + } catch (JSONException | NumberFormatException e) { + IterableLogger.e( + TAG, + "Could not add createdAt timestamp to json object" + ); + } + return json; + } +} + +final class IterablePushRegistrationRequestTask implements Runnable { + private final IterableApiRequest request; + private final PushRegistrationRetryState retryState; + private final int retryCount; + private final boolean retry; + private final IterablePushRegistrationRequestProcessor requestProcessor; + + IterablePushRegistrationRequestTask( + IterableApiRequest request, + PushRegistrationRetryState retryState, + int retryCount, + boolean retry, + IterablePushRegistrationRequestProcessor requestProcessor + ) { + this.request = request; + this.retryState = retryState; + this.retryCount = retryCount; + this.retry = retry; + this.requestProcessor = requestProcessor; + } + + @Override + public void run() { + if (retry && !retryState.canRetry()) { + return; + } + + IterableApiResponse response = IterableRequestTask.executeApiRequest( + request, + (newAuthToken, originalRequest) -> + requestProcessor.retryWithNewAuthToken( + newAuthToken, + originalRequest, + retryState + ) + ); + requestProcessor.deliverResult(() -> handleResponse(response)); + } + + void handleResponse(IterableApiResponse response) { + if (response == null || (retry && !retryState.canRetry())) { + return; + } + + if (shouldRetry(response)) { + int nextRetryCount = retryCount + 1; + long delayMs = retryCount > 2 + ? IterableRequestTask.RETRY_DELAY_MS * retryCount + : 0; + requestProcessor.scheduleRetry( + request, + retryState, + nextRetryCount, + delayMs + ); + return; + } + + if (response.success) { + handleSuccess(response); + } else { + handleFailure(response); + } + + if (request.legacyCallback != null) { + request.legacyCallback.execute(response.responseBody); + } + } + + private boolean shouldRetry(IterableApiResponse response) { + return retryState.canRetry() + && !response.success + && response.responseCode >= 500 + && retryCount <= IterableRequestTask.MAX_RETRY_COUNT; + } + + private void handleSuccess(IterableApiResponse response) { + if (!Objects.equals(request.resourcePath, ENDPOINT_DISABLE_DEVICE)) { + IterableApi.getInstance().getAuthManager().resetFailedAuth(); + IterableApi.getInstance().getAuthManager().pauseAuthRetries(false); + IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(true); + } + + if (request.successCallback != null) { + request.successCallback.onSuccess(response.responseJson); + } + } + + private void handleFailure(IterableApiResponse response) { + if (request.failureCallback == null) { + return; + } + + JSONObject responseJson = response.responseJson; + if (responseJson != null) { + try { + responseJson.put( + IterableConstants.HTTP_STATUS_CODE, + response.responseCode + ); + } catch (JSONException ignored) { + } + } + request.failureCallback.onFailure(response.errorMessage, responseJson); + } +} + +interface PushRegistrationRetryState { + boolean canRetry(); +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java index 613e646a5..1b69753e5 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushRegistrationTask.java @@ -1,21 +1,23 @@ package com.iterable.iterableapi; import android.content.Context; -import android.os.AsyncTask; /** * Created by David Truong dt@iterable.com */ -class IterablePushRegistrationTask extends AsyncTask { +class IterablePushRegistrationTask implements Runnable { static final String TAG = "IterablePushRegistration"; - IterablePushRegistrationData iterablePushRegistrationData; + final IterablePushRegistrationData iterablePushRegistrationData; + + IterablePushRegistrationTask(IterablePushRegistrationData iterablePushRegistrationData) { + this.iterablePushRegistrationData = iterablePushRegistrationData; + } /** * Registers or disables the device - * @param params Push registration request data */ - protected Void doInBackground(IterablePushRegistrationData... params) { - iterablePushRegistrationData = params[0]; + @Override + public void run() { if (iterablePushRegistrationData.pushIntegrationName != null) { PushRegistrationObject pushRegistrationObject = getDeviceToken(); if (pushRegistrationObject != null) { @@ -26,7 +28,8 @@ protected Void doInBackground(IterablePushRegistrationData... params) { iterablePushRegistrationData.authToken, iterablePushRegistrationData.pushIntegrationName, pushRegistrationObject.token, - IterableApi.getInstance().getDeviceAttributes()); + IterableApi.getInstance().getDeviceAttributes(), + Runnable::run); } else if (iterablePushRegistrationData.pushRegistrationAction == IterablePushRegistrationData.PushRegistrationAction.DISABLE) { IterableApi.sharedInstance.disableToken( @@ -42,7 +45,6 @@ protected Void doInBackground(IterablePushRegistrationData... params) { } else { IterableLogger.e("IterablePush", "iterablePushRegistrationData has not been specified"); } - return null; } /** @@ -108,5 +110,3 @@ static class PushRegistrationObject { } } } - - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index fb728bfdb..db2822496 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -76,6 +76,17 @@ private static void retryRequestWithNewAuthToken(String newAuthToken, IterableAp @WorkerThread static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiRequest) { + return executeApiRequest( + iterableApiRequest, + IterableRequestTask::retryRequestWithNewAuthToken + ); + } + + @WorkerThread + static IterableApiResponse executeApiRequest( + IterableApiRequest iterableApiRequest, + IterableRequestAuthRetryHandler authRetryHandler + ) { IterableApiResponse apiResponse = null; String requestResult = null; @@ -204,7 +215,7 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "JWT Authorization header error"); IterableApi.getInstance().getAuthManager().handleAuthFailure(iterableApiRequest.authToken, getMappedErrorCodeForMessage(jsonResponse)); - handleJwtAuthRetry(iterableApiRequest); + handleJwtAuthRetry(iterableApiRequest, authRetryHandler); } else { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "Invalid API Key"); } @@ -264,7 +275,10 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque * is obtained via the AuthTokenReadyListener callback. * For online requests or when autoRetry is disabled, use the existing inline retry. */ - private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) { + private static void handleJwtAuthRetry( + IterableApiRequest iterableApiRequest, + IterableRequestAuthRetryHandler authRetryHandler + ) { boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); if (autoRetry && iterableApiRequest.getProcessorType() == IterableApiRequest.ProcessorType.OFFLINE) { IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); @@ -276,7 +290,7 @@ private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) { null ); } else { - requestNewAuthTokenAndRetry(iterableApiRequest); + requestNewAuthTokenAndRetry(iterableApiRequest, authRetryHandler); } } @@ -425,7 +439,10 @@ private void handleErrorResponse(IterableApiResponse response) { } } - private static void requestNewAuthTokenAndRetry(IterableApiRequest iterableApiRequest) { + private static void requestNewAuthTokenAndRetry( + IterableApiRequest iterableApiRequest, + IterableRequestAuthRetryHandler authRetryHandler + ) { IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(false); long retryInterval = IterableApi.getInstance().getAuthManager().getNextRetryInterval(); IterableApi.getInstance().getAuthManager().scheduleAuthTokenRefresh( @@ -434,7 +451,10 @@ private static void requestNewAuthTokenAndRetry(IterableApiRequest iterableApiRe data -> { try { String newAuthToken = data.getString("newAuthToken"); - retryRequestWithNewAuthToken(newAuthToken, iterableApiRequest); + authRetryHandler.retryWithNewAuthToken( + newAuthToken, + iterableApiRequest + ); } catch (JSONException e) { e.printStackTrace(); } @@ -447,6 +467,10 @@ protected void setRetryCount(int count) { } } +interface IterableRequestAuthRetryHandler { + void retryWithNewAuthToken(String newAuthToken, IterableApiRequest request); +} + /** * Iterable Request object */ diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java index e0b06c0d4..e6576b06f 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiIntegrationTest.java @@ -1,6 +1,8 @@ package com.iterable.iterableapi; import android.content.Context; +import android.os.AsyncTask; +import android.os.Looper; import org.json.JSONObject; import org.junit.After; @@ -8,8 +10,10 @@ import org.junit.Test; import org.robolectric.shadows.ShadowPausedAsyncTask; -import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -18,6 +22,8 @@ import static android.os.Looper.getMainLooper; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertNotNull; +import static junit.framework.Assert.assertNull; +import static junit.framework.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; @@ -44,7 +50,8 @@ public void setUp() { } @After - public void tearDown() throws IOException { + public void tearDown() throws Exception { + waitForRequestWorkToFinish(); IterablePushRegistrationTask.Util.instance = originalPushRegistrationUtil; server.shutdown(); @@ -76,4 +83,176 @@ public void testDisablePushOnLogout() throws Exception { assertEquals("test@email.com", requestJson.getString(IterableConstants.KEY_EMAIL)); assertEquals(TEST_TOKEN, requestJson.getString(IterableConstants.KEY_TOKEN)); } + + @Test + public void testRegisterForPushFollowedByDisablePushPreservesRequestOrder() throws Exception { + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize(getContext(), "apiKey", new IterableConfig.Builder().setAutoPushRegistration(false).build()); + IterableApi.getInstance().setEmail("test@email.com"); + shadowOf(getMainLooper()).idle(); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + IterableApi.getInstance().registerForPush(); + IterableApi.getInstance().disablePush(); + + RecordedRequest registrationRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(registrationRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + registrationRequest.getPath() + ); + + RecordedRequest disableRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(disableRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, + disableRequest.getPath() + ); + } + + @Test + public void testManualDeviceTokenRegistrationFollowedByDisablePushPreservesRequestOrder() throws Exception { + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize(getContext(), "apiKey", new IterableConfig.Builder().setAutoPushRegistration(false).build()); + IterableApi.getInstance().setEmail("test@email.com"); + shadowOf(getMainLooper()).idle(); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + IterableApi.getInstance().registerDeviceToken(TEST_TOKEN); + IterableApi.getInstance().disablePush(); + + RecordedRequest registrationRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(registrationRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + registrationRequest.getPath() + ); + + RecordedRequest disableRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(disableRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, + disableRequest.getPath() + ); + } + + @Test + public void testDisablePushPreventsFailedRegistrationFromRetrying() throws Exception { + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize(getContext(), "apiKey", new IterableConfig.Builder().setAutoPushRegistration(false).build()); + IterableApi.getInstance().setEmail("test@email.com"); + shadowOf(getMainLooper()).idle(); + server.enqueue(new MockResponse().setResponseCode(500).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + IterableApi.getInstance().registerForPush(); + IterableApi.getInstance().disablePush(); + + RecordedRequest registrationRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(registrationRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + registrationRequest.getPath() + ); + + RecordedRequest disableRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(disableRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, + disableRequest.getPath() + ); + + waitForRequestWorkToFinish(); + + assertEquals(2, server.getRequestCount()); + } + + @Test + public void testPushRegistrationDeliversClientCallbackOnMainThread() throws Exception { + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize( + getContext(), + "apiKey", + new IterableConfig.Builder().setAutoPushRegistration(true).build() + ); + AtomicReference callbackLooper = new AtomicReference<>(); + AtomicReference failureReason = new AtomicReference<>(); + + IterableApi.getInstance().setEmail( + "test@email.com", + data -> callbackLooper.set(Looper.myLooper()), + (reason, data) -> failureReason.set(reason) + ); + waitForRequestWorkToFinish(); + + assertEquals(Looper.getMainLooper(), callbackLooper.get()); + assertNull(failureReason.get()); + } + + @Test + public void testPushRegistrationRetryDoesNotWaitForHostAsyncTaskSerialExecutor() throws Exception { + server.enqueue(new MockResponse().setResponseCode(500).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + IterableApi.initialize(getContext(), "apiKey", new IterableConfig.Builder().setAutoPushRegistration(true).build()); + + CountDownLatch hostTaskStarted = new CountDownLatch(1); + CountDownLatch releaseHostTask = new CountDownLatch(1); + AsyncTask.SERIAL_EXECUTOR.execute(() -> { + hostTaskStarted.countDown(); + try { + releaseHostTask.await(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + try { + assertTrue(hostTaskStarted.await(5, TimeUnit.SECONDS)); + IterableApi.getInstance().setEmail("test@email.com"); + shadowOf(getMainLooper()).idle(); + RecordedRequest request = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(request); + JSONObject requestJson = new JSONObject(request.getBody().readUtf8()); + assertEquals("/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, request.getPath()); + assertEquals("test@email.com", requestJson.getString(IterableConstants.KEY_EMAIL)); + JSONObject deviceJson = requestJson.getJSONObject(IterableConstants.KEY_DEVICE); + assertEquals(TEST_TOKEN, deviceJson.getString(IterableConstants.KEY_TOKEN)); + + waitForExecutor(IterableExecutors.push()); + shadowOf(getMainLooper()).idle(); + + RecordedRequest retryRequest = server.takeRequest(5, TimeUnit.SECONDS); + assertNotNull(retryRequest); + assertEquals( + "/" + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + retryRequest.getPath() + ); + } finally { + releaseHostTask.countDown(); + } + } + + private void waitForRequestWorkToFinish() throws InterruptedException { + waitForExecutor(IterableExecutors.push()); + shadowOf(getMainLooper()).idle(); + waitForExecutor(IterableExecutors.push()); + waitForExecutor(AsyncTask.SERIAL_EXECUTOR); + shadowOf(getMainLooper()).idle(); + } + + private void waitForExecutor(Executor executor) throws InterruptedException { + CountDownLatch executorDrained = new CountDownLatch(1); + executor.execute(executorDrained::countDown); + assertTrue(executorDrained.await(5, TimeUnit.SECONDS)); + } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java index a8ee54116..edbae4237 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java @@ -26,6 +26,7 @@ import java.util.Date; import java.util.List; import java.util.UUID; +import java.util.concurrent.Executor; import java.util.concurrent.TimeUnit; import okhttp3.mockwebserver.MockResponse; @@ -60,23 +61,19 @@ public class IterableApiTest extends BaseTest { private MockWebServer server; private IterableApiClient originalApiClient; private IterableApiClient mockApiClient; - private IterablePushRegistration.IterablePushRegistrationImpl originalPushRegistrationImpl; + private Executor pushRegistrationExecutor; @Before public void setUp() { server = new MockWebServer(); IterableApi.overrideURLEndpointPath(server.url("").toString()); + pushRegistrationExecutor = mock(Executor.class); reInitIterableApi(); - - originalPushRegistrationImpl = IterablePushRegistration.instance; - IterablePushRegistration.instance = mock(IterablePushRegistration.IterablePushRegistrationImpl.class); } @After public void tearDown() throws IOException { - IterablePushRegistration.instance = originalPushRegistrationImpl; - server.shutdown(); server = null; } @@ -84,8 +81,11 @@ public void tearDown() throws IOException { private void reInitIterableApi() { IterableInAppManager inAppManagerMock = mock(IterableInAppManager.class); IterableEmbeddedManager embeddedManagerMock = mock(IterableEmbeddedManager.class); + IterablePushRegistration pushRegistration = + new IterablePushRegistration(pushRegistrationExecutor); - IterableApi.sharedInstance = new IterableApi(inAppManagerMock, embeddedManagerMock); + IterableApi.sharedInstance = new IterableApi( + inAppManagerMock, embeddedManagerMock, pushRegistration); originalApiClient = IterableApi.sharedInstance.apiClient; mockApiClient = spy(originalApiClient); @@ -264,20 +264,22 @@ public void testSetEmailWithAutomaticPushRegistration() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); // Flush any pending looper callbacks from initialize, then reset mock shadowOf(getMainLooper()).idle(); - Mockito.reset(IterablePushRegistration.instance); + Mockito.reset(pushRegistrationExecutor); // Check that setEmail calls registerForPush IterableApi.getInstance().setEmail("test@email.com"); - ArgumentCaptor capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.ENABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); - Mockito.reset(IterablePushRegistration.instance); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.ENABLE, + captureScheduledPushRegistrationData().pushRegistrationAction + ); + Mockito.reset(pushRegistrationExecutor); // Check that setEmail(null) disables the device IterableApi.getInstance().setEmail(null); - capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.DISABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.DISABLE, + captureScheduledPushRegistrationData().pushRegistrationAction + ); } @Test @@ -287,27 +289,29 @@ public void testSetEmailWithoutAutomaticPushRegistration() throws Exception { // Check that setEmail doesn't call registerForPush or disablePush IterableApi.getInstance().setEmail("test@email.com"); IterableApi.getInstance().setEmail(null); - verify(IterablePushRegistration.instance, never()).executePushRegistrationTask(any(IterablePushRegistrationData.class)); + verify(pushRegistrationExecutor, never()).execute(any(Runnable.class)); } @Test public void testSetUserIdWithAutomaticPushRegistration() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); // Reset after initialize since it may trigger push registration via background init - Mockito.reset(IterablePushRegistration.instance); + Mockito.reset(pushRegistrationExecutor); // Check that setUserId calls registerForPush IterableApi.getInstance().setUserId("userId"); - ArgumentCaptor capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.ENABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); - Mockito.reset(IterablePushRegistration.instance); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.ENABLE, + captureScheduledPushRegistrationData().pushRegistrationAction + ); + Mockito.reset(pushRegistrationExecutor); // Check that setUserId(null) disables the device IterableApi.getInstance().setUserId(null); - capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.DISABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.DISABLE, + captureScheduledPushRegistrationData().pushRegistrationAction + ); } @Test @@ -317,25 +321,25 @@ public void testSetUserIdWithoutAutomaticPushRegistration() throws Exception { // Check that setEmail calls registerForPush IterableApi.getInstance().setUserId("userId"); IterableApi.getInstance().setUserId(null); - verify(IterablePushRegistration.instance, never()).executePushRegistrationTask(any(IterablePushRegistrationData.class)); + verify(pushRegistrationExecutor, never()).execute(any(Runnable.class)); } @Test public void testNoAutomaticPushRegistrationOnInit() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); IterableApi.getInstance().setEmail("test@email.com"); - Mockito.reset(IterablePushRegistration.instance); + Mockito.reset(pushRegistrationExecutor); reInitIterableApi(); IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); - verify(IterablePushRegistration.instance, never()).executePushRegistrationTask(any(IterablePushRegistrationData.class)); + verify(pushRegistrationExecutor, never()).execute(any(Runnable.class)); } @Test public void testAutomaticPushRegistrationOnInitAndForeground() throws Exception { IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); IterableApi.getInstance().setEmail("test@email.com"); - Mockito.reset(IterablePushRegistration.instance); + Mockito.reset(pushRegistrationExecutor); reInitIterableApi(); IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); @@ -343,14 +347,23 @@ public void testAutomaticPushRegistrationOnInitAndForeground() throws Exception IterableApi.initialize(getContext(), "fake_key", new IterableConfig.Builder().setPushIntegrationName("pushIntegration").setAutoPushRegistration(true).build()); ActivityController activityController = Robolectric.buildActivity(Activity.class).create().start().resume(); - ArgumentCaptor capturedPushRegistrationData = ArgumentCaptor.forClass(IterablePushRegistrationData.class); - verify(IterablePushRegistration.instance, atLeastOnce()).executePushRegistrationTask(capturedPushRegistrationData.capture()); - assertEquals(IterablePushRegistrationData.PushRegistrationAction.ENABLE, capturedPushRegistrationData.getValue().pushRegistrationAction); + ArgumentCaptor capturedTask = ArgumentCaptor.forClass(Runnable.class); + verify(pushRegistrationExecutor, atLeastOnce()).execute(capturedTask.capture()); + assertEquals( + IterablePushRegistrationData.PushRegistrationAction.ENABLE, + ((IterablePushRegistrationTask) capturedTask.getValue()).iterablePushRegistrationData.pushRegistrationAction + ); activityController.pause().stop().destroy(); IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); } + private IterablePushRegistrationData captureScheduledPushRegistrationData() { + ArgumentCaptor capturedTask = ArgumentCaptor.forClass(Runnable.class); + verify(pushRegistrationExecutor).execute(capturedTask.capture()); + return ((IterablePushRegistrationTask) capturedTask.getValue()).iterablePushRegistrationData; + } + @Test public void testPushRegistrationDeviceFields() throws Exception { server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java index a8a65c9f5..4af8f0f77 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableNotificationTest.java @@ -22,11 +22,13 @@ import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.net.URL; import static android.os.Looper.getMainLooper; import static androidx.test.core.app.ApplicationProvider.getApplicationContext; import static junit.framework.Assert.assertEquals; import static junit.framework.Assert.assertFalse; +import static junit.framework.Assert.assertNotNull; import static junit.framework.Assert.assertNull; import static junit.framework.Assert.assertTrue; @@ -51,8 +53,6 @@ public class IterableNotificationTest { String itbl_ghost = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":true}"; String itbl1 = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":false}"; String itbl2 = "{\"templateId\":2,\"campaignId\":2,\"messageId\":\"22222222222222222222222222222222\",\"isGhostPush\":false}}"; - String itbl_image = "{\"templateId\":1,\"campaignId\":1,\"messageId\":\"11111111111111111111111111111111\",\"isGhostPush\":false,\"attachment-url\":\"https://assets.iterable.com/assets/images/logos/itbl-logo-full-gray-800x300.png\"}"; - private Context getContext() { return getApplicationContext(); } @@ -113,8 +113,15 @@ public void testGhostPush() throws Exception { */ @Test public void testNotificationImage() throws Exception { + URL imageResource = getClass().getClassLoader().getResource("350x150.png"); + assertNotNull(imageResource); + JSONObject iterableData = new JSONObject(itbl1); + iterableData.put( + IterableConstants.ITERABLE_DATA_PUSH_IMAGE, + imageResource.toString() + ); Bundle notif = new Bundle(); - notif.putString(IterableConstants.ITERABLE_DATA_KEY, itbl_image); + notif.putString(IterableConstants.ITERABLE_DATA_KEY, iterableData.toString()); notif.putString(IterableConstants.ITERABLE_DATA_BODY, body); IterableNotificationBuilder iterableNotification = postNotification(notif); @@ -341,4 +348,4 @@ private Context iconTestContext(Bundle metaData, String drawableName, int drawab return context; } -} \ No newline at end of file +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessorTest.java new file mode 100644 index 000000000..7a83f6374 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationRequestProcessorTest.java @@ -0,0 +1,38 @@ +package com.iterable.iterableapi; + +import static org.junit.Assert.assertNotNull; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.Test; + +import java.util.concurrent.atomic.AtomicReference; + +public class IterablePushRegistrationRequestProcessorTest extends BaseTest { + + @Test + public void testMalformedCreatedAtDoesNotPreventPushRequestSubmission() throws JSONException { + AtomicReference submittedRequest = new AtomicReference<>(); + IterablePushRegistrationRequestProcessor processor = + new IterablePushRegistrationRequestProcessor( + submittedRequest::set, + Runnable::run, + (runnable, delayMs) -> { + } + ); + JSONObject requestJson = new JSONObject() + .put(IterableConstants.KEY_CREATED_AT, "not-a-timestamp"); + + processor.processPostRequest( + "apiKey", + IterableConstants.ENDPOINT_REGISTER_DEVICE_TOKEN, + requestJson, + null, + null, + null, + () -> true + ); + + assertNotNull(submittedRequest.get()); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java index 194ca8546..f032f2aaa 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterablePushRegistrationTaskTest.java @@ -5,17 +5,25 @@ import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.mockito.ArgumentCaptor; +import org.robolectric.shadows.ShadowLog; import java.util.HashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicReference; import okhttp3.mockwebserver.MockWebServer; import static android.os.Looper.getMainLooper; import static com.iterable.iterableapi.IterableTestUtils.stubAnyRequestReturningStatusCode; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; @@ -52,6 +60,7 @@ public void setUp() throws Exception { IterablePushRegistrationTask.Util.instance = pushRegistrationUtilMock; when(pushRegistrationUtilMock.getSenderId(any(Context.class))).thenReturn("12345"); + ShadowLog.clear(); } @After @@ -66,14 +75,36 @@ public void tearDown() throws Exception { @Test public void testEnableDevice() throws Exception { when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn(TEST_TOKEN); + AtomicReference registrationSubmissionThread = new AtomicReference<>(); + doAnswer(invocation -> { + registrationSubmissionThread.set(Thread.currentThread()); + return null; + }).when(apiMock).registerDeviceToken( + nullable(String.class), + nullable(String.class), + nullable(String.class), + any(String.class), + any(String.class), + anyMap(), + any(Executor.class) + ); + Thread taskExecutionThread = Thread.currentThread(); IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.ENABLE); IterableApi.getInstance().setDeviceAttribute(DEVICE_ATTRIBUTES_KEY, DEVICE_ATTRIBUTES_VALUE); - new IterablePushRegistrationTask().execute(data); + new IterablePushRegistrationTask(data).run(); deviceAttributes.put(DEVICE_ATTRIBUTES_KEY, DEVICE_ATTRIBUTES_VALUE); - shadowOf(getMainLooper()).idle(); - verify(apiMock).registerDeviceToken(eq(IterableTestUtils.userEmail), nullable(String.class), isNull(), eq(INTEGRATION_NAME), eq(TEST_TOKEN), eq(deviceAttributes)); + assertSame(taskExecutionThread, registrationSubmissionThread.get()); + verify(apiMock).registerDeviceToken( + eq(IterableTestUtils.userEmail), + nullable(String.class), + isNull(), + eq(INTEGRATION_NAME), + eq(TEST_TOKEN), + eq(deviceAttributes), + any(Executor.class) + ); verify(apiMock, never()).disableToken(eq(IterableTestUtils.userEmail), nullable(String.class), nullable(String.class), any(String.class), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); } @@ -84,9 +115,44 @@ public void testDisableDevice() throws Exception { when(pushRegistrationUtilMock.getFirebaseToken()).thenReturn("testToken"); IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.DISABLE); - new IterablePushRegistrationTask().execute(data); + new IterablePushRegistrationTask(data).run(); shadowOf(getMainLooper()).idle(); verify(apiMock).disableToken(eq(IterableTestUtils.userEmail), isNull(), isNull(), eq(TEST_TOKEN), nullable(IterableHelper.SuccessHandler.class), nullable(IterableHelper.FailureHandler.class)); } -} \ No newline at end of file + + @Test + public void testDispatcherUsesConfiguredExecutor() { + Executor executor = mock(Executor.class); + IterablePushRegistration pushRegistration = new IterablePushRegistration(executor); + IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.ENABLE); + + pushRegistration.executePushRegistrationTask(data); + + ArgumentCaptor taskCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(executor).execute(taskCaptor.capture()); + assertTrue(taskCaptor.getValue() instanceof IterablePushRegistrationTask); + } + + @Test + public void testFirebaseTokenFailureIsLoggedAndContained() { + RuntimeException tokenException = new RuntimeException("Unable to fetch token"); + when(pushRegistrationUtilMock.getFirebaseToken()).thenThrow(tokenException); + + IterablePushRegistrationData data = new IterablePushRegistrationData(IterableTestUtils.userEmail, null, null, INTEGRATION_NAME, IterablePushRegistrationData.PushRegistrationAction.ENABLE); + new IterablePushRegistrationTask(data).run(); + + verify(apiMock, never()).registerDeviceToken( + any(String.class), + nullable(String.class), + nullable(String.class), + any(String.class), + any(String.class), + anyMap(), + any(Executor.class) + ); + assertTrue(ShadowLog.getLogsForTag(IterablePushRegistrationTask.TAG).stream() + .anyMatch(item -> item.msg.contains("Exception while retrieving the device token") + && item.throwable == tokenException)); + } +}