diff --git a/Documentation/api-reference.md b/Documentation/api-reference.md index 52ba6d26..f809748e 100644 --- a/Documentation/api-reference.md +++ b/Documentation/api-reference.md @@ -16,6 +16,28 @@ Refer to the [Getting Started Guide](getting-started.md) | [resetIdentities](#resetIdentities) | | [setAdvertisingIdentifier](#setAdvertisingIdentifier) | | [updateIdentities](#updateIdentities) | +| [updateOperationalData](#updateoperationaldata) | + +------ + +### updateOperationalData + +Requests that operational data allowed by a matching rule is sent to the Edge Network. It supports JSON-compatible values, including empty strings, arrays, and objects. Values are not validated by Identity and are sent as provided. Only string values at `timezone` and `tokens.pushNotification` are deduplicated and stored; unexpected types at those paths and all other attributes are always forwarded. This API is fire-and-forget; it has no public callback. + +```java +public static void updateOperationalData(Map attributes) +``` + +```java +Identity.updateOperationalData( + Collections.singletonMap("timezone", "Asia/Tokyo")); +``` + +The data is sent to the Core rules engine. Each matching `forward-operational-data` rule returns only its enabled paths. Values a rule allows are sent in a device attributes request event whether collect consent is `yes`, `no`, or `pending`; ordinary Edge events remain governed by consent. When the latest collect consent is explicitly `y`, or the Hub shared state confirms that the Consent extension is not registered, Identity also compares push tokens with Messaging's latest shared-state value and timezone with the timezone set through the profile attributes API. Missing, pending, or malformed consent from a registered Consent extension does not enable these comparisons. Values Edge confirmed are compared at every consent state. Identity queues requests in event order and reads the current configuration, profile timezone and Messaging push token when each request is taken from the queue. It waits for the active request's device attributes response event before sending the next request. A response that isn't a success, including one with no status, is reported as failed; responses for other requests are ignored. Confirmed values are persisted and deduplicated across app restarts; set `edgeidentity.optimizeOperationalProfileDataSync` to `false` to disable all comparisons, but requests still run one at a time. If a request fails, its values are not stored and a queued duplicate can send them again. If no rule matches, or rules are not loaded yet, nothing is sent. + +After Edge reports success, confirmed strings are persisted under the individual `timezone` and `pushNotification` keys in the separate `operationalDataDataStore` collection. A confirmed timezone also replaces the timezone set through the profile attributes API. The complete confirmed state remains published under `profileAttributes` in Identity's standard shared state for the existing Android Messaging consumer. On registration, this shared state is published only when stored attributes exist; explicit updates and identity reset still publish an empty state when appropriate. In addition, Identity dispatches an `Operational data confirmed` event (`messaging` / `responseContent`), with the response event as its parent, containing only the successfully sent `tokens`, matching iOS. Failed, dropped, malformed, and unrelated results do not confirm tokens. + +An empty map is a no-op. Identity does not drop null values during deduplication, although the rules engine and transport determine which values can reach Edge. After rules and deduplication filter the values, every dispatched request includes SDK-owned `app.id` (the Android package name) and `app.platform: "fcm"`; caller-provided `app` values cannot override routing, and app-only requests are not sent. Live Activity deduplication and APNs sandbox routing are iOS-specific; arbitrary arrays allowed by a rule remain supported on Android. ------ diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/Identity.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/Identity.java index 5f3da633..9e2fa699 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/Identity.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/Identity.java @@ -231,6 +231,48 @@ public static void updateIdentities(@NonNull final IdentityMap identityMap) { MobileCore.dispatchEvent(updateIdentitiesEvent); } + /** + * Updates JSON-compatible operational data that should be delivered to the Edge Network + * independently of collect consent. Launch rules decide which values are sent. By default, the timezone and + * push token are sent only when they differ from the last value Edge confirmed. When collect consent is "y", or + * the Consent extension is not registered, they are also not sent when they match the timezone set through the + * profile attributes API or Messaging's push token. All other values are always sent; set + * {@code edgeidentity.optimizeOperationalProfileDataSync} to {@code false} in Configuration to send every update. + * Requests are sent one at a time. Values are not validated by this API and are sent as + * provided. Only string timezone and push-token values are deduplicated. Any {@code app} value is replaced by + * the SDK. + * + * @param attributes map of attribute names to JSON-compatible values + */ + public static void updateOperationalData(@NonNull final Map attributes) { + try { + if (attributes == null || attributes.isEmpty()) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Unable to update operational data, attributes are null or empty." + ); + return; + } + final Event event = new Event.Builder( + IdentityConstants.EventNames.UPDATE_OPERATIONAL_DATA, + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.REQUEST_SOURCE + ) + .setEventData(new HashMap<>(attributes)) + .build(); + MobileCore.dispatchEvent(event); + } catch (Exception exception) { + // The caller's map may be changed on another thread while it is copied; never throw into the app. + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Unable to update operational data, an error occurred: %s", + exception.getLocalizedMessage() + ); + } + } + /** * Removes the identity from the stored client-side {@link IdentityMap}. The Identity extension will stop sending this identifier. * This does not clear the identifier from the User Profile Graph. diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityConstants.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityConstants.java index 34e8e32b..54bebee4 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityConstants.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityConstants.java @@ -37,6 +37,11 @@ static final class EventNames { static final String REQUEST_IDENTITIES = "Edge Identity Request Identities"; static final String RESET_IDENTITIES_COMPLETE = "Edge Identity Reset Identities Complete"; static final String UPDATE_PROFILE_ATTRIBUTES = "Update Profile Attributes"; + static final String UPDATE_OPERATIONAL_DATA = "Update Operational Data"; + static final String OPERATIONAL_DATA_TO_RULES_ENGINE = "Operational data for rules engine"; + static final String EDGE_DEVICE_ATTRIBUTES_REQUEST = "Edge device attributes request"; + static final String OPERATIONAL_DATA_SYNC_RESULT = "Operational Data Sync Result"; + static final String OPERATIONAL_DATA_CONFIRMED = "Operational data confirmed"; private EventNames() {} } @@ -68,6 +73,25 @@ static final class Configuration { private Configuration() {} } + static final class Consent { + + static final String NAME = "com.adobe.edge.consent"; + static final String CONSENTS = "consents"; + static final String COLLECT = "collect"; + static final String VALUE = "val"; + static final String YES = "y"; + + private Consent() {} + } + + static final class Messaging { + + static final String NAME = "com.adobe.messaging"; + static final String PUSH_IDENTIFIER = "pushidentifier"; + + private Messaging() {} + } + static final class IdentityDirect { static final String NAME = "com.adobe.module.identity"; @@ -118,6 +142,7 @@ static final class DataStoreKey { static final String IDENTITY_DIRECT_DATASTORE_NAME = "visitorIDServiceDataStore"; static final String IDENTITY_DIRECT_ECID_KEY = "ADOBEMOBILE_PERSISTED_MID"; static final String PROFILE_ATTRIBUTES_DATASTORE_NAME = "profileAttributesDataStore"; + static final String OPERATIONAL_DATA_DATASTORE_NAME = "operationalDataDataStore"; private DataStoreKey() {} } @@ -138,6 +163,9 @@ private UrlKeys() {} */ static final class ProfileAttributes { + // Key of the profile attributes in the Edge Identity standard (non-XDM) shared state. + static final String SHARED_STATE_KEY = "profileAttributes"; + // Keys for the outgoing generic EDGE request event payload. The event carries the collated // data from every profile-attribute helper under a single "profile.updateAttributes" type. // Per-attribute event-data keys, payload keys, and persistence keys are owned by the @@ -150,5 +178,35 @@ static final class ProfileAttributes { private ProfileAttributes() {} } + static final class OperationalData { + + static final String REQUEST_SOURCE = "com.adobe.eventSource.requestOperationalData"; + static final String RULE_RESPONSE_SOURCE = "com.adobe.eventSource.responseOperationalData"; + static final String EDGE_DEVICE_ATTRIBUTES_TYPE = "com.adobe.eventType.edgeDeviceAttributes"; + static final String OPERATIONAL_DATA_TYPE = "com.adobe.eventType.generic.operationalData"; + static final String OPTIMIZE_OPERATIONAL_PROFILE_DATA_SYNC = "edgeidentity.optimizeOperationalProfileDataSync"; + static final String TIME_ZONE = "timeZone"; + static final String TIMEZONE = "timezone"; + static final String PUSH_NOTIFICATION = "pushNotification"; + static final String TOKENS = "tokens"; + static final String APP = "app"; + static final String APP_ID = "id"; + static final String APP_PLATFORM = "platform"; + static final String PLATFORM_FCM = "fcm"; + static final String STATUS = "status"; + static final String STATUS_SUCCESS = "success"; + static final String STATUS_FAILED = "failed"; + static final String REASON = "reason"; + static final String REQUEST_EVENT_ID = "requestEventId"; + static final String ATTRIBUTES = "attributes"; + static final String UNKNOWN_REASON = "unknown"; + static final String REASON_UNCHANGED = "unchanged"; + static final String REASON_IDENTITY_RESET = "identityReset"; + static final String REASON_MISSING_APP_IDENTIFIER = "missingAppIdentifier"; + static final String REASON_INTERNAL_ERROR = "internalError"; + + private OperationalData() {} + } + private IdentityConstants() {} } diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityExtension.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityExtension.java index e14b7ce5..644e546c 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityExtension.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityExtension.java @@ -46,9 +46,15 @@ public SharedStateResult getSharedState(final String stateOwner, final Event eve public void createXDMSharedState(final Map state, final Event event) { getApi().createXDMSharedState(state, event); } + + @Override + public void publishProfileAttributes(final Event event) { + operationalDataSync.publishProfileAttributes(event); + } }; private final IdentityState state; + private final OperationalDataSync operationalDataSync; /** * Constructor. @@ -64,6 +70,7 @@ protected IdentityExtension(final ExtensionApi extensionApi) { IdentityExtension(final ExtensionApi extensionApi, final IdentityState state) { super(extensionApi); this.state = state; + this.operationalDataSync = new OperationalDataSync(extensionApi, state.getProfileAttributeStore()); } @NonNull @Override @@ -94,6 +101,9 @@ protected String getVersion() { *
  • EventType {@link EventType#EDGE_IDENTITY} and EventSource {@link EventSource#UPDATE_IDENTITY}
  • *
  • EventType {@link EventType#EDGE_IDENTITY} and EventSource {@link EventSource#REMOVE_IDENTITY}
  • *
  • EventType {@link EventType#HUB} and EventSource {@link EventSource#SHARED_STATE}
  • + *
  • EventType {@link EventType#EDGE_IDENTITY} and EventSource {@link IdentityConstants.OperationalData#REQUEST_SOURCE}
  • + *
  • EventType {@link EventType#EDGE_IDENTITY} and EventSource {@link IdentityConstants.OperationalData#RULE_RESPONSE_SOURCE}
  • + *
  • EventType {@link IdentityConstants.OperationalData#EDGE_DEVICE_ATTRIBUTES_TYPE} and EventSource {@link EventSource#RESPONSE_CONTENT}
  • * *

    */ @@ -127,6 +137,27 @@ protected void onRegistered() { // HUB shared state event listener getApi().registerEventListener(EventType.HUB, EventSource.SHARED_STATE, this::handleIdentityDirectECIDUpdate); + + // Operational data sync listeners + getApi() + .registerEventListener( + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.REQUEST_SOURCE, + operationalDataSync::handleOperationalData + ); + getApi() + .registerEventListener( + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.RULE_RESPONSE_SOURCE, + operationalDataSync::handleOperationalDataRuleResponse + ); + getApi() + .registerEventListener( + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, + EventSource.RESPONSE_CONTENT, + operationalDataSync::handleOperationalDataResult + ); + operationalDataSync.publishStoredProfileAttributes(); } @Override @@ -344,9 +375,20 @@ void handleRequestReset(@NonNull final Event event) { // Add pending shared state to avoid race condition between updating and reading identity map final SharedStateResolver resolver = getApi().createPendingXDMSharedState(event); state.resetIdentifiers(); - // Clear synced profile attributes (and update their shared state) so the next update re-syncs - // against the new ECID - state.clearProfileAttributes(sharedStateHandle, event); + // Clear the stored profile attributes and operational data (and update their shared states) so the next update is sent + // again for the new ECID + try { + state.clearProfileAttributes(sharedStateHandle, event); + operationalDataSync.reset(event); + } catch (Exception exception) { + // The pending XDM shared state must always be resolved, or extensions reading it would wait forever. + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Unable to clear profile attributes and operational data during identity reset: %s", + exception.getLocalizedMessage() + ); + } resolver.resolve(state.getIdentityProperties().toXDMData()); // dispatch reset complete event diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityState.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityState.java index 740efc0c..017241d4 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityState.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityState.java @@ -64,6 +64,10 @@ class IdentityState { return identityProperties; } + ProfileAttributeStore getProfileAttributeStore() { + return profileAttributeStore; + } + /** * Completes init for this Identity extension. * If no ECID is loaded from persistence (ideally meaning first launch), attempts to migrate existing ECID @@ -370,7 +374,7 @@ private void dispatchAdIdConsentRequestEvent(final String consentVal) { void updateProfileAttributes(final Event event, final SharedStateCallback callback) { final Map eventData = event.getEventData(); if (MapUtils.isNullOrEmpty(eventData)) { - Log.warning( + Log.debug( IdentityConstants.LOG_TAG, LOG_SOURCE, "Event data is null or empty for '" + event.getName() + "'; skipping update." @@ -390,7 +394,7 @@ void updateProfileAttributes(final Event event, final SharedStateCallback callba } if (mergedAttributes.isEmpty()) { - Log.warning( + Log.debug( IdentityConstants.LOG_TAG, LOG_SOURCE, "No profile attributes collected from event data; skipping update." @@ -398,8 +402,9 @@ void updateProfileAttributes(final Event event, final SharedStateCallback callba return; } - // Persistence just changed: keep the shared state in sync, then dispatch only the changes. + // Persistence just changed: keep the shared states in sync, then dispatch only the changes. publishProfileAttributesSharedState(callback, event); + callback.publishProfileAttributes(event); dispatchProfileAttributesEdgeEvent(mergedAttributes); } diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/OperationalDataDeduplicator.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/OperationalDataDeduplicator.java new file mode 100644 index 00000000..589745a0 --- /dev/null +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/OperationalDataDeduplicator.java @@ -0,0 +1,152 @@ +/* + Copyright 2026 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.edge.identity; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Stores the confirmed timezone and push token strings and filters out those that do not need to be sent again. + * Values at any other path, and a timezone or push token that is not a string, are always sent and never stored. + */ +final class OperationalDataDeduplicator { + + private final ProfileAttributeStore store; + + OperationalDataDeduplicator(final ProfileAttributeStore store) { + this.store = store; + } + + /** The result of {@link #filter}: the values to send to Edge and the owner-held values. */ + static final class FilterResult { + + final Map changed; + final Map ownerMatches; + + FilterResult(final Map changed, final Map ownerMatches) { + this.changed = changed; + this.ownerMatches = ownerMatches; + } + } + + /** + * Removes the timezone and push token strings that match the confirmed value or the owner-held value. The owner + * of the timezone is the profile attributes API, and the owner of the push token is Messaging; the values they + * hold are owner-held. Owner-held values are returned separately and are not stored here; the caller stores them + * as confirmed. + * + * @param attributes the attributes to filter + * @param owners the owner-held values, in the same shape as {@code attributes}; may be null + * @return the values to send and the owner-held values; each is empty when there are none + */ + FilterResult filter(final Map attributes, final Map owners) { + final Map changed = new LinkedHashMap<>(); + final Map ownerMatches = new LinkedHashMap<>(); + if (attributes == null) return new FilterResult(changed, ownerMatches); + changed.putAll(attributes); + filterString( + IdentityConstants.OperationalData.TIMEZONE, + attributes, + changed, + ownerMatches, + store.getString(IdentityConstants.OperationalData.TIMEZONE), + owners != null ? owners.get(IdentityConstants.OperationalData.TIMEZONE) : null + ); + + final Map tokens = asMap(attributes.get(IdentityConstants.OperationalData.TOKENS)); + if (tokens == null) return new FilterResult(changed, ownerMatches); + final Map ownerTokens = owners != null + ? asMap(owners.get(IdentityConstants.OperationalData.TOKENS)) + : null; + final Map changedTokens = new LinkedHashMap<>(tokens); + final Map matchedTokens = new LinkedHashMap<>(); + filterString( + IdentityConstants.OperationalData.PUSH_NOTIFICATION, + tokens, + changedTokens, + matchedTokens, + store.getString(IdentityConstants.OperationalData.PUSH_NOTIFICATION), + ownerTokens != null ? ownerTokens.get(IdentityConstants.OperationalData.PUSH_NOTIFICATION) : null + ); + if (changedTokens.size() != tokens.size()) { + if (changedTokens.isEmpty()) { + changed.remove(IdentityConstants.OperationalData.TOKENS); + } else { + changed.put(IdentityConstants.OperationalData.TOKENS, changedTokens); + } + } + if (!matchedTokens.isEmpty()) ownerMatches.put(IdentityConstants.OperationalData.TOKENS, matchedTokens); + return new FilterResult(changed, ownerMatches); + } + + /** + * Stores the timezone and push token strings in {@code attributes}, replacing the confirmed value of each. + * Other values are ignored. + * + * @param attributes the attributes Edge confirmed, or owner-held values + */ + void commit(final Map attributes) { + if (attributes == null) return; + final Object timezone = attributes.get(IdentityConstants.OperationalData.TIMEZONE); + if (timezone instanceof String) store.setString(IdentityConstants.OperationalData.TIMEZONE, (String) timezone); + final Map tokens = asMap(attributes.get(IdentityConstants.OperationalData.TOKENS)); + final Object pushToken = tokens != null + ? tokens.get(IdentityConstants.OperationalData.PUSH_NOTIFICATION) + : null; + if (pushToken instanceof String) store.setString( + IdentityConstants.OperationalData.PUSH_NOTIFICATION, + (String) pushToken + ); + } + + /** Returns the confirmed strings in the same nested shape as the operational data. */ + Map readSynced() { + final Map synced = new LinkedHashMap<>(); + final String timezone = store.getString(IdentityConstants.OperationalData.TIMEZONE); + if (timezone != null) synced.put(IdentityConstants.OperationalData.TIMEZONE, timezone); + final String pushToken = store.getString(IdentityConstants.OperationalData.PUSH_NOTIFICATION); + if (pushToken != null) { + final Map tokens = new LinkedHashMap<>(); + tokens.put(IdentityConstants.OperationalData.PUSH_NOTIFICATION, pushToken); + synced.put(IdentityConstants.OperationalData.TOKENS, tokens); + } + return synced; + } + + /** Removes all confirmed operational data strings. */ + void clear() { + store.remove(IdentityConstants.OperationalData.TIMEZONE); + store.remove(IdentityConstants.OperationalData.PUSH_NOTIFICATION); + } + + private static Map asMap(final Object value) { + return value instanceof Map ? (Map) value : null; + } + + private static void filterString( + final String key, + final Map source, + final Map changed, + final Map ownerMatches, + final String synced, + final Object owner + ) { + final Object value = source.get(key); + if (!(value instanceof String)) return; + if (value.equals(synced)) { + changed.remove(key); + } else if (value.equals(owner)) { + changed.remove(key); + ownerMatches.put(key, value); + } + } +} diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/OperationalDataSync.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/OperationalDataSync.java new file mode 100644 index 00000000..f844e17c --- /dev/null +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/OperationalDataSync.java @@ -0,0 +1,496 @@ +/* + Copyright 2026 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.edge.identity; + +import android.app.Application; +import com.adobe.marketing.mobile.Event; +import com.adobe.marketing.mobile.EventSource; +import com.adobe.marketing.mobile.EventType; +import com.adobe.marketing.mobile.ExtensionApi; +import com.adobe.marketing.mobile.MobileCore; +import com.adobe.marketing.mobile.SharedStateResolution; +import com.adobe.marketing.mobile.SharedStateResult; +import com.adobe.marketing.mobile.SharedStateStatus; +import com.adobe.marketing.mobile.services.Log; +import com.adobe.marketing.mobile.services.ServiceProvider; +import com.adobe.marketing.mobile.util.DataReader; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Sends operational data to Edge after the rules engine has filtered it. + * + *
      + *
    1. {@link #handleOperationalData(Event)} sends the public API data to the rules engine.
    2. + *
    3. Each matching rule returns the attributes it allows in a rule response event, handled by + * {@link #handleOperationalDataRuleResponse(Event)}.
    4. + *
    5. Rule responses are queued, and one device attributes request event is sent to Edge at a time. Unchanged + * timezone and push token strings are left out; all other values are sent.
    6. + *
    7. When the device attributes response event for the active request arrives + * ({@link #handleOperationalDataResult(Event)}), the next request can be sent. On success the timezone and + * push token that were sent are stored as confirmed and published in {@code profileAttributes}. A + * {@code Operational Data Sync Result} event reports the outcome.
    8. + *
    + */ +final class OperationalDataSync { + + private static final String LOG_SOURCE = "OperationalDataSync"; + + private final ExtensionApi api; + private final ProfileAttributeStore profileAttributeStore; + private final OperationalDataDeduplicator deduplicator; + private final Deque pendingRequests = new ArrayDeque<>(); + private Event activeRequest; + + OperationalDataSync(final ExtensionApi api, final ProfileAttributeStore profileAttributeStore) { + this( + api, + profileAttributeStore, + new ProfileAttributeStore( + ServiceProvider + .getInstance() + .getDataStoreService() + .getNamedCollection(IdentityConstants.DataStoreKey.OPERATIONAL_DATA_DATASTORE_NAME) + ) + ); + } + + OperationalDataSync( + final ExtensionApi api, + final ProfileAttributeStore profileAttributeStore, + final ProfileAttributeStore operationalDataStore + ) { + this.api = api; + this.profileAttributeStore = + profileAttributeStore != null ? profileAttributeStore : new ProfileAttributeStore(null); + this.deduplicator = new OperationalDataDeduplicator(operationalDataStore); + } + + /** Forwards public API data to the rules engine, which decides which attributes may be sent. */ + void handleOperationalData(final Event event) { + final Map data = event.getEventData(); + if (data == null || data.isEmpty()) { + Log.warning(IdentityConstants.LOG_TAG, LOG_SOURCE, "Ignoring empty operational data request."); + return; + } + Log.debug( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Forwarding operational data to the rules engine. Nothing is sent to Edge unless a forward operational data rule matches." + ); + api.dispatch( + new Event.Builder( + IdentityConstants.EventNames.OPERATIONAL_DATA_TO_RULES_ENGINE, + IdentityConstants.OperationalData.OPERATIONAL_DATA_TYPE, + EventSource.REQUEST_CONTENT + ) + .setEventData(data) + .chainToParentEvent(event) + .build() + ); + } + + /** Queues a rule response until the active device attributes request has completed. */ + void handleOperationalDataRuleResponse(final Event event) { + if (event.getEventData() == null || event.getEventData().isEmpty()) { + Log.debug(IdentityConstants.LOG_TAG, LOG_SOURCE, "Rule response contains no operational data."); + return; + } + pendingRequests.addLast(event); + processQueue(); + } + + /** Sends queued requests until one is waiting for its response event or the queue is empty. */ + private void processQueue() { + while (activeRequest == null && !pendingRequests.isEmpty()) { + final Event ruleResponse = pendingRequests.removeFirst(); + try { + final Event request = buildEdgeRequest(ruleResponse); + if (request == null) { + continue; + } + activeRequest = request; + Log.debug( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Sending operational data to Edge (request '%s').", + request.getUniqueIdentifier() + ); + api.dispatch(request); + } catch (Exception exception) { + // Never leave the queue blocked on a request that was not sent. + activeRequest = null; + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Unable to send operational data for rule event '%s': %s", + ruleResponse.getUniqueIdentifier(), + exception.getLocalizedMessage() + ); + try { + final Map attributes = ruleResponse.getEventData() != null + ? new HashMap<>(ruleResponse.getEventData()) + : new HashMap<>(); + attributes.remove(IdentityConstants.OperationalData.APP); + dispatchSyncResult( + IdentityConstants.OperationalData.STATUS_FAILED, + IdentityConstants.OperationalData.REASON_INTERNAL_ERROR, + attributes, + ruleResponse + ); + } catch (Exception resultException) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Unable to report the operational data sync result for rule event '%s': %s", + ruleResponse.getUniqueIdentifier(), + resultException.getLocalizedMessage() + ); + } + } + } + } + + /** + * Returns the device attributes request event for the changed values in {@code ruleResponse}, or null if there is + * nothing to send. + */ + private Event buildEdgeRequest(final Event ruleResponse) { + final Map attributes = new HashMap<>(ruleResponse.getEventData()); + attributes.remove(IdentityConstants.OperationalData.APP); + final boolean dedup = DataReader.optBoolean( + configuration(), + IdentityConstants.OperationalData.OPTIMIZE_OPERATIONAL_PROFILE_DATA_SYNC, + true + ); + final OperationalDataDeduplicator.FilterResult result = dedup + ? deduplicator.filter(attributes, isCollectConsentYes() ? ownerValues() : null) + : new OperationalDataDeduplicator.FilterResult(attributes, Collections.emptyMap()); + if (!result.ownerMatches.isEmpty()) { + deduplicator.commit(result.ownerMatches); + Log.debug(IdentityConstants.LOG_TAG, LOG_SOURCE, "Marked operational data held by its owner as synced."); + publishProfileAttributes(ruleResponse); + } + final Map changed = result.changed; + if (changed.isEmpty()) { + Log.debug( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Operational data unchanged, not sending to Edge (rule event '%s').", + ruleResponse.getUniqueIdentifier() + ); + dispatchSyncResult( + IdentityConstants.OperationalData.STATUS_SUCCESS, + IdentityConstants.OperationalData.REASON_UNCHANGED, + attributes, + ruleResponse + ); + return null; + } + + final Application application = MobileCore.getApplication(); + final String appId = application != null ? application.getPackageName() : null; + if (appId == null || appId.isEmpty()) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Cannot send operational data without an app identifier. Dropping rule event '%s'.", + ruleResponse.getUniqueIdentifier() + ); + dispatchSyncResult( + IdentityConstants.OperationalData.STATUS_FAILED, + IdentityConstants.OperationalData.REASON_MISSING_APP_IDENTIFIER, + changed, + ruleResponse + ); + return null; + } + final Map app = new HashMap<>(); + app.put(IdentityConstants.OperationalData.APP_ID, appId); + app.put(IdentityConstants.OperationalData.APP_PLATFORM, IdentityConstants.OperationalData.PLATFORM_FCM); + changed.put(IdentityConstants.OperationalData.APP, app); + return new Event.Builder( + IdentityConstants.EventNames.EDGE_DEVICE_ATTRIBUTES_REQUEST, + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, + EventSource.REQUEST_CONTENT + ) + .setEventData(changed) + .chainToParentEvent(ruleResponse) + .build(); + } + + /** Handles the device attributes response event for the active request, then sends the next queued request. */ + void handleOperationalDataResult(final Event event) { + if (!isResponseToActiveRequest(event)) { + Log.debug( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Ignoring device attribute response '%s', it does not match the active request.", + event.getUniqueIdentifier() + ); + return; + } + final Event request = activeRequest; + activeRequest = null; + // Always move on to the next queued request, even if handling this response fails. + try { + final String requestId = request.getUniqueIdentifier(); + final Map attributes = new HashMap<>(request.getEventData()); + attributes.remove(IdentityConstants.OperationalData.APP); + + final Map responseData = event.getEventData(); + final String status = DataReader.optString(responseData, IdentityConstants.OperationalData.STATUS, null); + if (IdentityConstants.OperationalData.STATUS_SUCCESS.equals(status)) { + deduplicator.commit(attributes); + final String timezone = DataReader.optString( + attributes, + IdentityConstants.OperationalData.TIMEZONE, + null + ); + if (timezone != null) { + profileAttributeStore.setString(IdentityConstants.OperationalData.TIME_ZONE, timezone); + } + publishProfileAttributes(event); + Log.debug(IdentityConstants.LOG_TAG, LOG_SOURCE, "Operational data synced (request '%s').", requestId); + dispatchTokensConfirmed(attributes, event); + dispatchSyncResult(IdentityConstants.OperationalData.STATUS_SUCCESS, "", attributes, event); + } else { + final String reason = DataReader.optString( + responseData, + IdentityConstants.OperationalData.REASON, + IdentityConstants.OperationalData.UNKNOWN_REASON + ); + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Operational data sync failed (request '%s') with status '%s' and reason '%s'. Values remain unsynced.", + requestId, + status != null ? status : "missing", + reason + ); + dispatchSyncResult(IdentityConstants.OperationalData.STATUS_FAILED, reason, attributes, event); + } + } finally { + processQueue(); + } + } + + /** Tells Messaging which tokens Edge confirmed, so it does not need to send them again. */ + private void dispatchTokensConfirmed(final Map attributes, final Event parent) { + final Map tokens = DataReader.optTypedMap( + Object.class, + attributes, + IdentityConstants.OperationalData.TOKENS, + null + ); + if (tokens == null || tokens.isEmpty()) return; + api.dispatch( + new Event.Builder( + IdentityConstants.EventNames.OPERATIONAL_DATA_CONFIRMED, + EventType.MESSAGING, + EventSource.RESPONSE_CONTENT + ) + .setEventData(Collections.singletonMap(IdentityConstants.OperationalData.TOKENS, tokens)) + .chainToParentEvent(parent) + .build() + ); + } + + /** Dispatches the Operational Data Sync Result event, with {@code parent} as its parent event. */ + private void dispatchSyncResult( + final String status, + final String reason, + final Map attributes, + final Event parent + ) { + final Map result = new HashMap<>(); + result.put(IdentityConstants.OperationalData.STATUS, status); + result.put(IdentityConstants.OperationalData.REASON, reason); + result.put(IdentityConstants.OperationalData.ATTRIBUTES, attributes); + api.dispatch( + new Event.Builder( + IdentityConstants.EventNames.OPERATIONAL_DATA_SYNC_RESULT, + EventType.EDGE_IDENTITY, + EventSource.RESPONSE_CONTENT + ) + .setEventData(result) + .chainToParentEvent(parent) + .build() + ); + } + + /** A response matches when its parent event is the active request or its {@code requestEventId} names it. */ + private boolean isResponseToActiveRequest(final Event event) { + if (activeRequest == null) return false; + final String requestId = activeRequest.getUniqueIdentifier(); + return ( + requestId.equals(event.getParentID()) || + requestId.equals( + DataReader.optString(event.getEventData(), IdentityConstants.OperationalData.REQUEST_EVENT_ID, null) + ) + ); + } + + /** + * Returns the owner-held values, in the same shape as the operational data: the timezone set + * through the profile attributes API and the push token in Messaging's shared state. + */ + private Map ownerValues() { + final Map owners = new HashMap<>(); + final String timezone = profileAttributeStore.getString(IdentityConstants.OperationalData.TIME_ZONE); + if (timezone != null) owners.put(IdentityConstants.OperationalData.TIMEZONE, timezone); + + final SharedStateResult messaging = api.getSharedState( + IdentityConstants.SharedState.Messaging.NAME, + null, + false, + SharedStateResolution.LAST_SET + ); + if (messaging != null && messaging.getStatus() == SharedStateStatus.SET) { + final String pushToken = DataReader.optString( + messaging.getValue(), + IdentityConstants.SharedState.Messaging.PUSH_IDENTIFIER, + null + ); + if (pushToken != null) { + owners.put( + IdentityConstants.OperationalData.TOKENS, + Collections.singletonMap(IdentityConstants.OperationalData.PUSH_NOTIFICATION, pushToken) + ); + } + } + return owners; + } + + /** + * Returns true when collect consent is "y". When the Consent extension is not registered, consent is treated + * as "y", matching the Edge extension. + */ + private boolean isCollectConsentYes() { + final SharedStateResult hub = api.getSharedState( + IdentityConstants.SharedState.Hub.NAME, + null, + false, + SharedStateResolution.LAST_SET + ); + if (hub == null || hub.getStatus() != SharedStateStatus.SET) return false; + final Map extensions = DataReader.optTypedMap( + Object.class, + hub.getValue(), + IdentityConstants.SharedState.Hub.EXTENSIONS, + null + ); + final Map consentExtension = DataReader.optTypedMap( + Object.class, + extensions, + IdentityConstants.SharedState.Consent.NAME, + null + ); + if (consentExtension == null || consentExtension.isEmpty()) return true; + + final SharedStateResult consentResult = api.getXDMSharedState( + IdentityConstants.SharedState.Consent.NAME, + null, + false, + SharedStateResolution.ANY + ); + if (consentResult == null || consentResult.getStatus() != SharedStateStatus.SET) return false; + final Map consents = DataReader.optTypedMap( + Object.class, + consentResult.getValue(), + IdentityConstants.SharedState.Consent.CONSENTS, + null + ); + final Map collect = DataReader.optTypedMap( + Object.class, + consents, + IdentityConstants.SharedState.Consent.COLLECT, + null + ); + final String value = DataReader.optString(collect, IdentityConstants.SharedState.Consent.VALUE, null); + return IdentityConstants.SharedState.Consent.YES.equals(value); + } + + /** + * Reports the active and queued requests as failed, clears them and all confirmed values, then republishes the + * profile attributes. The profile attribute store itself is cleared by + * {@link IdentityState#clearProfileAttributes}. + */ + void reset(final Event event) { + final List dropped = new ArrayList<>(); + if (activeRequest != null) dropped.add(activeRequest); + dropped.addAll(pendingRequests); + for (final Event request : dropped) { + final Map attributes = new HashMap<>(request.getEventData()); + attributes.remove(IdentityConstants.OperationalData.APP); + dispatchSyncResult( + IdentityConstants.OperationalData.STATUS_FAILED, + IdentityConstants.OperationalData.REASON_IDENTITY_RESET, + attributes, + request + ); + } + pendingRequests.clear(); + activeRequest = null; + deduplicator.clear(); + publishProfileAttributes(event); + Log.debug( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Cleared operational data requests and synced values after identity reset." + ); + } + + /** + * Publishes the confirmed operational data, plus the timezone from the profile attribute store, as + * {@code profileAttributes} in the standard shared state. + */ + void publishProfileAttributes(final Event event) { + api.createSharedState( + Collections.singletonMap(IdentityConstants.ProfileAttributes.SHARED_STATE_KEY, profileAttributes()), + event + ); + } + + void publishStoredProfileAttributes() { + final Map attributes = profileAttributes(); + if (!attributes.isEmpty()) { + api.createSharedState( + Collections.singletonMap(IdentityConstants.ProfileAttributes.SHARED_STATE_KEY, attributes), + null + ); + } + } + + private Map profileAttributes() { + final Map attributes = new LinkedHashMap<>(deduplicator.readSynced()); + final String timezone = profileAttributeStore.getString(IdentityConstants.OperationalData.TIME_ZONE); + if (timezone != null) attributes.put(IdentityConstants.OperationalData.TIMEZONE, timezone); + return attributes; + } + + private Map configuration() { + final SharedStateResult result = api.getSharedState( + IdentityConstants.SharedState.Configuration.NAME, + null, + false, + SharedStateResolution.LAST_SET + ); + return result != null && result.getValue() != null ? result.getValue() : Collections.emptyMap(); + } +} diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeStore.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeStore.java index bc668ba7..f03ffdcf 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeStore.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeStore.java @@ -15,9 +15,8 @@ import com.adobe.marketing.mobile.services.NamedCollection; /** - * Thin, null-safe wrapper around the profile-attributes {@link NamedCollection}. Each - * {@link ProfileAttributeHandler} owns its own persistence key and reads/writes through this - * store, keeping per-attribute persistence concerns out of {@link IdentityStorageManager}. + * Thin, null-safe wrapper around an attribute {@link NamedCollection}. Profile handlers and + * device-attribute synchronization use separate collections with the same persistence helpers. * *

    All operations no-op (and log a warning) when the underlying collection is unavailable. */ @@ -43,7 +42,7 @@ String getString(final String key) { Log.warning( IdentityConstants.LOG_TAG, LOG_SOURCE, - "Profile attributes named collection is null. Unable to read key '" + key + "'." + "Attribute named collection is null. Unable to read key '" + key + "'." ); return null; } @@ -61,7 +60,7 @@ void setString(final String key, final String value) { Log.warning( IdentityConstants.LOG_TAG, LOG_SOURCE, - "Profile attributes named collection is null. Unable to write key '" + key + "'." + "Attribute named collection is null. Unable to write key '" + key + "'." ); return; } @@ -82,7 +81,7 @@ void remove(final String key) { Log.warning( IdentityConstants.LOG_TAG, LOG_SOURCE, - "Profile attributes named collection is null. Unable to remove key '" + key + "'." + "Attribute named collection is null. Unable to remove key '" + key + "'." ); return; } @@ -90,7 +89,7 @@ void remove(final String key) { } /** - * Clears every key in the profile-attributes collection. Invoked on reset to force a fresh + * Clears every key in the attribute collection. Invoked on reset to force a fresh * sync against the new ECID. */ void clearAll() { @@ -98,7 +97,7 @@ void clearAll() { Log.warning( IdentityConstants.LOG_TAG, LOG_SOURCE, - "Profile attributes named collection is null. Unable to clear profile attributes." + "Attribute named collection is null. Unable to clear stored attributes." ); return; } diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/SharedStateCallback.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/SharedStateCallback.java index 8580e407..a3b3c56a 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/SharedStateCallback.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/SharedStateCallback.java @@ -35,4 +35,11 @@ interface SharedStateCallback { * @param event current event for which to set the shared state; if null is passed, the next shared state version will be set */ void createXDMSharedState(final Map state, final Event event); + + /** + * Publishes the {@code profileAttributes} standard shared state. + * + * @param event current event for which to set the shared state; if null is passed, the next shared state version will be set + */ + void publishProfileAttributes(final Event event); } diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityExtensionTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityExtensionTests.java index 2210ed0b..d7fb0ed5 100644 --- a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityExtensionTests.java +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityExtensionTests.java @@ -22,6 +22,7 @@ import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.never; @@ -29,21 +30,29 @@ import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; +import android.app.Application; import com.adobe.marketing.mobile.Event; import com.adobe.marketing.mobile.EventSource; import com.adobe.marketing.mobile.EventType; import com.adobe.marketing.mobile.ExtensionApi; +import com.adobe.marketing.mobile.ExtensionEventListener; +import com.adobe.marketing.mobile.MobileCore; import com.adobe.marketing.mobile.SharedStateResolution; import com.adobe.marketing.mobile.SharedStateResolver; import com.adobe.marketing.mobile.SharedStateResult; import com.adobe.marketing.mobile.SharedStateStatus; +import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import org.junit.After; import org.junit.Before; import org.junit.Test; import org.mockito.ArgumentCaptor; import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import org.mockito.invocation.InvocationOnMock; import org.mockito.stubbing.Answer; @@ -51,6 +60,7 @@ public class IdentityExtensionTests { private IdentityExtension extension; + private MockedStatic mobileCore; @Mock ExtensionApi mockExtensionApi; @@ -64,6 +74,121 @@ public class IdentityExtensionTests { @Before public void setup() { MockitoAnnotations.openMocks(this); + final Application application = Mockito.mock(Application.class); + when(application.getPackageName()).thenReturn("com.example"); + mobileCore = Mockito.mockStatic(MobileCore.class); + mobileCore.when(MobileCore::getApplication).thenReturn(application); + } + + @After + public void teardown() { + mobileCore.close(); + } + + @Test + public void ruleResponseListener_sendsForwardedOperationalDataToEdge() { + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + final List dispatched = new ArrayList<>(); + doAnswer(call -> { + dispatched.add(call.getArgument(0)); + return null; + }) + .when(mockExtensionApi) + .dispatch(any()); + extension.onRegistered(); + final Event original = new Event.Builder( + IdentityConstants.EventNames.UPDATE_OPERATIONAL_DATA, + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.REQUEST_SOURCE + ) + .setEventData(Map.of("timezone", "Asia/Tokyo")) + .build(); + operationalDataListener().hear(original); + final Event operational = dispatched.get(0); + assertEquals(IdentityConstants.OperationalData.OPERATIONAL_DATA_TYPE, operational.getType()); + assertEquals(original.getUniqueIdentifier(), operational.getParentID()); + + final ArgumentCaptor rules = ArgumentCaptor.forClass(ExtensionEventListener.class); + verify(mockExtensionApi) + .registerEventListener( + eq(EventType.EDGE_IDENTITY), + eq(IdentityConstants.OperationalData.RULE_RESPONSE_SOURCE), + rules.capture() + ); + rules + .getValue() + .hear( + new Event.Builder( + "Forward", + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.RULE_RESPONSE_SOURCE + ) + .setEventData(Map.of("timezone", "Asia/Tokyo")) + .chainToParentEvent(operational) + .build() + ); + final Event edge = dispatched.get(1); + assertEquals(IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, edge.getType()); + assertEquals(EventSource.REQUEST_CONTENT, edge.getSource()); + assertEquals("Asia/Tokyo", edge.getEventData().get("timezone")); + + final ArgumentCaptor edgeResults = ArgumentCaptor.forClass( + ExtensionEventListener.class + ); + verify(mockExtensionApi) + .registerEventListener( + eq(IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE), + eq(EventSource.RESPONSE_CONTENT), + edgeResults.capture() + ); + edgeResults + .getValue() + .hear( + new Event.Builder( + "Device Attributes Response", + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, + EventSource.RESPONSE_CONTENT + ) + .setEventData(Map.of("status", "success")) + .chainToParentEvent(edge) + .build() + ); + verify(mockExtensionApi) + .createSharedState( + anyMap(), + org.mockito.ArgumentMatchers.argThat(stateEvent -> + stateEvent != null && edge.getUniqueIdentifier().equals(stateEvent.getParentID()) + ) + ); + } + + @Test + public void operationalDataAndResetEvents_waitForBootup() { + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + when(mockIdentityState.bootupIfReady(any())).thenReturn(false); + for (final Event event : new Event[] { + new Event.Builder( + "Forward", + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.RULE_RESPONSE_SOURCE + ) + .build(), + new Event.Builder( + "Response", + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, + EventSource.RESPONSE_CONTENT + ) + .build(), + new Event.Builder( + IdentityConstants.EventNames.UPDATE_OPERATIONAL_DATA, + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.REQUEST_SOURCE + ) + .build(), + new Event.Builder("Reset", EventType.GENERIC_IDENTITY, EventSource.REQUEST_RESET).build(), + }) { + assertFalse(extension.readyForEvent(event)); + } } // ======================================================================================== @@ -82,6 +207,12 @@ public void test_onRegistered_registersListeners() { .registerEventListener(eq(EventType.GENERIC_IDENTITY), eq(EventSource.REQUEST_RESET), any()); verify(mockExtensionApi) .registerEventListener(eq(EventType.PROFILE_ATTRIBUTE), eq(EventSource.REQUEST_CONTENT), any()); + verify(mockExtensionApi) + .registerEventListener( + eq(EventType.EDGE_IDENTITY), + eq(IdentityConstants.OperationalData.REQUEST_SOURCE), + any() + ); verify(mockExtensionApi) .registerEventListener(eq(EventType.EDGE_IDENTITY), eq(EventSource.REQUEST_IDENTITY), any()); verify(mockExtensionApi) @@ -89,10 +220,149 @@ public void test_onRegistered_registersListeners() { verify(mockExtensionApi) .registerEventListener(eq(EventType.EDGE_IDENTITY), eq(EventSource.REMOVE_IDENTITY), any()); verify(mockExtensionApi).registerEventListener(eq(EventType.HUB), eq(EventSource.SHARED_STATE), any()); - + verify(mockExtensionApi) + .registerEventListener( + eq(EventType.EDGE_IDENTITY), + eq(IdentityConstants.OperationalData.RULE_RESPONSE_SOURCE), + any() + ); + verify(mockExtensionApi) + .registerEventListener( + eq(IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE), + eq(EventSource.RESPONSE_CONTENT), + any() + ); verifyNoMoreInteractions(mockExtensionApi); } + @Test + public void test_handleOperationalData_sendsDataToRulesEngine() { + final Event event = new Event.Builder( + IdentityConstants.EventNames.UPDATE_OPERATIONAL_DATA, + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.REQUEST_SOURCE + ) + .setEventData(Collections.singletonMap(IdentityConstants.OperationalData.TIME_ZONE, "UTC")) + .build(); + + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + extension.onRegistered(); + operationalDataListener().hear(event); + + final ArgumentCaptor response = ArgumentCaptor.forClass(Event.class); + verify(mockExtensionApi).dispatch(response.capture()); + assertEquals(IdentityConstants.OperationalData.OPERATIONAL_DATA_TYPE, response.getValue().getType()); + assertEquals(event.getUniqueIdentifier(), response.getValue().getParentID()); + assertEquals("UTC", response.getValue().getEventData().get(IdentityConstants.OperationalData.TIME_ZONE)); + } + + @Test + public void test_handleOperationalData_doesNotReadDedupConfigurationBeforeRules() { + final Event event = new Event.Builder( + IdentityConstants.EventNames.UPDATE_OPERATIONAL_DATA, + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.REQUEST_SOURCE + ) + .setEventData(Collections.singletonMap(IdentityConstants.OperationalData.TIME_ZONE, "UTC")) + .build(); + when( + mockExtensionApi.getSharedState( + IdentityConstants.SharedState.Configuration.NAME, + event, + false, + SharedStateResolution.LAST_SET + ) + ) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Collections.singletonMap( + IdentityConstants.OperationalData.OPTIMIZE_OPERATIONAL_PROFILE_DATA_SYNC, + false + ) + ) + ); + + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + extension.onRegistered(); + operationalDataListener().hear(event); + + verify(mockExtensionApi, never()).getSharedState(any(), any(), anyBoolean(), any()); + } + + @Test + public void test_onRegistered_registersOperationalDataListenerOnRequestOperationalDataSource() { + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + extension.onRegistered(); + + verify(mockExtensionApi) + .registerEventListener( + eq(EventType.EDGE_IDENTITY), + eq("com.adobe.eventSource.requestOperationalData"), + any() + ); + } + + @Test + public void test_requestContentEvent_doesNotReachOperationalDataSync() { + final Map listeners = new HashMap<>(); + doAnswer(call -> { + listeners.put(call.getArgument(0) + "|" + call.getArgument(1), call.getArgument(2)); + return null; + }) + .when(mockExtensionApi) + .registerEventListener(any(), any(), any()); + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + extension.onRegistered(); + final Event event = new Event.Builder( + IdentityConstants.EventNames.UPDATE_OPERATIONAL_DATA, + EventType.EDGE_IDENTITY, + EventSource.REQUEST_CONTENT + ) + .setEventData(Collections.singletonMap(IdentityConstants.OperationalData.TIMEZONE, "UTC")) + .build(); + + // Delivers the event the way the event hub does: only to listeners registered for its type and source. + final ExtensionEventListener listener = listeners.get(event.getType() + "|" + event.getSource()); + if (listener != null) { + listener.hear(event); + } + + verify(mockExtensionApi, never()).dispatch(any()); + } + + @Test + public void test_requestOperationalDataEventWithOtherName_isSentToRulesEngine() { + final Event event = new Event.Builder( + "Other Name", + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.REQUEST_SOURCE + ) + .setEventData(Collections.singletonMap(IdentityConstants.OperationalData.TIMEZONE, "UTC")) + .build(); + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + extension.onRegistered(); + + operationalDataListener().hear(event); + + final ArgumentCaptor response = ArgumentCaptor.forClass(Event.class); + verify(mockExtensionApi).dispatch(response.capture()); + assertEquals(IdentityConstants.OperationalData.OPERATIONAL_DATA_TYPE, response.getValue().getType()); + assertEquals(event.getUniqueIdentifier(), response.getValue().getParentID()); + assertEquals("UTC", response.getValue().getEventData().get(IdentityConstants.OperationalData.TIMEZONE)); + } + + private ExtensionEventListener operationalDataListener() { + final ArgumentCaptor listener = ArgumentCaptor.forClass(ExtensionEventListener.class); + verify(mockExtensionApi) + .registerEventListener( + eq(EventType.EDGE_IDENTITY), + eq(IdentityConstants.OperationalData.REQUEST_SOURCE), + listener.capture() + ); + return listener.getValue(); + } + // ======================================================================================== // getName // ======================================================================================== @@ -947,11 +1217,37 @@ public void test_handleRequestReset() { extension.handleRequestReset(resetEvent); verify(mockIdentityState).resetIdentifiers(); - // reset also clears synced profile attributes (and updates their shared state) for the new ECID + // reset also clears the stored profile attributes and operational data (and updates their shared states) for the new ECID verify(mockIdentityState).clearProfileAttributes(any(), eq(resetEvent)); + verify(mockExtensionApi) + .createSharedState( + eq(Collections.singletonMap("profileAttributes", Collections.emptyMap())), + eq(resetEvent) + ); // verify pending state is created and resolved verify(mockExtensionApi).createPendingXDMSharedState(eq(resetEvent)); verify(mockSharedStateResolver).resolve(eq(properties.toXDMData(false))); } + + @Test + public void test_handleRequestReset_whenClearingAttributesThrows_stillResolvesXDMSharedState() { + final IdentityProperties properties = new IdentityProperties(); + when(mockIdentityState.getIdentityProperties()).thenReturn(properties); + when(mockExtensionApi.createPendingXDMSharedState(any())).thenReturn(mockSharedStateResolver); + Mockito + .doThrow(new IllegalStateException("clear failed")) + .when(mockIdentityState) + .clearProfileAttributes(any(), any()); + + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + + Event resetEvent = new Event.Builder("Test event", EventType.EDGE_IDENTITY, EventSource.REQUEST_IDENTITY) + .build(); + + extension.handleRequestReset(resetEvent); + + verify(mockIdentityState).resetIdentifiers(); + verify(mockSharedStateResolver).resolve(eq(properties.toXDMData(false))); + } } diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityStateTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityStateTests.java index 6c4a96b4..c2c0fbf9 100644 --- a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityStateTests.java +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityStateTests.java @@ -137,6 +137,8 @@ public void testBootUpIfReady_waitsForHubSharedState_hubStateStatusIsSet() { Collections.singletonMap(TIMEZONE_KEY, "Asia/Kolkata") ); verify(mockSharedStateCallback).createXDMSharedState(expectedXDMState, null); + // The standard profileAttributes shared state is published at registration when stored values exist, not at bootup. + verify(mockSharedStateCallback, never()).publishProfileAttributes(any()); } @Test @@ -831,6 +833,7 @@ public void testUpdateProfileAttributes_whenEventDataNull_skipsSaveAndDispatch() verify(mockProfileAttributeStore, never()).setString(any(), any()); mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any()), never()); verify(mockSharedStateCallback, never()).createXDMSharedState(any(), any()); + verify(mockSharedStateCallback, never()).publishProfileAttributes(any()); } } @@ -851,6 +854,7 @@ public void testUpdateProfileAttributes_whenEventDataEmpty_skipsSaveAndDispatch( verify(mockProfileAttributeStore, never()).setString(any(), any()); mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any()), never()); verify(mockSharedStateCallback, never()).createXDMSharedState(any(), any()); + verify(mockSharedStateCallback, never()).publishProfileAttributes(any()); } } @@ -877,6 +881,7 @@ public void testUpdateProfileAttributes_whenEventDataHasNoMatchingHandlerKey_ski verify(mockProfileAttributeStore, never()).setString(any(), any()); mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any()), never()); verify(mockSharedStateCallback, never()).createXDMSharedState(any(), any()); + verify(mockSharedStateCallback, never()).publishProfileAttributes(any()); } } @@ -917,6 +922,7 @@ public void testUpdateTimeZone_whenChanged_savesThenDispatchesEdgeEvent() { "{ \"profileAttributes\": { \"timeZone\": \"America/New_York\" } }", sharedStateCaptor.getValue() ); + verify(mockSharedStateCallback).publishProfileAttributes(event); } } @@ -932,6 +938,7 @@ public void testUpdateTimeZone_whenUnchanged_skipsSaveAndDispatch() { verify(mockProfileAttributeStore, never()).setString(eq(TIMEZONE_KEY), any()); mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any()), never()); verify(mockSharedStateCallback, never()).createXDMSharedState(any(), any()); + verify(mockSharedStateCallback, never()).publishProfileAttributes(any()); } } @@ -946,6 +953,7 @@ public void testUpdateTimeZone_whenNoTimeZoneValue_skipsSaveAndDispatch() { verify(mockProfileAttributeStore, never()).setString(eq(TIMEZONE_KEY), any()); mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any()), never()); verify(mockSharedStateCallback, never()).createXDMSharedState(any(), any()); + verify(mockSharedStateCallback, never()).publishProfileAttributes(any()); } } diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityStorageManagerTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityStorageManagerTests.java index bda584fa..7fde4887 100644 --- a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityStorageManagerTests.java +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityStorageManagerTests.java @@ -66,6 +66,12 @@ public void before() throws Exception { .thenReturn(mockProfileAttributesNamedCollection); } + @Test + public void testStorageManager_doesNotCreateOperationalDataTransactionStore() { + new IdentityStorageManager(mockDataStoreService); + verify(mockDataStoreService, never()).getNamedCollection("operationalDataDataStore"); + } + @Test public void testLoadPropertiesFromPersistence_edgeIdentityDataStoreIsNull() { // setup diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityTests.java index 8c5a3bbf..442590f0 100644 --- a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityTests.java +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/IdentityTests.java @@ -31,9 +31,11 @@ import com.adobe.marketing.mobile.MobileCore; import com.adobe.marketing.mobile.util.JSONUtils; import java.util.ArrayList; +import java.util.ConcurrentModificationException; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import org.json.JSONObject; import org.junit.Before; import org.junit.Test; @@ -630,6 +632,78 @@ public void testUpdateIdentities_EmptyMap() { } } + @Test + public void testUpdateOperationalData_mapIsDispatchedUnchanged() { + final Map attributes = new HashMap<>(); + attributes.put("timezone", "America/Los_Angeles"); + attributes.put( + "tokens", + Map.of( + "pushNotification", + List.of("push-token"), + "liveActivityStart", + List.of(Map.of("attributeType", "GameScore", "value", "start-token")) + ) + ); + final ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class); + + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateOperationalData(attributes); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(eventCaptor.capture()), times(1)); + } + + final Event event = eventCaptor.getValue(); + assertEquals(IdentityConstants.EventNames.UPDATE_OPERATIONAL_DATA, event.getName()); + assertEquals(EventType.EDGE_IDENTITY, event.getType()); + assertEquals("com.adobe.eventSource.requestOperationalData", event.getSource()); + assertEquals(attributes, event.getEventData()); + } + + @Test + public void testOperationalDataMap_dispatchesWithoutResponseCallback() { + try (MockedStatic core = Mockito.mockStatic(MobileCore.class)) { + Identity.updateOperationalData(Map.of("timezone", "Asia/Tokyo")); + core.verify(() -> MobileCore.dispatchEvent(any(Event.class))); + core.verifyNoMoreInteractions(); + } + } + + @Test + public void testUpdateOperationalData_emptyMapDoesNotDispatch() { + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateOperationalData(Map.of()); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any(Event.class)), never()); + } + } + + @Test + public void testUpdateOperationalData_nullAttributesDoesNotDispatch() { + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateOperationalData(null); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any(Event.class)), never()); + } + } + + @Test + public void testUpdateOperationalData_whenCopyingAttributesThrows_doesNotThrowOrDispatch() { + final Map attributes = new HashMap() { + @Override + public Set> entrySet() { + throw new ConcurrentModificationException(); + } + }; + attributes.put("timezone", "Asia/Tokyo"); + + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateOperationalData(attributes); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any(Event.class)), never()); + } + } + // ======================================================================================== // removeIdentity API // ======================================================================================== diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/OperationalDataDeduplicatorTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/OperationalDataDeduplicatorTests.java new file mode 100644 index 00000000..bbe54888 --- /dev/null +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/OperationalDataDeduplicatorTests.java @@ -0,0 +1,396 @@ +/* + Copyright 2026 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.edge.identity; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.json.JSONObject; +import org.junit.Before; +import org.junit.Test; + +public class OperationalDataDeduplicatorTests { + + private ProfileAttributeStore store; + private OperationalDataDeduplicator deduplicator; + private Map persisted; + + @Before + public void setup() { + store = mock(ProfileAttributeStore.class); + persisted = new HashMap<>(); + when(store.getString(anyString())).thenAnswer(call -> persisted.get(call.getArgument(0))); + doAnswer(call -> persisted.put(call.getArgument(0), call.getArgument(1))) + .when(store) + .setString(anyString(), anyString()); + doAnswer(call -> persisted.remove(call.getArgument(0))).when(store).remove(anyString()); + deduplicator = new OperationalDataDeduplicator(store); + } + + @Test + public void filterDoesNotWriteUntilCommit() { + final Map attributes = attributes("Europe/London", "push"); + assertEquals(attributes, deduplicator.filter(attributes, null).changed); + assertEquals(attributes, deduplicator.filter(attributes, null).changed); + assertTrue(persisted.isEmpty()); + deduplicator.commit(attributes); + assertTrue(deduplicator.filter(attributes, null).changed.isEmpty()); + assertEquals(Map.of("timezone", "Europe/London", "pushNotification", "push"), persisted); + final OperationalDataDeduplicator restarted = new OperationalDataDeduplicator(store); + assertEquals(attributes, restarted.readSynced()); + assertTrue(restarted.filter(attributes, null).changed.isEmpty()); + } + + @Test + public void timezoneAndPushTokenAreDeduplicatedIndependently() { + deduplicator.commit(attributes("same", "old")); + assertEquals( + Map.of("tokens", Map.of("pushNotification", "new")), + deduplicator.filter(attributes("same", "new"), null).changed + ); + assertEquals(Map.of("timezone", "new"), deduplicator.filter(attributes("new", "old"), null).changed); + } + + @Test + public void filterAndReadSyncedUseCurrentStoreValues() { + deduplicator.commit(attributes("old", "old")); + persisted.put("timezone", "external"); + persisted.put("pushNotification", "external-push"); + final Map external = attributes("external", "external-push"); + assertEquals(external, deduplicator.readSynced()); + assertTrue(deduplicator.filter(external, null).changed.isEmpty()); + assertEquals(attributes("old", "old"), deduplicator.filter(attributes("old", "old"), null).changed); + persisted.remove("timezone"); + persisted.remove("pushNotification"); + assertTrue(deduplicator.readSynced().isEmpty()); + assertEquals(external, deduplicator.filter(external, null).changed); + } + + @Test + public void ownerMatchesAreReturnedSeparatelyAndNotCommitted() { + final Map attributes = attributes("Europe/London", "push"); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter( + attributes, + attributes("Europe/London", "other") + ); + assertEquals(Map.of("tokens", Map.of("pushNotification", "push")), result.changed); + assertEquals(Map.of("timezone", "Europe/London"), result.ownerMatches); + assertTrue(persisted.isEmpty()); + assertEquals(attributes, deduplicator.filter(attributes, null).changed); + deduplicator.commit(result.ownerMatches); + assertEquals(Map.of("timezone", "Europe/London"), deduplicator.readSynced()); + } + + @Test + public void nestedOwnerMatchesKeepTheirPathAndUnknownSiblings() { + final Map attributes = Map.of("tokens", Map.of("pushNotification", "push", "other", "x")); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertEquals(Map.of("tokens", Map.of("other", "x")), result.changed); + assertEquals(Map.of("tokens", Map.of("pushNotification", "push")), result.ownerMatches); + deduplicator.commit(result.ownerMatches); + assertEquals(result.ownerMatches, deduplicator.readSynced()); + } + + @Test + public void matchingBothOwnersRemovesTokensWrapper() { + final Map attributes = attributes("Europe/London", "push"); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertTrue(result.changed.isEmpty()); + assertEquals(attributes, result.ownerMatches); + assertTrue(persisted.isEmpty()); + } + + @Test + public void confirmedValuesTakePriorityOverOwnerMatches() { + final Map attributes = attributes("Europe/London", "push"); + deduplicator.commit(attributes); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertTrue(result.changed.isEmpty()); + assertTrue(result.ownerMatches.isEmpty()); + } + + @Test + public void changedConfirmedValueCanMatchCurrentOwner() { + deduplicator.commit(attributes("old", "old")); + final Map attributes = attributes("current", "current"); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertTrue(result.changed.isEmpty()); + assertEquals(attributes, result.ownerMatches); + assertEquals(attributes("old", "old"), deduplicator.readSynced()); + } + + @Test + public void unexpectedOwnerTypesDoNotMatchStrings() { + final Map attributes = attributes("1", "true"); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes(1, true)); + assertEquals(attributes, result.changed); + assertTrue(result.ownerMatches.isEmpty()); + } + + @Test + public void unexpectedOwnerTokensContainerDoesNotMatch() { + final Map attributes = attributes("current", "push"); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter( + attributes, + Map.of("tokens", "push") + ); + assertEquals(attributes, result.changed); + assertTrue(result.ownerMatches.isEmpty()); + } + + @Test + public void emptyStringsAreStoredAndCompared() { + final Map attributes = attributes("", ""); + assertEquals(attributes, deduplicator.filter(attributes, attributes("previous", "previous")).changed); + deduplicator.commit(attributes); + assertEquals(Map.of("timezone", "", "pushNotification", ""), persisted); + assertEquals(attributes, deduplicator.readSynced()); + assertTrue(deduplicator.filter(attributes, null).changed.isEmpty()); + assertTrue(new OperationalDataDeduplicator(store).filter(attributes, null).changed.isEmpty()); + } + + @Test + public void emptyStringsCanMatchOwners() { + final Map attributes = attributes("", ""); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertTrue(result.changed.isEmpty()); + assertEquals(attributes, result.ownerMatches); + } + + @Test + public void originallyEmptyTokensMapIsPreserved() { + final Map tokens = new LinkedHashMap<>(); + final Map attributes = Map.of("tokens", tokens); + deduplicator.commit(attributes("stored", "stored")); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertEquals(attributes, result.changed); + assertSame(tokens, result.changed.get("tokens")); + assertTrue(result.ownerMatches.isEmpty()); + } + + @Test + public void matchingPushTokenRemovesOnlySelectedFieldAndKeepsInputUnchanged() { + final Map sibling = Map.of("nested", List.of("value")); + final Map tokens = new LinkedHashMap<>(); + tokens.put("pushNotification", "push"); + tokens.put("other", sibling); + final Map attributes = Map.of("tokens", tokens); + deduplicator.commit(Map.of("tokens", Map.of("pushNotification", "push"))); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, null); + assertEquals(Map.of("tokens", Map.of("other", sibling)), result.changed); + assertEquals("push", tokens.get("pushNotification")); + assertSame(sibling, ((Map) result.changed.get("tokens")).get("other")); + assertTrue(result.ownerMatches.isEmpty()); + } + + @Test + public void invalidNestedKeysArePreservedWhenMatchingPushTokenIsRemoved() { + final Map tokens = new LinkedHashMap<>(); + tokens.put("pushNotification", "push"); + tokens.put(1, "non-string-key"); + tokens.put(null, null); + final Map attributes = Map.of("tokens", tokens); + deduplicator.commit(attributes); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + final Map changedTokens = (Map) result.changed.get("tokens"); + assertEquals(2, changedTokens.size()); + assertEquals("non-string-key", changedTokens.get(1)); + assertTrue(changedTokens.containsKey(null)); + assertNull(changedTokens.get(null)); + assertEquals("push", tokens.get("pushNotification")); + assertEquals(Map.of("pushNotification", "push"), persisted); + assertTrue(result.ownerMatches.isEmpty()); + } + + @Test + public void nullAndInvalidJSONValuesPassThroughWithoutValidation() { + final Object unsupported = new Object(); + final Map nested = new LinkedHashMap<>(); + nested.put("null", null); + nested.put("unsupported", unsupported); + nested.put("nan", Double.NaN); + nested.put("jsonNull", JSONObject.NULL); + final Map attributes = new LinkedHashMap<>(nested); + attributes.put("nested", nested); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertEquals(attributes, result.changed); + assertSame(nested, result.changed.get("nested")); + assertSame(unsupported, result.changed.get("unsupported")); + assertTrue(result.changed.containsKey("null")); + assertTrue(result.ownerMatches.isEmpty()); + deduplicator.commit(result.changed); + assertTrue(persisted.isEmpty()); + } + + @Test + public void unexpectedKnownTypesAreNeverComparedOrPersisted() { + final Object[] unexpected = { + null, + JSONObject.NULL, + 1, + 1.0, + true, + Double.NaN, + Double.POSITIVE_INFINITY, + new Object(), + List.of(), + List.of("a", "b"), + new String[] { "a", "b" }, + Arrays.asList("a", null), + Map.of(), + Map.of("nested", "value"), + }; + for (final Object value : unexpected) { + final Map attributes = attributes(value, value); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertEquals(attributes, result.changed); + assertSame(value, result.changed.get("timezone")); + assertSame(attributes.get("tokens"), result.changed.get("tokens")); + assertTrue(result.ownerMatches.isEmpty()); + deduplicator.commit(attributes); + assertTrue(persisted.isEmpty()); + assertTrue(deduplicator.readSynced().isEmpty()); + } + } + + @Test + public void unexpectedKnownTypesDoNotOverwriteConfirmedStrings() { + final Map confirmed = attributes("1", "true"); + deduplicator.commit(confirmed); + final Map unexpected = attributes(1, true); + assertEquals(unexpected, deduplicator.filter(unexpected, unexpected).changed); + deduplicator.commit(unexpected); + deduplicator.commit(attributes(null, JSONObject.NULL)); + assertEquals(confirmed, deduplicator.readSynced()); + assertTrue(deduplicator.filter(confirmed, null).changed.isEmpty()); + } + + @Test + public void unexpectedTokensContainerIsForwardedAndIgnoredByCommit() { + for (final Object value : Arrays.asList(null, "push", List.of("push"), 1, JSONObject.NULL)) { + final Map attributes = new LinkedHashMap<>(); + attributes.put("tokens", value); + assertEquals(attributes, deduplicator.filter(attributes, attributes).changed); + deduplicator.commit(attributes); + assertTrue(persisted.isEmpty()); + } + } + + @Test + public void mixedNestedAttributesOnlyDeduplicateSelectedStrings() { + final Map custom = Map.of("timezone", "same", "tokens", Map.of("pushNotification", "push")); + final Map attributes = Map.of( + "timezone", + "same", + "custom", + custom, + "tokens", + Map.of("pushNotification", "push", "nested", custom, "liveActivityStart", List.of("ios-only")) + ); + deduplicator.commit(attributes); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertEquals( + Map.of("custom", custom, "tokens", Map.of("nested", custom, "liveActivityStart", List.of("ios-only"))), + result.changed + ); + assertSame(custom, result.changed.get("custom")); + assertTrue(result.ownerMatches.isEmpty()); + assertEquals(attributes("same", "push"), deduplicator.readSynced()); + } + + @Test + public void unknownValuesRemainTheOriginalReferences() { + final Map cyclic = new LinkedHashMap<>(); + cyclic.put("self", cyclic); + final Map attributes = Map.of("custom", cyclic, "tokens", Map.of("other", cyclic)); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(attributes, attributes); + assertSame(cyclic, result.changed.get("custom")); + assertSame(attributes.get("tokens"), result.changed.get("tokens")); + assertTrue(result.ownerMatches.isEmpty()); + deduplicator.commit(attributes); + assertTrue(persisted.isEmpty()); + } + + @Test + public void commitReplacesOnlyProvidedStringsAndKeepsOtherConfirmedValues() { + deduplicator.commit(attributes("Asia/Tokyo", "token-1")); + deduplicator.commit(Map.of("tokens", Map.of("pushNotification", "token-2", "other", "ignored"))); + assertEquals(attributes("Asia/Tokyo", "token-2"), deduplicator.readSynced()); + deduplicator.commit(Map.of("timezone", "Europe/London", "other", "ignored")); + assertEquals(attributes("Europe/London", "token-2"), new OperationalDataDeduplicator(store).readSynced()); + assertEquals(Map.of("timezone", "Europe/London", "pushNotification", "token-2"), persisted); + } + + @Test + public void readSyncedReconstructsOnlyPresentKeys() { + assertTrue(deduplicator.readSynced().isEmpty()); + persisted.put("pushNotification", "push"); + persisted.put("other", "ignored"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "push")), deduplicator.readSynced()); + persisted.remove("pushNotification"); + persisted.put("timezone", "Asia/Tokyo"); + assertEquals(Map.of("timezone", "Asia/Tokyo"), deduplicator.readSynced()); + } + + @Test + public void modifyingReadSyncedDoesNotChangePersistence() { + final Map attributes = attributes("Europe/London", "push"); + deduplicator.commit(attributes); + final Map snapshot = deduplicator.readSynced(); + ((Map) snapshot.get("tokens")).clear(); + snapshot.clear(); + assertEquals(attributes, deduplicator.readSynced()); + } + + @Test + public void clearRemovesOnlyBothConfirmedDeviceKeys() { + deduplicator.commit(attributes("Europe/London", "push")); + persisted.put("other", "keep"); + deduplicator.clear(); + assertEquals(Map.of("other", "keep"), persisted); + assertTrue(deduplicator.readSynced().isEmpty()); + assertTrue(new OperationalDataDeduplicator(store).readSynced().isEmpty()); + assertEquals( + attributes("Europe/London", "push"), + deduplicator.filter(attributes("Europe/London", "push"), null).changed + ); + verify(store).remove("timezone"); + verify(store).remove("pushNotification"); + verify(store, never()).clearAll(); + } + + @Test + public void nullAndEmptyAttributeMapsDoNotWrite() { + assertTrue(deduplicator.filter(null, null).changed.isEmpty()); + final OperationalDataDeduplicator.FilterResult result = deduplicator.filter(Map.of(), attributes("x", "y")); + assertTrue(result.changed.isEmpty()); + assertTrue(result.ownerMatches.isEmpty()); + deduplicator.commit(null); + deduplicator.commit(Map.of()); + assertTrue(persisted.isEmpty()); + } + + private static Map attributes(final Object timezone, final Object pushToken) { + final Map tokens = new LinkedHashMap<>(); + tokens.put("pushNotification", pushToken); + final Map attributes = new LinkedHashMap<>(); + attributes.put("timezone", timezone); + attributes.put("tokens", tokens); + return attributes; + } +} diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/OperationalDataSyncTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/OperationalDataSyncTests.java new file mode 100644 index 00000000..159f97af --- /dev/null +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/OperationalDataSyncTests.java @@ -0,0 +1,1554 @@ +/* + Copyright 2026 Adobe. All rights reserved. + This file is licensed to you under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. You may obtain a copy + of the License at http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, software distributed under + the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + OF ANY KIND, either express or implied. See the License for the specific language + governing permissions and limitations under the License. +*/ + +package com.adobe.marketing.mobile.edge.identity; + +import static org.junit.Assert.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import android.app.Application; +import com.adobe.marketing.mobile.Event; +import com.adobe.marketing.mobile.EventSource; +import com.adobe.marketing.mobile.EventType; +import com.adobe.marketing.mobile.ExtensionApi; +import com.adobe.marketing.mobile.MobileCore; +import com.adobe.marketing.mobile.SharedStateResult; +import com.adobe.marketing.mobile.SharedStateStatus; +import com.adobe.marketing.mobile.services.NamedCollection; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.MockedStatic; + +public class OperationalDataSyncTests { + + private ExtensionApi api; + private Map stored; + private NamedCollection collection; + private List dispatched; + private List results; + private List confirmed; + private Map deviceStored; + private NamedCollection deviceCollection; + private List> published; + private OperationalDataSync sync; + private MockedStatic mobileCore; + private Application application; + + @Before + public void setup() { + application = mock(Application.class); + when(application.getPackageName()).thenReturn("com.example"); + mobileCore = mockStatic(MobileCore.class); + mobileCore.when(MobileCore::getApplication).thenReturn(application); + api = mock(ExtensionApi.class); + stored = new HashMap<>(); + collection = mock(NamedCollection.class); + when(collection.getString(anyString(), any())).thenAnswer(call -> stored.get(call.getArgument(0))); + doAnswer(call -> stored.put(call.getArgument(0), call.getArgument(1))) + .when(collection) + .setString(anyString(), anyString()); + doAnswer(call -> stored.remove(call.getArgument(0))).when(collection).remove(anyString()); + doAnswer(call -> { + stored.clear(); + return null; + }) + .when(collection) + .removeAll(); + dispatched = new ArrayList<>(); + results = new ArrayList<>(); + confirmed = new ArrayList<>(); + deviceStored = new HashMap<>(); + deviceCollection = mock(NamedCollection.class); + when(deviceCollection.getString(anyString(), any())) + .thenAnswer(call -> deviceStored.get(call.getArgument(0))); + doAnswer(call -> deviceStored.put(call.getArgument(0), call.getArgument(1))) + .when(deviceCollection) + .setString(anyString(), anyString()); + doAnswer(call -> deviceStored.remove(call.getArgument(0))).when(deviceCollection).remove(anyString()); + published = new ArrayList<>(); + doAnswer(call -> record(call.getArgument(0))).when(api).dispatch(any()); + doAnswer(call -> published.add(call.getArgument(0))).when(api).createSharedState(anyMap(), any()); + sync = newSync(); + } + + private OperationalDataSync newSync() { + return new OperationalDataSync( + api, + new ProfileAttributeStore(collection), + new ProfileAttributeStore(deviceCollection) + ); + } + + @After + public void teardown() { + mobileCore.close(); + } + + private boolean record(final Event event) { + if (EventType.MESSAGING.equals(event.getType())) return confirmed.add(event); + return isResultEvent(event) ? results.add(event) : dispatched.add(event); + } + + private static boolean isResultEvent(final Event event) { + return ( + EventType.EDGE_IDENTITY.equals(event.getType()) && EventSource.RESPONSE_CONTENT.equals(event.getSource()) + ); + } + + private void seedSynced(final Map attributes) { + new OperationalDataDeduplicator(new ProfileAttributeStore(deviceCollection)).commit(attributes); + sync = newSync(); + } + + private void consentRegistered() { + when(api.getSharedState(eq(IdentityConstants.SharedState.Hub.NAME), any(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of("extensions", Map.of(IdentityConstants.SharedState.Consent.NAME, Map.of("version", "3.0.0"))) + ) + ); + } + + private void collectConsent(final String value) { + consentRegistered(); + when(api.getXDMSharedState(eq(IdentityConstants.SharedState.Consent.NAME), any(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of("consents", Map.of("collect", Map.of("val", value))) + ) + ); + } + + private void messagingPushToken(final String token) { + when(api.getSharedState(eq(IdentityConstants.SharedState.Messaging.NAME), any(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of(IdentityConstants.SharedState.Messaging.PUSH_IDENTIFIER, token) + ) + ); + } + + @SuppressWarnings("unchecked") + /** Mirrors IdentityExtension reset: IdentityState clears the profile store before the sync resets. */ + private void resetLikeExtension() { + stored.clear(); + sync.reset(null); + } + + private Map syncedValues() { + return new OperationalDataDeduplicator(new ProfileAttributeStore(deviceCollection)).readSynced(); + } + + private void edgeResponse(final Map data, final Event parent) { + final Event.Builder builder = new Event.Builder( + "Device Attributes Response", + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, + EventSource.RESPONSE_CONTENT + ) + .setEventData(data); + if (parent != null) builder.chainToParentEvent(parent); + sync.handleOperationalDataResult(builder.build()); + } + + private static Event request(final Map data) { + return new Event.Builder( + IdentityConstants.EventNames.UPDATE_OPERATIONAL_DATA, + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.REQUEST_SOURCE + ) + .setEventData(data) + .build(); + } + + private static Event consequence(final Map data) { + return new Event.Builder( + "Forward operational data", + EventType.EDGE_IDENTITY, + IdentityConstants.OperationalData.RULE_RESPONSE_SOURCE + ) + .setEventData(data) + .build(); + } + + private void dedupConfig(final boolean enabled) { + when(api.getSharedState(eq(IdentityConstants.SharedState.Configuration.NAME), any(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of("edgeidentity.optimizeOperationalProfileDataSync", enabled) + ) + ); + } + + private void edgeResult(final Event edgeRequest, final String status) { + edgeResponse(Map.of("status", status), edgeRequest); + } + + private void succeedAll() { + for (int i = 0; i < dispatched.size(); i++) { + final Event event = dispatched.get(i); + if (IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE.equals(event.getType())) edgeResult( + event, + "success" + ); + } + } + + @SuppressWarnings("unchecked") + private Map lastProfileAttributes() { + return (Map) published.get(published.size() - 1).get("profileAttributes"); + } + + @Test + public void handleOperationalData_dispatchesOperationalDataChainedToApiEvent() { + final Event request = request(Map.of("timezone", "Asia/Tokyo")); + sync.handleOperationalData(request); + + assertEquals(1, dispatched.size()); + final Event operational = dispatched.get(0); + assertEquals(IdentityConstants.OperationalData.OPERATIONAL_DATA_TYPE, operational.getType()); + assertEquals(EventSource.REQUEST_CONTENT, operational.getSource()); + assertEquals(request.getUniqueIdentifier(), operational.getParentID()); + assertNull(operational.getResponseID()); + assertEquals("Asia/Tokyo", operational.getEventData().get("timezone")); + assertFalse(operational.getEventData().containsKey("app")); + } + + @Test + public void ruleResponse_pushOnlyAddsSdkAppAfterFilteringAndOverridesCallerApp() { + sync.handleOperationalDataRuleResponse( + consequence( + Map.of( + "tokens", + Map.of("pushNotification", "token-1"), + "app", + Map.of("id", "caller.app", "platform", "apns") + ) + ) + ); + assertEquals( + Map.of( + "tokens", + Map.of("pushNotification", "token-1"), + "app", + Map.of("id", "com.example", "platform", "fcm") + ), + dispatched.get(0).getEventData() + ); + } + + @Test + public void ruleResponse_missingApplicationDoesNotSendOrPersist() { + mobileCore.when(MobileCore::getApplication).thenReturn(null); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + assertTrue(dispatched.isEmpty()); + assertTrue(stored.isEmpty()); + assertTrue(published.isEmpty()); + } + + @Test + public void ruleResponse_emptyPackageDoesNotSendOrPersist() { + when(application.getPackageName()).thenReturn(""); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertTrue(dispatched.isEmpty()); + assertTrue(stored.isEmpty()); + assertTrue(published.isEmpty()); + } + + @Test + public void handleOperationalData_ignoresEmptyOrInvalidData() { + sync.handleOperationalData(request(new HashMap<>())); + sync.handleOperationalData(request(null)); + assertTrue(dispatched.isEmpty()); + } + + @Test + public void ruleResponse_sendsForwardedValuesToEdgeAndPublishesState() { + final Map app = Map.of("id", "com.example", "platform", "fcm"); + final Event forward = consequence(Map.of("timezone", "Asia/Tokyo", "app", app)); + sync.handleOperationalDataRuleResponse(forward); + + assertEquals(1, dispatched.size()); + final Event edge = dispatched.get(0); + assertEquals(IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, edge.getType()); + assertEquals(EventSource.REQUEST_CONTENT, edge.getSource()); + assertEquals(forward.getUniqueIdentifier(), edge.getParentID()); + assertEquals(Map.of("timezone", "Asia/Tokyo", "app", app), edge.getEventData()); + assertNull(stored.get("timeZone")); + assertTrue(published.isEmpty()); + + edgeResult(edge, "success"); + assertEquals("Asia/Tokyo", stored.get("timeZone")); + assertEquals(Map.of("timezone", "Asia/Tokyo"), lastProfileAttributes()); + } + + @Test + public void edgeSuccess_publishesPushTokenForMessaging() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + assertEquals(Map.of("id", "com.example", "platform", "fcm"), dispatched.get(0).getEventData().get("app")); + edgeResult(dispatched.get(0), "success"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), lastProfileAttributes()); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), syncedValues()); + } + + @Test + public void persistedPushToken_isDedupedAfterRestart() { + final Map token = Map.of("tokens", Map.of("pushNotification", "token-1")); + sync.handleOperationalDataRuleResponse(consequence(token)); + edgeResult(dispatched.get(0), "success"); + + sync = newSync(); + sync.handleOperationalDataRuleResponse(consequence(token)); + + assertEquals(1, dispatched.size()); + } + + @Test + public void nullPushToken_isSentButNotStoredBeforeAndAfterRestart() { + final Map tokens = new HashMap<>(); + tokens.put("pushNotification", null); + final Map attributes = Map.of("tokens", tokens); + sync.handleOperationalDataRuleResponse(consequence(attributes)); + assertEquals(tokens, dispatched.get(0).getEventData().get("tokens")); + edgeResult(dispatched.get(0), "success"); + assertTrue(deviceStored.isEmpty()); + sync = newSync(); + sync.handleOperationalDataRuleResponse(consequence(attributes)); + + assertEquals(2, dispatched.size()); + assertEquals(tokens, dispatched.get(1).getEventData().get("tokens")); + } + + @Test + public void consentYes_ownerMatchIsNotSentAndIsCopiedToStore() { + collectConsent("y"); + messagingPushToken("token-1"); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + + assertTrue(dispatched.isEmpty()); + assertEquals(1, results.size()); + assertEquals("unchanged", results.get(0).getEventData().get("reason")); + assertTrue(stored.isEmpty()); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), syncedValues()); + } + + @Test + public void ownerMatch_publishesStandardStateChainedToRuleResponse() { + collectConsent("y"); + messagingPushToken("token-1"); + final Event rule = consequence( + Map.of("timezone", "Asia/Tokyo", "tokens", Map.of("pushNotification", "token-1")) + ); + + sync.handleOperationalDataRuleResponse(rule); + + assertEquals(1, dispatched.size()); + final Map expected = Map.of("tokens", Map.of("pushNotification", "token-1")); + verify(api).createSharedState(eq(Collections.singletonMap("profileAttributes", expected)), eq(rule)); + assertEquals(1, published.size()); + } + + @Test + public void ownerMatch_publishesEvenWhenEverythingIsDeduped() { + collectConsent("y"); + messagingPushToken("token-1"); + final Event rule = consequence(Map.of("tokens", Map.of("pushNotification", "token-1"))); + + sync.handleOperationalDataRuleResponse(rule); + + assertTrue(dispatched.isEmpty()); + final Map expected = Map.of("tokens", Map.of("pushNotification", "token-1")); + verify(api).createSharedState(eq(Collections.singletonMap("profileAttributes", expected)), eq(rule)); + assertEquals(1, published.size()); + } + + @Test + public void ownerMatch_publishesEvenWithoutAppIdentifier() { + when(application.getPackageName()).thenReturn(""); + collectConsent("y"); + messagingPushToken("token-1"); + final Event rule = consequence( + Map.of("timezone", "Asia/Tokyo", "tokens", Map.of("pushNotification", "token-1")) + ); + + sync.handleOperationalDataRuleResponse(rule); + + assertTrue(dispatched.isEmpty()); + final Map expected = Map.of("tokens", Map.of("pushNotification", "token-1")); + verify(api).createSharedState(eq(Collections.singletonMap("profileAttributes", expected)), eq(rule)); + assertEquals(expected, syncedValues()); + } + + @Test + public void noOwnerMatch_doesNotPublish() { + collectConsent("y"); + messagingPushToken("token-old"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-new")))); + assertEquals(1, dispatched.size()); + assertTrue(published.isEmpty()); + + edgeResult(dispatched.get(0), "success"); + assertEquals(1, published.size()); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-new")))); + assertEquals(1, published.size()); + } + + @Test + public void ownerMatchCopiedToStore_isDedupedWhenConsentIsLaterDenied() { + stored.put("timeZone", "Asia/Tokyo"); + collectConsent("y"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertTrue(dispatched.isEmpty()); + + collectConsent("n"); + sync = newSync(); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertTrue(dispatched.isEmpty()); + } + + @Test + public void consentYes_ownerMatchCopiesOnlyMatchingLeaf() { + collectConsent("y"); + messagingPushToken("token-1"); + + sync.handleOperationalDataRuleResponse( + consequence(Map.of("timezone", "Asia/Tokyo", "tokens", Map.of("pushNotification", "token-1"))) + ); + + assertEquals( + Map.of("timezone", "Asia/Tokyo", "app", Map.of("id", "com.example", "platform", "fcm")), + dispatched.get(0).getEventData() + ); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), syncedValues()); + } + + @Test + public void consentYes_changedPushTokenIsSentAfterComparisonWithMessagingState() { + collectConsent("y"); + messagingPushToken("token-old"); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-new")))); + + assertEquals(1, dispatched.size()); + assertEquals( + Map.of( + "tokens", + Map.of("pushNotification", "token-new"), + "app", + Map.of("id", "com.example", "platform", "fcm") + ), + dispatched.get(0).getEventData() + ); + edgeResult(dispatched.get(0), "success"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-new")), lastProfileAttributes()); + } + + @Test + public void consentYes_timezoneUsesPersistedOwnerValueForDedup() { + stored.put("timeZone", "Asia/Tokyo"); + collectConsent("y"); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + + assertTrue(dispatched.isEmpty()); + } + + @Test + public void consentExtensionNotRegistered_treatsConsentAsYes() { + when(api.getSharedState(eq(IdentityConstants.SharedState.Hub.NAME), any(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of("extensions", Map.of("com.adobe.edge", Map.of("version", "3.0.0"))) + ) + ); + messagingPushToken("token-1"); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + + assertTrue(dispatched.isEmpty()); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), syncedValues()); + verify(api, never()) + .getXDMSharedState(eq(IdentityConstants.SharedState.Consent.NAME), any(), anyBoolean(), any()); + } + + @Test + public void hubSharedStateUnavailable_doesNotTreatConsentAsYes() { + messagingPushToken("token-1"); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + + assertEquals(1, dispatched.size()); + assertTrue(deviceStored.isEmpty()); + } + + @Test + public void consentNo_sendsOperationalData() { + collectConsent("n"); + stored.put("timeZone", "Asia/Tokyo"); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + + assertEquals(1, dispatched.size()); + assertEquals(IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, dispatched.get(0).getType()); + } + + @Test + public void consentNo_unchangedValueIsDedupedAgainstPersistedDeviceStore() { + seedSynced(Map.of("tokens", Map.of("pushNotification", "token-1"))); + collectConsent("n"); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + + assertTrue(dispatched.isEmpty()); + } + + @Test + public void pendingConsent_sendsOperationalData() { + consentRegistered(); + stored.put("timeZone", "Asia/Tokyo"); + when(api.getXDMSharedState(eq(IdentityConstants.SharedState.Consent.NAME), any(), anyBoolean(), any())) + .thenReturn(new SharedStateResult(SharedStateStatus.PENDING, null)); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + + assertEquals(1, dispatched.size()); + assertEquals(IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, dispatched.get(0).getType()); + } + + @Test + public void edgeFailure_doesNotPublishAndAllowsResend() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + edgeResult(dispatched.get(0), "failed"); + assertNull(stored.get("timeZone")); + assertTrue(published.isEmpty()); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertEquals(2, dispatched.size()); + } + + @Test + public void edgeResult_forUnknownRequestIsIgnored() { + edgeResult( + new Event.Builder( + "Other", + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, + EventSource.REQUEST_CONTENT + ) + .build(), + "success" + ); + assertTrue(published.isEmpty()); + } + + @Test + public void ruleResponse_appOnlyConsequenceIsNotSent() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("app", Map.of("id", "com.example")))); + assertTrue(dispatched.isEmpty()); + } + + @Test + public void multipleConsequences_areSentInOrderAndLaterDuplicatesAreDropped() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse( + consequence(Map.of("timezone", "Asia/Tokyo", "tokens", Map.of("pushNotification", Map.of("token", "t1")))) + ); + + assertEquals(1, dispatched.size()); + assertTrue(published.isEmpty()); + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + assertEquals( + Map.of("timezone", "Asia/Tokyo", "app", Map.of("id", "com.example", "platform", "fcm")), + dispatched.get(0).getEventData() + ); + assertEquals( + Map.of( + "tokens", + Map.of("pushNotification", Map.of("token", "t1")), + "app", + Map.of("id", "com.example", "platform", "fcm") + ), + dispatched.get(1).getEventData() + ); + } + + @Test + public void changedValue_isSentAgain() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + assertEquals(1, dispatched.size()); + succeedAll(); + + assertEquals(2, dispatched.size()); + assertEquals("Europe/Paris", dispatched.get(1).getEventData().get("timezone")); + assertEquals("Europe/Paris", stored.get("timeZone")); + } + + @Test + public void parentThenChild_confirmationReplacesParentAndDrainsQueue() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of()))); + edgeResult(dispatched.get(0), "success"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + edgeResult(dispatched.get(1), "success"); + + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), lastProfileAttributes()); + assertEquals(Map.of(), dispatched.get(0).getEventData().get("tokens")); + assertEquals(3, dispatched.size()); + edgeResult(dispatched.get(2), "success"); + sync.publishProfileAttributes(null); + final Map expected = Map.of( + "tokens", + Map.of("pushNotification", "token-1"), + "timezone", + "Europe/Paris" + ); + assertEquals(expected, lastProfileAttributes()); + sync = newSync(); + sync.publishProfileAttributes(null); + assertEquals(expected, lastProfileAttributes()); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + assertEquals(3, dispatched.size()); + } + + @Test + public void childThenEmptyParent_keepsConfirmedChildBeforeAndAfterRestart() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(dispatched.get(0), "success"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of()))); + assertEquals(Map.of(), dispatched.get(1).getEventData().get("tokens")); + edgeResult(dispatched.get(1), "success"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), lastProfileAttributes()); + + sync = newSync(); + sync.publishProfileAttributes(null); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), lastProfileAttributes()); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of()))); + assertEquals(3, dispatched.size()); + } + + @Test + public void literalDottedKey_isNotTreatedAsKnownPath() { + final Map attributes = Map.of( + "tokens", + Map.of("pushNotification", "token-1"), + "tokens.pushNotification", + "literal" + ); + sync.handleOperationalDataRuleResponse(consequence(attributes)); + edgeResult(dispatched.get(0), "success"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), lastProfileAttributes()); + + sync.handleOperationalDataRuleResponse(consequence(attributes)); + assertEquals(2, dispatched.size()); + assertEquals( + Map.of("tokens.pushNotification", "literal", "app", Map.of("id", "com.example", "platform", "fcm")), + dispatched.get(1).getEventData() + ); + } + + @Test + public void emptyParent_isNotStoredAndChildConfirmationIsStored() { + seedSynced(Map.of("tokens", Map.of())); + assertTrue(deviceStored.isEmpty()); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(dispatched.get(0), "success"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), lastProfileAttributes()); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + assertEquals(1, dispatched.size()); + } + + @Test + public void failedParentReplacement_preservesConfirmedChildren() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(dispatched.get(0), "success"); + final Map persisted = new HashMap<>(deviceStored); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of()))); + edgeResult(dispatched.get(1), "failed"); + assertEquals(persisted, deviceStored); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), lastProfileAttributes()); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + assertEquals(2, dispatched.size()); + } + + @Test + public void persistedTimezone_isDedupedAfterRestart() { + seedSynced(Map.of("timezone", "Asia/Tokyo")); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertTrue(dispatched.isEmpty()); + } + + @Test + public void dedupEnabledByConfiguration_skipsConfirmedDuplicates() { + dedupConfig(true); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + edgeResult(dispatched.get(0), "success"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertEquals(1, dispatched.size()); + } + + @Test + public void dedupDisabledByConfiguration_sendsDuplicates() { + dedupConfig(false); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertEquals(1, dispatched.size()); + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + } + + @Test + public void dedupDisabledByConfiguration_forwardsUnexpectedAndNullValuesOnEveryCall() { + dedupConfig(false); + collectConsent("y"); + messagingPushToken("token-1"); + seedSynced(Map.of("tokens", Map.of("pushNotification", "token-1"))); + final Map attributes = new HashMap<>(); + attributes.put("timezone", null); + attributes.put("tokens", Map.of("pushNotification", "token-1")); + attributes.put("custom", Double.NaN); + sync.handleOperationalDataRuleResponse(consequence(attributes)); + sync.handleOperationalDataRuleResponse(consequence(attributes)); + succeedAll(); + + attributes.put("app", Map.of("id", "com.example", "platform", "fcm")); + assertEquals(2, dispatched.size()); + assertEquals(attributes, dispatched.get(0).getEventData()); + assertEquals(attributes, dispatched.get(1).getEventData()); + assertEquals(Map.of("pushNotification", "token-1"), deviceStored); + } + + @Test + public void explicitNull_isSentWithoutClearingConfirmedToken() { + final Map data = new HashMap<>(); + data.put("tokens", Map.of("pushNotification", "s1")); + sync.handleOperationalDataRuleResponse(consequence(data)); + final Map tokens = new HashMap<>(); + tokens.put("pushNotification", null); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", tokens))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", tokens))); + succeedAll(); + + assertEquals(3, dispatched.size()); + assertEquals(tokens, dispatched.get(1).getEventData().get("tokens")); + assertEquals(tokens, dispatched.get(2).getEventData().get("tokens")); + assertEquals(Map.of("tokens", Map.of("pushNotification", "s1")), lastProfileAttributes()); + } + + @Test + public void customNestedValues_areAlwaysSentInFull() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("custom", Map.of("a", 1, "b", 2)))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("custom", Map.of("a", 1, "b", 3)))); + edgeResult(dispatched.get(0), "success"); + + assertEquals(2, dispatched.size()); + assertEquals( + Map.of("custom", Map.of("a", 1, "b", 3), "app", Map.of("id", "com.example", "platform", "fcm")), + dispatched.get(1).getEventData() + ); + } + + @Test + public void reset_clearsSentValues() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo", "custom", Map.of("a", 1)))); + succeedAll(); + resetLikeExtension(); + assertTrue(deviceStored.isEmpty()); + assertTrue(lastProfileAttributes().isEmpty()); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo", "custom", Map.of("a", 1)))); + assertEquals(2, dispatched.size()); + } + + @Test + public void queuedDuplicate_isSentAfterFailure() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + final Event queued = consequence(Map.of("timezone", "Asia/Tokyo")); + sync.handleOperationalDataRuleResponse(queued); + assertEquals(1, dispatched.size()); + + edgeResult(dispatched.get(0), "failed"); + assertNull(stored.get("timeZone")); + assertTrue(published.isEmpty()); + assertEquals(2, dispatched.size()); + assertEquals(queued.getUniqueIdentifier(), dispatched.get(1).getParentID()); + + edgeResult(dispatched.get(1), "success"); + assertEquals("Asia/Tokyo", stored.get("timeZone")); + assertEquals(Map.of("timezone", "Asia/Tokyo"), lastProfileAttributes()); + } + + @Test + public void droppedRequest_releasesNextConsequence() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + edgeResult(dispatched.get(0), "dropped"); + assertEquals(2, dispatched.size()); + assertTrue(published.isEmpty()); + assertEquals("Europe/Paris", dispatched.get(1).getEventData().get("timezone")); + } + + @Test + public void unrelatedAndDuplicateResults_doNotReleaseNextRequest() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/London"))); + edgeResult(consequence(Map.of()), "success"); + assertEquals(1, dispatched.size()); + + edgeResult(dispatched.get(0), "success"); + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + assertEquals("Asia/Tokyo", stored.get("timeZone")); + + edgeResult(dispatched.get(1), "success"); + assertEquals(3, dispatched.size()); + assertEquals("Europe/Paris", stored.get("timeZone")); + } + + @Test + public void reset_discardsQueueAndIgnoresLateResult() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + final Event oldRequest = dispatched.get(0); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + resetLikeExtension(); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/London"))); + edgeResult(oldRequest, "success"); + + assertEquals(2, dispatched.size()); + assertNull(stored.get("timeZone")); + assertTrue(lastProfileAttributes().isEmpty()); + edgeResult(dispatched.get(1), "success"); + assertEquals("Europe/London", stored.get("timeZone")); + } + + @Test + public void success_publishesBeforeDispatchingNextRequest() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + doAnswer(call -> { + assertEquals("Asia/Tokyo", stored.get("timeZone")); + assertEquals(Map.of("timezone", "Asia/Tokyo"), lastProfileAttributes()); + return record(call.getArgument(0)); + }) + .when(api) + .dispatch(any()); + + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + } + + @Test + public void queue_skipsUnchangedAndEmptyConsequencesWithoutBlockingLaterChanges() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("app", Map.of("id", "com.example")))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + assertEquals("Europe/Paris", dispatched.get(1).getEventData().get("timezone")); + } + + @Test + public void matchingResultWithInvalidStatus_releasesQueueAndReportsFailure() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/London"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "America/Denver"))); + final List> invalidResponses = List.of( + Map.of(), + Map.of("status", "retry"), + Map.of("status", true) + ); + final List timezones = List.of("Asia/Tokyo", "Europe/Paris", "Europe/London"); + for (int index = 0; index < invalidResponses.size(); index++) { + edgeResponse(invalidResponses.get(index), dispatched.get(index)); + assertEquals(index + 2, dispatched.size()); + assertEquals( + Map.of("status", "failed", "reason", "unknown", "attributes", Map.of("timezone", timezones.get(index))), + results.get(index).getEventData() + ); + } + assertTrue(stored.isEmpty()); + assertTrue(deviceStored.isEmpty()); + assertTrue(published.isEmpty()); + } + + @Test + public void unchainedResultMatchedByRequestEventId_releasesQueue() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + + edgeResponse( + Map.of("status", "failed", "requestEventId", dispatched.get(0).getUniqueIdentifier(), "reason", "decode"), + null + ); + + assertEquals(2, dispatched.size()); + assertEquals( + Map.of("status", "failed", "reason", "decode", "attributes", Map.of("timezone", "Asia/Tokyo")), + results.get(0).getEventData() + ); + } + + @Test + public void unchainedSuccessMatchedByRequestEventId_commitsValues() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + + edgeResponse(Map.of("status", "success", "requestEventId", dispatched.get(0).getUniqueIdentifier()), null); + + assertEquals(Map.of("timezone", "Asia/Tokyo"), syncedValues()); + assertEquals("success", results.get(0).getEventData().get("status")); + } + + @Test + public void nonMatchingResult_doesNotAdvanceQueueOrReport() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + + edgeResponse(Map.of("status", "success", "requestEventId", "other"), null); + edgeResponse(Map.of("status", "success"), consequence(Map.of())); + + assertEquals(1, dispatched.size()); + assertTrue(results.isEmpty()); + assertTrue(deviceStored.isEmpty()); + } + + @Test + public void queuedConsequence_readsLatestConsent() { + consentRegistered(); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + when(api.getXDMSharedState(eq(IdentityConstants.SharedState.Consent.NAME), isNull(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult(SharedStateStatus.SET, Map.of("consents", Map.of("collect", Map.of("val", "y")))) + ); + when(api.getSharedState(eq(IdentityConstants.SharedState.Messaging.NAME), isNull(), anyBoolean(), any())) + .thenReturn(new SharedStateResult(SharedStateStatus.SET, Map.of("pushidentifier", "token-1"))); + + edgeResult(dispatched.get(0), "success"); + assertEquals(1, dispatched.size()); + verify(api, atLeastOnce()) + .getXDMSharedState(eq(IdentityConstants.SharedState.Consent.NAME), isNull(), eq(false), any()); + } + + @Test + public void success_dispatchesSyncResultWithOnlySentAttributes() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + edgeResult(dispatched.get(0), "success"); + sync.handleOperationalDataRuleResponse( + consequence(Map.of("timezone", "Asia/Tokyo", "tokens", Map.of("pushNotification", "token-1"))) + ); + final Event edgeRequest = dispatched.get(1); + assertFalse(edgeRequest.getEventData().containsKey("timezone")); + final Event response = new Event.Builder( + "Device Attributes Response", + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, + EventSource.RESPONSE_CONTENT + ) + .setEventData(Map.of("status", "success")) + .chainToParentEvent(edgeRequest) + .build(); + sync.handleOperationalDataResult(response); + + assertEquals(2, dispatched.size()); + assertEquals(2, results.size()); + final Event result = results.get(1); + assertEquals("Operational Data Sync Result", result.getName()); + assertEquals(EventType.EDGE_IDENTITY, result.getType()); + assertEquals(EventSource.RESPONSE_CONTENT, result.getSource()); + assertEquals(response.getUniqueIdentifier(), result.getParentID()); + assertEquals( + Map.of( + "status", + "success", + "reason", + "", + "attributes", + Map.of("tokens", Map.of("pushNotification", "token-1")) + ), + result.getEventData() + ); + } + + @Test + public void failedAndDropped_dispatchFailedSyncResultWithReason() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + edgeResponse(Map.of("status", "failed", "reason", "network", "httpStatus", 503), dispatched.get(0)); + edgeResult(dispatched.get(1), "dropped"); + + assertEquals(2, results.size()); + assertEquals("Operational Data Sync Result", results.get(0).getName()); + assertEquals(EventType.EDGE_IDENTITY, results.get(0).getType()); + assertEquals(EventSource.RESPONSE_CONTENT, results.get(0).getSource()); + assertEquals( + Map.of("status", "failed", "reason", "network", "attributes", Map.of("timezone", "Asia/Tokyo")), + results.get(0).getEventData() + ); + assertEquals( + Map.of("status", "failed", "reason", "unknown", "attributes", Map.of("timezone", "Europe/Paris")), + results.get(1).getEventData() + ); + } + + @Test + public void failure_nonStringReasonIsReportedAsUnknown() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + edgeResponse(Map.of("status", "failed", "reason", 503), dispatched.get(0)); + + assertEquals( + Map.of("status", "failed", "reason", "unknown", "attributes", Map.of("timezone", "Asia/Tokyo")), + results.get(0).getEventData() + ); + } + + @Test + public void profileAttributes_arePublishedOnlyInStandardSharedState() { + stored.put("timeZone", "Asia/Tokyo"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(dispatched.get(0), "success"); + + verify(api, never()).createXDMSharedState(any(), any()); + assertEquals( + Map.of( + "profileAttributes", + Map.of("tokens", Map.of("pushNotification", "token-1"), "timezone", "Asia/Tokyo") + ), + published.get(0) + ); + } + + @Test + public void pushTokenArrays_areAlwaysSentAndNeverStored() { + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", List.of("a", "b", "c")))) + ); + edgeResult(dispatched.get(0), "success"); + + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", List.of("c", "a", "b")))) + ); + assertEquals(2, dispatched.size()); + assertTrue(deviceStored.isEmpty()); + + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", List.of("a", "b")))) + ); + edgeResult(dispatched.get(1), "success"); + assertEquals(3, dispatched.size()); + } + + @Test + public void commit_ignoresUnexpectedTypesAndKeepsConfirmedStrings() { + seedSynced(Map.of("tokens", Map.of("pushNotification", "token-1"), "timezone", "Asia/Tokyo")); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-2")))); + edgeResult(dispatched.get(0), "success"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-2"), "timezone", "Asia/Tokyo"), syncedValues()); + + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", Map.of("token", "token-3")))) + ); + edgeResult(dispatched.get(1), "success"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-2"), "timezone", "Asia/Tokyo"), syncedValues()); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", Map.of())))); + edgeResult(dispatched.get(2), "success"); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-2"), "timezone", "Asia/Tokyo"), syncedValues()); + } + + @Test + public void nestedPushObject_isAlwaysSentAndNeverStored() { + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", Map.of("token", "t1", "metadata", "old")))) + ); + edgeResult(dispatched.get(0), "success"); + dispatched.clear(); + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", Map.of("metadata", "old", "token", "t1")))) + ); + assertEquals(1, dispatched.size()); + assertTrue(deviceStored.isEmpty()); + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", Map.of("token", "t1", "metadata", "new")))) + ); + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + assertEquals( + Map.of("pushNotification", Map.of("token", "t1", "metadata", "new")), + dispatched.get(1).getEventData().get("tokens") + ); + } + + @Test + public void invalidLeaves_areForwardedWithoutValidation() { + final Map attributes = new HashMap<>(); + attributes.put("timezone", "Asia/Tokyo"); + attributes.put("nullValue", null); + attributes.put("invalidValue", Double.NaN); + sync.handleOperationalDataRuleResponse(consequence(attributes)); + attributes.put("app", Map.of("id", "com.example", "platform", "fcm")); + assertEquals(attributes, dispatched.get(0).getEventData()); + } + + @Test + public void queuedConsequence_readsLatestDedupConfiguration() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + when(api.getSharedState(eq(IdentityConstants.SharedState.Configuration.NAME), isNull(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of("edgeidentity.optimizeOperationalProfileDataSync", false) + ) + ); + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + assertEquals("Asia/Tokyo", dispatched.get(1).getEventData().get("timezone")); + } + + @Test + public void queuedConsequence_doesNotTrustOwnerAfterLatestConsentIsDenied() { + collectConsent("y"); + messagingPushToken("token-1"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("custom", "first"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + when(api.getXDMSharedState(eq(IdentityConstants.SharedState.Consent.NAME), isNull(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult(SharedStateStatus.SET, Map.of("consents", Map.of("collect", Map.of("val", "n")))) + ); + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + assertEquals(Map.of("pushNotification", "token-1"), dispatched.get(1).getEventData().get("tokens")); + } + + @Test + public void queuedConsequence_readsLatestMessagingAndTimezoneOwners() { + collectConsent("y"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("custom", "first"))); + sync.handleOperationalDataRuleResponse( + consequence(Map.of("timezone", "Europe/London", "tokens", Map.of("pushNotification", "token-1"))) + ); + stored.put("timeZone", "Europe/London"); + when(api.getSharedState(eq(IdentityConstants.SharedState.Messaging.NAME), isNull(), anyBoolean(), any())) + .thenReturn(new SharedStateResult(SharedStateStatus.SET, Map.of("pushidentifier", "token-1"))); + edgeResult(dispatched.get(0), "success"); + assertEquals(1, dispatched.size()); + assertEquals(2, results.size()); + assertEquals("unchanged", results.get(1).getEventData().get("reason")); + assertEquals( + Map.of("timezone", "Europe/London", "tokens", Map.of("pushNotification", "token-1")), + syncedValues() + ); + } + + @Test + public void failureDropAndUnrelatedSuccess_doNotConfirmTokens() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(consequence(Map.of()), "success"); + edgeResult(dispatched.get(0), "failed"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(dispatched.get(1), "dropped"); + assertEquals(2, results.size()); + for (final Event result : results) assertEquals("failed", result.getEventData().get("status")); + assertTrue(deviceStored.isEmpty()); + assertTrue(published.isEmpty()); + } + + @Test + public void nullTimezone_isSentWithoutClearingOwner() { + stored.put("timeZone", "Asia/Tokyo"); + final Map clear = new HashMap<>(); + clear.put("timezone", null); + sync.handleOperationalDataRuleResponse(consequence(clear)); + sync.handleOperationalDataRuleResponse(consequence(clear)); + assertEquals("Asia/Tokyo", stored.get("timeZone")); + assertEquals(1, dispatched.size()); + assertTrue(dispatched.get(0).getEventData().containsKey("timezone")); + assertNull(dispatched.get(0).getEventData().get("timezone")); + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + assertTrue(deviceStored.isEmpty()); + } + + private void assertSyncResult( + final Event result, + final String status, + final String reason, + final Map attributes, + final Event parent + ) { + assertEquals("Operational Data Sync Result", result.getName()); + assertEquals(EventType.EDGE_IDENTITY, result.getType()); + assertEquals(EventSource.RESPONSE_CONTENT, result.getSource()); + assertEquals(Map.of("status", status, "reason", reason, "attributes", attributes), result.getEventData()); + assertEquals(parent.getUniqueIdentifier(), result.getParentID()); + } + + @Test + public void allValuesSynced_reportsUnchangedWithoutEdgeRequest() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + succeedAll(); + results.clear(); + final Event rule = consequence( + Map.of("timezone", "Asia/Tokyo", "app", Map.of("id", "caller.app", "platform", "apns")) + ); + + sync.handleOperationalDataRuleResponse(rule); + + assertEquals(1, dispatched.size()); + assertEquals(1, results.size()); + assertSyncResult(results.get(0), "success", "unchanged", Map.of("timezone", "Asia/Tokyo"), rule); + } + + @Test + public void allValuesOwnerMatched_reportsUnchangedAndPublishesOnce() { + collectConsent("y"); + messagingPushToken("token-1"); + final Event rule = consequence(Map.of("tokens", Map.of("pushNotification", "token-1"))); + + sync.handleOperationalDataRuleResponse(rule); + + assertTrue(dispatched.isEmpty()); + assertEquals(1, published.size()); + assertEquals(1, results.size()); + assertSyncResult( + results.get(0), + "success", + "unchanged", + Map.of("tokens", Map.of("pushNotification", "token-1")), + rule + ); + } + + @Test + public void partiallySyncedValues_areSentWithoutUnchangedResult() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + succeedAll(); + results.clear(); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo", "custom", "x"))); + + assertEquals(2, dispatched.size()); + assertTrue(results.isEmpty()); + edgeResult(dispatched.get(1), "success"); + assertEquals(1, results.size()); + assertEquals( + Map.of("status", "success", "reason", "", "attributes", Map.of("custom", "x")), + results.get(0).getEventData() + ); + } + + @Test + public void reset_reportsActiveAndQueuedRequestsAsFailedInOrder() { + final Event first = consequence(Map.of("timezone", "Asia/Tokyo")); + final Event second = consequence(Map.of("custom", "a", "app", Map.of("id", "caller.app", "platform", "apns"))); + final Event third = consequence(Map.of("custom", "b")); + sync.handleOperationalDataRuleResponse(first); + sync.handleOperationalDataRuleResponse(second); + sync.handleOperationalDataRuleResponse(third); + final Event active = dispatched.get(0); + + resetLikeExtension(); + + assertEquals(3, results.size()); + assertSyncResult(results.get(0), "failed", "identityReset", Map.of("timezone", "Asia/Tokyo"), active); + assertSyncResult(results.get(1), "failed", "identityReset", Map.of("custom", "a"), second); + assertSyncResult(results.get(2), "failed", "identityReset", Map.of("custom", "b"), third); + + edgeResult(active, "success"); + assertEquals(3, results.size()); + assertEquals(1, dispatched.size()); + assertTrue(deviceStored.isEmpty()); + } + + @Test + public void reset_withNothingPending_reportsNoResults() { + resetLikeExtension(); + assertTrue(results.isEmpty()); + + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + succeedAll(); + results.clear(); + resetLikeExtension(); + assertTrue(results.isEmpty()); + } + + @Test + public void missingAppIdentifier_reportsChangedValuesAndContinuesQueue() { + when(application.getPackageName()).thenReturn("com.example", "", "com.example"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + final Event noAppId = consequence( + Map.of("timezone", "Asia/Tokyo", "custom", "x", "app", Map.of("id", "caller.app", "platform", "apns")) + ); + final Event next = consequence(Map.of("custom", "y")); + sync.handleOperationalDataRuleResponse(noAppId); + sync.handleOperationalDataRuleResponse(next); + + edgeResult(dispatched.get(0), "success"); + + assertEquals(2, results.size()); + assertSyncResult(results.get(1), "failed", "missingAppIdentifier", Map.of("custom", "x"), noAppId); + assertEquals(2, dispatched.size()); + assertEquals(next.getUniqueIdentifier(), dispatched.get(1).getParentID()); + } + + private Event edgeResultEvent(final Event edgeRequest, final String status) { + final Event response = new Event.Builder( + "Device Attributes Response", + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE, + EventSource.RESPONSE_CONTENT + ) + .setEventData(Map.of("status", status)) + .chainToParentEvent(edgeRequest) + .build(); + sync.handleOperationalDataResult(response); + return response; + } + + @Test + public void success_withTokens_dispatchesConfirmedTokensAfterPublishAndBeforeResult() { + final List order = new ArrayList<>(); + doAnswer(call -> { + final Event event = call.getArgument(0); + order.add(event.getName()); + return record(event); + }) + .when(api) + .dispatch(any()); + doAnswer(call -> { + order.add("publish"); + return published.add(call.getArgument(0)); + }) + .when(api) + .createSharedState(anyMap(), any()); + sync.handleOperationalDataRuleResponse( + consequence(Map.of("timezone", "Asia/Tokyo", "tokens", Map.of("pushNotification", "token-1"))) + ); + order.clear(); + + final Event response = edgeResultEvent(dispatched.get(0), "success"); + + assertEquals(1, confirmed.size()); + final Event event = confirmed.get(0); + assertEquals("Operational data confirmed", event.getName()); + assertEquals(EventType.MESSAGING, event.getType()); + assertEquals(EventSource.RESPONSE_CONTENT, event.getSource()); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), event.getEventData()); + assertEquals(response.getUniqueIdentifier(), event.getParentID()); + assertEquals(List.of("publish", "Operational data confirmed", "Operational Data Sync Result"), order); + } + + @Test + public void success_withoutTokens_doesNotDispatchConfirmedTokens() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + edgeResult(dispatched.get(0), "success"); + assertEquals(1, results.size()); + assertTrue(confirmed.isEmpty()); + } + + @Test + public void failure_withTokens_doesNotDispatchConfirmedTokens() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(dispatched.get(0), "failed"); + assertEquals(1, results.size()); + assertTrue(confirmed.isEmpty()); + } + + @Test + public void unchanged_ownerMatchedToken_doesNotDispatchConfirmedTokens() { + collectConsent("y"); + messagingPushToken("token-1"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + assertEquals(1, published.size()); + assertEquals(1, results.size()); + assertEquals("unchanged", results.get(0).getEventData().get("reason")); + assertTrue(confirmed.isEmpty()); + } + + @Test + public void reset_withPendingTokenRequest_doesNotDispatchConfirmedTokens() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + resetLikeExtension(); + assertEquals(2, results.size()); + assertTrue(confirmed.isEmpty()); + } + + @Test + public void partialDedup_confirmsOnlyTheSentTokens() { + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", "token-1", "other", "o-1"))) + ); + edgeResult(dispatched.get(0), "success"); + confirmed.clear(); + + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", "token-1", "other", "o-2"))) + ); + assertEquals(Map.of("other", "o-2"), dispatched.get(1).getEventData().get("tokens")); + final Event response = edgeResultEvent(dispatched.get(1), "success"); + + assertEquals(1, confirmed.size()); + assertEquals(Map.of("tokens", Map.of("other", "o-2")), confirmed.get(0).getEventData()); + assertEquals(response.getUniqueIdentifier(), confirmed.get(0).getParentID()); + } + + @Test + public void dispatchFailure_reportsInternalErrorAndSendsNextQueuedRequest() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + final Map app = Map.of("id", "com.example", "platform", "fcm"); + final Event failing = consequence(Map.of("app", app, "tokens", Map.of("pushNotification", "token-1"))); + sync.handleOperationalDataRuleResponse(failing); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/London"))); + final int[] sends = { 0 }; + doAnswer(call -> { + final Event event = call.getArgument(0); + if ( + IdentityConstants.OperationalData.EDGE_DEVICE_ATTRIBUTES_TYPE.equals(event.getType()) && + sends[0]++ == 0 + ) { + throw new IllegalStateException("dispatch failed"); + } + return record(event); + }) + .when(api) + .dispatch(any()); + + edgeResult(dispatched.get(0), "success"); + + assertEquals(2, results.size()); + assertSyncResult( + results.get(1), + "failed", + "internalError", + Map.of("tokens", Map.of("pushNotification", "token-1")), + failing + ); + assertEquals(2, dispatched.size()); + assertEquals("Europe/London", dispatched.get(1).getEventData().get("timezone")); + + edgeResult(dispatched.get(1), "success"); + assertEquals(3, results.size()); + assertEquals("Europe/London", syncedValues().get("timezone")); + } + + @Test + public void successHandlingThrows_stillSendsNextQueuedRequest() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Europe/London"))); + assertEquals(1, dispatched.size()); + doThrow(new IllegalStateException("publish failed")).when(api).createSharedState(anyMap(), any()); + + assertThrows(IllegalStateException.class, () -> edgeResult(dispatched.get(0), "success")); + + assertEquals(2, dispatched.size()); + assertEquals("Europe/London", dispatched.get(1).getEventData().get("timezone")); + } + + @Test + public void customKey_sentTwice_isSentBothTimesAndNeverStored() { + sync.handleOperationalDataRuleResponse(consequence(Map.of("custom", "value"))); + edgeResult(dispatched.get(0), "success"); + sync.handleOperationalDataRuleResponse(consequence(Map.of("custom", "value"))); + edgeResult(dispatched.get(1), "success"); + + assertEquals(2, dispatched.size()); + assertEquals("value", dispatched.get(1).getEventData().get("custom")); + assertTrue(deviceStored.isEmpty()); + assertFalse(lastProfileAttributes().containsKey("custom")); + } + + @Test + public void mixedRequest_sendsOnlyCustomKeyAndStoresOnlyKnownValues() { + seedSynced(Map.of("timezone", "Asia/Tokyo")); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo", "custom", "value"))); + + assertEquals(1, dispatched.size()); + assertEquals( + Map.of("custom", "value", "app", Map.of("id", "com.example", "platform", "fcm")), + dispatched.get(0).getEventData() + ); + assertTrue(results.isEmpty()); + + edgeResult(dispatched.get(0), "success"); + assertEquals(Map.of("timezone", "Asia/Tokyo"), syncedValues()); + } + + @Test + public void unknownTokenKey_withSyncedPushToken_sendsOnlyUnknownKey() { + seedSynced(Map.of("tokens", Map.of("pushNotification", "token-1"))); + sync.handleOperationalDataRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", "token-1", "foo", "bar"))) + ); + + assertEquals(1, dispatched.size()); + assertEquals(Map.of("foo", "bar"), dispatched.get(0).getEventData().get("tokens")); + } + + @Test + public void storedUnknownKeys_areNotPublished() { + deviceStored.put("timezone", "Asia/Tokyo"); + deviceStored.put("custom", "value"); + deviceStored.put("foo", "bar"); + sync.publishStoredProfileAttributes(); + assertEquals(Map.of("timezone", "Asia/Tokyo"), lastProfileAttributes()); + assertEquals(Map.of("timezone", "Asia/Tokyo"), syncedValues()); + } + + @Test + public void storedProfileAttributes_emptyStore_doesNotPublish() { + sync.publishStoredProfileAttributes(); + assertTrue(published.isEmpty()); + } + + @Test + public void storedProfileAttributes_legacyConfirmedMapIsNotRestored() { + deviceStored.put("confirmedAttributes", "{\"timezone\":\"Asia/Tokyo\"}"); + sync = newSync(); + sync.publishStoredProfileAttributes(); + assertTrue(published.isEmpty()); + sync.handleOperationalDataRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertEquals(1, dispatched.size()); + } + + @Test + public void storedProfileAttributes_restoresTokensAndPrioritizesProfileTimezone() { + seedSynced(Map.of("timezone", "Asia/Tokyo", "tokens", Map.of("pushNotification", "token-1"))); + stored.put("timeZone", "Europe/London"); + sync.publishStoredProfileAttributes(); + assertEquals( + Map.of("timezone", "Europe/London", "tokens", Map.of("pushNotification", "token-1")), + lastProfileAttributes() + ); + } + + @Test + public void explicitPublication_emptyStore_stillPublishesForReset() { + sync.publishProfileAttributes(null); + assertEquals(1, published.size()); + assertTrue(lastProfileAttributes().isEmpty()); + } +} diff --git a/code/gradle.properties b/code/gradle.properties index 5ab2758a..393af23c 100644 --- a/code/gradle.properties +++ b/code/gradle.properties @@ -15,7 +15,7 @@ org.gradle.caching=true moduleName=edgeidentity moduleVersion=3.1.0 -mavenCoreVersion=3.8.0 +mavenCoreVersion=3.11.0 mavenRepoName=AdobeMobileEdgeIdentitySdk mavenRepoDescription=Adobe Experience Platform Edge Identity extension for the Adobe Experience Platform Mobile SDK