From 33558ba0864c9be04a85a708650a42f606c22eb4 Mon Sep 17 00:00:00 2001 From: Grace Date: Fri, 1 May 2026 10:08:22 +0100 Subject: [PATCH 1/7] Squash Commits --- CHANGELOG.md | 4 + OptimoveSDK/gradle.properties | 8 +- .../com/optimove/android/AuthJwtResolver.java | 64 +++++++ .../com/optimove/android/AuthManager.java | 32 ++++ .../optimove/android/AuthTokenException.java | 28 +++ .../optimove/android/AuthTokenProvider.java | 15 ++ .../java/com/optimove/android/Optimove.java | 15 ++ .../optimove/android/OptimoveAuthHeaders.java | 11 ++ .../com/optimove/android/OptimoveConfig.java | 29 +++ .../EmbeddedMessageEventRequest.java | 5 + .../OptimoveEmbeddedMessaging.java | 49 +++-- .../main/common/EventHandlerFactory.java | 27 ++- .../main/tools/networking/HttpClient.java | 111 ++++++++---- .../optimobile/AnalyticsUploadHelper.java | 150 +++++++++++----- .../optimobile/InAppMessagePresenter.java | 14 +- .../optimobile/InAppRequestService.java | 16 +- .../android/optimobile/Optimobile.java | 8 + .../optimobile/OptimobileHttpClient.java | 36 ++-- .../OverlayMessagingRequestService.java | 17 +- .../optistream/OptistreamDbHelper.java | 49 +++-- .../android/optistream/OptistreamHandler.java | 170 ++++++++++++++---- .../OptistreamPersistanceAdapter.java | 60 +++++-- .../OptimovePreferenceCenter.java | 26 ++- .../android/realtime/RealtimeManager.java | 134 ++++++++++++-- .../optimove/android/AuthJwtResolverTest.java | 33 ++++ .../com/optimove/android/AuthManagerTest.java | 55 ++++++ .../com/optimove/android/OptitrackTests.java | 51 +++--- .../com/optimove/android/RealtimeTest.java | 69 ++++++- 28 files changed, 1069 insertions(+), 217 deletions(-) create mode 100644 OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthJwtResolver.java create mode 100644 OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthManager.java create mode 100644 OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthTokenException.java create mode 100644 OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/AuthTokenProvider.java create mode 100644 OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveAuthHeaders.java create mode 100644 OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/AuthJwtResolverTest.java create mode 100644 OptimoveSDK/optimove-sdk/src/test/java/com/optimove/android/AuthManagerTest.java 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..261d59ab 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,14 @@ public static OptimoveConfig getConfig() { return currentConfig; } + /** + * Returns the shared {@link AuthManager} instance, or {@code null} if federated auth is not configured. + */ + @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..e251aaba --- /dev/null +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveAuthHeaders.java @@ -0,0 +1,11 @@ +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"; + + 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..56043a3a 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,17 @@ 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); + 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 +130,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 +150,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 +178,17 @@ 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); + 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 +196,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 +219,83 @@ 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); + 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..0248ff6a 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,105 @@ 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); + groups.computeIfAbsent(key, k -> new ArrayList<>()).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 ""; + } + String u = event.optString("userId", ""); + return u == null ? "" : u.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 +195,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 +222,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..ebc14e89 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 @@ -40,7 +40,6 @@ class InAppMessagePresenter implements AppStateWatcher.AppStateChangedListener { private InAppMessageView view; private boolean interceptionInProgress = false; - private int lastShownByInterceptorId = -1; InAppMessagePresenter(Context context, @NonNull OptimoveConfig.InAppDisplayMode defaultDisplayMode) { this.context = context.getApplicationContext(); @@ -128,7 +127,6 @@ synchronized void presentMessages(List itemsToPresent, List { interceptionInProgress = false; - lastShownByInterceptorId = message.getInAppId(); if (view != null) { view.showMessage(message); } else { 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..3cbfb723 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,15 @@ 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); + 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..e8f8908a 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; @@ -50,10 +57,19 @@ public OptistreamHandler(@NonNull HttpClient httpClient, @NonNull LifecycleObserver lifecycleObserver, @NonNull OptistreamPersistanceAdapter optistreamPersistanceAdapter, @NonNull OptitrackConfigs optitrackConfigs) { + this(httpClient, lifecycleObserver, optistreamPersistanceAdapter, optitrackConfigs, null); + } + + public OptistreamHandler(@NonNull HttpClient httpClient, + @NonNull LifecycleObserver lifecycleObserver, + @NonNull OptistreamPersistanceAdapter optistreamPersistanceAdapter, + @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 +88,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 +106,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 +133,120 @@ 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()); + + Runnable postOnExecutor = () -> postGroupJson(group, groups, index, null); + + if (authManager != null && !customerKey.isEmpty()) { + 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"); + dispatchRequestWaitsForResponse = false; + scheduleTheNextDispatch(); + return; + } + postGroupJson(group, groups, index, token); + })); + } else { + postOnExecutor.run(); + } + } + + 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 +255,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..86ceb497 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,20 +78,113 @@ 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)) - .destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE) - .send(); + 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); + 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(); + }); + } else { + 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) { @@ -97,7 +199,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..5e52347d 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 @@ -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); optistreamHandler.reportEvents(Collections.singletonList(getRegularEvent(true, "some_name"))); - verify(optistreamDbHelper, timeout(1000)).removeEvents(lastId); + verify(optistreamDbHelper, timeout(1000)).removeEventsByIds(anyList()); } @Test @@ -174,8 +177,8 @@ public void realtimeEventShouldBeDispatchedImmediately() throws Exception { String regularEventJson = new Gson().toJson(regularEvent); OptistreamHandler optistreamHandler = new OptistreamHandler(httpClient, lifecycleObserver, optistreamDbHelper , optitrackConfigs); - OptistreamDbHelper.EventsBulk eventBulk = new OptistreamDbHelper.EventsBulk("1", - Collections.singletonList(regularEventJson)); + OptistreamPersistanceAdapter.EventsBulk eventBulk = new OptistreamPersistanceAdapter.EventsBulk( + Collections.singletonList(new OptistreamPersistanceAdapter.QueuedEvent(1L, regularEventJson))); when(optistreamDbHelper.getFirstEvents(anyInt())).thenReturn(eventBulk); optistreamHandler.reportEvents(Collections.singletonList(regularEvent)); @@ -195,8 +198,8 @@ public void nonRealtimeEventShouldntBeDispatchedImmediately() throws Exception { String regularEventJson = new Gson().toJson(regularEvent); OptistreamHandler optistreamHandler = new OptistreamHandler(httpClient, lifecycleObserver, optistreamDbHelper , optitrackConfigs); - OptistreamDbHelper.EventsBulk eventBulk = new OptistreamDbHelper.EventsBulk("1", - Collections.singletonList(regularEventJson)); + 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()); + } } From 6575554cfa7117b7d451ad2adc0b284f4ef5a70a Mon Sep 17 00:00:00 2001 From: Grace Date: Fri, 1 May 2026 10:14:42 +0100 Subject: [PATCH 2/7] add headers --- .../java/com/optimove/android/OptimoveAuthHeaders.java | 2 ++ .../android/main/tools/networking/HttpClient.java | 9 ++++++--- .../android/optimobile/OptimobileHttpClient.java | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) 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 index e251aaba..2d55d28f 100644 --- a/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveAuthHeaders.java +++ b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/OptimoveAuthHeaders.java @@ -5,6 +5,8 @@ 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/main/tools/networking/HttpClient.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/main/tools/networking/HttpClient.java index 56043a3a..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 @@ -113,7 +113,8 @@ public void send() { json); Request.Builder rb = new Request.Builder().url(url).post(body) - .addHeader(OptimoveAuthHeaders.AUTH_CAPABLE, OptimoveAuthHeaders.AUTH_CAPABLE_VALUE); + .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); } @@ -179,7 +180,8 @@ public void send() { } Request.Builder rb = new Request.Builder().url(url).get() - .addHeader(OptimoveAuthHeaders.AUTH_CAPABLE, OptimoveAuthHeaders.AUTH_CAPABLE_VALUE); + .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); } @@ -291,7 +293,8 @@ private Request buildRequest(Request.Builder builder, String url, int tenantId, .addHeader("Accept", "application/json") .addHeader("Content-Type", "application/json") .addHeader("X-Tenant-Id", String.valueOf(tenantId)) - .addHeader(OptimoveAuthHeaders.AUTH_CAPABLE, OptimoveAuthHeaders.AUTH_CAPABLE_VALUE); + .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); } 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 3cbfb723..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 @@ -110,7 +110,8 @@ private Request buildRequest(Request.Builder builder, String url, @Nullable Stri .addHeader(Optimobile.KEY_AUTH_HEADER, this.authHeader) .addHeader("Accept", "application/json") .addHeader("Content-Type", "application/json") - .addHeader(OptimoveAuthHeaders.AUTH_CAPABLE, OptimoveAuthHeaders.AUTH_CAPABLE_VALUE); + .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); } From 0fc027aacb715c43042d32a153fa406eb2488f5f Mon Sep 17 00:00:00 2001 From: Grace Date: Tue, 5 May 2026 11:12:29 +0100 Subject: [PATCH 3/7] auth failure no longer aborts the batch & clean up constructor --- .../src/main/java/com/optimove/android/Optimove.java | 3 --- .../optimove/android/optistream/OptistreamHandler.java | 10 +--------- .../com/optimove/android/realtime/RealtimeManager.java | 2 +- 3 files changed, 2 insertions(+), 13 deletions(-) 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 261d59ab..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 @@ -292,9 +292,6 @@ public static OptimoveConfig getConfig() { return currentConfig; } - /** - * Returns the shared {@link AuthManager} instance, or {@code null} if federated auth is not configured. - */ @Nullable public static AuthManager getAuthManager() { return authManager; 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 e8f8908a..973dd68b 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 @@ -53,13 +53,6 @@ public final static class Constants { } - public OptistreamHandler(@NonNull HttpClient httpClient, - @NonNull LifecycleObserver lifecycleObserver, - @NonNull OptistreamPersistanceAdapter optistreamPersistanceAdapter, - @NonNull OptitrackConfigs optitrackConfigs) { - this(httpClient, lifecycleObserver, optistreamPersistanceAdapter, optitrackConfigs, null); - } - public OptistreamHandler(@NonNull HttpClient httpClient, @NonNull LifecycleObserver lifecycleObserver, @NonNull OptistreamPersistanceAdapter optistreamPersistanceAdapter, @@ -178,8 +171,7 @@ private void sendCustomerGroups(List Date: Tue, 5 May 2026 11:18:47 +0100 Subject: [PATCH 4/7] fix broken tests --- .../optimove/android/realtime/RealtimeManager.java | 10 +++++----- .../java/com/optimove/android/OptitrackTests.java | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) 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 b30862a5..bcd89cd4 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 @@ -138,11 +138,11 @@ private void dispatchGroupAtIndex(List> groups, int index) }); } else { 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(); + .userJwt(null) + .successListener(jsonResponse -> dispatchGroupAtIndex(groups, index + 1)) + .errorListener(e -> onRealtimeRequestFailed(e, groups, index, group)) + .destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE) + .send(); } } 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 5e52347d..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 @@ -74,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))); @@ -96,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 @@ -149,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); } @@ -165,7 +165,7 @@ public void eventsShouldBeRemovedWhenDispatchSucceed() { 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)).removeEventsByIds(anyList()); @@ -176,7 +176,7 @@ 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); + , optitrackConfigs, null); OptistreamPersistanceAdapter.EventsBulk eventBulk = new OptistreamPersistanceAdapter.EventsBulk( Collections.singletonList(new OptistreamPersistanceAdapter.QueuedEvent(1L, regularEventJson))); when(optistreamDbHelper.getFirstEvents(anyInt())).thenReturn(eventBulk); @@ -197,7 +197,7 @@ 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); + , optitrackConfigs, null); OptistreamPersistanceAdapter.EventsBulk eventBulk = new OptistreamPersistanceAdapter.EventsBulk( Collections.singletonList(new OptistreamPersistanceAdapter.QueuedEvent(1L, regularEventJson))); when(optistreamDbHelper.getFirstEvents(anyInt())).thenReturn(eventBulk); From 7ceccc4caa33294e56a4a3d0c1619a22534a37fa Mon Sep 17 00:00:00 2001 From: Grace Date: Tue, 5 May 2026 12:32:12 +0100 Subject: [PATCH 5/7] address PR comments --- .../optimobile/AnalyticsUploadHelper.java | 10 +++++-- .../optimobile/InAppMessagePresenter.java | 16 ++++++---- .../android/optistream/OptistreamHandler.java | 29 +++++++++---------- .../android/realtime/RealtimeManager.java | 18 ++++++------ 4 files changed, 41 insertions(+), 32 deletions(-) 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 0248ff6a..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 @@ -75,7 +75,12 @@ private boolean flushBatchToNetwork(Context context, List row Map> groups = new LinkedHashMap<>(); for (AnalyticsEventRow row : rows) { String key = analyticsUserKey(row.event); - groups.computeIfAbsent(key, k -> new ArrayList<>()).add(row); + List group = groups.get(key); + if (group == null) { + group = new ArrayList<>(); + groups.put(key, group); + } + group.add(row); } AuthManager authManager = Optimove.getAuthManager(); @@ -135,8 +140,7 @@ private static String analyticsUserKey(JSONObject event) { if (!event.has("userId") || event.isNull("userId")) { return ""; } - String u = event.optString("userId", ""); - return u == null ? "" : u.trim(); + return event.optString("userId", "").trim(); } catch (Exception e) { return ""; } 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 ebc14e89..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 @@ -40,6 +40,7 @@ class InAppMessagePresenter implements AppStateWatcher.AppStateChangedListener { private InAppMessageView view; private boolean interceptionInProgress = false; + private int lastShownByInterceptorId = -1; InAppMessagePresenter(Context context, @NonNull OptimoveConfig.InAppDisplayMode defaultDisplayMode) { this.context = context.getApplicationContext(); @@ -127,6 +128,7 @@ synchronized void presentMessages(List itemsToPresent, List { interceptionInProgress = false; + lastShownByInterceptorId = message.getInAppId(); if (view != null) { view.showMessage(message); } else { @@ -357,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/optistream/OptistreamHandler.java b/OptimoveSDK/optimove-sdk/src/main/java/com/optimove/android/optistream/OptistreamHandler.java index 973dd68b..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 @@ -163,22 +163,21 @@ private void sendCustomerGroups(List group = groups.get(index); String customerKey = group.isEmpty() ? "" : customerKeyFromJson(group.get(0).getEventJson()); - Runnable postOnExecutor = () -> postGroupJson(group, groups, index, null); - - if (authManager != null && !customerKey.isEmpty()) { - 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); - })); - } else { - postOnExecutor.run(); + 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, 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 bcd89cd4..4abd8162 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 @@ -136,18 +136,18 @@ private void dispatchGroupAtIndex(List> groups, int index) .destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE) .send(); }); - } else { - 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(); - } + } else { + 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 Exception e, @NonNull List> groups, int index, @NonNull List group) { From e5526ad617511d788d45449287d01adfe644a436 Mon Sep 17 00:00:00 2001 From: Grace Date: Thu, 7 May 2026 13:27:19 +0100 Subject: [PATCH 6/7] address comments --- .../main/java/com/optimove/android/realtime/RealtimeManager.java | 1 + 1 file changed, 1 insertion(+) 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 4abd8162..b4d912d2 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 @@ -127,6 +127,7 @@ private void dispatchGroupAtIndex(List> groups, int index) 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)) From 496d0f02280dfc9fcce78a71215cda24882e8f40 Mon Sep 17 00:00:00 2001 From: Grace Date: Thu, 7 May 2026 13:28:16 +0100 Subject: [PATCH 7/7] address comments --- .../android/realtime/RealtimeManager.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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 b4d912d2..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 @@ -137,14 +137,15 @@ private void dispatchGroupAtIndex(List> groups, int index) .destination("%s", RealtimeConstants.REPORT_EVENT_REQUEST_ROUTE) .send(); }); - } else { + 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(); - } + .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(