diff --git a/Documentation/api-reference.md b/Documentation/api-reference.md index 52ba6d26..6e72354e 100644 --- a/Documentation/api-reference.md +++ b/Documentation/api-reference.md @@ -16,6 +16,31 @@ Refer to the [Getting Started Guide](getting-started.md) | [resetIdentities](#resetIdentities) | | [setAdvertisingIdentifier](#setAdvertisingIdentifier) | | [updateIdentities](#updateIdentities) | +| [updateDeviceAttributesBypassConsent](#updatedeviceattributesbypassconsent) | + +------ + +### updateDeviceAttributesBypassConsent + +Requests rule-authorized operational device attribute synchronization. The map overload supports JSON-compatible values, including empty strings, arrays, and objects. Null and non-JSON attribute leaves are dropped without discarding valid sibling attributes, matching the current iOS implementation. This API is fire-and-forget; it has no public callback. + +```java +public static void updateDeviceAttributesBypassConsent(DeviceAttributes attributes) +public static void updateDeviceAttributesBypassConsent(Map attributes) +``` + +```java +Identity.updateDeviceAttributesBypassConsent( + 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. Authorized operational values use the Edge bypass request under collect consent `yes`, `no`, or `pending`; ordinary Edge events remain governed by consent. Only when the latest collect consent is explicitly `y` does Identity also compare push tokens with Messaging's latest shared-state value and timezone with its persisted owner value. Missing, pending, or malformed consent does not authorize owner-store comparisons. Confirmed bypass values are compared at every consent state. Identity queues requests in event order and reads current configuration and owner state when each request is dequeued. It waits for a correlated terminal `success`, `failed`, or `dropped` result before advancing; malformed results are ignored. Confirmed values are persisted and deduplicated across app restarts; set `edgeidentity.optimizeOperationalProfileDataSync` to `false` to disable all dedup 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 bypass values are persisted under `confirmedAttributes` in the separate `deviceAttributesDataStore` collection. Existing confirmed values from the earlier profile-store layout are restored when the new store has no saved values. Timezone success also updates the profile timezone owner. The complete confirmed state remains published under `profileAttributes` in Identity's standard shared state for the existing Android Messaging consumer. In addition, Identity emits a chained `Device attributes confirmed` event (`messaging` / `responseContent`) containing only the successfully sent `tokens`, matching iOS. Failed, dropped, malformed, and unrelated results do not confirm tokens. + +An empty map or a typed object with no values is a no-op. The typed convenience object preserves explicitly supplied empty timezone and push-token strings. Null leaves are not sent by either overload. After rules and dedup filtering, 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 owner-store comparison and APNs sandbox routing are iOS-specific; arbitrary rule-authorized arrays remain supported by the Android map overload. + +See [the device attribute flow and local setup](device-attribute-transactions.md) for rule configuration and shared-state behavior. ------ diff --git a/code/app/src/main/java/com/adobe/marketing/edge/identity/testapp/EdgeIdentityApplication.kt b/code/app/src/main/java/com/adobe/marketing/edge/identity/testapp/EdgeIdentityApplication.kt index 60d51dee..5774b6a3 100644 --- a/code/app/src/main/java/com/adobe/marketing/edge/identity/testapp/EdgeIdentityApplication.kt +++ b/code/app/src/main/java/com/adobe/marketing/edge/identity/testapp/EdgeIdentityApplication.kt @@ -20,8 +20,8 @@ import com.adobe.marketing.mobile.edge.consent.Consent import com.adobe.marketing.mobile.edge.identity.Identity class EdgeIdentityApplication : Application() { - // TODO: Set up the preferred Environment File ID from your mobile property configured in Data Collection UI - private var ENVIRONMENT_FILE_ID: String = "" + // Datastream ID (edge.configId) from the "Luma Mobile App - vinamra" datastream, not a Launch/Tags Environment File ID + private var EDGE_CONFIG_ID: String = "8f56dfea-5290-4cf7-8ea2-9e5f0fb5d051" override fun onCreate() { super.onCreate() @@ -32,7 +32,12 @@ class EdgeIdentityApplication : Application() { MobileCore.registerExtensions( listOf(Edge.EXTENSION, Identity.EXTENSION, Consent.EXTENSION, Assurance.EXTENSION) ) { - MobileCore.configureWithAppID(ENVIRONMENT_FILE_ID) + MobileCore.updateConfiguration( + mapOf( + "edge.configId" to EDGE_CONFIG_ID, + "consent.default" to mapOf("consents" to mapOf("collect" to mapOf("val" to "y"))) + ) + ) } } } diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeHandler.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/AttributeHandler.java similarity index 60% rename from code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeHandler.java rename to code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/AttributeHandler.java index 69dd0748..ad750bfa 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeHandler.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/AttributeHandler.java @@ -15,19 +15,16 @@ import java.util.Map; /** - * Handles a single profile attribute end-to-end: it claims a key on the incoming - * {@code PROFILE_ATTRIBUTE} event, owns its dedup + persistence, and reports its - * contribution to the outgoing collated {@code profile.updateAttributes} Edge payload and - * to the profile-attributes shared state. + * Handles a single attribute end-to-end: it claims a key on the incoming event, owns its dedup + * and persistence, and reports its contribution to the outgoing payload. * *

Each attribute (timezone today; locale, push identifier, ... in the future) is one handler - * registered in {@link ProfileAttributeHandlers}. The collector layer in {@link IdentityState} - * iterates every registered handler and stitches their contributions into a single Edge event - * and into the shared state. + * registered independently for profile and device attribute paths. The two paths use separate + * stores so that a value on one path cannot suppress dispatch on the other. */ -interface ProfileAttributeHandler { +interface AttributeHandler { /** - * Returns the key on the incoming {@code PROFILE_ATTRIBUTE} event data that this handler + * Returns the key on the incoming event data that this handler * claims (e.g. {@code "timeZone"}). The collector uses this purely as an input filter: * {@link #collectFromEvent(Event)} is invoked only when this key is present in the event * data. @@ -41,9 +38,8 @@ interface ProfileAttributeHandler { String getAttributeKey(); /** - * Reads this handler's value from the update {@code event}, dedups against persistence, and - * on change writes the new value to persistence before returning the contribution to add to - * the outgoing collated {@code profile.updateAttributes} Edge payload. Returns {@code null} + * Reads this handler's value from the update {@code event}, optionally dedups against persistence, and + * writes the new value to persistence before returning its contribution. Returns {@code null} * (or an empty map) when nothing should be contributed (key absent, value empty, or * unchanged). * @@ -51,14 +47,24 @@ interface ProfileAttributeHandler { * death between persist and dispatch leaves the pending value in storage; the next sync will * pick it up via dedup. * - * @param event the profile attributes update event + * @param event the attribute update event + * @param dedup whether unchanged values should be skipped * @return the contribution to merge into the outgoing payload, or {@code null} */ - Map collectFromEvent(Event event); + Map collectFromEvent(Event event, boolean dedup); /** - * Returns this handler's persisted contribution as it should appear in the profile-attributes - * shared state, or {@code null} (or an empty map) when nothing is stored. + * Collects an attribute using the existing profile-path dedup behavior. + * + * @param event the attribute update event + * @return the changed attribute contribution, or {@code null} + */ + default Map collectFromEvent(final Event event) { + return collectFromEvent(event, true); + } + + /** + * Returns this handler's persisted contribution, or {@code null} (or an empty map) when nothing is stored. * * @return the persisted contribution, or {@code null} */ diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeDeduplicator.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeDeduplicator.java new file mode 100644 index 00000000..cfc737ca --- /dev/null +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeDeduplicator.java @@ -0,0 +1,281 @@ +/* + 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 androidx.annotation.VisibleForTesting; +import com.adobe.marketing.mobile.services.Log; +import com.adobe.marketing.mobile.util.DataReader; +import com.adobe.marketing.mobile.util.JSONUtils; +import java.lang.reflect.Array; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Compares device attributes with Edge-confirmed and consent-granted owner values. + * Filtering never writes storage; only the successful Edge payload is committed. + */ +final class DeviceAttributeDeduplicator { + + private static final String LOG_SOURCE = "DeviceAttributeDeduplicator"; + private final ProfileAttributeStore store; + private final Map, Object> synced = new LinkedHashMap<>(); + + DeviceAttributeDeduplicator(final ProfileAttributeStore store) { + this.store = store; + final Map persisted = store.getMap(IdentityConstants.DeviceAttributes.SYNCED_VALUES); + if (persisted != null) synced.putAll(paths(persisted)); + } + + Map filter( + final Map attributes, + final boolean dedup, + final Map messagingState, + final String timezone + ) { + final Map, Object> toSend = new LinkedHashMap<>(); + for (final Map.Entry, Object> entry : paths(attributes).entrySet()) { + final List path = entry.getKey(); + final Object value = entry.getValue(); + final String canonical = canonicalize(value); + if (canonical == null) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Dropping invalid device attribute at path %s.", + path + ); + continue; + } + if ( + dedup && + ( + canonical.equals(canonicalize(synced.get(path))) || + matchesOwner(path, value, messagingState, timezone) + ) + ) continue; + try { + toSend.put(path, snapshot(canonical)); + } catch (JSONException exception) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Unable to snapshot device attribute at path %s.", + path + ); + } + } + return tree(toSend); + } + + private static boolean matchesOwner( + final List path, + final Object value, + final Map messagingState, + final String timezone + ) { + if (path.equals(List.of(IdentityConstants.DeviceAttributes.TIMEZONE))) return value.equals(timezone); + if ( + path.equals( + List.of(IdentityConstants.DeviceAttributes.TOKENS, IdentityConstants.DeviceAttributes.PUSH_NOTIFICATION) + ) + ) { + return ( + value instanceof String && + value.equals( + DataReader.optString(messagingState, IdentityConstants.SharedState.Messaging.PUSH_IDENTIFIER, null) + ) + ); + } + return false; + } + + void commit(final Map attributes) { + for (final Map.Entry, Object> entry : paths(attributes).entrySet()) { + final String canonical = canonicalize(entry.getValue()); + if (canonical == null) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Cannot confirm invalid device attribute at path %s.", + entry.getKey() + ); + continue; + } + try { + final Object value = snapshot(canonical); + removeRelatedPaths(entry.getKey()); + synced.put(entry.getKey(), value); + } catch (JSONException exception) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Unable to confirm device attribute at path %s.", + entry.getKey() + ); + } + } + store.setMap(IdentityConstants.DeviceAttributes.SYNCED_VALUES, readSynced()); + } + + Map readSynced() { + return tree(synced); + } + + void clear() { + synced.clear(); + store.remove(IdentityConstants.DeviceAttributes.SYNCED_VALUES); + } + + private static Object snapshot(final String canonical) throws JSONException { + return JSONUtils.toMap(new JSONObject("{\"value\":" + canonical + "}")).get("value"); + } + + private void removeRelatedPaths(final List path) { + synced.keySet().removeIf(existing -> isPrefix(existing, path) || isPrefix(path, existing)); + } + + private static boolean isPrefix(final List prefix, final List path) { + return prefix.size() <= path.size() && path.subList(0, prefix.size()).equals(prefix); + } + + private static Map, Object> paths(final Map attributes) { + final Map, Object> result = new LinkedHashMap<>(); + if (attributes != null) extract(attributes, Collections.emptyList(), result); + return result; + } + + private static void extract( + final Map attributes, + final List prefix, + final Map, Object> result + ) { + for (final Map.Entry entry : attributes.entrySet()) { + if (!(entry.getKey() instanceof String)) { + Log.warning(IdentityConstants.LOG_TAG, LOG_SOURCE, "Dropping device attribute with a non-string key."); + continue; + } + final String key = (String) entry.getKey(); + if (prefix.isEmpty() && IdentityConstants.DeviceAttributes.APP.equals(key)) continue; + final List path = new ArrayList<>(prefix); + path.add(key); + final Object value = entry.getValue(); + if (value instanceof Map && !((Map) value).isEmpty()) { + extract((Map) value, path, result); + } else { + result.put(path, value); + } + } + } + + private static Map tree(final Map, Object> attributes) { + final Map result = new LinkedHashMap<>(); + for (final Map.Entry, Object> entry : attributes.entrySet()) set( + entry.getValue(), + entry.getKey(), + 0, + result + ); + return result; + } + + @SuppressWarnings("unchecked") + private static void set( + final Object value, + final List path, + final int index, + final Map attributes + ) { + final String key = path.get(index); + if (index == path.size() - 1) { + attributes.put(key, value); + return; + } + final Map child = attributes.get(key) instanceof Map + ? new LinkedHashMap<>((Map) attributes.get(key)) + : new LinkedHashMap<>(); + set(value, path, index + 1, child); + attributes.put(key, child); + } + + @VisibleForTesting + static String canonicalize(final Object value) { + return value == null || value == JSONObject.NULL ? null : canonicalValue(value); + } + + private static String canonicalValue(final Object value) { + if (value == null || value == JSONObject.NULL) { + return "null"; + } + if (value instanceof String) { + return JSONObject.quote((String) value); + } + if (value instanceof Boolean) { + return value.toString(); + } + if (value instanceof Number) { + final double number = ((Number) value).doubleValue(); + if (!Double.isFinite(number)) return null; + try { + return number == 0 ? "0" : JSONObject.numberToString((Number) value); + } catch (JSONException exception) { + return null; + } + } + if (value instanceof Map) { + final Map sorted = new TreeMap<>(); + for (final Map.Entry entry : ((Map) value).entrySet()) { + if (!(entry.getKey() instanceof String)) { + return null; + } + sorted.put((String) entry.getKey(), entry.getValue()); + } + final List entries = new ArrayList<>(); + for (final Map.Entry entry : sorted.entrySet()) { + final String canonicalValue = canonicalValue(entry.getValue()); + if (canonicalValue == null) { + return null; + } + entries.add(JSONObject.quote(entry.getKey()) + ":" + canonicalValue); + } + return "{" + String.join(",", entries) + "}"; + } + if (value instanceof List) { + final List elements = new ArrayList<>(); + for (final Object element : (List) value) { + final String canonicalElement = canonicalValue(element); + if (canonicalElement == null) { + return null; + } + elements.add(canonicalElement); + } + return "[" + String.join(",", elements) + "]"; + } + if (value.getClass().isArray()) { + final List elements = new ArrayList<>(); + for (int index = 0; index < Array.getLength(value); index++) { + final String canonicalElement = canonicalValue(Array.get(value, index)); + if (canonicalElement == null) { + return null; + } + elements.add(canonicalElement); + } + return "[" + String.join(",", elements) + "]"; + } + return null; + } +} diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeSync.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeSync.java new file mode 100644 index 00000000..c226b0f1 --- /dev/null +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeSync.java @@ -0,0 +1,279 @@ +/* + 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.Collections; +import java.util.Deque; +import java.util.Map; + +/** + * Sends device attributes to Edge after the rules engine has filtered them. + * + *

    + *
  1. {@link #handleDeviceAttributes(Event)} sends the public API data to the rules engine.
  2. + *
  3. Each forward-operational-data consequence comes back through {@link #handleDeviceAttributeRuleResponse(Event)}.
  4. + *
  5. Consequences wait in a FIFO queue until the previous Edge request finishes. Values already + * synced are dropped and only changed values are sent to Edge.
  6. + *
  7. When Edge reports success ({@link #handleDeviceAttributeResult(Event)}), the complete confirmed store is persisted and + * published in {@code profileAttributes}, which Messaging reads to keep its token store in sync.
  8. + *
+ * + *

Confirming a path replaces previously confirmed ancestor and descendant paths, while + * preserving unrelated values. Failed requests leave the confirmed store unchanged.

+ */ +final class DeviceAttributeSync { + + static final String RULE_RESPONSE_SOURCE = "com.adobe.eventSource.responseOperationalData"; + static final String BYPASS_TYPE = "com.adobe.eventType.edgeBypassConsent"; + private static final String LOG_SOURCE = "DeviceAttributeSync"; + private static final String APP = "app"; + + private final ExtensionApi api; + private final ProfileAttributeStore profileAttributeStore; + private final Deque deviceAttributeQueue = new ArrayDeque<>(); + private Event deviceAttributesForEdge; + private final DeviceAttributeDeduplicator deviceAttributeDeduplicator; + + DeviceAttributeSync(final ExtensionApi api, final ProfileAttributeStore profileAttributeStore) { + this( + api, + profileAttributeStore, + new ProfileAttributeStore( + ServiceProvider + .getInstance() + .getDataStoreService() + .getNamedCollection(IdentityConstants.DataStoreKey.DEVICE_ATTRIBUTES_DATASTORE_NAME) + ) + ); + } + + DeviceAttributeSync( + final ExtensionApi api, + final ProfileAttributeStore profileAttributeStore, + final ProfileAttributeStore deviceAttributeStore + ) { + this.api = api; + this.profileAttributeStore = + profileAttributeStore != null ? profileAttributeStore : new ProfileAttributeStore(null); + this.deviceAttributeDeduplicator = new DeviceAttributeDeduplicator(deviceAttributeStore); + // Preserve confirmed values from the earlier shared-store implementation. + final Map persisted = this.profileAttributeStore.getMap("deviceAttributes"); + if ( + deviceAttributeStore.getMap(IdentityConstants.DeviceAttributes.SYNCED_VALUES) == null && persisted != null + ) { + deviceAttributeDeduplicator.commit(persisted); + } + } + + /** Forwards public API data to the rules engine, which decides which attributes may be sent. */ + void handleDeviceAttributes(final Event event) { + final Map data = event.getEventData(); + if (data == null || data.isEmpty()) { + Log.warning(IdentityConstants.LOG_TAG, LOG_SOURCE, "Ignoring empty or invalid device attribute request."); + return; + } + final Event operationalEvent = new Event.Builder( + IdentityConstants.EventNames.DEVICE_ATTRIBUTES_TO_RULES_ENGINE, + IdentityConstants.DeviceAttributes.OPERATIONAL_DATA_TYPE, + EventSource.REQUEST_CONTENT + ) + .setEventData(data) + .chainToParentEvent(event) + .build(); + api.dispatch(operationalEvent); + } + + /** Queues a consequence until the previous Edge request has completed. */ + void handleDeviceAttributeRuleResponse(final Event event) { + if (event.getEventData() == null || event.getEventData().isEmpty()) { + Log.debug(IdentityConstants.LOG_TAG, LOG_SOURCE, "Rule response contains no device attributes."); + return; + } + deviceAttributeQueue.addLast(event); + sendNextDeviceAttributeRequest(); + } + + private void sendNextDeviceAttributeRequest() { + while (deviceAttributesForEdge == null && !deviceAttributeQueue.isEmpty()) sendChangedValues( + deviceAttributeQueue.removeFirst() + ); + } + + private void sendChangedValues(final Event event) { + final Map attributes = event.getEventData(); + final boolean dedup = !Boolean.FALSE.equals( + configuration().get(IdentityConstants.DeviceAttributes.DEDUP_CONFIG_KEY) + ); + final boolean compareOwners = dedup && isCollectConsentGranted(); + final SharedStateResult messagingResult = compareOwners + ? api.getSharedState( + IdentityConstants.SharedState.Messaging.NAME, + null, + false, + SharedStateResolution.LAST_SET + ) + : null; + final Map messagingState = messagingResult != null && + messagingResult.getStatus() == SharedStateStatus.SET + ? messagingResult.getValue() + : null; + final String timezone = compareOwners ? profileAttributeStore.getString("timeZone") : null; + final Map payload = deviceAttributeDeduplicator.filter( + attributes, + dedup, + messagingState, + timezone + ); + if (payload.isEmpty()) { + Log.debug(IdentityConstants.LOG_TAG, LOG_SOURCE, "Device attributes unchanged, not sending to Edge."); + return; + } + + 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 device attributes without an app package identifier." + ); + return; + } + payload.put(APP, Map.of("id", appId, "platform", "fcm")); + final Event edgeEvent = new Event.Builder("Edge bypass request", BYPASS_TYPE, EventSource.REQUEST_CONTENT) + .setEventData(payload) + .chainToParentEvent(event) + .build(); + deviceAttributesForEdge = edgeEvent; + api.dispatch(edgeEvent); + } + + /** Stores successful values before processing the next queued consequence. */ + void handleDeviceAttributeResult(final Event event) { + if ( + deviceAttributesForEdge == null || + !deviceAttributesForEdge.getUniqueIdentifier().equals(event.getParentID()) + ) return; + final String status = DataReader.optString(event.getEventData(), "status", null); + if (!"success".equals(status) && !"failed".equals(status) && !"dropped".equals(status)) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Ignoring device attribute result with an invalid status." + ); + return; + } + if ("success".equals(status)) { + final Map attributes = deviceAttributesForEdge.getEventData(); + deviceAttributeDeduplicator.commit(attributes); + final String timezone = DataReader.optString(attributes, IdentityConstants.DeviceAttributes.TIMEZONE, null); + if (timezone != null) profileAttributeStore.setString("timeZone", timezone); + publishProfileAttributes(event); + final Map tokens = DataReader.optTypedMap( + Object.class, + attributes, + IdentityConstants.DeviceAttributes.TOKENS, + null + ); + if (tokens != null && !tokens.isEmpty()) { + api.dispatch( + new Event.Builder( + IdentityConstants.EventNames.DEVICE_ATTRIBUTES_CONFIRMED, + EventType.MESSAGING, + EventSource.RESPONSE_CONTENT + ) + .setEventData(Collections.singletonMap(IdentityConstants.DeviceAttributes.TOKENS, tokens)) + .chainToParentEvent(event) + .build() + ); + } + } else { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Device attribute sync %s: %s. Values remain unsynced.", + status, + DataReader.optString(event.getEventData(), "reason", "unknown") + ); + } + deviceAttributesForEdge = null; + sendNextDeviceAttributeRequest(); + } + + private boolean isCollectConsentGranted() { + final SharedStateResult consentResult = api.getXDMSharedState( + IdentityConstants.SharedState.Consent.NAME, + null, + false, + SharedStateResolution.ANY + ); + if (consentResult != null && consentResult.getStatus() == SharedStateStatus.SET) { + 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); + } + + return false; + } + + void reset(final Event event) { + deviceAttributeQueue.clear(); + deviceAttributesForEdge = null; + deviceAttributeDeduplicator.clear(); + profileAttributeStore.clearAll(); + publishProfileAttributes(event); + } + + void publishProfileAttributes(final Event event) { + final Map attributes = deviceAttributeDeduplicator.readSynced(); + final String timezone = profileAttributeStore.getString("timeZone"); + if (timezone != null) attributes.put("timezone", timezone); + api.createSharedState( + Collections.singletonMap(IdentityConstants.ProfileAttributes.SHARED_STATE_KEY, attributes), + event + ); + } + + 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/DeviceAttributes.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributes.java new file mode 100644 index 00000000..c567fff0 --- /dev/null +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributes.java @@ -0,0 +1,85 @@ +/* + 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; + +/** + * Input for {@link Identity#updateDeviceAttributesBypassConsent(DeviceAttributes)}. + * Unset or empty attributes are ignored. Use the builder to configure the attributes to update. + */ +public final class DeviceAttributes { + + private final String timeZone; + private final String pushToken; + + private DeviceAttributes(final Builder builder) { + this.timeZone = builder.timeZone; + this.pushToken = builder.pushToken; + } + + /** + * Returns the IANA time zone identifier to synchronize, or {@code null} when unset. + * + * @return the time zone identifier + */ + public String getTimeZone() { + return timeZone; + } + + /** + * Returns the push token to synchronize, or {@code null} when unset. + * + * @return the push token + */ + public String getPushToken() { + return pushToken; + } + + /** + * Builder for {@link DeviceAttributes}. + */ + public static final class Builder { + + private String timeZone; + private String pushToken; + + /** + * Sets the IANA time zone identifier. + * + * @param timeZone the time zone identifier + * @return this builder + */ + public Builder setTimeZone(final String timeZone) { + this.timeZone = timeZone; + return this; + } + + /** + * Sets the push token. + * + * @param pushToken the push token + * @return this builder + */ + public Builder setPushToken(final String pushToken) { + this.pushToken = pushToken; + return this; + } + + /** + * Builds the device attributes value. + * + * @return the configured attributes + */ + public DeviceAttributes build() { + return new DeviceAttributes(this); + } + } +} 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..ad227fe1 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,63 @@ public static void updateIdentities(@NonNull final IdentityMap identityMap) { MobileCore.dispatchEvent(updateIdentitiesEvent); } + /** + * Updates device attributes that should be delivered to the Edge Network independently of collect consent. + * Launch rules decide which values are sent; values already successfully synced are skipped. + * Set {@code edgeidentity.optimizeOperationalProfileDataSync} to {@code false} in + * Configuration to dispatch every update. Explicitly empty timezone and push-token values are passed + * through to the rules engine and Edge so callers can clear the stored values. + * + * @param attributes device attributes to update + */ + public static void updateDeviceAttributesBypassConsent(@NonNull final DeviceAttributes attributes) { + if (attributes == null) { + Log.debug( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Unable to update device attributes, attributes are null." + ); + return; + } + + final Map eventData = new HashMap<>(); + if (attributes.getTimeZone() != null) { + eventData.put(IdentityConstants.DeviceAttributes.TIMEZONE, attributes.getTimeZone()); + } + if (attributes.getPushToken() != null) { + eventData.put( + IdentityConstants.DeviceAttributes.TOKENS, + Map.of(IdentityConstants.DeviceAttributes.PUSH_NOTIFICATION, attributes.getPushToken()) + ); + } + updateDeviceAttributesBypassConsent(eventData); + } + + /** + * Updates JSON-compatible device attributes that should be delivered to the Edge Network + * independently of collect consent. Attribute keys and nested values are forwarded unchanged. + * Launch rules decide which values are sent. By default, a value is sent only when it differs + * from the last successfully synced value. Consequences wait for the preceding Edge request to finish. Set + * {@code edgeidentity.optimizeOperationalProfileDataSync} to {@code false} to dispatch every update. An empty + * map is ignored. Null and non-JSON attribute leaves are dropped; valid siblings and empty strings + * or containers are preserved. Every dispatched update uses SDK-generated {@code app} context; + * caller-provided app routing is ignored. Optimistic owner values are compared only when collect + * consent is explicitly {@code "y"}; confirmed bypass values are compared at every consent state. + * + * @param attributes map of attribute names to JSON-compatible values + */ + public static void updateDeviceAttributesBypassConsent(@NonNull final Map attributes) { + if (attributes == null || attributes.isEmpty()) return; + final Event event = new Event.Builder( + IdentityConstants.EventNames.UPDATE_DEVICE_ATTRIBUTES, + EventType.EDGE_IDENTITY, + EventSource.REQUEST_CONTENT + ) + .setEventData(new HashMap<>(attributes)) + .build(); + MobileCore.dispatchEvent(event); + } + /** * 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..46673cab 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,9 @@ 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_DEVICE_ATTRIBUTES = "Update Device Attributes"; + static final String DEVICE_ATTRIBUTES_TO_RULES_ENGINE = "Device attributes for rules engine"; + static final String DEVICE_ATTRIBUTES_CONFIRMED = "Device attributes confirmed"; private EventNames() {} } @@ -68,6 +71,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"; @@ -91,7 +113,6 @@ private Namespaces() {} static final class XDMKeys { static final String IDENTITY_MAP = "identityMap"; - static final String PROFILE_ATTRIBUTES = "profileAttributes"; static final String ID = "id"; static final String AUTHENTICATED_STATE = "authenticatedState"; static final String PRIMARY = "primary"; @@ -118,6 +139,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 DEVICE_ATTRIBUTES_DATASTORE_NAME = "deviceAttributesDataStore"; private DataStoreKey() {} } @@ -138,10 +160,13 @@ 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 - // individual ProfileAttributeHandler implementations. + // individual AttributeHandler implementations. static final String XDM = "xdm"; static final String DATA = "data"; static final String EVENT_TYPE = "eventType"; @@ -150,5 +175,19 @@ static final class ProfileAttributes { private ProfileAttributes() {} } + static final class DeviceAttributes { + + 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 SYNCED_VALUES = "confirmedAttributes"; + static final String OPERATIONAL_DATA_TYPE = "com.adobe.eventType.generic.operationalData"; + static final String DEDUP_CONFIG_KEY = "edgeidentity.optimizeOperationalProfileDataSync"; + + private DeviceAttributes() {} + } + 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..10239d02 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) { + deviceAttributeSync.publishProfileAttributes(event); + } }; private final IdentityState state; + private final DeviceAttributeSync deviceAttributeSync; /** * Constructor. @@ -64,6 +70,7 @@ protected IdentityExtension(final ExtensionApi extensionApi) { IdentityExtension(final ExtensionApi extensionApi, final IdentityState state) { super(extensionApi); this.state = state; + this.deviceAttributeSync = new DeviceAttributeSync(extensionApi, state.getProfileAttributeStore()); } @NonNull @Override @@ -114,6 +121,8 @@ protected void onRegistered() { EventSource.REQUEST_CONTENT, this::handleProfileAttributes ); + getApi() + .registerEventListener(EventType.EDGE_IDENTITY, EventSource.REQUEST_CONTENT, this::handleDeviceAttributes); // EDGE_IDENTITY event listeners getApi() @@ -127,10 +136,42 @@ protected void onRegistered() { // HUB shared state event listener getApi().registerEventListener(EventType.HUB, EventSource.SHARED_STATE, this::handleIdentityDirectECIDUpdate); + getApi() + .registerEventListener( + EventType.EDGE_IDENTITY, + DeviceAttributeSync.RULE_RESPONSE_SOURCE, + deviceAttributeSync::handleDeviceAttributeRuleResponse + ); + getApi() + .registerEventListener( + DeviceAttributeSync.BYPASS_TYPE, + EventSource.RESPONSE_CONTENT, + deviceAttributeSync::handleDeviceAttributeResult + ); + deviceAttributeSync.publishProfileAttributes(null); + } + + private static boolean isResetEvent(final Event event) { + return ( + EventType.GENERIC_IDENTITY.equals(event.getType()) && EventSource.REQUEST_RESET.equals(event.getSource()) + ); + } + + /** Device attribute events skip bootup gating so they are processed in order without waiting. */ + private static boolean isDeviceAttributeEvent(final Event event) { + return ( + DeviceAttributeSync.RULE_RESPONSE_SOURCE.equals(event.getSource()) || + DeviceAttributeSync.BYPASS_TYPE.equals(event.getType()) || + IdentityConstants.EventNames.UPDATE_DEVICE_ATTRIBUTES.equals(event.getName()) + ); } @Override public boolean readyForEvent(@NonNull final Event event) { + if (isResetEvent(event) || isDeviceAttributeEvent(event)) { + state.bootupIfReady(sharedStateHandle); + return true; + } if (!state.bootupIfReady(sharedStateHandle)) return false; // Get url variables request depends on Configuration shared state @@ -344,9 +385,9 @@ 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 + // Clear profile attributes and republish their standard shared state so the next update re-syncs // against the new ECID - state.clearProfileAttributes(sharedStateHandle, event); + deviceAttributeSync.reset(event); resolver.resolve(state.getIdentityProperties().toXDMData()); // dispatch reset complete event @@ -415,6 +456,23 @@ void handleProfileAttributes(@NonNull final Event event) { state.updateProfileAttributes(event, sharedStateHandle); } + /** + * Handles internal device-attribute updates and dispatches an operational-data event. + * + * @param event the event containing device attribute data + */ + void handleDeviceAttributes(@NonNull final Event event) { + if (!IdentityConstants.EventNames.UPDATE_DEVICE_ATTRIBUTES.equals(event.getName())) { + Log.trace( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Ignoring device-attribute listener event with unexpected name '" + event.getName() + "'." + ); + return; + } + deviceAttributeSync.handleDeviceAttributes(event); + } + /** * Fetches the latest Identity properties and shares the XDMSharedState. * 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..9551ce26 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 @@ -38,7 +38,7 @@ class IdentityState { private final ProfileAttributeStore profileAttributeStore; private IdentityProperties identityProperties; private boolean hasBooted; - private final List profileAttributeHandlers; + private final List profileAttributeHandlers; IdentityState() { this(new IdentityStorageManager(ServiceProvider.getInstance().getDataStoreService())); @@ -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 @@ -89,7 +93,8 @@ boolean bootupIfReady(final SharedStateCallback callback) { hasBooted = true; Log.debug(IdentityConstants.LOG_TAG, LOG_SOURCE, "Edge Identity has successfully booted up"); - publishProfileAttributesSharedState(callback, null); + callback.createXDMSharedState(buildXDMSharedState(), null); + callback.publishProfileAttributes(null); return hasBooted; } @@ -358,7 +363,7 @@ private void dispatchAdIdConsentRequestEvent(final String consentVal) { } /** - * Collector layer for profile-attribute update requests. Runs every {@link ProfileAttributeHandler} + * Collector layer for profile-attribute update requests. Runs every {@link AttributeHandler} * whose key is present in the event (each owns its own dedup + persistence) and dispatches a * single collated {@code profile.updateAttributes} Edge event with whatever changed. When * anything changed, the profile-attributes shared state is also (re)published from persistence @@ -379,7 +384,7 @@ void updateProfileAttributes(final Event event, final SharedStateCallback callba } final Map mergedAttributes = new HashMap<>(); - for (final ProfileAttributeHandler handler : profileAttributeHandlers) { + for (final AttributeHandler handler : profileAttributeHandlers) { if (!eventData.containsKey(handler.getAttributeKey())) { continue; } @@ -398,27 +403,15 @@ void updateProfileAttributes(final Event event, final SharedStateCallback callba return; } - // Persistence just changed: keep the shared state in sync, then dispatch only the changes. - publishProfileAttributesSharedState(callback, event); + // Persistence just changed: keep the profileAttributes standard shared state in sync, then dispatch the changes. + callback.publishProfileAttributes(event); dispatchProfileAttributesEdgeEvent(mergedAttributes); } - /** - * Clears all stored profile attributes and updates the shared state to match (now empty). Invoked - * on reset so that the next update re-syncs the attributes against the newly generated ECID. - * - * @param callback {@link SharedStateCallback} used to publish the updated (empty) shared state - * @param event the reset {@link Event} the shared state is versioned at - */ - void clearProfileAttributes(final SharedStateCallback callback, final Event event) { - profileAttributeStore.clearAll(); - publishProfileAttributesSharedState(callback, event); - } - /** * Dispatches a single generic {@code profile.updateAttributes} {@link EventType#EDGE} / * {@link EventSource#REQUEST_CONTENT} event whose {@code data} is the collated contribution of - * all {@link ProfileAttributeHandler}s (currently only the timezone handler). Callers must only + * all profile {@link AttributeHandler}s (currently only the timezone handler). Callers must only * invoke this with a non-empty {@code data} map (see {@link #updateProfileAttributes}). The * payload shape is * {@code {"xdm": {"eventType": "profile.updateAttributes"}, "data": { ...collated attributes... }}}; @@ -450,46 +443,11 @@ private void dispatchProfileAttributesEdgeEvent(final Map data) } /** - * Publishes the full XDM shared state, merging the identity map with any stored profile - * attributes under the {@code profileAttributes} key. Called on bootup, after any profile - * attribute change, and after reset. - * - * @param callback {@link SharedStateCallback} used to create the XDM shared state - * @param event the {@link Event} the shared state is versioned at; {@code null} for the next version - */ - private void publishProfileAttributesSharedState(final SharedStateCallback callback, final Event event) { - callback.createXDMSharedState(buildXDMSharedState(), event); - } - - /** - * Builds the full XDM shared state map, combining the identity map with any stored profile - * attributes nested under {@link IdentityConstants.XDMKeys#PROFILE_ATTRIBUTES}. The - * {@code profileAttributes} key is omitted when no attributes are stored. + * Builds the identity-only XDM shared state, excluding locally coordinated profile values. * * @return the merged XDM state map */ Map buildXDMSharedState() { - final Map xdmState = new HashMap<>(identityProperties.toXDMData(false)); - final Map attributes = collectStoredProfileAttributes(); - if (!attributes.isEmpty()) { - xdmState.put(IdentityConstants.XDMKeys.PROFILE_ATTRIBUTES, attributes); - } - return xdmState; - } - - /** - * Collates every {@link ProfileAttributeHandler}'s persisted contribution into a single map. - * - * @return the collated stored attributes; empty when nothing is persisted - */ - private Map collectStoredProfileAttributes() { - final Map mergedData = new HashMap<>(); - for (final ProfileAttributeHandler handler : profileAttributeHandlers) { - final Map attributes = handler.collectFromStorage(); - if (attributes != null) { - mergedData.putAll(attributes); - } - } - return mergedData; + return new HashMap<>(identityProperties.toXDMData(false)); } } diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityStorageManager.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityStorageManager.java index e4441900..3e5c0444 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityStorageManager.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/IdentityStorageManager.java @@ -41,7 +41,7 @@ class IdentityStorageManager { } /** - * Returns the shared {@link ProfileAttributeStore} backing every {@link ProfileAttributeHandler}. + * Returns the store backing the consent-gated profile-attribute handlers. * * @return the profile-attributes store */ diff --git a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeHandlers.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeHandlers.java index 2914cdd3..db2f253f 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeHandlers.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/ProfileAttributeHandlers.java @@ -14,7 +14,7 @@ import java.util.List; /** - * Central registry of every {@link ProfileAttributeHandler} the Edge Identity extension knows + * Central registry of every {@link AttributeHandler} the Edge Identity extension knows * about. Adding a new attribute is a one-line addition here plus one new handler file; the * collector layer in {@link IdentityState} is attribute-agnostic and needs no changes. */ @@ -26,9 +26,9 @@ private ProfileAttributeHandlers() {} * Returns the full list of registered handlers, each bound to {@code store} for persistence. * * @param store the shared profile-attributes store every handler reads/writes through - * @return an immutable list of registered {@link ProfileAttributeHandler}s + * @return an immutable list of registered {@link AttributeHandler}s */ - static List all(final ProfileAttributeStore store) { + static List all(final ProfileAttributeStore store) { return List.of(new TimeZoneAttributeHandler(store)); } } 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..02936738 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 @@ -13,11 +13,17 @@ import com.adobe.marketing.mobile.services.Log; import com.adobe.marketing.mobile.services.NamedCollection; +import com.adobe.marketing.mobile.util.JSONUtils; +import java.lang.reflect.Array; +import java.util.List; +import java.util.Map; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; /** - * 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,13 +49,65 @@ 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; } return collection.getString(key, null); } + Map getMap(final String key) { + final String value = getString(key); + if (value == null) return null; + try { + return JSONUtils.toMap(new JSONObject(value)); + } catch (JSONException exception) { + Log.warning(IdentityConstants.LOG_TAG, LOG_SOURCE, "Unable to read stored map for key '" + key + "'."); + return null; + } + } + + void setMap(final String key, final Map value) { + if (value == null) { + remove(key); + return; + } + try { + final JSONObject json = new JSONObject(); + for (final Map.Entry entry : value.entrySet()) { + json.put(entry.getKey(), toJsonValue(entry.getValue())); + } + setString(key, json.toString()); + } catch (JSONException exception) { + Log.error(IdentityConstants.LOG_TAG, LOG_SOURCE, "Unable to persist map for key '" + key + "'."); + } + } + + private static Object toJsonValue(final Object value) throws JSONException { + if (value == null) return JSONObject.NULL; + if (value instanceof Map) { + final JSONObject json = new JSONObject(); + for (final Map.Entry entry : ((Map) value).entrySet()) { + if (!(entry.getKey() instanceof String)) { + throw new JSONException("Map key is not a string"); + } + json.put((String) entry.getKey(), toJsonValue(entry.getValue())); + } + return json; + } + if (value instanceof List) { + final JSONArray json = new JSONArray(); + for (final Object element : (List) value) json.put(toJsonValue(element)); + return json; + } + if (value.getClass().isArray()) { + final JSONArray json = new JSONArray(); + for (int index = 0; index < Array.getLength(value); index++) json.put(toJsonValue(Array.get(value, index))); + return json; + } + return value; + } + /** * Persists {@code value} under {@code key}. A {@code null} value removes the key. * @@ -61,7 +119,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 +140,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 +148,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 +156,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/main/java/com/adobe/marketing/mobile/edge/identity/TimeZoneAttributeHandler.java b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/TimeZoneAttributeHandler.java index d7096a3e..73a8fd73 100644 --- a/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/TimeZoneAttributeHandler.java +++ b/code/edgeidentity/src/main/java/com/adobe/marketing/mobile/edge/identity/TimeZoneAttributeHandler.java @@ -16,15 +16,18 @@ import com.adobe.marketing.mobile.util.DataReader; import com.adobe.marketing.mobile.util.StringUtils; import java.util.Map; +import java.util.Set; +import java.util.TimeZone; /** - * {@link ProfileAttributeHandler} for the device timezone. Reads the IANA identifier from the + * {@link AttributeHandler} for the device timezone. Reads the IANA identifier from the * incoming event under {@code "timeZone"}, persists it, and contributes to the outgoing payload * under the same {@code "timeZone"} key (XDM field name). */ -final class TimeZoneAttributeHandler implements ProfileAttributeHandler { +final class TimeZoneAttributeHandler implements AttributeHandler { private static final String LOG_SOURCE = "TimeZoneAttributeHandler"; + private static final Set AVAILABLE_TIME_ZONES = Set.of(TimeZone.getAvailableIDs()); private final ProfileAttributeStore store; @@ -38,12 +41,20 @@ public String getAttributeKey() { } @Override - public Map collectFromEvent(final Event event) { + public Map collectFromEvent(final Event event, final boolean dedup) { final String newTimeZone = DataReader.optString(event.getEventData(), getAttributeKey(), null); if (StringUtils.isNullOrEmpty(newTimeZone)) { return null; } - if (newTimeZone.equals(store.getString(getAttributeKey()))) { + if (!AVAILABLE_TIME_ZONES.contains(newTimeZone)) { + Log.warning( + IdentityConstants.LOG_TAG, + LOG_SOURCE, + "Timezone '" + newTimeZone + "' is not a valid time zone, skipping sync." + ); + return null; + } + if (dedup && newTimeZone.equals(store.getString(getAttributeKey()))) { Log.debug( IdentityConstants.LOG_TAG, LOG_SOURCE, diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeDeduplicatorTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeDeduplicatorTests.java new file mode 100644 index 00000000..548c1e2d --- /dev/null +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeDeduplicatorTests.java @@ -0,0 +1,193 @@ +/* + 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.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; + +public class DeviceAttributeDeduplicatorTests { + + private ProfileAttributeStore store; + private DeviceAttributeDeduplicator deduplicator; + private Map> persisted; + + @Before + public void setup() { + store = mock(ProfileAttributeStore.class); + persisted = new HashMap<>(); + when(store.getMap(anyString())).thenAnswer(call -> persisted.get(call.getArgument(0))); + doAnswer(call -> persisted.put(call.getArgument(0), call.getArgument(1))) + .when(store) + .setMap(anyString(), anyMap()); + doAnswer(call -> persisted.remove(call.getArgument(0))).when(store).remove(anyString()); + deduplicator = new DeviceAttributeDeduplicator(store); + } + + @Test + public void filterDoesNotWriteUntilCommit() { + final Map attributes = Map.of("timezone", "Europe/London"); + assertEquals(attributes, deduplicator.filter(attributes, true, null, null)); + assertEquals(attributes, deduplicator.filter(attributes, true, null, null)); + assertTrue(persisted.isEmpty()); + deduplicator.commit(attributes); + assertTrue(deduplicator.filter(attributes, true, null, null).isEmpty()); + assertEquals(attributes, new DeviceAttributeDeduplicator(store).readSynced()); + } + + @Test + public void nestedAttributesAreDeduplicatedIndependently() { + deduplicator.commit(Map.of("custom", Map.of("a", "old", "b", "same"))); + assertEquals( + Map.of("custom", Map.of("a", "new")), + deduplicator.filter(Map.of("custom", Map.of("a", "new", "b", "same")), true, null, null) + ); + } + + @Test + public void filteredPayloadDoesNotRetainMutableValues() { + final List values = new ArrayList<>(List.of(new HashMap<>(Map.of("value", "original")))); + final Map attributes = Map.of("custom", values); + final Map payload = deduplicator.filter(attributes, true, null, null); + values.clear(); + assertEquals(Map.of("custom", List.of(Map.of("value", "original"))), payload); + deduplicator.commit(payload); + assertTrue(deduplicator.filter(payload, true, null, null).isEmpty()); + assertEquals(attributes, deduplicator.filter(attributes, true, null, null)); + } + + @Test + public void commitDoesNotRetainMutableValues() { + final List values = new ArrayList<>(List.of("original")); + deduplicator.commit(Map.of("custom", values)); + values.clear(); + assertEquals(Map.of("custom", List.of("original")), deduplicator.readSynced()); + } + + @Test + public void ownerMatchesDoNotCommitAndDisabledDedupIgnoresAllStores() { + final Map attributes = Map.of( + "timezone", + "Europe/London", + "tokens", + Map.of("pushNotification", "push") + ); + final Map messagingState = Map.of("pushidentifier", "push"); + assertTrue(deduplicator.filter(attributes, true, messagingState, "Europe/London").isEmpty()); + assertTrue(persisted.isEmpty()); + assertEquals(attributes, deduplicator.filter(attributes, true, null, null)); + deduplicator.commit(attributes); + assertEquals(attributes, deduplicator.filter(attributes, false, messagingState, "Europe/London")); + } + + @Test + public void emptyPushTokenIsPreservedAndComparedAsAString() { + final Map attributes = Map.of("tokens", Map.of("pushNotification", "")); + assertEquals(attributes, deduplicator.filter(attributes, true, Map.of("pushidentifier", "previous"), null)); + assertTrue(deduplicator.filter(attributes, true, Map.of("pushidentifier", ""), null).isEmpty()); + assertEquals(attributes, deduplicator.filter(attributes, true, null, null)); + deduplicator.commit(attributes); + assertTrue(deduplicator.filter(attributes, true, null, null).isEmpty()); + } + + @Test + public void nullIsDroppedButEmptyContainersAreValuesAndAppIsNotAnAttribute() { + final Map attributes = new HashMap<>(); + attributes.put("null", null); + attributes.put("array", List.of()); + attributes.put("object", Map.of()); + attributes.put("app", Map.of("id", "caller")); + final Map expected = Map.of("array", List.of(), "object", Map.of()); + assertEquals(expected, deduplicator.filter(attributes, false, null, null)); + deduplicator.commit(attributes); + assertEquals(expected, deduplicator.readSynced()); + } + + @Test + public void invalidJSONLeavesAreDroppedWithoutWritingStorage() { + final Map nested = new HashMap<>(); + nested.put("valid", "value"); + nested.put("null", null); + nested.put("invalid", new Object()); + assertEquals( + Map.of("nested", Map.of("valid", "value")), + deduplicator.filter(Map.of("nested", nested, "nan", Double.NaN), false, null, null) + ); + assertTrue(persisted.isEmpty()); + } + + @Test + public void arraysAreAtomicAndOrderChangesAreSent() { + deduplicator.commit(Map.of("array", List.of("first", "second"))); + assertEquals( + Map.of("array", List.of("second", "first")), + deduplicator.filter(Map.of("array", List.of("second", "first")), true, null, null) + ); + assertEquals( + Map.of("array", Arrays.asList("first", null)), + deduplicator.filter(Map.of("array", Arrays.asList("first", null)), true, null, null) + ); + } + + @Test + public void booleansRemainDistinctFromNumbersAndNumbersSurviveSnapshots() { + deduplicator.commit(Map.of("value", true)); + assertEquals(Map.of("value", 1), deduplicator.filter(Map.of("value", 1), true, null, null)); + deduplicator.commit(deduplicator.filter(Map.of("value", 1.0), true, null, null)); + assertTrue(deduplicator.filter(Map.of("value", 1.0), true, null, null).isEmpty()); + assertTrue(new DeviceAttributeDeduplicator(store).filter(Map.of("value", 1.0), true, null, null).isEmpty()); + } + + @Test + public void clearRemovesOnlyConfirmedDeviceValues() { + deduplicator.commit(Map.of("timezone", "Europe/London")); + deduplicator.clear(); + assertTrue(persisted.isEmpty()); + assertTrue(deduplicator.readSynced().isEmpty()); + verify(store).remove(IdentityConstants.DeviceAttributes.SYNCED_VALUES); + verify(store, never()).clearAll(); + } + + @Test + public void testCanonicalization_isIndependentOfMapKeyOrder() { + final Map firstNestedValue = new LinkedHashMap<>(); + firstNestedValue.put("b", 2); + firstNestedValue.put("a", 1); + assertEquals("{\"a\":1,\"b\":2}", DeviceAttributeDeduplicator.canonicalize(firstNestedValue)); + assertEquals( + DeviceAttributeDeduplicator.canonicalize(firstNestedValue), + DeviceAttributeDeduplicator.canonicalize(Map.of("a", 1, "b", 2)) + ); + } + + @Test + public void testCanonicalization_rejectsUnsupportedValuesAndTopLevelNull() { + final Map attributes = new HashMap<>(); + attributes.put(null, "invalid-key"); + assertNull(DeviceAttributeDeduplicator.canonicalize(attributes)); + assertNull(DeviceAttributeDeduplicator.canonicalize(new Object())); + assertNull(DeviceAttributeDeduplicator.canonicalize(Double.NaN)); + assertNull(DeviceAttributeDeduplicator.canonicalize(null)); + final Map nestedNull = new HashMap<>(); + nestedNull.put("value", null); + assertEquals("{\"value\":null}", DeviceAttributeDeduplicator.canonicalize(nestedNull)); + } +} diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeSyncTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeSyncTests.java new file mode 100644 index 00000000..886bec0c --- /dev/null +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/DeviceAttributeSyncTests.java @@ -0,0 +1,918 @@ +/* + 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.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 DeviceAttributeSyncTests { + + private ExtensionApi api; + private Map stored; + private NamedCollection collection; + private List dispatched; + private List confirmations; + private Map deviceStored; + private NamedCollection deviceCollection; + private List> published; + private DeviceAttributeSync 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<>(); + confirmations = 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 -> { + final Event event = call.getArgument(0); + return EventType.MESSAGING.equals(event.getType()) ? confirmations.add(event) : dispatched.add(event); + }) + .when(api) + .dispatch(any()); + doAnswer(call -> published.add(call.getArgument(0))).when(api).createSharedState(anyMap(), any()); + sync = newSync(); + } + + private DeviceAttributeSync newSync() { + return new DeviceAttributeSync( + api, + new ProfileAttributeStore(collection), + new ProfileAttributeStore(deviceCollection) + ); + } + + @After + public void teardown() { + mobileCore.close(); + } + + private static Event request(final Map data) { + return new Event.Builder( + IdentityConstants.EventNames.UPDATE_DEVICE_ATTRIBUTES, + EventType.EDGE_IDENTITY, + EventSource.REQUEST_CONTENT + ) + .setEventData(data) + .build(); + } + + private static Event consequence(final Map data) { + return new Event.Builder( + "Forward operational data", + EventType.EDGE_IDENTITY, + DeviceAttributeSync.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) { + sync.handleDeviceAttributeResult( + new Event.Builder( + "Device Attributes Response", + DeviceAttributeSync.BYPASS_TYPE, + EventSource.RESPONSE_CONTENT + ) + .setEventData(Map.of("status", status)) + .chainToParentEvent(edgeRequest) + .build() + ); + } + + private void succeedAll() { + for (int i = 0; i < dispatched.size(); i++) { + final Event event = dispatched.get(i); + if (DeviceAttributeSync.BYPASS_TYPE.equals(event.getType())) edgeResult(event, "success"); + } + } + + @SuppressWarnings("unchecked") + private Map lastProfileAttributes() { + return (Map) published.get(published.size() - 1).get("profileAttributes"); + } + + @Test + public void handleDeviceAttributes_dispatchesOperationalDataChainedToApiEvent() { + final Event request = request(Map.of("timezone", "Asia/Tokyo")); + sync.handleDeviceAttributes(request); + + assertEquals(1, dispatched.size()); + final Event operational = dispatched.get(0); + assertEquals(IdentityConstants.DeviceAttributes.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.handleDeviceAttributeRuleResponse( + 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.handleDeviceAttributeRuleResponse(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.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertTrue(dispatched.isEmpty()); + assertTrue(stored.isEmpty()); + assertTrue(published.isEmpty()); + } + + @Test + public void handleDeviceAttributes_ignoresEmptyOrInvalidData() { + sync.handleDeviceAttributes(request(new HashMap<>())); + sync.handleDeviceAttributes(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.handleDeviceAttributeRuleResponse(forward); + + assertEquals(1, dispatched.size()); + final Event edge = dispatched.get(0); + assertEquals(DeviceAttributeSync.BYPASS_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.handleDeviceAttributeRuleResponse(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()); + assertNotNull(deviceStored.get(IdentityConstants.DeviceAttributes.SYNCED_VALUES)); + assertNull(stored.get("deviceAttributes")); + } + + @Test + public void persistedPushToken_isDedupedAfterRestart() { + final Map token = Map.of("tokens", Map.of("pushNotification", "token-1")); + sync.handleDeviceAttributeRuleResponse(consequence(token)); + edgeResult(dispatched.get(0), "success"); + + sync = newSync(); + sync.handleDeviceAttributeRuleResponse(consequence(token)); + + assertEquals(1, dispatched.size()); + } + + @Test + public void nullPushToken_isDroppedBeforeAndAfterRestart() { + final Map tokens = new HashMap<>(); + tokens.put("pushNotification", null); + final Map attributes = Map.of("tokens", tokens); + sync.handleDeviceAttributeRuleResponse(consequence(attributes)); + assertTrue(dispatched.isEmpty()); + assertTrue(deviceStored.isEmpty()); + sync = newSync(); + sync.handleDeviceAttributeRuleResponse(consequence(attributes)); + + assertTrue(dispatched.isEmpty()); + } + + @Test + public void consentYes_doesNotSendOrPersistDeviceAttributes() { + 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", "y")))) + ); + when(api.getSharedState(eq(IdentityConstants.SharedState.Messaging.NAME), any(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of(IdentityConstants.SharedState.Messaging.PUSH_IDENTIFIER, "token-1") + ) + ); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + + assertTrue(dispatched.isEmpty()); + assertTrue(stored.isEmpty()); + assertTrue(published.isEmpty()); + } + + @Test + public void consentYes_changedPushTokenIsSentAfterComparisonWithMessagingState() { + 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", "y")))) + ); + when(api.getSharedState(eq(IdentityConstants.SharedState.Messaging.NAME), any(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of(IdentityConstants.SharedState.Messaging.PUSH_IDENTIFIER, "token-old") + ) + ); + + sync.handleDeviceAttributeRuleResponse(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"); + 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", "y")))) + ); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + + assertTrue(dispatched.isEmpty()); + } + + @Test + public void consentExtensionNotRegistered_doesNotTrustOptimisticOwner() { + 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())) + ) + ); + when(api.getSharedState(eq(IdentityConstants.SharedState.Messaging.NAME), any(), anyBoolean(), any())) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Map.of(IdentityConstants.SharedState.Messaging.PUSH_IDENTIFIER, "token-1") + ) + ); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + + assertEquals(1, dispatched.size()); + assertTrue(stored.isEmpty()); + } + + @Test + public void consentNo_sendsDeviceAttributesThroughBypass() { + 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", "n")))) + ); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + + assertEquals(1, dispatched.size()); + assertEquals(DeviceAttributeSync.BYPASS_TYPE, dispatched.get(0).getType()); + } + + @Test + public void consentNo_unchangedValueIsDedupedAgainstPersistedDeviceStore() { + stored.put("deviceAttributes", "{\"tokens\":{\"pushNotification\":\"token-1\"}}"); + sync = newSync(); + 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", "n")))) + ); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + + assertTrue(dispatched.isEmpty()); + } + + @Test + public void pendingConsent_sendsDeviceAttributesThroughBypass() { + when(api.getXDMSharedState(eq(IdentityConstants.SharedState.Consent.NAME), any(), anyBoolean(), any())) + .thenReturn(new SharedStateResult(SharedStateStatus.PENDING, null)); + 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", "1.0.0"))) + ) + ); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + + assertEquals(1, dispatched.size()); + assertEquals(DeviceAttributeSync.BYPASS_TYPE, dispatched.get(0).getType()); + } + + @Test + public void edgeFailure_doesNotPublishAndAllowsResend() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + edgeResult(dispatched.get(0), "failed"); + assertNull(stored.get("timeZone")); + assertTrue(published.isEmpty()); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertEquals(2, dispatched.size()); + } + + @Test + public void edgeResult_forUnknownRequestIsIgnored() { + edgeResult( + new Event.Builder("Other", DeviceAttributeSync.BYPASS_TYPE, EventSource.REQUEST_CONTENT).build(), + "success" + ); + assertTrue(published.isEmpty()); + } + + @Test + public void ruleResponse_appOnlyConsequenceIsNotSent() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("app", Map.of("id", "com.example")))); + assertTrue(dispatched.isEmpty()); + } + + @Test + public void multipleConsequences_areSentInOrderAndLaterDuplicatesAreDropped() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse( + 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.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(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.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of()))); + edgeResult(dispatched.get(0), "success"); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + edgeResult(dispatched.get(1), "success"); + + assertEquals(Map.of("prefs", Map.of("lang", "en")), lastProfileAttributes()); + assertEquals(Map.of(), dispatched.get(0).getEventData().get("prefs")); + assertEquals(3, dispatched.size()); + edgeResult(dispatched.get(2), "success"); + sync.publishProfileAttributes(null); + assertEquals(Map.of("prefs", Map.of("lang", "en"), "timezone", "Europe/Paris"), lastProfileAttributes()); + sync = newSync(); + sync.publishProfileAttributes(null); + assertEquals(Map.of("prefs", Map.of("lang", "en"), "timezone", "Europe/Paris"), lastProfileAttributes()); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + assertEquals(3, dispatched.size()); + } + + @Test + public void childThenParent_confirmationRemovesStaleChildBeforeAndAfterRestart() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + edgeResult(dispatched.get(0), "success"); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of()))); + edgeResult(dispatched.get(1), "success"); + assertEquals(Map.of("prefs", Map.of()), lastProfileAttributes()); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + assertEquals(3, dispatched.size()); + edgeResult(dispatched.get(2), "failed"); + sync = newSync(); + sync.publishProfileAttributes(null); + assertEquals(Map.of("prefs", Map.of()), lastProfileAttributes()); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + assertEquals(4, dispatched.size()); + edgeResult(dispatched.get(3), "success"); + assertEquals(Map.of("prefs", Map.of("lang", "en")), lastProfileAttributes()); + } + + @Test + public void relatedPathCleanup_preservesSiblingsAndLiteralDottedKeys() { + sync.handleDeviceAttributeRuleResponse( + consequence( + Map.of("prefs", Map.of("settings", Map.of("lang", "en"), "theme", "dark"), "prefs.settings", "literal") + ) + ); + edgeResult(dispatched.get(0), "success"); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("settings", "disabled")))); + edgeResult(dispatched.get(1), "success"); + assertEquals( + Map.of("prefs", Map.of("settings", "disabled", "theme", "dark"), "prefs.settings", "literal"), + lastProfileAttributes() + ); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("settings", Map.of("lang", "en"))))); + edgeResult(dispatched.get(2), "success"); + assertEquals( + Map.of("prefs", Map.of("settings", Map.of("lang", "en"), "theme", "dark"), "prefs.settings", "literal"), + lastProfileAttributes() + ); + } + + @Test + public void restoredParent_thenChildConfirmationReplacesParent() { + stored.put("deviceAttributes", "{\"prefs\":{}}"); + sync = newSync(); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + edgeResult(dispatched.get(0), "success"); + assertEquals(Map.of("prefs", Map.of("lang", "en")), lastProfileAttributes()); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + assertEquals(1, dispatched.size()); + } + + @Test + public void failedParentReplacement_preservesConfirmedChildren() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + edgeResult(dispatched.get(0), "success"); + final Object persisted = deviceStored.get(IdentityConstants.DeviceAttributes.SYNCED_VALUES); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of()))); + edgeResult(dispatched.get(1), "failed"); + assertEquals(persisted, deviceStored.get(IdentityConstants.DeviceAttributes.SYNCED_VALUES)); + assertEquals(Map.of("prefs", Map.of("lang", "en")), lastProfileAttributes()); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("prefs", Map.of("lang", "en")))); + assertEquals(2, dispatched.size()); + } + + @Test + public void persistedTimezone_isDedupedAfterRestart() { + stored.put("deviceAttributes", "{\"timezone\":\"Asia/Tokyo\"}"); + sync = newSync(); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertTrue(dispatched.isEmpty()); + } + + @Test + public void dedupEnabledByConfiguration_skipsConfirmedDuplicates() { + dedupConfig(true); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + edgeResult(dispatched.get(0), "success"); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertEquals(1, dispatched.size()); + } + + @Test + public void dedupDisabledByConfiguration_sendsDuplicates() { + dedupConfig(false); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertEquals(1, dispatched.size()); + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + } + + @Test + public void explicitNull_isDroppedWithoutClearingConfirmedToken() { + final Map data = new HashMap<>(); + data.put("tokens", new HashMap<>(Map.of("pushNotification", Map.of("token", "s1")))); + sync.handleDeviceAttributeRuleResponse(consequence(data)); + final Map tokens = new HashMap<>(); + tokens.put("pushNotification", null); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", tokens))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", tokens))); + succeedAll(); + + assertEquals(1, dispatched.size()); + assertEquals(Map.of("tokens", Map.of("pushNotification", Map.of("token", "s1"))), lastProfileAttributes()); + } + + @Test + public void customNestedValues_areDedupedPerLeaf() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("custom", Map.of("a", 1, "b", 2)))); + sync.handleDeviceAttributeRuleResponse(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("b", 3), "app", Map.of("id", "com.example", "platform", "fcm")), + dispatched.get(1).getEventData() + ); + } + + @Test + public void reset_clearsSentValues() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo", "custom", Map.of("a", 1)))); + succeedAll(); + sync.reset(null); + assertNull(stored.get("timeZone")); + assertNull(stored.get("deviceAttributes")); + assertTrue(deviceStored.isEmpty()); + assertTrue(lastProfileAttributes().isEmpty()); + + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo", "custom", Map.of("a", 1)))); + assertEquals(2, dispatched.size()); + } + + @Test + public void queuedDuplicate_isSentAfterFailure() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + final Event queued = consequence(Map.of("timezone", "Asia/Tokyo")); + sync.handleDeviceAttributeRuleResponse(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.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(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.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + sync.handleDeviceAttributeRuleResponse(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.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + final Event oldRequest = dispatched.get(0); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + sync.reset(null); + sync.handleDeviceAttributeRuleResponse(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.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + doAnswer(call -> { + assertEquals("Asia/Tokyo", stored.get("timeZone")); + assertEquals(Map.of("timezone", "Asia/Tokyo"), lastProfileAttributes()); + dispatched.add(call.getArgument(0)); + return null; + }) + .when(api) + .dispatch(any()); + + edgeResult(dispatched.get(0), "success"); + assertEquals(2, dispatched.size()); + } + + @Test + public void queue_skipsUnchangedAndEmptyConsequencesWithoutBlockingLaterChanges() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("app", Map.of("id", "com.example")))); + sync.handleDeviceAttributeRuleResponse(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 invalidResult_doesNotCommitOrAdvanceQueue() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Europe/Paris"))); + final Event activeRequest = dispatched.get(0); + for (final Map data : List.of( + Map.of(), + Map.of("status", "retry"), + Map.of("status", true) + )) { + sync.handleDeviceAttributeResult( + new Event.Builder( + "Device Attributes Response", + DeviceAttributeSync.BYPASS_TYPE, + EventSource.RESPONSE_CONTENT + ) + .setEventData(data) + .chainToParentEvent(activeRequest) + .build() + ); + assertEquals(1, dispatched.size()); + assertTrue(stored.isEmpty()); + assertTrue(published.isEmpty()); + } + edgeResult(activeRequest, "success"); + assertEquals(2, dispatched.size()); + } + + @Test + public void queuedConsequence_readsLatestConsent() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(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 pendingConsent_doesNotTrustOwnerEvenWhenConsentExtensionIsAbsent() { + stored.put("timeZone", "Asia/Tokyo"); + when(api.getXDMSharedState(eq(IdentityConstants.SharedState.Consent.NAME), any(), anyBoolean(), any())) + .thenReturn(new SharedStateResult(SharedStateStatus.PENDING, null)); + when(api.getSharedState(eq(IdentityConstants.SharedState.Hub.NAME), any(), anyBoolean(), any())) + .thenReturn(new SharedStateResult(SharedStateStatus.SET, Map.of("extensions", Map.of()))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + assertEquals(1, dispatched.size()); + } + + @Test + public void success_notifiesMessagingWithOnlySentTokens() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + edgeResult(dispatched.get(0), "success"); + sync.handleDeviceAttributeRuleResponse( + consequence(Map.of("timezone", "Asia/Tokyo", "tokens", Map.of("pushNotification", "token-1"))) + ); + final Event edgeRequest = dispatched.get(1); + assertFalse(edgeRequest.getEventData().containsKey("timezone")); + edgeResult(edgeRequest, "success"); + assertEquals(2, dispatched.size()); + assertEquals(1, confirmations.size()); + final Event confirmed = confirmations.get(0); + assertEquals("Device attributes confirmed", confirmed.getName()); + assertEquals(EventType.MESSAGING, confirmed.getType()); + assertEquals(EventSource.RESPONSE_CONTENT, confirmed.getSource()); + assertEquals(Map.of("tokens", Map.of("pushNotification", "token-1")), confirmed.getEventData()); + } + + @Test + public void nestedPushObject_deduplicatesChangedLeavesIndependently() { + sync.handleDeviceAttributeRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", Map.of("token", "t1", "metadata", "old")))) + ); + edgeResult(dispatched.get(0), "success"); + dispatched.clear(); + sync.handleDeviceAttributeRuleResponse( + consequence(Map.of("tokens", Map.of("pushNotification", Map.of("token", "t1", "metadata", "new")))) + ); + assertEquals( + Map.of("pushNotification", Map.of("metadata", "new")), + dispatched.get(0).getEventData().get("tokens") + ); + } + + @Test + public void invalidLeaves_areDroppedWithoutDiscardingValidSiblings() { + final Map attributes = new HashMap<>(); + attributes.put("timezone", "Asia/Tokyo"); + attributes.put("nullValue", null); + attributes.put("invalidValue", Double.NaN); + sync.handleDeviceAttributeRuleResponse(consequence(attributes)); + assertEquals( + Map.of("timezone", "Asia/Tokyo", "app", Map.of("id", "com.example", "platform", "fcm")), + dispatched.get(0).getEventData() + ); + } + + @Test + public void queuedConsequence_readsLatestDedupConfiguration() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Asia/Tokyo"))); + sync.handleDeviceAttributeRuleResponse(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() { + 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", "y")))) + ); + when(api.getSharedState(eq(IdentityConstants.SharedState.Messaging.NAME), any(), anyBoolean(), any())) + .thenReturn(new SharedStateResult(SharedStateStatus.SET, Map.of("pushidentifier", "token-1"))); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("custom", "first"))); + sync.handleDeviceAttributeRuleResponse(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() { + 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", "y")))) + ); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("custom", "first"))); + sync.handleDeviceAttributeRuleResponse( + 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()); + assertTrue(confirmations.isEmpty()); + } + + @Test + public void failureDropAndUnrelatedSuccess_doNotConfirmTokens() { + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(consequence(Map.of()), "success"); + edgeResult(dispatched.get(0), "failed"); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "token-1")))); + edgeResult(dispatched.get(1), "dropped"); + assertTrue(confirmations.isEmpty()); + assertTrue(deviceStored.isEmpty()); + assertTrue(published.isEmpty()); + } + + @Test + public void migratedConfirmedValuesRemainIndependentFromProfileOwner() { + stored.put("deviceAttributes", "{\"timezone\":\"Europe/London\",\"tokens\":{\"pushNotification\":\"old\"}}"); + stored.put("timeZone", "Asia/Tokyo"); + sync = newSync(); + assertNotNull(deviceStored.get(IdentityConstants.DeviceAttributes.SYNCED_VALUES)); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("timezone", "Europe/London"))); + assertTrue(dispatched.isEmpty()); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "new")))); + edgeResult(dispatched.get(0), "success"); + sync = newSync(); + sync.handleDeviceAttributeRuleResponse(consequence(Map.of("tokens", Map.of("pushNotification", "old")))); + assertEquals(2, dispatched.size()); + assertEquals("Asia/Tokyo", stored.get("timeZone")); + } + + @Test + public void nullTimezone_isDroppedWithoutClearingOwner() { + stored.put("timeZone", "Asia/Tokyo"); + final Map clear = new HashMap<>(); + clear.put("timezone", null); + sync.handleDeviceAttributeRuleResponse(consequence(clear)); + sync.handleDeviceAttributeRuleResponse(consequence(clear)); + assertEquals("Asia/Tokyo", stored.get("timeZone")); + assertTrue(dispatched.isEmpty()); + assertTrue(deviceStored.isEmpty()); + } +} 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..3af02a33 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,100 @@ 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_sendsForwardedDeviceAttributesToEdge() { + 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_DEVICE_ATTRIBUTES, + EventType.EDGE_IDENTITY, + EventSource.REQUEST_CONTENT + ) + .setEventData(Map.of("timezone", "Asia/Tokyo")) + .build(); + extension.handleDeviceAttributes(original); + final Event operational = dispatched.get(0); + assertEquals(IdentityConstants.DeviceAttributes.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(DeviceAttributeSync.RULE_RESPONSE_SOURCE), + rules.capture() + ); + rules + .getValue() + .hear( + new Event.Builder("Forward", EventType.EDGE_IDENTITY, DeviceAttributeSync.RULE_RESPONSE_SOURCE) + .setEventData(Map.of("timezone", "Asia/Tokyo")) + .chainToParentEvent(operational) + .build() + ); + final Event edge = dispatched.get(1); + assertEquals(DeviceAttributeSync.BYPASS_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(DeviceAttributeSync.BYPASS_TYPE), + eq(EventSource.RESPONSE_CONTENT), + edgeResults.capture() + ); + edgeResults + .getValue() + .hear( + new Event.Builder( + "Device Attributes Response", + DeviceAttributeSync.BYPASS_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 ruleResponsesAreReadyBeforeBootup() { + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + final Event response = new Event.Builder( + "Forward", + EventType.EDGE_IDENTITY, + DeviceAttributeSync.RULE_RESPONSE_SOURCE + ) + .build(); + assertTrue(extension.readyForEvent(response)); } // ======================================================================================== @@ -82,6 +186,8 @@ 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(EventSource.REQUEST_CONTENT), any()); verify(mockExtensionApi) .registerEventListener(eq(EventType.EDGE_IDENTITY), eq(EventSource.REQUEST_IDENTITY), any()); verify(mockExtensionApi) @@ -89,10 +195,65 @@ 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(DeviceAttributeSync.RULE_RESPONSE_SOURCE), any()); + verify(mockExtensionApi) + .registerEventListener(eq(DeviceAttributeSync.BYPASS_TYPE), eq(EventSource.RESPONSE_CONTENT), any()); + verify(mockExtensionApi).createSharedState(anyMap(), any()); verifyNoMoreInteractions(mockExtensionApi); } + @Test + public void test_handleDeviceAttributes_sendsDataToRulesEngine() { + final Event event = new Event.Builder( + IdentityConstants.EventNames.UPDATE_DEVICE_ATTRIBUTES, + EventType.EDGE_IDENTITY, + EventSource.REQUEST_CONTENT + ) + .setEventData(Collections.singletonMap(IdentityConstants.DeviceAttributes.TIME_ZONE, "UTC")) + .build(); + + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + extension.handleDeviceAttributes(event); + + final ArgumentCaptor response = ArgumentCaptor.forClass(Event.class); + verify(mockExtensionApi).dispatch(response.capture()); + assertEquals(IdentityConstants.DeviceAttributes.OPERATIONAL_DATA_TYPE, response.getValue().getType()); + assertEquals(event.getUniqueIdentifier(), response.getValue().getParentID()); + assertEquals("UTC", response.getValue().getEventData().get(IdentityConstants.DeviceAttributes.TIME_ZONE)); + } + + @Test + public void test_handleDeviceAttributes_doesNotReadDedupConfigurationBeforeRules() { + final Event event = new Event.Builder( + IdentityConstants.EventNames.UPDATE_DEVICE_ATTRIBUTES, + EventType.EDGE_IDENTITY, + EventSource.REQUEST_CONTENT + ) + .setEventData(Collections.singletonMap(IdentityConstants.DeviceAttributes.TIME_ZONE, "UTC")) + .build(); + when( + mockExtensionApi.getSharedState( + IdentityConstants.SharedState.Configuration.NAME, + event, + false, + SharedStateResolution.LAST_SET + ) + ) + .thenReturn( + new SharedStateResult( + SharedStateStatus.SET, + Collections.singletonMap(IdentityConstants.DeviceAttributes.DEDUP_CONFIG_KEY, false) + ) + ); + + extension = new IdentityExtension(mockExtensionApi, mockIdentityState); + extension.handleDeviceAttributes(event); + + verify(mockExtensionApi, never()).getSharedState(any(), any(), anyBoolean(), any()); + } + // ======================================================================================== // getName // ======================================================================================== @@ -947,8 +1108,12 @@ 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 - verify(mockIdentityState).clearProfileAttributes(any(), eq(resetEvent)); + // reset clears profile attributes and republishes them as an empty standard shared state + verify(mockExtensionApi) + .createSharedState( + eq(Collections.singletonMap("profileAttributes", Collections.emptyMap())), + eq(resetEvent) + ); // verify pending state is created and resolved verify(mockExtensionApi).createPendingXDMSharedState(eq(resetEvent)); 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..da5c8642 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 @@ -128,15 +128,12 @@ public void testBootUpIfReady_waitsForHubSharedState_hubStateStatusIsSet() { assertNotNull(identityState.getIdentityProperties().getECID()); verify(mockIdentityStorageManager).savePropertiesToPersistence(identityState.getIdentityProperties()); - // Bootup publishes a single XDM shared state combining identityMap + profileAttributes + // Profile values are published separately from transport identities. final Map expectedXDMState = new HashMap<>( identityState.getIdentityProperties().toXDMData(false) ); - expectedXDMState.put( - IdentityConstants.XDMKeys.PROFILE_ATTRIBUTES, - Collections.singletonMap(TIMEZONE_KEY, "Asia/Kolkata") - ); verify(mockSharedStateCallback).createXDMSharedState(expectedXDMState, null); + verify(mockSharedStateCallback).publishProfileAttributes(null); } @Test @@ -910,13 +907,9 @@ public void testUpdateTimeZone_whenChanged_savesThenDispatchesEdgeEvent() { "}"; JSONAsserts.assertEquals(expected, edgeEvent.getEventData()); - // XDM shared state is (re)published with profileAttributes wrapping the new value - final ArgumentCaptor sharedStateCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockSharedStateCallback).createXDMSharedState(sharedStateCaptor.capture(), any()); - JSONAsserts.assertEquals( - "{ \"profileAttributes\": { \"timeZone\": \"America/New_York\" } }", - sharedStateCaptor.getValue() - ); + // Profile values are published only in the standard shared state, never in the XDM state. + verify(mockSharedStateCallback).publishProfileAttributes(event); + verify(mockSharedStateCallback, never()).createXDMSharedState(any(), any()); } } @@ -931,7 +924,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()); } } @@ -949,20 +942,6 @@ public void testUpdateTimeZone_whenNoTimeZoneValue_skipsSaveAndDispatch() { } } - @Test - public void testClearProfileAttributes_clearsStorageAndPublishesEmptySharedState() { - final IdentityState state = new IdentityState(mockIdentityStorageManager); - final Event resetEvent = new Event.Builder("Reset", EventType.EDGE_IDENTITY, EventSource.REQUEST_RESET).build(); - - state.clearProfileAttributes(mockSharedStateCallback, resetEvent); - - verify(mockProfileAttributeStore, times(1)).clearAll(); - - final ArgumentCaptor sharedStateCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockSharedStateCallback).createXDMSharedState(sharedStateCaptor.capture(), any()); - assertTrue(sharedStateCaptor.getValue().isEmpty()); - } - // Test helpers /** @@ -1000,6 +979,26 @@ private Event fakeUpdateTimeZoneEvent(final String timeZone) { .build(); } + private Event fakeUpdateDeviceAttributesEvent(final String timeZone, final String pushToken) { + final Map eventData = new HashMap<>(); + if (timeZone != null) { + eventData.put(IdentityConstants.DeviceAttributes.TIMEZONE, timeZone); + } + if (pushToken != null) { + eventData.put( + IdentityConstants.DeviceAttributes.TOKENS, + Map.of(IdentityConstants.DeviceAttributes.PUSH_NOTIFICATION, pushToken) + ); + } + return new Event.Builder( + IdentityConstants.EventNames.UPDATE_DEVICE_ATTRIBUTES, + EventType.EDGE_IDENTITY, + EventSource.REQUEST_CONTENT + ) + .setEventData(eventData) + .build(); + } + /** * Verifies the Identity map contains the required ad ID and ECID * Valid ECID string and identity map is always required 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..9c97e5db 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_doesNotCreateDeviceAttributeTransactionStore() { + new IdentityStorageManager(mockDataStoreService); + verify(mockDataStoreService, never()).getNamedCollection("deviceAttributesDataStore"); + } + @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..a41fd5eb 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 @@ -630,6 +630,109 @@ public void testUpdateIdentities_EmptyMap() { } } + @Test + public void testUpdateDeviceAttributesBypassConsent_dispatchesInternalDeviceEvent() { + final ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class); + final DeviceAttributes attributes = new DeviceAttributes.Builder() + .setTimeZone("America/New_York") + .setPushToken("push-token") + .build(); + + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateDeviceAttributesBypassConsent(attributes); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(eventCaptor.capture()), times(1)); + } + + final Event event = eventCaptor.getValue(); + assertEquals(IdentityConstants.EventNames.UPDATE_DEVICE_ATTRIBUTES, event.getName()); + assertEquals(EventType.EDGE_IDENTITY, event.getType()); + assertEquals(EventSource.REQUEST_CONTENT, event.getSource()); + assertEquals("America/New_York", event.getEventData().get(IdentityConstants.DeviceAttributes.TIMEZONE)); + assertEquals( + Map.of(IdentityConstants.DeviceAttributes.PUSH_NOTIFICATION, "push-token"), + event.getEventData().get(IdentityConstants.DeviceAttributes.TOKENS) + ); + } + + @Test + public void testUpdateDeviceAttributesBypassConsent_emptyPushTokenIsIncluded() { + final DeviceAttributes attributes = new DeviceAttributes.Builder().setTimeZone("").setPushToken("").build(); + final ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class); + + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateDeviceAttributesBypassConsent(attributes); + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(eventCaptor.capture()), times(1)); + } + + final Event event = eventCaptor.getValue(); + assertEquals("", event.getEventData().get(IdentityConstants.DeviceAttributes.TIMEZONE)); + assertEquals( + Map.of(IdentityConstants.DeviceAttributes.PUSH_NOTIFICATION, ""), + event.getEventData().get(IdentityConstants.DeviceAttributes.TOKENS) + ); + } + + @Test + public void testUpdateDeviceAttributesBypassConsent_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.updateDeviceAttributesBypassConsent(attributes); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(eventCaptor.capture()), times(1)); + } + + assertEquals(attributes, eventCaptor.getValue().getEventData()); + } + + @Test + public void testDeviceAttributesMap_dispatchesWithoutResponseCallback() { + try (MockedStatic core = Mockito.mockStatic(MobileCore.class)) { + Identity.updateDeviceAttributesBypassConsent(Map.of("timezone", "Asia/Tokyo")); + core.verify(() -> MobileCore.dispatchEvent(any(Event.class))); + core.verifyNoMoreInteractions(); + } + } + + @Test + public void testUpdateDeviceAttributesBypassConsent_emptyAttributesDoesNotDispatch() { + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateDeviceAttributesBypassConsent(new DeviceAttributes.Builder().build()); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any(Event.class)), never()); + } + } + + @Test + public void testUpdateDeviceAttributesBypassConsent_emptyMapDoesNotDispatch() { + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateDeviceAttributesBypassConsent(Map.of()); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any(Event.class)), never()); + } + } + + @Test + public void testUpdateDeviceAttributesBypassConsent_nullAttributesDoesNotDispatch() { + try (MockedStatic mockedStaticMobileCore = Mockito.mockStatic(MobileCore.class)) { + Identity.updateDeviceAttributesBypassConsent((DeviceAttributes) null); + + mockedStaticMobileCore.verify(() -> MobileCore.dispatchEvent(any(Event.class)), never()); + } + } + // ======================================================================================== // removeIdentity API // ======================================================================================== diff --git a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/TimeZoneAttributeHandlerTests.java b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/TimeZoneAttributeHandlerTests.java index d6be7be6..f1544cbd 100644 --- a/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/TimeZoneAttributeHandlerTests.java +++ b/code/edgeidentity/src/test/java/com/adobe/marketing/mobile/edge/identity/TimeZoneAttributeHandlerTests.java @@ -79,6 +79,14 @@ public void testCollectFromEvent_whenEmpty_returnsNull() { assertNull(result); } + @Test + public void testCollectFromEvent_whenInvalidTimeZone_returnsNull() { + final Map result = handler.collectFromEvent(fakeTimeZoneEvent("Not/A_TimeZone")); + + verify(mockStore, never()).setString(eq(KEY), any()); + assertNull(result); + } + @Test public void testCollectFromEvent_whenKeyAbsent_returnsNull() { final Event event = new Event.Builder(