diff --git a/CHANGELOG.md b/CHANGELOG.md index cee7fbd3..8a708e1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 7.14.0 + +- - Adds federated JWT authentication to the Android SDK. When enableAuth() is used, the SDK fetches JWTs from a client-provided closure and attaches them via X-User-JWT on all user-identified requests. + ## 7.13.0 - Implementation for Overlay Messaging channel. Check optimove developer docs for more. diff --git a/OptimoveSDK/gradle.properties b/OptimoveSDK/gradle.properties index cbfe81ec..c3fa6e29 100644 --- a/OptimoveSDK/gradle.properties +++ b/OptimoveSDK/gradle.properties @@ -7,13 +7,11 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx1536m - -sdk_version=7.13.0 -sdk_version_code=71300 - +sdk_version=7.14.0 +sdk_version_code=71400 sdk_platform=Android android.useAndroidX=true android.enableJetifier=true android.defaults.buildfeatures.buildconfig=true android.nonTransitiveRClass=false -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthJwtResolver.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthJwtResolver.java new file mode 100644 index 00000000..8ebe8ad3 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthJwtResolver.java @@ -0,0 +1,64 @@ +package com.optimove.android; + +import androidx.annotation.Nullable; + +import com.optimove.android.main.tools.opti_logger.OptiLoggerStreamsContainer; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public final class AuthJwtResolver { + + private AuthJwtResolver() { + } + + /** + * When federated auth is configured and {@code userId} is non-empty, a JWT is required before sending + * user-identified requests. Returns true if {@code jwt} is missing after {@link #blockingJwt} (failure, + * timeout, or empty token). + */ + public static boolean isMissingRequiredJwt( + @Nullable AuthManager authManager, + @Nullable String userId, + @Nullable String jwt) { + if (authManager == null) { + return false; + } + if (userId == null || userId.trim().isEmpty()) { + return false; + } + return jwt == null || jwt.isEmpty(); + } + + @Nullable + public static String blockingJwt(@Nullable AuthManager authManager, @Nullable String userId, long timeoutMs) { + if (authManager == null || userId == null || userId.isEmpty()) { + return null; + } + CountDownLatch latch = new CountDownLatch(1); + AtomicReference tokenRef = new AtomicReference<>(); + AtomicReference errorRef = new AtomicReference<>(); + authManager.getToken(userId, (token, error) -> { + tokenRef.set(token); + errorRef.set(error); + latch.countDown(); + }); + try { + if (!latch.await(timeoutMs, TimeUnit.MILLISECONDS)) { + OptiLoggerStreamsContainer.warn("JWT fetch timed out for user '%s' after %d ms", userId, timeoutMs); + return null; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + OptiLoggerStreamsContainer.warn("JWT fetch interrupted for user '%s'", userId); + return null; + } + Exception error = errorRef.get(); + if (error != null) { + OptiLoggerStreamsContainer.warn("JWT fetch failed for user '%s': %s", userId, error.getMessage()); + return null; + } + return tokenRef.get(); + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthManager.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthManager.java new file mode 100644 index 00000000..292edd12 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthManager.java @@ -0,0 +1,32 @@ +package com.optimove.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public final class AuthManager { + + private final @NonNull AuthTokenProvider provider; + + public AuthManager(@NonNull AuthTokenProvider provider) { + this.provider = provider; + } + + public void getToken(@Nullable String userId, @NonNull AuthTokenProvider.Callback completion) { + if (userId == null) { + completion.onComplete(null, new AuthTokenException(AuthTokenException.Kind.NO_USER_ID)); + return; + } + String id = userId.trim(); + if (id.isEmpty()) { + completion.onComplete(null, new AuthTokenException(AuthTokenException.Kind.NO_USER_ID)); + return; + } + provider.getToken(id, (token, error) -> { + if (token != null) { + completion.onComplete(token, null); + } else { + completion.onComplete(null, error != null ? error : new AuthTokenException(AuthTokenException.Kind.TOKEN_FETCH_FAILED)); + } + }); + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthTokenException.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthTokenException.java new file mode 100644 index 00000000..aa9f3370 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthTokenException.java @@ -0,0 +1,28 @@ +package com.optimove.android; + +import androidx.annotation.NonNull; + +public final class AuthTokenException extends Exception { + + public enum Kind { + TOKEN_FETCH_FAILED("Failed to fetch auth token from provider."), + NO_USER_ID("No userId available for auth token request."); + + private final String message; + + Kind(String message) { + this.message = message; + } + } + + private final @NonNull Kind kind; + + public AuthTokenException(@NonNull Kind kind) { + super(kind.message); + this.kind = kind; + } + + public @NonNull Kind getKind() { + return kind; + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthTokenProvider.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthTokenProvider.java new file mode 100644 index 00000000..9da2011c --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthTokenProvider.java @@ -0,0 +1,15 @@ +package com.optimove.android; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + + +public interface AuthTokenProvider { + + void getToken(@NonNull String userId, @NonNull Callback callback); + + @FunctionalInterface + interface Callback { + void onComplete(@Nullable String token, @Nullable Exception error); + } +} \ No newline at end of file diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/Optimove.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/Optimove.java index 8216980b..643f3439 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/Optimove.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/Optimove.java @@ -11,6 +11,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.optimove.android.AuthManager; +import com.optimove.android.AuthTokenProvider; import com.optimove.android.embeddedmessaging.OptimoveEmbeddedMessaging; import com.optimove.android.main.common.EventHandlerFactory; import com.optimove.android.main.common.EventHandlerProvider; @@ -72,6 +74,7 @@ final public class Optimove { private final LifecycleObserver lifecycleObserver; private static OptimoveConfig currentConfig; + private static @Nullable AuthManager authManager; public enum IBeaconProximity { UNKNOWN, @@ -103,6 +106,9 @@ private Optimove(@NonNull Context context, OptimoveConfig config) { this.localConfigKeysPreferences = context.getSharedPreferences(TenantConfigsKeys.LOCAL_INIT_SP_FILE, Context.MODE_PRIVATE); this.lifecycleObserver = new LifecycleObserver(); + AuthTokenProvider authTokenProvider = config.getAuthTokenProvider(); + AuthManager localAuthManager = authTokenProvider != null ? new AuthManager(authTokenProvider) : null; + Optimove.authManager = localAuthManager; this.eventHandlerProvider = new EventHandlerProvider(EventHandlerFactory.builder() .userInfo(userInfo) .httpClient(HttpClient.getInstance()) @@ -110,6 +116,7 @@ private Optimove(@NonNull Context context, OptimoveConfig config) { .optistreamDbHelper(new OptistreamDbHelper(context)) .lifecycleObserver(lifecycleObserver) .context(context) + .authManager(localAuthManager) .build()); this.optimoveLifecycleEventGenerator = new OptimoveLifecycleEventGenerator(eventHandlerProvider, userInfo, @@ -285,6 +292,11 @@ public static OptimoveConfig getConfig() { return currentConfig; } + @Nullable + public static AuthManager getAuthManager() { + return authManager; + } + /** * Enables remote logs for investigations. Don't call it unless we explicitly asked you to. */ diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveAuthHeaders.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveAuthHeaders.java new file mode 100644 index 00000000..2d55d28f --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveAuthHeaders.java @@ -0,0 +1,13 @@ +package com.optimove.android; + +public final class OptimoveAuthHeaders { + + public static final String USER_JWT = "X-User-JWT"; + public static final String AUTH_CAPABLE = "X-Optimove-Auth-Capable"; + public static final String AUTH_CAPABLE_VALUE = "1"; + public static final String PLATFORM = "X-Optimove-Platform"; + public static final String PLATFORM_VALUE = "android"; + + private OptimoveAuthHeaders() { + } +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveConfig.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveConfig.java index af52f559..f6281d9c 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveConfig.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveConfig.java @@ -73,6 +73,7 @@ public final class OptimoveConfig { private @Nullable EmbeddedMessagingConfig embeddedMessagingConfig; + private @Nullable AuthTokenProvider authTokenProvider; private boolean overlayMessagingEnabled; private @Nullable Integer overlayMessagingSessionLengthHours; @@ -204,6 +205,10 @@ private void setMinLogLevel(@Nullable LogLevel minLogLevel) { this.minLogLevel = minLogLevel; } + private void setAuthTokenProvider(@Nullable AuthTokenProvider authTokenProvider) { + this.authTokenProvider = authTokenProvider; + } + void setCredentials(@Nullable String optimoveCredentials, @Nullable String optimobileCredentials) { if (optimoveCredentials == null && optimobileCredentials == null) { throw new IllegalArgumentException("Should provide at least optimove or optimobile credentials"); @@ -419,6 +424,10 @@ public boolean usesDelayedConfiguration() { return this.embeddedMessagingConfig; } + public @Nullable AuthTokenProvider getAuthTokenProvider() { + return this.authTokenProvider; + } + public boolean isOverlayMessagingEnabled() { return this.overlayMessagingEnabled; } @@ -486,6 +495,7 @@ public static class Builder { private @Nullable LogLevel minLogLevel; + private @Nullable AuthTokenProvider authTokenProvider; private @Nullable Integer overlayMessagingSessionLengthHours; /** @@ -600,6 +610,24 @@ public Builder enableOverlayMessaging(int sessionLengthHours) { return this; } + /** + * Enables JWT-based federated authentication for user-identified SDK traffic. + *

+ * When set, the SDK will call {@link AuthTokenProvider#getToken(String, AuthTokenProvider.Callback)} to obtain + * a JWT per request context; the token is sent as the {@code X-User-JWT} header. + *

+ * Threading: the provider may be invoked from a background thread; the {@link AuthTokenProvider.Callback} + * may complete on any thread. + * + * @param provider non-null implementation that fetches JWTs for a given user id + * @return this builder + */ + @NonNull + public Builder enableAuth(@NonNull AuthTokenProvider provider) { + this.authTokenProvider = provider; + return this; + } + /** * The minimum amount of time the user has to have left the app for a session end event to be * recorded. @@ -690,6 +718,7 @@ public OptimoveConfig build() { newConfig.setMinLogLevel(this.minLogLevel); + newConfig.setAuthTokenProvider(this.authTokenProvider); newConfig.overlayMessagingEnabled = this.overlayMessagingSessionLengthHours != null; newConfig.overlayMessagingSessionLengthHours = this.overlayMessagingSessionLengthHours; diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/embeddedmessaging/EmbeddedMessageEventRequest.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/embeddedmessaging/EmbeddedMessageEventRequest.java index 9fad2c47..1b9dfadb 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/embeddedmessaging/EmbeddedMessageEventRequest.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/embeddedmessaging/EmbeddedMessageEventRequest.java @@ -26,6 +26,11 @@ public EmbeddedMessageEventRequest( this.customerId = customerId; this.visitorId = visitorId; } + + public String getCustomerId() { + return customerId; + } + public JSONObject toJSONObject() throws JSONException { SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS"); JSONObject metricsObj = new JSONObject(); diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/embeddedmessaging/OptimoveEmbeddedMessaging.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/embeddedmessaging/OptimoveEmbeddedMessaging.java index a32401dd..fcd93417 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/embeddedmessaging/OptimoveEmbeddedMessaging.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/embeddedmessaging/OptimoveEmbeddedMessaging.java @@ -7,6 +7,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.optimove.android.AuthJwtResolver; +import com.optimove.android.AuthManager; import com.optimove.android.Optimove; import com.optimove.android.main.common.UserInfo; import com.optimove.android.main.tools.networking.HttpClient; @@ -101,7 +103,7 @@ public void getMessagesAsync(ContainerRequestOptions[] containerRequestOptions, handler.post(() -> embeddedMessagesGetHandler.run(ResultType.ERROR_USER_NOT_SET, null)); return; } - Runnable task = new GetEmbeddedMessagesRunnable(config, userId, embeddedMessagesGetHandler, containerRequestOptions); + Runnable task = new GetEmbeddedMessagesRunnable(config, userId, userInfo.getUserId(), embeddedMessagesGetHandler, containerRequestOptions); executorService.submit(task); } @@ -129,7 +131,7 @@ public void deleteMessageAsync(EmbeddedMessage message, @NonNull EmbeddedMessage new Date(), UUID.randomUUID().toString(), EventType.DELETED, context, userId, userInfo.getVisitorId()); - Runnable task = new EventReportEmbeddedMessagesRunnable(config, request, embeddedMessagesDeleteHandler); + Runnable task = new EventReportEmbeddedMessagesRunnable(config, request, userInfo.getUserId(), embeddedMessagesDeleteHandler); executorService.submit(task); } @@ -158,7 +160,7 @@ public void reportClickMetricAsync( EmbeddedMessageEventRequest request = new EmbeddedMessageEventRequest( new Date(), UUID.randomUUID().toString(), EventType.CLICKED, context, userId, userInfo.getVisitorId()); - Runnable task = new EventReportEmbeddedMessagesRunnable(config, request, embeddedMessagesMetricsHandler); + Runnable task = new EventReportEmbeddedMessagesRunnable(config, request, userInfo.getUserId(), embeddedMessagesMetricsHandler); executorService.submit(task); } @@ -188,7 +190,7 @@ public void setAsReadASync(EmbeddedMessage message, boolean isRead, new Date(), UUID.randomUUID().toString(), isRead ? EventType.READ : EventType.UNREAD, context, userId, userInfo.getVisitorId()); Runnable task = new EventReportEmbeddedMessagesRunnable( - config, request, embeddedMessagesStatusHandler); + config, request, userInfo.getUserId(), embeddedMessagesStatusHandler); executorService.submit(task); } @@ -206,12 +208,16 @@ private EmbeddedMessagingConfig handleConfigForAsyncSetCall(EmbeddedMessagesSetH class GetEmbeddedMessagesRunnable extends EmbeddedMessagesRunnableBase implements Runnable { private final EmbeddedMessagesGetHandler callback; private final String customerId; + @Nullable + private final String authId; private final ContainerRequestOptions[] requestBody; GetEmbeddedMessagesRunnable( - EmbeddedMessagingConfig config, String customerId, EmbeddedMessagesGetHandler callback, ContainerRequestOptions[] requestBody) { + EmbeddedMessagingConfig config, String customerId, @Nullable String authId, + EmbeddedMessagesGetHandler callback, ContainerRequestOptions[] requestBody) { super(config); this.customerId = customerId; + this.authId = authId; this.callback = callback; this.requestBody = requestBody; } @@ -229,7 +235,12 @@ public void run() { for (ContainerRequestOptions cm : requestBody) { postBody.put(cm.toJSONObject()); } - result = super.postSync(url, postBody, true); + String jwt = super.resolveJwt(this.authId); + if (AuthJwtResolver.isMissingRequiredJwt(Optimove.getAuthManager(), this.authId, jwt)) { + this.fireCallback(ResultType.ERROR, null); + return; + } + result = super.postSync(url, postBody, true, jwt); } catch (Exception e) { Log.e(TAG, e.getMessage()); e.printStackTrace(); @@ -245,16 +256,19 @@ private void fireCallback(ResultType result, @Nullable EmbeddedMessagesResponse class EventReportEmbeddedMessagesRunnable extends EmbeddedMessagesRunnableBase implements Runnable { private final EmbeddedMessagesSetHandler callback; - private final EmbeddedMessageEventRequest request; + @Nullable + private final String authId; EventReportEmbeddedMessagesRunnable( EmbeddedMessagingConfig config, EmbeddedMessageEventRequest request, + @Nullable String authId, EmbeddedMessagesSetHandler callback) { super(config); this.callback = callback; this.request = request; + this.authId = authId; } @Override @@ -265,7 +279,13 @@ public void run() { JSONArray postData = new JSONArray(); postData.put(request.toJSONObject()); String url = super.getBaseUrl("events/report"); - result = super.postSync(url, postData, false); + String jwt = super.resolveJwt(this.authId); + if (AuthJwtResolver.isMissingRequiredJwt( + Optimove.getAuthManager(), this.authId, jwt)) { + this.fireCallback(ResultType.ERROR); + return; + } + result = super.postSync(url, postData, false, jwt); } catch (Exception e) { Log.e(TAG, e.getMessage()); e.printStackTrace(); @@ -286,10 +306,19 @@ public EmbeddedMessagesRunnableBase(EmbeddedMessagingConfig config) { this.config = config; } - public EmbeddedMessagingResult postSync(String url, JSONArray postData, boolean expectResponse) { + @Nullable + protected String resolveJwt(@Nullable String userId) { + AuthManager authManager = Optimove.getAuthManager(); + if (userId == null || authManager == null) { + return null; + } + return AuthJwtResolver.blockingJwt(authManager, userId, 30_000L); + } + + public EmbeddedMessagingResult postSync(String url, JSONArray postData, boolean expectResponse, @Nullable String userJwt) { HttpClient httpClient = HttpClient.getInstance(); - try (Response response = httpClient.postSync(url, postData, config.getTenantId())) { + try (Response response = httpClient.postSync(url, postData, config.getTenantId(), userJwt)) { return handleResponse(response, expectResponse); } catch (Exception e) { Log.e(TAG, e.getMessage()); diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/common/EventHandlerFactory.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/common/EventHandlerFactory.java index b80d3191..509ebad6 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/common/EventHandlerFactory.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/common/EventHandlerFactory.java @@ -2,6 +2,9 @@ import android.content.Context; +import androidx.annotation.Nullable; + +import com.optimove.android.AuthManager; import com.optimove.android.main.event_handlers.DestinationDecider; import com.optimove.android.main.event_handlers.EventDecorator; import com.optimove.android.main.event_handlers.EventMemoryBuffer; @@ -28,16 +31,20 @@ public class EventHandlerFactory { private OptistreamDbHelper optistreamDbHelper; private LifecycleObserver lifecycleObserver; private Context context; + @Nullable + private AuthManager authManager; private EventHandlerFactory(HttpClient httpClient, UserInfo userInfo, int maximumBufferSize, OptistreamDbHelper optistreamDbHelper, - LifecycleObserver lifecycleObserver, Context context) { + LifecycleObserver lifecycleObserver, Context context, + @Nullable AuthManager authManager) { this.httpClient = httpClient; this.userInfo = userInfo; this.maximumBufferSize = maximumBufferSize; this.optistreamDbHelper = optistreamDbHelper; this.lifecycleObserver = lifecycleObserver; this.context = context; + this.authManager = authManager; } @@ -53,16 +60,16 @@ public EventNormalizer getEventNormalizer(int maxNumberOfParams) { return new EventNormalizer(maxNumberOfParams); } - public EventDecorator getEventDecorator(Map eventConfigs, int maxNumberOfParams) { + public EventDecorator getEventDecorator(Map eventConfigs, int maxNumberOfParams) { return new EventDecorator(eventConfigs, maxNumberOfParams); } public RealtimeManager getRealtimeMananger(RealtimeConfigs realtimeConfigs) { - return new RealtimeManager(httpClient, realtimeConfigs, context); + return new RealtimeManager(httpClient, realtimeConfigs, context, authManager); } public OptistreamHandler getOptistreamHandler(OptitrackConfigs optitrackConfigs) { - return new OptistreamHandler(httpClient, lifecycleObserver, optistreamDbHelper, optitrackConfigs); + return new OptistreamHandler(httpClient, lifecycleObserver, optistreamDbHelper, optitrackConfigs, authManager); } public DestinationDecider getDestinationDecider(Map eventConfigs, @@ -110,6 +117,8 @@ public interface ContextStep { public interface Build { EventHandlerFactory build(); + + Build authManager(@Nullable AuthManager authManager); } public static class Builder implements OptistreamDbHelperStep, MaximumBufferSizeStep, HttpClientStep, UserInfoStep, @@ -121,6 +130,8 @@ public static class Builder implements OptistreamDbHelperStep, MaximumBufferSize private OptistreamDbHelper optistreamDbHelper; private LifecycleObserver lifecycleObserver; private Context context; + @Nullable + private AuthManager authManager; @Override public HttpClientStep userInfo(UserInfo userInfo) { @@ -158,10 +169,16 @@ public Build context(Context context) { return this; } + @Override + public Build authManager(@Nullable AuthManager authManager) { + this.authManager = authManager; + return this; + } + @Override public EventHandlerFactory build() { return new EventHandlerFactory(httpClient, userInfo, maximumBufferSize, optistreamDbHelper, - lifecycleObserver, context); + lifecycleObserver, context, authManager); } } diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/tools/networking/HttpClient.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/tools/networking/HttpClient.java index 9a0b837b..90a60bcb 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/tools/networking/HttpClient.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/tools/networking/HttpClient.java @@ -4,9 +4,7 @@ import androidx.annotation.Nullable; import com.google.gson.Gson; -import com.optimove.android.Optimove; -import com.optimove.android.OptimoveConfig; -import com.optimove.android.optimobile.Optimobile; +import com.optimove.android.OptimoveAuthHeaders; import org.json.JSONArray; import org.json.JSONObject; @@ -59,6 +57,8 @@ public abstract static class RequestBuilder { protected SuccessListener successListener; @Nullable protected ErrorListener errorListener; + @Nullable + protected String userJwt; protected RequestBuilder(String baseUrl) { this.baseUrl = baseUrl; @@ -86,6 +86,11 @@ public RequestBuilder errorListener(ErrorListener errorListener) { return this; } + public RequestBuilder userJwt(@Nullable String jwt) { + this.userJwt = jwt; + return this; + } + public abstract void send(); } @@ -107,12 +112,18 @@ public void send() { RequestBody body = RequestBody.create(MediaType.parse("application/json; charset=utf-8"), json); - Request request = new Request.Builder().url(url).post(body).build(); + Request.Builder rb = new Request.Builder().url(url).post(body) + .addHeader(OptimoveAuthHeaders.AUTH_CAPABLE, OptimoveAuthHeaders.AUTH_CAPABLE_VALUE) + .addHeader(OptimoveAuthHeaders.PLATFORM, OptimoveAuthHeaders.PLATFORM_VALUE); + if (userJwt != null && !userJwt.isEmpty()) { + rb.addHeader(OptimoveAuthHeaders.USER_JWT, userJwt); + } + Request request = rb.build(); okHttpClient.newCall(request).enqueue(new Callback() { @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { - if (errorListener !=null) { + if (errorListener != null) { errorListener.sendError(e); } } @@ -120,8 +131,8 @@ public void onFailure(@NonNull Call call, @NonNull IOException e) { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { if (!response.isSuccessful()) { - if (errorListener !=null) { - errorListener.sendError(new Exception("Response wasn't successful - " + response.message())); + if (errorListener != null) { + errorListener.sendError(new HttpStatusException(response.code(), response.message())); } return; } @@ -140,6 +151,19 @@ public void onResponse(@NonNull Call call, @NonNull Response response) { } } + public static final class HttpStatusException extends Exception { + private final int code; + + public HttpStatusException(int code, String message) { + super("Response wasn't successful - " + code + " " + message); + this.code = code; + } + + public int getCode() { + return code; + } + } + public class CustomRequestBuilder extends RequestBuilder { Class typeToParse; @@ -155,12 +179,18 @@ public void send() { url = baseUrl; } - Request request = new Request.Builder().url(url).get().build(); + Request.Builder rb = new Request.Builder().url(url).get() + .addHeader(OptimoveAuthHeaders.AUTH_CAPABLE, OptimoveAuthHeaders.AUTH_CAPABLE_VALUE) + .addHeader(OptimoveAuthHeaders.PLATFORM, OptimoveAuthHeaders.PLATFORM_VALUE); + if (userJwt != null && !userJwt.isEmpty()) { + rb.addHeader(OptimoveAuthHeaders.USER_JWT, userJwt); + } + Request request = rb.build(); okHttpClient.newCall(request).enqueue(new Callback() { @Override public void onFailure(@NonNull Call call, @NonNull IOException e) { - if (errorListener !=null) { + if (errorListener != null) { errorListener.sendError(e); } } @@ -168,8 +198,8 @@ public void onFailure(@NonNull Call call, @NonNull IOException e) { @Override public void onResponse(@NonNull Call call, @NonNull Response response) throws IOException { if (!response.isSuccessful() || response.body() == null) { - if (errorListener !=null) { - errorListener.sendError(new Exception("Response wasn't successful - " + response.message())); + if (errorListener != null) { + errorListener.sendError(new HttpStatusException(response.code(), response.message())); } return; } @@ -191,70 +221,84 @@ public interface ErrorListener { } public Response getSync(String url, int tenantId) throws IOException { - Request.Builder builder = new Request.Builder().get(); - - Request request = this.buildRequest(builder, url, tenantId); + return getSync(url, tenantId, null); + } + public Response getSync(String url, int tenantId, @Nullable String userJwt) throws IOException { + Request.Builder builder = new Request.Builder().get(); + Request request = this.buildRequest(builder, url, tenantId, userJwt); return this.doSyncRequest(request); } public Response putSync(String url, JSONArray data, int tenantId) throws IOException { - String dataStr = data.toString(); + return putSync(url, data, tenantId, null); + } + public Response putSync(String url, JSONArray data, int tenantId, @Nullable String userJwt) throws IOException { + String dataStr = data.toString(); RequestBody body = RequestBody.create(dataStr, MediaType.parse("application/json; charset=utf-8")); - Request.Builder builder = new Request.Builder().put(body); - Request request = this.buildRequest(builder, url, tenantId); - + Request request = this.buildRequest(builder, url, tenantId, userJwt); return this.doSyncRequest(request); } public Response putSingleSync(String url, JSONObject data, int tenantId) throws IOException { - String dataStr = data.toString(); + return putSingleSync(url, data, tenantId, null); + } + public Response putSingleSync(String url, JSONObject data, int tenantId, @Nullable String userJwt) throws IOException { + String dataStr = data.toString(); RequestBody body = RequestBody.create(dataStr, MediaType.parse("application/json; charset=utf-8")); - Request.Builder builder = new Request.Builder().put(body); - Request request = this.buildRequest(builder, url, tenantId); - + Request request = this.buildRequest(builder, url, tenantId, userJwt); return this.doSyncRequest(request); } public Response postSync(String url, JSONArray data, int tenantId) throws IOException { - String dataStr = data.toString(); + return postSync(url, data, tenantId, null); + } + public Response postSync(String url, JSONArray data, int tenantId, @Nullable String userJwt) throws IOException { + String dataStr = data.toString(); RequestBody body = RequestBody.create(dataStr, MediaType.parse("application/json; charset=utf-8")); - Request.Builder builder = new Request.Builder().post(body); - Request request = this.buildRequest(builder, url, tenantId); - + Request request = this.buildRequest(builder, url, tenantId, userJwt); return this.doSyncRequest(request); } public Response postSingleSync(String url, JSONObject data, int tenantId) throws IOException { - String dataStr = data.toString(); + return postSingleSync(url, data, tenantId, null); + } + public Response postSingleSync(String url, JSONObject data, int tenantId, @Nullable String userJwt) throws IOException { + String dataStr = data.toString(); RequestBody body = RequestBody.create(dataStr, MediaType.parse("application/json; charset=utf-8")); - Request.Builder builder = new Request.Builder().post(body); - Request request = this.buildRequest(builder, url, tenantId); - + Request request = this.buildRequest(builder, url, tenantId, userJwt); return this.doSyncRequest(request); } public Response deleteSync(String url, int tenantId) throws IOException { - Request.Builder builder = new Request.Builder().delete(); - Request request = this.buildRequest(builder, url, tenantId); + return deleteSync(url, tenantId, null); + } + public Response deleteSync(String url, int tenantId, @Nullable String userJwt) throws IOException { + Request.Builder builder = new Request.Builder().delete(); + Request request = this.buildRequest(builder, url, tenantId, userJwt); return this.doSyncRequest(request); } - private Request buildRequest(Request.Builder builder, String url, int tenantId) { - return builder.url(url) + private Request buildRequest(Request.Builder builder, String url, int tenantId, @Nullable String userJwt) { + builder.url(url) .addHeader("Accept", "application/json") .addHeader("Content-Type", "application/json") .addHeader("X-Tenant-Id", String.valueOf(tenantId)) - .build(); + .addHeader(OptimoveAuthHeaders.AUTH_CAPABLE, OptimoveAuthHeaders.AUTH_CAPABLE_VALUE) + .addHeader(OptimoveAuthHeaders.PLATFORM, OptimoveAuthHeaders.PLATFORM_VALUE); + if (userJwt != null && !userJwt.isEmpty()) { + builder.addHeader(OptimoveAuthHeaders.USER_JWT, userJwt); + } + return builder.build(); } private Response doSyncRequest(Request request) throws IOException { diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/AnalyticsUploadHelper.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/AnalyticsUploadHelper.java index a7a27fdd..678f4f8e 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/AnalyticsUploadHelper.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/AnalyticsUploadHelper.java @@ -9,12 +9,19 @@ import androidx.annotation.WorkerThread; +import com.optimove.android.AuthJwtResolver; +import com.optimove.android.AuthManager; +import com.optimove.android.Optimove; + import org.json.JSONArray; import org.json.JSONException; import org.json.JSONObject; import java.io.IOException; import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; import okhttp3.Response; @@ -27,26 +34,32 @@ enum Result { FAILED_NO_RETRY } - /** - * package - */ + private static final class AnalyticsEventRow { + final long id; + final JSONObject event; + + AnalyticsEventRow(long id, JSONObject event) { + this.id = id; + this.event = event; + } + } + @WorkerThread Result flushEvents(Context context) { try (SQLiteOpenHelper dbHelper = new AnalyticsDbHelper(context)) { SQLiteDatabase db = dbHelper.getReadableDatabase(); - Pair, Long> eventsResult = this.getBatchOfEvents(db, 0L); - ArrayList events = eventsResult.first; - long maxEventId = eventsResult.second; - - while (!events.isEmpty()) { - if (!this.flushBatchToNetwork(context, events, maxEventId)) { + long cursor = 0L; + while (true) { + Pair, Long> batch = this.getBatchOfEvents(db, cursor); + List rows = batch.first; + if (rows.isEmpty()) { + break; + } + if (!this.flushBatchToNetwork(context, rows)) { return Result.FAILED_RETRY_LATER; } - - eventsResult = this.getBatchOfEvents(db, maxEventId); - events = eventsResult.first; - maxEventId = eventsResult.second; + cursor = batch.second; } } catch (SQLiteException e) { e.printStackTrace(); @@ -58,48 +71,109 @@ Result flushEvents(Context context) { return Result.SUCCESS; } - private boolean flushBatchToNetwork(Context context, ArrayList events, long maxEventId) throws Optimobile.PartialInitialisationException { - // Pack into JSON - JSONArray data = new JSONArray(events); + private boolean flushBatchToNetwork(Context context, List rows) throws Optimobile.PartialInitialisationException { + Map> groups = new LinkedHashMap<>(); + for (AnalyticsEventRow row : rows) { + String key = analyticsUserKey(row.event); + List group = groups.get(key); + if (group == null) { + group = new ArrayList<>(); + groups.put(key, group); + } + group.add(row); + } + + AuthManager authManager = Optimove.getAuthManager(); final OptimobileHttpClient httpClient = OptimobileHttpClient.getInstance(); final String url = Optimobile.urlForService(UrlBuilder.Service.EVENTS, "/v1/app-installs/" + Optimobile.getInstallId() + "/events"); - boolean result = false; - try (Response response = httpClient.postSync(url, data)) { - if (response.isSuccessful()) { - result = true; + final String installId = Optimobile.getInstallId(); + + for (List group : groups.values()) { + JSONArray data = new JSONArray(); + for (AnalyticsEventRow r : group) { + data.put(r.event); } - } catch (IOException e) { - e.printStackTrace(); + String jwt = null; + String uidKey = group.isEmpty() ? "" : analyticsUserKey(group.get(0).event); + boolean visitorBatch = uidKey.isEmpty() || installId.equals(uidKey); + if (authManager != null && !visitorBatch) { + jwt = AuthJwtResolver.blockingJwt(authManager, uidKey, 30_000L); + } + if (!visitorBatch + && AuthJwtResolver.isMissingRequiredJwt(authManager, uidKey, jwt)) { + return false; + } + try (Response response = httpClient.postSync(url, data, jwt)) { + if (!response.isSuccessful()) { + int code = response.code(); + boolean authNotConfigured = authManager == null + && code == 401 + && !visitorBatch; + if (authNotConfigured) { + List ids = new ArrayList<>(group.size()); + for (AnalyticsEventRow r : group) { + ids.add(r.id); + } + deletePersistedEventsByIds(context, ids); + continue; + } + return false; + } + } catch (IOException e) { + e.printStackTrace(); + return false; + } + List ids = new ArrayList<>(group.size()); + for (AnalyticsEventRow r : group) { + ids.add(r.id); + } + deletePersistedEventsByIds(context, ids); } + return true; + } - // Clean up batch from DB - if (result) { - deletePersistedEvents(context, maxEventId); + private static String analyticsUserKey(JSONObject event) { + try { + if (!event.has("userId") || event.isNull("userId")) { + return ""; + } + return event.optString("userId", "").trim(); + } catch (Exception e) { + return ""; } - - return result; } - private void deletePersistedEvents(Context context, long maxEventId){ + private void deletePersistedEventsByIds(Context context, List ids) { + if (ids.isEmpty()) { + return; + } try (SQLiteOpenHelper dbHelper = new AnalyticsDbHelper(context)) { SQLiteDatabase db = dbHelper.getWritableDatabase(); - + StringBuilder ph = new StringBuilder(); + String[] args = new String[ids.size()]; + for (int i = 0; i < ids.size(); i++) { + if (i > 0) { + ph.append(','); + } + ph.append('?'); + args[i] = String.valueOf(ids.get(i)); + } db.delete( AnalyticsContract.AnalyticsEvent.TABLE_NAME, - AnalyticsContract.AnalyticsEvent.COL_ID + " <= ?", - new String[]{String.valueOf(maxEventId)}); + AnalyticsContract.AnalyticsEvent.COL_ID + " IN (" + ph + ")", + args); - Optimobile.log(TAG, "Deleted persistent events up to " + maxEventId + " (inclusive)"); + Optimobile.log(TAG, "Deleted persistent analytics events: " + ids.size()); } catch (SQLiteException e) { - Optimobile.log(TAG, "Failed to delete persistent events up to " + maxEventId + " (inclusive)"); + Optimobile.log(TAG, "Failed to delete persistent analytics events"); e.printStackTrace(); } } - private Pair, Long> getBatchOfEvents(SQLiteDatabase db, long minEventId) { + private Pair, Long> getBatchOfEvents(SQLiteDatabase db, long minEventId) { String[] projection = { AnalyticsContract.AnalyticsEvent.COL_ID, AnalyticsContract.AnalyticsEvent.COL_HAPPENED_AT_MILLIS, @@ -125,8 +199,8 @@ private Pair, Long> getBatchOfEvents(SQLiteDatabase db, lo String.valueOf(100) ); - ArrayList events = new ArrayList<>(); - long maxEventId = -1L; + List rows = new ArrayList<>(); + long maxEventId = minEventId; while (cursor.moveToNext()) { JSONObject event = new JSONObject(); @@ -152,16 +226,16 @@ private Pair, Long> getBatchOfEvents(SQLiteDatabase db, lo event.put("userId", userId); - events.add(event); - - maxEventId = cursor.getLong(cursor.getColumnIndex(AnalyticsContract.AnalyticsEvent.COL_ID)); + long rowId = cursor.getLong(cursor.getColumnIndex(AnalyticsContract.AnalyticsEvent.COL_ID)); + rows.add(new AnalyticsEventRow(rowId, event)); + maxEventId = Math.max(maxEventId, rowId); } catch (JSONException e) { e.printStackTrace(); } } cursor.close(); - return new Pair<>(events, maxEventId); + return new Pair<>(rows, maxEventId); } } diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessagePresenter.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessagePresenter.java index fafc99d4..e1be83f2 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessagePresenter.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppMessagePresenter.java @@ -363,4 +363,4 @@ private void showMessageDirectly(@NonNull InAppMessage message) { void retryPresentingMessages() { Optimobile.handler.post(this::presentMessageToClient); } -} +} \ No newline at end of file diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppRequestService.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppRequestService.java index 8d2b43f2..68895a7b 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppRequestService.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/InAppRequestService.java @@ -3,6 +3,10 @@ import android.content.Context; import android.net.Uri; +import com.optimove.android.AuthJwtResolver; +import com.optimove.android.AuthManager; +import com.optimove.android.Optimove; + import org.json.JSONArray; import org.json.JSONException; @@ -37,7 +41,17 @@ static List readInAppMessages(Context c, Date lastSyncTime) { try { String url = Optimobile.urlForService(UrlBuilder.Service.PUSH, "/v1/users/" + encodedIdentifier + "/messages" + params); - try (Response response = httpClient.getSync(url)) { + String jwt = null; + String associatedUserId = Optimobile.getAssociatedUserIdentifier(c); + AuthManager authManager = Optimove.getAuthManager(); + if (authManager != null && associatedUserId != null) { + jwt = AuthJwtResolver.blockingJwt(authManager, associatedUserId, 30_000L); + } + if (AuthJwtResolver.isMissingRequiredJwt(authManager, associatedUserId, jwt)) { + return null; + } + + try (Response response = httpClient.getSync(url, jwt)) { if (!response.isSuccessful()) { logFailedResponse(response); } else { diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/Optimobile.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/Optimobile.java index 6de753bb..e9b02086 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/Optimobile.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/Optimobile.java @@ -218,6 +218,14 @@ public static String getCurrentUserIdentifier(@NonNull Context context) { } } + @Nullable + public static String getAssociatedUserIdentifier(@NonNull Context context) { + synchronized (userIdLocker) { + SharedPreferences preferences = context.getSharedPreferences(SharedPrefs.PREFS_FILE, Context.MODE_PRIVATE); + return preferences.getString(SharedPrefs.KEY_USER_IDENTIFIER, null); + } + } + private static void associateUserWithInstallImpl(Context context, @NonNull final String userIdentifier, @Nullable final JSONObject attributes) { if (TextUtils.isEmpty(userIdentifier)) { throw new IllegalArgumentException("Optimobile.associateUserWithInstall requires a non-empty user identifier"); diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OptimobileHttpClient.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OptimobileHttpClient.java index 1e4ca006..43e79970 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OptimobileHttpClient.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OptimobileHttpClient.java @@ -6,6 +6,7 @@ import androidx.annotation.Nullable; import com.optimove.android.Optimove; +import com.optimove.android.OptimoveAuthHeaders; import com.optimove.android.OptimoveConfig; import org.json.JSONArray; @@ -49,7 +50,6 @@ private OkHttpClient buildOkHttpClient() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { return new OkHttpClient(); } - //ciphers available on Android 4.4 have intersections with the approved ones in MODERN_TLS, but the intersections are on bad cipher list, so, //perhaps not supported by CloudFlare. On older devices allow all ciphers ConnectionSpec spec = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) @@ -62,32 +62,38 @@ private OkHttpClient buildOkHttpClient() { } Response postSync(String url, JSONArray data) throws IOException, Optimobile.PartialInitialisationException { - String dataStr = data.toString(); + return postSync(url, data, null); + } + Response postSync(String url, JSONArray data, @Nullable String userJwt) throws IOException, Optimobile.PartialInitialisationException { + String dataStr = data.toString(); RequestBody body = RequestBody.create(dataStr, MediaType.parse("application/json; charset=utf-8")); - Request.Builder builder = new Request.Builder().post(body); - Request request = this.buildRequest(builder, url); - + Request request = this.buildRequest(builder, url, userJwt); return this.doSyncRequest(request); } Response getSync(String url) throws IOException, Optimobile.PartialInitialisationException { - Request.Builder builder = new Request.Builder().get(); - - Request request = this.buildRequest(builder, url); + return getSync(url, null); + } + Response getSync(String url, @Nullable String userJwt) throws IOException, Optimobile.PartialInitialisationException { + Request.Builder builder = new Request.Builder().get(); + Request request = this.buildRequest(builder, url, userJwt); return this.doSyncRequest(request); } void getAsync(String url, Callback callback) throws Optimobile.PartialInitialisationException { - Request.Builder builder = new Request.Builder().get(); - Request request = this.buildRequest(builder, url); + getAsync(url, callback, null); + } + void getAsync(String url, Callback callback, @Nullable String userJwt) throws Optimobile.PartialInitialisationException { + Request.Builder builder = new Request.Builder().get(); + Request request = this.buildRequest(builder, url, userJwt); this.doAsyncRequest(request, callback); } - private Request buildRequest(Request.Builder builder, String url) throws Optimobile.PartialInitialisationException { + private Request buildRequest(Request.Builder builder, String url, @Nullable String userJwt) throws Optimobile.PartialInitialisationException { if (this.authHeader == null) { OptimoveConfig config = Optimove.getConfig(); @@ -100,11 +106,16 @@ private Request buildRequest(Request.Builder builder, String url) throws Optimob this.authHeader = buildBasicAuthHeader(apiKey, secretKey); } - return builder.url(url) + builder.url(url) .addHeader(Optimobile.KEY_AUTH_HEADER, this.authHeader) .addHeader("Accept", "application/json") .addHeader("Content-Type", "application/json") - .build(); + .addHeader(OptimoveAuthHeaders.AUTH_CAPABLE, OptimoveAuthHeaders.AUTH_CAPABLE_VALUE) + .addHeader(OptimoveAuthHeaders.PLATFORM, OptimoveAuthHeaders.PLATFORM_VALUE); + if (userJwt != null && !userJwt.isEmpty()) { + builder.addHeader(OptimoveAuthHeaders.USER_JWT, userJwt); + } + return builder.build(); } private Response doSyncRequest(Request request) throws IOException { diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRequestService.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRequestService.java index ae757df4..5b26100a 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRequestService.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optimobile/OverlayMessagingRequestService.java @@ -2,10 +2,13 @@ import android.content.Context; import android.net.Uri; -import android.util.Log; import androidx.annotation.Nullable; +import com.optimove.android.AuthJwtResolver; +import com.optimove.android.AuthManager; +import com.optimove.android.Optimove; + import org.json.JSONException; import org.json.JSONObject; @@ -28,7 +31,17 @@ class OverlayMessagingRequestService { String url = Optimobile.urlForService(UrlBuilder.Service.OVERLAY_MESSAGING, "/api/v1/users/" + encodedIdentifier + "/messages/mobile?messageType=" + messageType); - try (Response response = httpClient.getSync(url)) { + String jwt = null; + String associatedUserId = Optimobile.getAssociatedUserIdentifier(c); + AuthManager authManager = Optimove.getAuthManager(); + if (authManager != null && associatedUserId != null) { + jwt = AuthJwtResolver.blockingJwt(authManager, associatedUserId, 30_000L); + } + if (AuthJwtResolver.isMissingRequiredJwt(authManager, associatedUserId, jwt)) { + return null; + } + + try (Response response = httpClient.getSync(url, jwt)) { if (!response.isSuccessful()) { logFailedResponse(response); diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamDbHelper.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamDbHelper.java index 3fd4ec65..c6eb9b99 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamDbHelper.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamDbHelper.java @@ -8,6 +8,7 @@ import android.database.sqlite.SQLiteOpenHelper; import android.provider.BaseColumns; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.optimove.android.main.tools.opti_logger.OptiLoggerStreamsContainer; @@ -66,8 +67,8 @@ public boolean insertEvent(String eventJson) { contentValues.put(OptistreamEntry.COLUMN_CREATED_AT, System.currentTimeMillis()); db.insert(OptistreamEntry.TABLE_NAME, null, contentValues); - } catch (Throwable e){ - OptiLoggerStreamsContainer.error("An error occurred while inserting events - %s",e.getMessage()); + } catch (Throwable e) { + OptiLoggerStreamsContainer.error("An error occurred while inserting events - %s", e.getMessage()); return false; } return true; @@ -93,12 +94,41 @@ public void removeEvents(String lastId) { } } + @Override + public void removeEventsByIds(@NonNull List rowIds) { + if (rowIds.isEmpty()) { + return; + } + try { + SQLiteDatabase db = this.getWritableDatabase(); + StringBuilder placeholders = new StringBuilder(); + String[] args = new String[rowIds.size()]; + for (int i = 0; i < rowIds.size(); i++) { + if (i > 0) { + placeholders.append(','); + } + placeholders.append('?'); + args[i] = String.valueOf(rowIds.get(i)); + } + String query = OptistreamEntry._ID + " IN (" + placeholders + ")"; + db.delete(OptistreamEntry.TABLE_NAME, query, args); + } catch (SQLiteException e) { + OptiLoggerStreamsContainer.error("An SQL error occurred while removing events by ids - %s", + e.getMessage()); + close(); + dbFile.delete(); + } catch (Throwable e) { + OptiLoggerStreamsContainer.error("An error occurred while removing events by ids - %s", e.getMessage()); + close(); + dbFile.delete(); + } + } + @Override public @Nullable EventsBulk getFirstEvents(int numberOfEvents) { try { - String lastId = null; - List eventJsons = new ArrayList<>(); + List queued = new ArrayList<>(); SQLiteDatabase db = this.getReadableDatabase(); String eventsQuery = "SELECT * FROM " + OptistreamEntry.TABLE_NAME + @@ -109,15 +139,14 @@ public void removeEvents(String lastId) { boolean exists = res.moveToFirst(); while (exists) { - eventJsons.add(res.getString(res.getColumnIndex(OptistreamEntry.COLUMN_DATA))); - if (res.isLast()) { - lastId = res.getString(res.getColumnIndex(OptistreamEntry._ID)); - } + long id = res.getLong(res.getColumnIndexOrThrow(OptistreamEntry._ID)); + String data = res.getString(res.getColumnIndexOrThrow(OptistreamEntry.COLUMN_DATA)); + queued.add(new QueuedEvent(id, data)); exists = res.moveToNext(); } res.close(); - return new EventsBulk(lastId, eventJsons); + return new EventsBulk(queued); } catch (Exception e) { OptiLoggerStreamsContainer.error("An error occurred while querying events - %s", e.getMessage()); @@ -125,4 +154,4 @@ public void removeEvents(String lastId) { } } -} \ No newline at end of file +} diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamHandler.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamHandler.java index 4dac8987..a3ac5fa2 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamHandler.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamHandler.java @@ -4,15 +4,20 @@ import androidx.annotation.Nullable; import com.google.gson.Gson; +import com.optimove.android.AuthManager; import com.optimove.android.main.common.LifecycleObserver; import com.optimove.android.main.sdk_configs.configs.OptitrackConfigs; import com.optimove.android.main.tools.networking.HttpClient; +import com.optimove.android.main.tools.networking.HttpClient.HttpStatusException; import com.optimove.android.main.tools.opti_logger.OptiLoggerStreamsContainer; import org.json.JSONArray; import org.json.JSONObject; +import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -33,6 +38,8 @@ public class OptistreamHandler implements LifecycleObserver.ActivityStopped { @NonNull private Gson optistreamGson; + @Nullable + private final AuthManager authManager; @Nullable private ScheduledFuture timerDispatchFuture; @@ -49,11 +56,13 @@ public final static class Constants { public OptistreamHandler(@NonNull HttpClient httpClient, @NonNull LifecycleObserver lifecycleObserver, @NonNull OptistreamPersistanceAdapter optistreamPersistanceAdapter, - @NonNull OptitrackConfigs optitrackConfigs) { + @NonNull OptitrackConfigs optitrackConfigs, + @Nullable AuthManager authManager) { this.httpClient = httpClient; this.lifecycleObserver = lifecycleObserver; this.optistreamPersistanceAdapter = optistreamPersistanceAdapter; this.optitrackConfigs = optitrackConfigs; + this.authManager = authManager; this.singleThreadScheduledExecutor = Executors.newSingleThreadScheduledExecutor(); this.optistreamGson = new Gson(); } @@ -72,7 +81,7 @@ public void reportEvents(List optistreamEvents) { try { singleThreadScheduledExecutor.submit(() -> { boolean immediateEventFound = false; - for (OptistreamEvent optistreamEvent: optistreamEvents) { + for (OptistreamEvent optistreamEvent : optistreamEvents) { optistreamPersistanceAdapter.insertEvent(optistreamGson.toJson(optistreamEvent)); if (optistreamEvent.getMetadata().isRealtime()) { immediateEventFound = true; @@ -90,43 +99,21 @@ public void reportEvents(List optistreamEvents) { } } - private void dispatchBulkIfExists(){ + private void dispatchBulkIfExists() { if (dispatchRequestWaitsForResponse) { - return; //protects from sending same events twice + return; } - OptistreamDbHelper.EventsBulk eventsBulk = optistreamPersistanceAdapter.getFirstEvents(Constants.EVENT_BATCH_LIMIT); + OptistreamPersistanceAdapter.EventsBulk eventsBulk = optistreamPersistanceAdapter.getFirstEvents(Constants.EVENT_BATCH_LIMIT); if (eventsBulk == null) { scheduleTheNextDispatch(); return; } - List eventJsons = eventsBulk.getEventJsons(); - if (eventJsons != null && !eventJsons.isEmpty()) { + List queue = eventsBulk.getEvents(); + if (queue != null && !queue.isEmpty()) { try { - JSONArray jsonArrayToDispatch = new JSONArray(); - for (String eventJson: eventJsons) { - jsonArrayToDispatch.put(new JSONObject(eventJson)); - } dispatchRequestWaitsForResponse = true; - httpClient.postJson(optitrackConfigs.getOptitrackEndpoint(), jsonArrayToDispatch.toString()) - .errorListener(error -> { - OptiLoggerStreamsContainer.error("Events dispatching failed - %s", - error.getMessage()); - // some error occurred, try again in the next dispatch - dispatchRequestWaitsForResponse = false; - scheduleTheNextDispatch(); - }) - .successListener(response -> { - try { - singleThreadScheduledExecutor.submit(() -> { - optistreamPersistanceAdapter.removeEvents(eventsBulk.getLastId()); - dispatchRequestWaitsForResponse = false; - dispatchBulkIfExists(); - }); - } catch (Throwable throwable) { - OptiLoggerStreamsContainer.error("Error while submitting a command - %s", throwable.getMessage()); - } - }) - .send(); + List> groups = groupByCustomer(queue); + sendCustomerGroups(groups, 0); } catch (Throwable e) { dispatchRequestWaitsForResponse = false; OptiLoggerStreamsContainer.error("Events dispatching failed - %s", @@ -139,7 +126,118 @@ private void dispatchBulkIfExists(){ } } - private void scheduleTheNextDispatch(){ + private static String customerKeyFromJson(String json) { + try { + JSONObject o = new JSONObject(json); + if (!o.has("customer") || o.isNull("customer")) { + return ""; + } + String c = o.optString("customer", ""); + return c == null ? "" : c.trim(); + } catch (Exception e) { + return ""; + } + } + + private static List> groupByCustomer( + List events) { + Map> map = new LinkedHashMap<>(); + for (OptistreamPersistanceAdapter.QueuedEvent e : events) { + String key = customerKeyFromJson(e.getEventJson()); + List group = map.get(key); + if (group == null) { + group = new ArrayList<>(); + map.put(key, group); + } + group.add(e); + } + return new ArrayList<>(map.values()); + } + + private void sendCustomerGroups(List> groups, int index) { + if (index >= groups.size()) { + dispatchRequestWaitsForResponse = false; + dispatchBulkIfExists(); + return; + } + List group = groups.get(index); + String customerKey = group.isEmpty() ? "" : customerKeyFromJson(group.get(0).getEventJson()); + + if (authManager == null || customerKey.isEmpty()) { + postGroupJson(group, groups, index, null); + return; + } + + authManager.getToken(customerKey, (token, error) -> + singleThreadScheduledExecutor.submit(() -> { + if (error != null || token == null) { + OptiLoggerStreamsContainer.error("Optistream auth token failed - %s", + error != null ? error.getMessage() : "null token"); + sendCustomerGroups(groups, index + 1); + return; + } + postGroupJson(group, groups, index, token); + })); + } + + private void postGroupJson(List group, + List> allGroups, + int index, + @Nullable String jwt) { + try { + JSONArray jsonArrayToDispatch = new JSONArray(); + for (OptistreamPersistanceAdapter.QueuedEvent qe : group) { + jsonArrayToDispatch.put(new JSONObject(qe.getEventJson())); + } + List ids = new ArrayList<>(group.size()); + for (OptistreamPersistanceAdapter.QueuedEvent qe : group) { + ids.add(qe.getRowId()); + } + httpClient.postJson(optitrackConfigs.getOptitrackEndpoint(), jsonArrayToDispatch.toString()) + .userJwt(jwt) + .errorListener(error -> { + boolean authNotConfigured = authManager == null + && error instanceof HttpStatusException + && ((HttpStatusException) error).getCode() == 401; + if (authNotConfigured) { + OptiLoggerStreamsContainer.error( + "Optistream unauthorized (401) with auth not configured; dropping %d event(s)", + ids.size()); + try { + singleThreadScheduledExecutor.submit(() -> { + optistreamPersistanceAdapter.removeEventsByIds(ids); + sendCustomerGroups(allGroups, index + 1); + }); + } catch (Throwable throwable) { + OptiLoggerStreamsContainer.error("Error while submitting a command - %s", + throwable.getMessage()); + } + return; + } + OptiLoggerStreamsContainer.error("Events dispatching failed - %s", + error.getMessage()); + dispatchRequestWaitsForResponse = false; + scheduleTheNextDispatch(); + }) + .successListener(response -> { + try { + singleThreadScheduledExecutor.submit(() -> { + optistreamPersistanceAdapter.removeEventsByIds(ids); + sendCustomerGroups(allGroups, index + 1); + }); + } catch (Throwable throwable) { + OptiLoggerStreamsContainer.error("Error while submitting a command - %s", throwable.getMessage()); + } + }) + .send(); + } catch (Throwable e) { + dispatchRequestWaitsForResponse = false; + OptiLoggerStreamsContainer.error("Events dispatching failed - %s", e.getMessage()); + scheduleTheNextDispatch(); + } + } + + private void scheduleTheNextDispatch() { try { this.timerDispatchFuture = this.singleThreadScheduledExecutor.schedule(this::dispatchBulkIfExists, Constants.DISPATCH_INTERVAL_IN_SECONDS, TimeUnit.SECONDS); @@ -148,6 +246,7 @@ private void scheduleTheNextDispatch(){ e.getMessage()); } } + @Override public void activityStopped() { // Stop the scheduled dispatch if exists diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamPersistanceAdapter.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamPersistanceAdapter.java index 4aad829c..3cd6a573 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamPersistanceAdapter.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamPersistanceAdapter.java @@ -1,7 +1,9 @@ package com.optimove.android.optistream; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.Collections; import java.util.List; public interface OptistreamPersistanceAdapter { @@ -10,32 +12,64 @@ public interface OptistreamPersistanceAdapter { void removeEvents(String lastId); + void removeEventsByIds(@NonNull List rowIds); + @Nullable EventsBulk getFirstEvents(int numberOfEvents); + final class QueuedEvent { + private final long rowId; + private final String eventJson; + + public QueuedEvent(long rowId, @NonNull String eventJson) { + this.rowId = rowId; + this.eventJson = eventJson; + } + + public long getRowId() { + return rowId; + } + + @NonNull + public String getEventJson() { + return eventJson; + } + } + class EventsBulk { - private String lastId; - private List eventJsons; + private final List events; - public EventsBulk(String lastId, List eventJsons) { - this.lastId = lastId; - this.eventJsons = eventJsons; + public EventsBulk(@NonNull List events) { + this.events = events; } - public String getLastId() { - return lastId; + @NonNull + public List getEvents() { + return events; } - public void setLastId(String lastId) { - this.lastId = lastId; + public boolean isEmpty() { + return events.isEmpty(); } - public List getEventJsons() { - return eventJsons; + @Nullable + public String getLastIdLegacy() { + if (events.isEmpty()) { + return null; + } + return String.valueOf(events.get(events.size() - 1).getRowId()); } - public void setEventJsons(List eventJsons) { - this.eventJsons = eventJsons; + @NonNull + public List getEventJsons() { + if (events.isEmpty()) { + return Collections.emptyList(); + } + java.util.ArrayList jsons = new java.util.ArrayList<>(events.size()); + for (QueuedEvent e : events) { + jsons.add(e.getEventJson()); + } + return jsons; } } } diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/preferencecenter/OptimovePreferenceCenter.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/preferencecenter/OptimovePreferenceCenter.java index fae27f5e..c1b239de 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/preferencecenter/OptimovePreferenceCenter.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/preferencecenter/OptimovePreferenceCenter.java @@ -7,6 +7,8 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.optimove.android.AuthJwtResolver; +import com.optimove.android.AuthManager; import com.optimove.android.Optimove; import com.optimove.android.main.common.UserInfo; import com.optimove.android.main.tools.networking.HttpClient; @@ -142,7 +144,17 @@ public void run() { String encodedCustomerId = URLEncoder.encode(this.customerId, "UTF-8"); String url = "https://preference-center-" + region + ".optimove.net/api/v1/preferences?customerId=" + encodedCustomerId + "&brandGroupId=" + config.getBrandGroupId(); - try (Response response = httpClient.getSync(url, config.getTenantId())) { + String jwt = null; + AuthManager authManager = Optimove.getAuthManager(); + if (authManager != null) { + jwt = AuthJwtResolver.blockingJwt(authManager, this.customerId, 30_000L); + } + if (AuthJwtResolver.isMissingRequiredJwt(authManager, this.customerId, jwt)) { + this.fireCallback(ResultType.ERROR, null); + return; + } + + try (Response response = httpClient.getSync(url, config.getTenantId(), jwt)) { if (!response.isSuccessful()) { logFailedResponse(response); } else { @@ -188,7 +200,17 @@ public void run() { String url = "https://preference-center-" + region + ".optimove.net/api/v1/preferences?customerId=" + encodedCustomerId + "&brandGroupId=" + config.getBrandGroupId(); JSONArray data = mapPreferenceUpdatesToArray(updates); - try (Response response = httpClient.putSync(url, data, config.getTenantId())) { + String jwt = null; + AuthManager authManager = Optimove.getAuthManager(); + if (authManager != null) { + jwt = AuthJwtResolver.blockingJwt(authManager, this.customerId, 30_000L); + } + if (AuthJwtResolver.isMissingRequiredJwt(authManager, this.customerId, jwt)) { + this.fireCallback(ResultType.ERROR); + return; + } + + try (Response response = httpClient.putSync(url, data, config.getTenantId(), jwt)) { if (!response.isSuccessful()) { logFailedResponse(response); } else { diff --git a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/realtime/RealtimeManager.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/realtime/RealtimeManager.java index a1d7339b..f03021d6 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/realtime/RealtimeManager.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/realtime/RealtimeManager.java @@ -4,17 +4,22 @@ import android.content.SharedPreferences; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import com.google.gson.Gson; +import com.optimove.android.AuthManager; import com.optimove.android.main.events.core_events.SetEmailEvent; import com.optimove.android.main.events.core_events.SetUserIdEvent; import com.optimove.android.main.sdk_configs.configs.RealtimeConfigs; import com.optimove.android.main.tools.networking.HttpClient; +import com.optimove.android.main.tools.networking.HttpClient.HttpStatusException; import com.optimove.android.main.tools.opti_logger.OptiLoggerStreamsContainer; import com.optimove.android.optistream.OptistreamEvent; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import static com.optimove.android.realtime.RealtimeConstants.FAILED_SET_EMAIL_EVENT_KEY; import static com.optimove.android.realtime.RealtimeConstants.FAILED_SET_USER_EVENT_KEY; @@ -31,12 +36,16 @@ public final class RealtimeManager { private final Gson realtimeGson; + @Nullable + private final AuthManager authManager; + public RealtimeManager(@NonNull HttpClient httpClient, @NonNull RealtimeConfigs realtimeConfigs, - @NonNull Context context) { + @NonNull Context context, @Nullable AuthManager authManager) { this.httpClient = httpClient; this.realtimePreferences = context.getSharedPreferences(REALTIME_SP_NAME, Context.MODE_PRIVATE); this.realtimeConfigs = realtimeConfigs; this.realtimeGson = new Gson(); + this.authManager = authManager; } public void reportEvents(List optistreamEvents) { @@ -44,12 +53,12 @@ public void reportEvents(List optistreamEvents) { List optistreamEventsToDispatch = new ArrayList<>(); boolean setUserEventFound = false; boolean setEmailEventFound = false; - for (OptistreamEvent optistreamEvent: optistreamEvents) { + for (OptistreamEvent optistreamEvent : optistreamEvents) { if (optistreamEvent.getName() .equals(SetUserIdEvent.EVENT_NAME)) { setUserEventFound = true; } else if (optistreamEvent.getName() - .equals(SetEmailEvent.EVENT_NAME)){ + .equals(SetEmailEvent.EVENT_NAME)) { setEmailEventFound = true; } } @@ -69,22 +78,117 @@ public void reportEvents(List optistreamEvents) { } } optistreamEventsToDispatch.addAll(optistreamEvents); - dispatchEvents(optistreamEventsToDispatch); + dispatchEventsGrouped(optistreamEventsToDispatch); + } + + private static String userKey(@Nullable OptistreamEvent e) { + if (e == null) { + return ""; + } + String uid = e.getUserId(); + if (uid == null) { + return ""; + } + String t = uid.trim(); + return t.isEmpty() ? "" : t; } - private void dispatchEvents(List optistreamEvents) { - httpClient.postJson(realtimeConfigs.getRealtimeGateway(), new Gson().toJson(optistreamEvents)) - .successListener(jsonResponse -> - realtimePreferences.edit() - .remove(FAILED_SET_USER_EVENT_KEY) - .remove(FAILED_SET_EMAIL_EVENT_KEY) - .apply() - ) - .errorListener(e -> dispatchingFailed(e, optistreamEvents)) + private List> groupByUserId(List events) { + Map> map = new LinkedHashMap<>(); + for (OptistreamEvent ev : events) { + String key = userKey(ev); + List group = map.get(key); + if (group == null) { + group = new ArrayList<>(); + map.put(key, group); + } + group.add(ev); + } + return new ArrayList<>(map.values()); + } + + private void dispatchEventsGrouped(List allEvents) { + List> groups = groupByUserId(allEvents); + dispatchGroupAtIndex(groups, 0); + } + + private void dispatchGroupAtIndex(List> groups, int index) { + if (index >= groups.size()) { + realtimePreferences.edit() + .remove(FAILED_SET_USER_EVENT_KEY) + .remove(FAILED_SET_EMAIL_EVENT_KEY) + .apply(); + return; + } + List group = groups.get(index); + String key = group.isEmpty() ? "" : userKey(group.get(0)); + + if (authManager != null && !key.isEmpty()) { + authManager.getToken(key, (token, error) -> { + if (error != null || token == null) { + dispatchingFailed(error != null ? error : new Exception("null token"), group); + dispatchGroupAtIndex(groups, index + 1); + return; + } + httpClient.postJson(realtimeConfigs.getRealtimeGateway(), realtimeGson.toJson(group)) + .userJwt(token) + .successListener(jsonResponse -> dispatchGroupAtIndex(groups, index + 1)) + .errorListener(e -> onRealtimeRequestFailed(e, groups, index, group)) + .destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE) + .send(); + }); + return; + } + + httpClient.postJson(realtimeConfigs.getRealtimeGateway(), realtimeGson.toJson(group)) + .userJwt(null) + .successListener(jsonResponse -> dispatchGroupAtIndex(groups, index + 1)) + .errorListener(e -> onRealtimeRequestFailed(e, groups, index, group)) .destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE) .send(); } + private void onRealtimeRequestFailed( + @NonNull Exception e, + @NonNull List> groups, + int index, + @NonNull List group) { + if (authManager == null && e instanceof HttpStatusException && ((HttpStatusException) e).getCode() == 401) { + OptiLoggerStreamsContainer.error( + "Realtime unauthorized (401) with auth not configured; discarding batch without retry"); + clearFailProtectedPrefsMatchingGroup(group); + dispatchGroupAtIndex(groups, index + 1); + return; + } + dispatchingFailed(e, group); + } + + private void clearFailProtectedPrefsMatchingGroup(@NonNull List group) { + boolean clearUser = false; + boolean clearEmail = false; + for (OptistreamEvent optistreamEvent : group) { + if (optistreamEvent.getName() + .equals(SetUserIdEvent.EVENT_NAME)) { + clearUser = true; + } + if (optistreamEvent.getName() + .equals(SetEmailEvent.EVENT_NAME)) { + clearEmail = true; + } + } + if (!clearUser && !clearEmail) { + return; + } + SharedPreferences.Editor ed = realtimePreferences.edit(); + if (clearUser) { + ed.remove(FAILED_SET_USER_EVENT_KEY); + } + if (clearEmail) { + ed.remove(FAILED_SET_EMAIL_EVENT_KEY); + } + ed.apply(); + } + private void dispatchingFailed(Exception e, List optistreamEvents) { //add failed to shared prefs (if important) OptiLoggerStreamsContainer.error("Events dispatching to RT failed - %s", @@ -97,7 +201,7 @@ private void dispatchingFailed(Exception e, List optistreamEven .apply(); } if (optistreamEvent.getName() - .equals(SetEmailEvent.EVENT_NAME)){ + .equals(SetEmailEvent.EVENT_NAME)) { realtimePreferences.edit() .putString(FAILED_SET_EMAIL_EVENT_KEY, realtimeGson.toJson(optistreamEvent)) .apply(); diff --git a/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/AuthJwtResolverTest.java b/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/AuthJwtResolverTest.java new file mode 100644 index 00000000..8eeb4d61 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/AuthJwtResolverTest.java @@ -0,0 +1,33 @@ +package com.optimove.android; + +import org.junit.Assert; +import org.junit.Test; + +public class AuthJwtResolverTest { + + private final AuthManager dummyManager = new AuthManager((userId, callback) -> { }); + + @Test + public void isMissingRequiredJwt_noProvider_false() { + Assert.assertFalse(AuthJwtResolver.isMissingRequiredJwt((AuthManager) null, "u1", null)); + Assert.assertFalse(AuthJwtResolver.isMissingRequiredJwt((AuthManager) null, "u1", "jwt")); + } + + @Test + public void isMissingRequiredJwt_emptyUserId_false() { + Assert.assertFalse(AuthJwtResolver.isMissingRequiredJwt(dummyManager, null, null)); + Assert.assertFalse(AuthJwtResolver.isMissingRequiredJwt(dummyManager, "", null)); + Assert.assertFalse(AuthJwtResolver.isMissingRequiredJwt(dummyManager, " ", null)); + } + + @Test + public void isMissingRequiredJwt_providerAndUser_missingJwt_true() { + Assert.assertTrue(AuthJwtResolver.isMissingRequiredJwt(dummyManager, "u1", null)); + Assert.assertTrue(AuthJwtResolver.isMissingRequiredJwt(dummyManager, "u1", "")); + } + + @Test + public void isMissingRequiredJwt_hasToken_false() { + Assert.assertFalse(AuthJwtResolver.isMissingRequiredJwt(dummyManager, "u1", "jwt")); + } +} diff --git a/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/AuthManagerTest.java b/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/AuthManagerTest.java new file mode 100644 index 00000000..cd151bc8 --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/AuthManagerTest.java @@ -0,0 +1,55 @@ +package com.optimove.android; + +import org.junit.Assert; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public class AuthManagerTest { + + @Test + public void getToken_forwardsTokenFromProvider() throws Exception { + AuthManager manager = new AuthManager((userId, callback) -> callback.onComplete("the-jwt", null)); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference token = new AtomicReference<>(); + AtomicReference err = new AtomicReference<>(); + manager.getToken("user-1", (t, e) -> { + token.set(t); + err.set(e); + latch.countDown(); + }); + Assert.assertTrue(latch.await(2, TimeUnit.SECONDS)); + Assert.assertEquals("the-jwt", token.get()); + Assert.assertNull(err.get()); + } + + @Test + public void getToken_nullUserId_returnsNoUserIdError() throws Exception { + AuthManager manager = new AuthManager((userId, callback) -> callback.onComplete("x", null)); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference err = new AtomicReference<>(); + manager.getToken(null, (t, e) -> { + err.set(e); + latch.countDown(); + }); + Assert.assertTrue(latch.await(2, TimeUnit.SECONDS)); + Assert.assertTrue(err.get() instanceof AuthTokenException); + Assert.assertEquals(AuthTokenException.Kind.NO_USER_ID, ((AuthTokenException) err.get()).getKind()); + } + + @Test + public void getToken_providerReturnsNullToken_usesTokenFetchFailed() throws Exception { + AuthManager manager = new AuthManager((userId, callback) -> callback.onComplete(null, null)); + CountDownLatch latch = new CountDownLatch(1); + AtomicReference err = new AtomicReference<>(); + manager.getToken("u", (t, e) -> { + err.set(e); + latch.countDown(); + }); + Assert.assertTrue(latch.await(2, TimeUnit.SECONDS)); + Assert.assertTrue(err.get() instanceof AuthTokenException); + Assert.assertEquals(AuthTokenException.Kind.TOKEN_FETCH_FAILED, ((AuthTokenException) err.get()).getKind()); + } +} diff --git a/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/OptitrackTests.java b/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/OptitrackTests.java index 16bfc262..dfbd6cfe 100644 --- a/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/OptitrackTests.java +++ b/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/OptitrackTests.java @@ -5,7 +5,6 @@ import com.optimove.android.main.common.UserInfo; import com.optimove.android.main.sdk_configs.configs.OptitrackConfigs; import com.optimove.android.main.tools.networking.HttpClient; -import com.optimove.android.optistream.OptistreamDbHelper; import com.optimove.android.optistream.OptistreamEvent; import com.optimove.android.optistream.OptistreamHandler; import com.optimove.android.optistream.OptistreamPersistanceAdapter; @@ -24,6 +23,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; + +import static org.mockito.ArgumentMatchers.anyList; import java.util.Map; import static org.junit.Assert.fail; @@ -52,7 +53,7 @@ public class OptitrackTests { @Mock HttpClient httpClient; @Mock - OptistreamDbHelper optistreamDbHelper; + OptistreamPersistanceAdapter optistreamDbHelper; @Before public void setUp() { @@ -62,6 +63,8 @@ public void setUp() { when(builder.errorListener(any())).thenReturn(builder); when(builder.destination(any(), any())).thenReturn(builder); when(builder.successListener(any())).thenReturn(builder); + when(builder.userJwt(any())).thenReturn(builder); + when(delayedResponseBuilder.userJwt(any())).thenReturn(delayedResponseBuilder); } @Test @@ -71,7 +74,7 @@ public void nonRealtimeEventsShouldBeStoredInOrder() throws Exception { OptistreamPersistanceAdapter optistreamPersistanceAdapter = new MockedOptistreamPersistency(); OptistreamHandler optistreamHandler = new OptistreamHandler(httpClient, lifecycleObserver, - optistreamPersistanceAdapter, optitrackConfigs); + optistreamPersistanceAdapter, optitrackConfigs, null); for (int i = 0; i < numOfEvents; i++) { optistreamHandler.reportEvents(Collections.singletonList(getRegularEvent(false, "some_name_" + i))); @@ -93,7 +96,7 @@ public void eventsShouldBeDispatchedOnceAndInOrder() throws Exception { OptistreamPersistanceAdapter optistreamPersistanceAdapter = new MockedOptistreamPersistency(); OptistreamHandler optistreamHandler = new OptistreamHandler(httpClient, lifecycleObserver, - optistreamPersistanceAdapter, optitrackConfigs); + optistreamPersistanceAdapter, optitrackConfigs, null); applyHttpRandomDelaySuccessInvocation(maxResponseTime); //generating numOfEvents events with random realtime @@ -146,7 +149,7 @@ public void eventShouldBePersisted() { OptistreamEvent regularEvent = getRegularEvent(true, "some_name"); String regularEventJson = new Gson().toJson(regularEvent); OptistreamHandler optistreamHandler = new OptistreamHandler(httpClient, lifecycleObserver, optistreamDbHelper - , optitrackConfigs); + , optitrackConfigs, null); optistreamHandler.reportEvents(Collections.singletonList(regularEvent)); verify(optistreamDbHelper, timeout(1000)).insertEvent(regularEventJson); } @@ -154,18 +157,18 @@ public void eventShouldBePersisted() { @Test public void eventsShouldBeRemovedWhenDispatchSucceed() { Gson gson = new Gson(); - String lastId = "some_id"; - OptistreamDbHelper.EventsBulk eventBulk = new OptistreamDbHelper.EventsBulk(lastId, - Collections.singletonList(gson.toJson(getRegularEvent(false, "some_name")))); + OptistreamPersistanceAdapter.EventsBulk eventBulk = new OptistreamPersistanceAdapter.EventsBulk( + Collections.singletonList(new OptistreamPersistanceAdapter.QueuedEvent(1L, + gson.toJson(getRegularEvent(false, "some_name"))))); applyHttpSuccessInvocation(); when(optistreamDbHelper.getFirstEvents(OptistreamHandler.Constants.EVENT_BATCH_LIMIT)).thenReturn(eventBulk, - new OptistreamDbHelper.EventsBulk(null, new ArrayList<>())); + new OptistreamPersistanceAdapter.EventsBulk(Collections.emptyList())); OptistreamHandler optistreamHandler = new OptistreamHandler(httpClient, lifecycleObserver, optistreamDbHelper - , optitrackConfigs); + , optitrackConfigs, null); optistreamHandler.reportEvents(Collections.singletonList(getRegularEvent(true, "some_name"))); - verify(optistreamDbHelper, timeout(1000)).removeEvents(lastId); + verify(optistreamDbHelper, timeout(1000)).removeEventsByIds(anyList()); } @Test @@ -173,9 +176,9 @@ public void realtimeEventShouldBeDispatchedImmediately() throws Exception { OptistreamEvent regularEvent = getRegularEvent(true, "some_name"); String regularEventJson = new Gson().toJson(regularEvent); OptistreamHandler optistreamHandler = new OptistreamHandler(httpClient, lifecycleObserver, optistreamDbHelper - , optitrackConfigs); - OptistreamDbHelper.EventsBulk eventBulk = new OptistreamDbHelper.EventsBulk("1", - Collections.singletonList(regularEventJson)); + , optitrackConfigs, null); + OptistreamPersistanceAdapter.EventsBulk eventBulk = new OptistreamPersistanceAdapter.EventsBulk( + Collections.singletonList(new OptistreamPersistanceAdapter.QueuedEvent(1L, regularEventJson))); when(optistreamDbHelper.getFirstEvents(anyInt())).thenReturn(eventBulk); optistreamHandler.reportEvents(Collections.singletonList(regularEvent)); @@ -194,9 +197,9 @@ public void nonRealtimeEventShouldntBeDispatchedImmediately() throws Exception { OptistreamEvent regularEvent = getRegularEvent(false, "some_name"); String regularEventJson = new Gson().toJson(regularEvent); OptistreamHandler optistreamHandler = new OptistreamHandler(httpClient, lifecycleObserver, optistreamDbHelper - , optitrackConfigs); - OptistreamDbHelper.EventsBulk eventBulk = new OptistreamDbHelper.EventsBulk("1", - Collections.singletonList(regularEventJson)); + , optitrackConfigs, null); + OptistreamPersistanceAdapter.EventsBulk eventBulk = new OptistreamPersistanceAdapter.EventsBulk( + Collections.singletonList(new OptistreamPersistanceAdapter.QueuedEvent(1L, regularEventJson))); when(optistreamDbHelper.getFirstEvents(anyInt())).thenReturn(eventBulk); optistreamHandler.reportEvents(Collections.singletonList(regularEvent)); @@ -267,24 +270,26 @@ public boolean insertEvent(String eventJson) { @Override public void removeEvents(String lastId) { - // lastId is an index in this case - for (int i = 0; i < Integer.valueOf(lastId); i++) { - optistreamEventsEntries.remove(0); + for (int i = 0; i < Integer.parseInt(lastId); i++) { + if (!optistreamEventsEntries.isEmpty()) { + optistreamEventsEntries.remove(0); + } } } + @Override + public void removeEventsByIds(List rowIds) { + optistreamEventsEntries.removeIf(entry -> rowIds.contains((long) entry.id)); + } + @Override public OptistreamPersistanceAdapter.EventsBulk getFirstEvents(int numberOfEvents) { - List optistreamEvents = new ArrayList<>(); - String lastId = ""; - for (int i = 0; i < numberOfEvents; i++) { - if (i < optistreamEventsEntries.size()) { - optistreamEvents.add(optistreamEventsEntries.get(i) - .getOptistreamEventData()); - lastId = String.valueOf(optistreamEventsEntries.get(i).id); - } + List queued = new ArrayList<>(); + for (int i = 0; i < numberOfEvents && i < optistreamEventsEntries.size(); i++) { + OptistreamEventEntry entry = optistreamEventsEntries.get(i); + queued.add(new OptistreamPersistanceAdapter.QueuedEvent(entry.id, entry.getOptistreamEventData())); } - return new OptistreamPersistanceAdapter.EventsBulk(lastId, optistreamEvents); + return new OptistreamPersistanceAdapter.EventsBulk(queued); } } diff --git a/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/RealtimeTest.java b/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/RealtimeTest.java index 84f93260..0b650296 100644 --- a/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/RealtimeTest.java +++ b/OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/RealtimeTest.java @@ -5,6 +5,7 @@ import com.google.gson.Gson; import com.optimove.android.main.common.UserInfo; +import com.optimove.android.main.tools.networking.HttpClient.HttpStatusException; import com.optimove.android.main.events.core_events.SetEmailEvent; import com.optimove.android.main.events.core_events.SetUserIdEvent; import com.optimove.android.main.sdk_configs.configs.RealtimeConfigs; @@ -31,9 +32,11 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.after; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -78,10 +81,11 @@ public void setUp() { when(builder.errorListener(any())).thenReturn(builder); when(builder.destination(any(), any())).thenReturn(builder); when(builder.successListener(any())).thenReturn(builder); + when(builder.userJwt(any())).thenReturn(builder); when(userInfo.getUserId()).thenReturn(userId); when(userInfo.getEmail()).thenReturn(userEmail); - realtimeManager = new RealtimeManager(httpClient, realtimeConfigs, context); + realtimeManager = new RealtimeManager(httpClient, realtimeConfigs, context, null); } @@ -261,6 +265,59 @@ private OptistreamEvent getSetEmailEvent() { .withMetadata(mock(OptistreamEvent.Metadata.class)) .build(); } + // --- Auth tests --- + + @Test + public void jwtIsAttachedForUserIdentifiedEventWhenAuthConfigured() { + AuthManager authManager = new AuthManager((uid, cb) -> cb.onComplete("the-jwt", null)); + RealtimeManager authRealtimeManager = new RealtimeManager(httpClient, realtimeConfigs, context, authManager); + + authRealtimeManager.reportEvents(Collections.singletonList(getRegularEvent())); + + verify(builder, timeout(1000)).userJwt("the-jwt"); + } + + @Test + public void dispatchFailsWhenTokenProviderReturnsError() { + AuthManager failingAuth = new AuthManager((uid, cb) -> + cb.onComplete(null, new RuntimeException("provider error"))); + RealtimeManager authRealtimeManager = new RealtimeManager(httpClient, realtimeConfigs, context, failingAuth); + OptistreamEvent event = getSetUserIdEvent(); + + authRealtimeManager.reportEvents(Collections.singletonList(event)); + + String expectedJson = new Gson().toJson(event); + InOrder inOrder = inOrder(editor); + inOrder.verify(editor, timeout(500)).putString(FAILED_SET_USER_EVENT_KEY, expectedJson); + inOrder.verify(editor, timeout(500)).apply(); + verify(httpClient, never()).postJson(anyString(), anyString()); + } + + @Test + public void unauthorizedWithoutAuthDiscardsBatchWithoutStoringEvent() { + applyHttpErrorInvocation(new HttpStatusException(401, "Unauthorized")); + OptistreamEvent event = getSetUserIdEvent(); + + realtimeManager.reportEvents(Collections.singletonList(event)); + + verify(editor, after(500).never()).putString(anyString(), anyString()); + } + + @Test + public void unauthorizedWithAuthConfiguredStoresEventInPrefs() { + applyHttpErrorInvocation(new HttpStatusException(401, "Unauthorized")); + AuthManager authManager = new AuthManager((uid, cb) -> cb.onComplete("the-jwt", null)); + RealtimeManager authRealtimeManager = new RealtimeManager(httpClient, realtimeConfigs, context, authManager); + OptistreamEvent event = getSetUserIdEvent(); + + authRealtimeManager.reportEvents(Collections.singletonList(event)); + + String expectedJson = new Gson().toJson(event); + InOrder inOrder = inOrder(editor); + inOrder.verify(editor, timeout(500)).putString(FAILED_SET_USER_EVENT_KEY, expectedJson); + inOrder.verify(editor, timeout(500)).apply(); + } + private void applyHttpSuccessInvocation() { doAnswer(invocation -> { new Thread(() -> { @@ -273,4 +330,14 @@ private void applyHttpSuccessInvocation() { }).when(builder) .successListener(any()); } + + private void applyHttpErrorInvocation(Exception e) { + doAnswer(invocation -> { + HttpClient.ErrorListener errorListener = + (HttpClient.ErrorListener) invocation.getArguments()[0]; + errorListener.sendError(e); + return builder; + }).when(builder) + .errorListener(any()); + } }