diff --git a/CHANGELOG.md b/CHANGELOG.md index 5455af8ebc..e8d13c1858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## CHANGE LOG. +### June 2026 +* [CleverTap Android SDK v8.3.0](docs/CTCORECHANGELOG.md). + ### May 20, 2026 * [CleverTap Android SDK v8.2.0](docs/CTCORECHANGELOG.md). diff --git a/README.md b/README.md index 70b8514f79..04e519fa95 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ We publish the SDK to `mavenCentral` as an `AAR` file. Just declare it as depend ```groovy dependencies { - implementation "com.clevertap.android:clevertap-android-sdk:8.2.0" + implementation "com.clevertap.android:clevertap-android-sdk:8.3.0" } ``` @@ -34,7 +34,7 @@ Alternatively, you can download and add the AAR file included in this repo in yo ```groovy dependencies { - implementation (name: "clevertap-android-sdk-8.2.0", ext: 'aar') + implementation (name: "clevertap-android-sdk-8.3.0", ext: 'aar') } ``` @@ -46,7 +46,7 @@ Add the Firebase Messaging library and Android Support Library v4 as dependencie ```groovy dependencies { - implementation "com.clevertap.android:clevertap-android-sdk:8.2.0" + implementation "com.clevertap.android:clevertap-android-sdk:8.3.0" implementation "androidx.core:core:1.13.0" implementation "com.google.firebase:firebase-messaging:24.0.0" implementation "com.google.android.gms:play-services-ads:23.6.0" // Required only if you enable Google ADID collection in the SDK (turned off by default). diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManager.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManager.java index d7df4027ce..4b8a620c88 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManager.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/AnalyticsManager.java @@ -13,6 +13,7 @@ import androidx.annotation.WorkerThread; +import com.clevertap.android.sdk.displayunits.DisplayUnitCache; import com.clevertap.android.sdk.displayunits.model.CleverTapDisplayUnit; import com.clevertap.android.sdk.events.BaseEventQueueManager; import com.clevertap.android.sdk.events.FlattenedEventData; @@ -205,9 +206,9 @@ public void pushDisplayUnitClickedEventForID(String unitID) { event.put("evtName", Constants.NOTIFICATION_CLICKED_EVENT_NAME); //wzrk fields - if (controllerManager.getCTDisplayUnitController() != null) { - CleverTapDisplayUnit displayUnit = controllerManager.getCTDisplayUnitController() - .getDisplayUnitForID(unitID); + DisplayUnitCache cache = controllerManager.getDisplayUnitCache(); + if (cache != null) { + CleverTapDisplayUnit displayUnit = cache.getDisplayUnitForID(unitID); if (displayUnit != null) { JSONObject eventExtraData = displayUnit.getWZRKFields(); if (eventExtraData != null) { @@ -229,6 +230,115 @@ public void pushDisplayUnitClickedEventForID(String unitID) { } } + /** + * Raises a Native Display element click event. + * + * Element-level analog of {@link #pushDisplayUnitClickedEventForID(String)} — for + * Native Display units that host multiple interactive child elements (buttons, + * images, etc.), this method records which child element was clicked alongside + * the existing wzrk_* campaign attribution. + * + * Caller's additionalProperties (which should include wzrk_element_id from the + * action metadata injected by the BE) are merged verbatim first; the cached + * unit's wzrk_* fields are then layered on top. + */ + @Override + public void pushDisplayUnitElementClickedEventForID( + String unitID, + HashMap additionalProperties) { + JSONObject event = new JSONObject(); + try { + event.put("evtName", Constants.NOTIFICATION_CLICKED_EVENT_NAME); + + DisplayUnitCache cache = controllerManager.getDisplayUnitCache(); + if (cache == null) { + config.getLogger().verbose(config.getAccountId(), + Constants.FEATURE_DISPLAY_UNIT + "Element click dropped — no display-unit cache installed"); + return; + } + CleverTapDisplayUnit displayUnit = cache.getDisplayUnitForID(unitID); + if (displayUnit == null) { + config.getLogger().verbose(config.getAccountId(), + Constants.FEATURE_DISPLAY_UNIT + "Element click dropped — no unit found for id: " + unitID); + return; + } + + JSONObject eventExtraData = new JSONObject(); + mergeAdditionalProperties(eventExtraData, additionalProperties); + JSONObject cachedWzrkFields = displayUnit.getWZRKFields(); + if (cachedWzrkFields != null) { + Iterator it = cachedWzrkFields.keys(); + while (it.hasNext()) { + String k = it.next(); + try { + eventExtraData.put(k, cachedWzrkFields.get(k)); + } catch (JSONException ignored) { + } + } + } + + event.put("evtData", eventExtraData); + try { + coreMetaData.setWzrkParams(filterWzrkFields(eventExtraData)); + } catch (Throwable t) { + // no-op + } + baseEventQueueManager.queueEvent(context, event, Constants.RAISED_EVENT, + getFlattenedEventProperties(eventExtraData)); + } catch (Throwable t) { + config.getLogger().verbose(config.getAccountId(), + Constants.FEATURE_DISPLAY_UNIT + + "Failed to push Display Unit element clicked event" + t); + } + } + + /** + * Merge caller-supplied {@code additionalProperties} verbatim into the click + * event's {@code evtData}. The {@code wzrk_*} namespace is enforced by the + * caller of this helper — by layering the cached unit's {@code wzrk_*} + * fields on top after this merge, so server-controlled attribution wins + * over any same-named caller key. + */ + private void mergeAdditionalProperties(JSONObject eventData, + HashMap extras) { + if (extras == null || extras.isEmpty()) { + return; + } + for (Map.Entry entry : extras.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + if (key == null || key.isEmpty() || value == null) { + continue; + } + try { + eventData.put(key, value); + } catch (JSONException ignored) { + // skip unserialisable entries + } + } + } + + /** + * Project only the {@code wzrk_*} keys back out of the merged event data so + * {@link CoreMetaData#setWzrkParams(JSONObject)} retains its existing + * server-namespace contract (it feeds {@code wzrk_ref} batch headers; caller- + * supplied non-wzrk extras must not ride along on unrelated subsequent events). + */ + private JSONObject filterWzrkFields(JSONObject merged) { + JSONObject out = new JSONObject(); + Iterator it = merged.keys(); + while (it.hasNext()) { + String k = it.next(); + if (k.startsWith(Constants.WZRK_PREFIX)) { + try { + out.put(k, merged.get(k)); + } catch (JSONException ignored) { + } + } + } + return out; + } + @Override public void pushDisplayUnitViewedEventForID(String unitID) { JSONObject event = new JSONObject(); @@ -237,9 +347,9 @@ public void pushDisplayUnitViewedEventForID(String unitID) { event.put("evtName", Constants.NOTIFICATION_VIEWED_EVENT_NAME); //wzrk fields - if (controllerManager.getCTDisplayUnitController() != null) { - CleverTapDisplayUnit displayUnit = controllerManager.getCTDisplayUnitController() - .getDisplayUnitForID(unitID); + DisplayUnitCache cache = controllerManager.getDisplayUnitCache(); + if (cache != null) { + CleverTapDisplayUnit displayUnit = cache.getDisplayUnitForID(unitID); if (displayUnit != null) { JSONObject eventExtras = displayUnit.getWZRKFields(); if (eventExtras != null) { diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/BaseAnalyticsManager.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/BaseAnalyticsManager.java index c67b7242b7..ff3eee4ad0 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/BaseAnalyticsManager.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/BaseAnalyticsManager.java @@ -3,6 +3,7 @@ import android.os.Bundle; import com.clevertap.android.sdk.inapp.CTInAppNotification; import java.util.ArrayList; +import java.util.HashMap; import java.util.Map; import org.json.JSONObject; @@ -25,6 +26,9 @@ public abstract class BaseAnalyticsManager { public abstract void pushDisplayUnitClickedEventForID(String unitID); + public abstract void pushDisplayUnitElementClickedEventForID( + String unitID, HashMap additionalProperties); + public abstract void pushDisplayUnitViewedEventForID(String unitID); @SuppressWarnings({"unused"}) diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/CleverTapAPI.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/CleverTapAPI.java index 23e20429c7..6180db7c10 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/CleverTapAPI.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/CleverTapAPI.java @@ -30,6 +30,7 @@ import androidx.annotation.RestrictTo.Scope; import androidx.annotation.WorkerThread; import com.clevertap.android.sdk.cryption.ICryptHandler; +import com.clevertap.android.sdk.displayunits.DisplayUnitCache; import com.clevertap.android.sdk.displayunits.DisplayUnitListener; import com.clevertap.android.sdk.displayunits.model.CleverTapDisplayUnit; import com.clevertap.android.sdk.events.EventDetail; @@ -1472,14 +1473,13 @@ public String getAccountId() { */ @Nullable public ArrayList getAllDisplayUnits() { - - if (coreState.getControllerManager().getCTDisplayUnitController() != null) { - return coreState.getControllerManager().getCTDisplayUnitController().getAllDisplayUnits(); - } else { - getConfigLogger() - .verbose(getAccountId(), Constants.FEATURE_DISPLAY_UNIT + "Failed to get all Display Units"); - return null; + DisplayUnitCache cache = coreState.getControllerManager().getDisplayUnitCache(); + if (cache != null) { + return cache.getAllDisplayUnits(); } + getConfigLogger() + .verbose(getAccountId(), Constants.FEATURE_DISPLAY_UNIT + "Failed to get all Display Units"); + return null; } /** @@ -1792,13 +1792,13 @@ public void onComplete(@NonNull com.google.android.gms.tasks.Task task) */ @Nullable public CleverTapDisplayUnit getDisplayUnitForId(String unitID) { - if (coreState.getControllerManager().getCTDisplayUnitController() != null) { - return coreState.getControllerManager().getCTDisplayUnitController().getDisplayUnitForID(unitID); - } else { - getConfigLogger().verbose(getAccountId(), - Constants.FEATURE_DISPLAY_UNIT + "Failed to get Display Unit for id: " + unitID); - return null; + DisplayUnitCache cache = coreState.getControllerManager().getDisplayUnitCache(); + if (cache != null) { + return cache.getDisplayUnitForID(unitID); } + getConfigLogger().verbose(getAccountId(), + Constants.FEATURE_DISPLAY_UNIT + "Failed to get Display Unit for id: " + unitID); + return null; } /** @@ -2495,6 +2495,29 @@ public void pushDeepLink(Uri uri) { coreState.getAnalyticsManager().pushDeepLink(uri, false); } + /** + * Replaces the SDK's display-unit cache with the supplied implementation. + * Pass {@code null} to clear the reference (subsequent server responses + * will lazily install a fresh default {@link + * com.clevertap.android.sdk.displayunits.CTDisplayUnitController}). + * + *

The new instance receives subsequent {@code updateDisplayUnits} + * calls (e.g. from server responses) and serves all lookup sites: + * {@link #getDisplayUnitForId(String)}, {@link #getAllDisplayUnits()}, + * {@link #pushDisplayUnitViewedEventForID(String)}, and + * {@link #pushDisplayUnitClickedEventForID(String)}. + * + *

Implementations must be thread-safe. The display-unit listener + * registered via {@link #setDisplayUnitListener} fires only for + * server-pipeline activity — replacing the cache or mutating its + * contents from outside the SDK does not synthesise a listener fire. + * + * @since 8.3.0 + */ + public void setDisplayUnitCache(@Nullable DisplayUnitCache cache) { + coreState.getControllerManager().setDisplayUnitCache(cache); + } + /** * Raises the Display Unit Clicked event * @@ -2505,6 +2528,36 @@ public void pushDisplayUnitClickedEventForID(String unitID) { coreState.getAnalyticsManager().pushDisplayUnitClickedEventForID(unitID); } + /** + * Raises a Native Display element click event for the given unit + element. + * + * Element-level analog of {@link #pushDisplayUnitClickedEventForID(String)} — for + * Native Display units that host multiple interactive child elements (buttons, + * images, etc.), this method records which child element was clicked alongside + * the existing wzrk_* campaign attribution. + * + *

{@code evtData} is assembled in two layers (later layers win on key collision): + *

    + *
  1. Caller's {@code additionalProperties}, merged verbatim — should include + * {@code wzrk_element_id} and other {@code wzrk_*} attribution fields injected + * by the BE into the action's {@code metadata} object.
  2. + *
  3. Cached unit's {@code wzrk_*} fields layered on top — so server-controlled + * attribution always wins over same-named caller-supplied keys.
  4. + *
+ * + * @param unitID the unitID of the Display Unit + * ({@link CleverTapDisplayUnit#getUnitID()}) + * @param additionalProperties per-click context including {@code wzrk_element_id} + * and other {@code wzrk_*} fields from BE action metadata + */ + @SuppressWarnings("unused") + public void pushDisplayUnitElementClickedEventForID( + String unitID, + HashMap additionalProperties) { + coreState.getAnalyticsManager().pushDisplayUnitElementClickedEventForID( + unitID, additionalProperties); + } + /** * Raises the Display Unit Viewed event * diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/ControllerManager.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/ControllerManager.java index 5bdc118d8d..4eb1fd230b 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/ControllerManager.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/ControllerManager.java @@ -3,8 +3,10 @@ import android.content.Context; import androidx.annotation.AnyThread; import androidx.annotation.WorkerThread; +import androidx.annotation.Nullable; import com.clevertap.android.sdk.db.BaseDatabaseManager; import com.clevertap.android.sdk.displayunits.CTDisplayUnitController; +import com.clevertap.android.sdk.displayunits.DisplayUnitCache; import com.clevertap.android.sdk.featureFlags.CTFeatureFlagsController; import com.clevertap.android.sdk.inapp.InAppController; import com.clevertap.android.sdk.inbox.CTInboxController; @@ -27,7 +29,7 @@ public class ControllerManager { private final BaseDatabaseManager baseDatabaseManager; - private CTDisplayUnitController ctDisplayUnitController; + private volatile DisplayUnitCache displayUnitCache; /** *

@@ -79,13 +81,60 @@ public ControllerManager(Context context, baseDatabaseManager = databaseManager; } + /** + * @return the active {@link DisplayUnitCache}; {@code null} until the SDK + * receives its first {@code adUnit_notifs} response or a host installs a + * cache via {@link #setDisplayUnitCache(DisplayUnitCache)}. + */ + @Nullable + public DisplayUnitCache getDisplayUnitCache() { + return displayUnitCache; + } + + /** + * Returns the existing cache, or lazily installs a default + * {@link CTDisplayUnitController} if none is present. The check-and-create + * is atomic — prevents a race between the server-response thread and host + * code calling {@link #setDisplayUnitCache}. + */ + @Nullable + public synchronized DisplayUnitCache getOrCreateDisplayUnitCache() { + if (displayUnitCache == null) { + displayUnitCache = new CTDisplayUnitController(); + } + return displayUnitCache; + } + + /** + * Replaces the display-unit cache. Pass {@code null} to clear the + * reference (subsequent server responses will lazily install a fresh + * default {@link CTDisplayUnitController}). + */ + public synchronized void setDisplayUnitCache(@Nullable DisplayUnitCache cache) { + displayUnitCache = cache; + } + + /** + * @deprecated since 8.3.0. Use {@link #getDisplayUnitCache()} instead. + * Returns the active cache only when it is the default + * {@link CTDisplayUnitController}; returns {@code null} when a host has + * installed a custom {@link DisplayUnitCache}. + */ + @Deprecated + @Nullable public CTDisplayUnitController getCTDisplayUnitController() { - return ctDisplayUnitController; + return displayUnitCache instanceof CTDisplayUnitController + ? (CTDisplayUnitController) displayUnitCache : null; } + /** + * @deprecated since 8.3.0. Use + * {@link #setDisplayUnitCache(DisplayUnitCache)} instead. + */ + @Deprecated public void setCTDisplayUnitController( final CTDisplayUnitController CTDisplayUnitController) { - ctDisplayUnitController = CTDisplayUnitController; + setDisplayUnitCache(CTDisplayUnitController); } /** diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/displayunits/CTDisplayUnitController.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/displayunits/CTDisplayUnitController.java index ac073336fd..9bf8939ed6 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/displayunits/CTDisplayUnitController.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/displayunits/CTDisplayUnitController.java @@ -7,13 +7,13 @@ import com.clevertap.android.sdk.displayunits.model.CleverTapDisplayUnit; import java.util.ArrayList; import java.util.HashMap; -import org.json.JSONArray; -import org.json.JSONObject; +import java.util.List; /** * Controller class for caching & supplying the Display Units to the client. + * Default implementation of {@link DisplayUnitCache}. */ -public class CTDisplayUnitController { +public class CTDisplayUnitController implements DisplayUnitCache { final HashMap items = new HashMap<>(); @@ -22,6 +22,7 @@ public class CTDisplayUnitController { * * @return ArrayList - Could be null in case no Display Units are there in the cache */ + @Override @Nullable public synchronized ArrayList getAllDisplayUnits() { if (!items.isEmpty()) { @@ -38,8 +39,9 @@ public synchronized ArrayList getAllDisplayUnits() { * @param unitId - unitID of the Display Unit {@link CleverTapDisplayUnit#getUnitID()} * @return CleverTapDisplayUnit - Could be null in case no Display Units with the ID is found */ + @Override @Nullable - public synchronized CleverTapDisplayUnit getDisplayUnitForID(String unitId) { + public synchronized CleverTapDisplayUnit getDisplayUnitForID(@Nullable String unitId) { if (!TextUtils.isEmpty(unitId)) { return items.get(unitId); } else { @@ -51,47 +53,28 @@ public synchronized CleverTapDisplayUnit getDisplayUnitForID(String unitId) { /** * clears the existing Display Units */ + @Override public synchronized void reset() { items.clear(); Logger.d(Constants.FEATURE_DISPLAY_UNIT, "Cleared Display Units Cache"); } /** - * Replaces the old Display Units with the new ones, post transformation of Json objects to Display Unit objects + * Replaces the old Display Units with the supplied list. * - * @param messages - json-array of Display Unit items - * @return ArrayList - could be null in case of null/empty/invalid json array + * @param displayUnits parsed display units; may be {@code null} or empty. */ - @Nullable - public synchronized ArrayList updateDisplayUnits(JSONArray messages) { - - //flush existing display units before updating with the new ones. + @Override + public synchronized void updateDisplayUnits(@Nullable List displayUnits) { reset(); - - if (messages != null && messages.length() > 0) { - final ArrayList list = new ArrayList<>(); - try { - for (int i = 0; i < messages.length(); i++) { - //parse each display Unit - CleverTapDisplayUnit item = CleverTapDisplayUnit.toDisplayUnit((JSONObject) messages.get(i)); - if (TextUtils.isEmpty(item.getError())) { - items.put(item.getUnitID(), item); - list.add(item); - } else { - Logger.d(Constants.FEATURE_DISPLAY_UNIT, - "Failed to convert JsonArray item at index:" + i + " to Display Unit"); - } - } - } catch (Exception e) { - Logger.d(Constants.FEATURE_DISPLAY_UNIT, - "Failed while parsing Display Unit:" + e.getLocalizedMessage()); - return null; + if (displayUnits == null || displayUnits.isEmpty()) { + Logger.d(Constants.FEATURE_DISPLAY_UNIT, "Empty Display Units list, cache cleared"); + return; + } + for (CleverTapDisplayUnit unit : displayUnits) { + if (unit != null && !TextUtils.isEmpty(unit.getUnitID())) { + items.put(unit.getUnitID(), unit); } - - return !list.isEmpty() ? list : null; - } else { - Logger.d(Constants.FEATURE_DISPLAY_UNIT, "Null json array response can't parse Display Units "); - return null; } } } \ No newline at end of file diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/displayunits/DisplayUnitCache.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/displayunits/DisplayUnitCache.java new file mode 100644 index 0000000000..0e624b7542 --- /dev/null +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/displayunits/DisplayUnitCache.java @@ -0,0 +1,53 @@ +package com.clevertap.android.sdk.displayunits; + +import androidx.annotation.Nullable; +import com.clevertap.android.sdk.displayunits.model.CleverTapDisplayUnit; +import java.util.ArrayList; +import java.util.List; + +/** + * In-memory storage contract for {@link CleverTapDisplayUnit}s. + * The default implementation ({@link CTDisplayUnitController}) is + * server-pipeline-driven. Hosts may install their own implementation via + * {@link com.clevertap.android.sdk.CleverTapAPI#setDisplayUnitCache(DisplayUnitCache)} + * to expose units produced outside the standard server-response pipeline + * (for example, server-driven UI SDKs that fetch units through their own + * pipeline). + * + *

Implementors must be thread-safe — methods may be invoked from any thread. + * + *

The display unit listener registered via + * {@link com.clevertap.android.sdk.CleverTapAPI#setDisplayUnitListener} only + * fires for server-pipeline activity. Replacing the cache or mutating its + * contents from outside the SDK does not synthesise a listener fire. + */ +public interface DisplayUnitCache { + + /** + * @return all units currently held; {@code null} or empty if none. + */ + @Nullable + ArrayList getAllDisplayUnits(); + + /** + * @param unitID the unit identifier; implementations must tolerate + * {@code null} or empty input by returning {@code null}. + * @return the unit with the given id, or {@code null} if absent. + */ + @Nullable + CleverTapDisplayUnit getDisplayUnitForID(@Nullable String unitID); + + /** + * Called by the SDK when a server response delivers an updated set of + * display units. The default implementation replaces the cache contents; + * hosts may choose merge semantics for their own implementations. + * + * @param displayUnits parsed display units; may be {@code null} or empty. + */ + void updateDisplayUnits(@Nullable List displayUnits); + + /** + * Clears all units. Called by the SDK on logout / reset flows. + */ + void reset(); +} diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/login/LoginController.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/login/LoginController.java index 6eddaf1461..2d5563c64c 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/login/LoginController.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/login/LoginController.java @@ -6,6 +6,7 @@ import androidx.annotation.WorkerThread; import com.clevertap.android.sdk.AnalyticsManager; +import com.clevertap.android.sdk.displayunits.DisplayUnitCache; import com.clevertap.android.sdk.BaseCallbackManager; import com.clevertap.android.sdk.CTLockManager; import com.clevertap.android.sdk.CleverTapInstanceConfig; @@ -297,8 +298,9 @@ private void _onUserLogin(final Map profile, final String clever * Resets the Display Units in the cache */ private void resetDisplayUnits() { - if (controllerManager.getCTDisplayUnitController() != null) { - controllerManager.getCTDisplayUnitController().reset(); + DisplayUnitCache cache = controllerManager.getDisplayUnitCache(); + if (cache != null) { + cache.reset(); } else { config.getLogger().verbose(config.getAccountId(), Constants.FEATURE_DISPLAY_UNIT + "Can't reset Display Units, DisplayUnitcontroller is null"); diff --git a/clevertap-core/src/main/java/com/clevertap/android/sdk/response/DisplayUnitResponse.java b/clevertap-core/src/main/java/com/clevertap/android/sdk/response/DisplayUnitResponse.java index 2870f2eca5..eb9f948600 100644 --- a/clevertap-core/src/main/java/com/clevertap/android/sdk/response/DisplayUnitResponse.java +++ b/clevertap-core/src/main/java/com/clevertap/android/sdk/response/DisplayUnitResponse.java @@ -1,21 +1,22 @@ package com.clevertap.android.sdk.response; import android.content.Context; +import android.text.TextUtils; +import androidx.annotation.NonNull; import com.clevertap.android.sdk.BaseCallbackManager; import com.clevertap.android.sdk.CleverTapInstanceConfig; import com.clevertap.android.sdk.Constants; import com.clevertap.android.sdk.ControllerManager; import com.clevertap.android.sdk.Logger; -import com.clevertap.android.sdk.displayunits.CTDisplayUnitController; +import com.clevertap.android.sdk.displayunits.DisplayUnitCache; import com.clevertap.android.sdk.displayunits.model.CleverTapDisplayUnit; import java.util.ArrayList; import org.json.JSONArray; +import org.json.JSONException; import org.json.JSONObject; public class DisplayUnitResponse extends CleverTapResponseDecorator { - private final Object displayUnitControllerLock = new Object(); - private final BaseCallbackManager callbackManager; private final CleverTapInstanceConfig config; @@ -72,25 +73,59 @@ public void processResponse(final JSONObject response, final String stringBody, } /** - * Parses the Display Units using the JSON response + * Parses the Display Units from the JSON response, populates the cache and + * notifies the callback only when at least one valid unit was received. + * + * A null or empty array is a no-op: the cache is not touched and the callback + * is not fired. This preserves the legacy pre-8.x contract and matches iOS + * parity — iOS guards on displayUnitJSON.count > 0 before doing anything. * * @param messages - Json array of Display Unit items */ private void parseDisplayUnits(JSONArray messages) { if (messages == null || messages.length() == 0) { logger.verbose(config.getAccountId(), - Constants.FEATURE_DISPLAY_UNIT + "Can't parse Display Units, jsonArray is either empty or null"); + Constants.FEATURE_DISPLAY_UNIT + "Can't parse Display Units, jsonArray is null or empty"); return; } - synchronized (displayUnitControllerLock) {// lock to avoid multiple instance creation for controller - if (controllerManager.getCTDisplayUnitController() == null) { - controllerManager.setCTDisplayUnitController(new CTDisplayUnitController()); - } + DisplayUnitCache cache = controllerManager.getOrCreateDisplayUnitCache(); + if (cache == null) { + logger.verbose(config.getAccountId(), + Constants.FEATURE_DISPLAY_UNIT + "No display-unit cache available"); + return; + } + + ArrayList displayUnits = parseDisplayUnitsFromJson(messages); + cache.updateDisplayUnits(displayUnits); + if (!displayUnits.isEmpty()) { + callbackManager.notifyDisplayUnitsLoaded(displayUnits); } - ArrayList displayUnits = controllerManager.getCTDisplayUnitController() - .updateDisplayUnits(messages); + } - callbackManager.notifyDisplayUnitsLoaded(displayUnits); + /** + * Converts a JSON array of display units into model objects, filtering out + * malformed entries. + */ + @NonNull + private ArrayList parseDisplayUnitsFromJson(@NonNull JSONArray messages) { + final ArrayList list = new ArrayList<>(); + for (int i = 0; i < messages.length(); i++) { + try { + CleverTapDisplayUnit unit = CleverTapDisplayUnit.toDisplayUnit(messages.getJSONObject(i)); + if (TextUtils.isEmpty(unit.getError())) { + list.add(unit); + } else { + logger.verbose(config.getAccountId(), + Constants.FEATURE_DISPLAY_UNIT + "Failed to convert JsonArray item at index:" + i + + " to Display Unit"); + } + } catch (JSONException e) { + logger.verbose(config.getAccountId(), + Constants.FEATURE_DISPLAY_UNIT + "Failed to parse Display Unit at index " + i + + ": " + e.getLocalizedMessage()); + } + } + return list; } } diff --git a/clevertap-core/src/test/java/com/clevertap/android/sdk/AnalyticsManagerTest.kt b/clevertap-core/src/test/java/com/clevertap/android/sdk/AnalyticsManagerTest.kt index ca93fd95da..3f9035705b 100644 --- a/clevertap-core/src/test/java/com/clevertap/android/sdk/AnalyticsManagerTest.kt +++ b/clevertap-core/src/test/java/com/clevertap/android/sdk/AnalyticsManagerTest.kt @@ -37,6 +37,9 @@ import io.mockk.verify import org.json.JSONArray import org.json.JSONObject import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -44,8 +47,6 @@ import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import java.util.concurrent.Future import kotlin.apply -import kotlin.test.assertEquals -import kotlin.test.assertFalse @RunWith(RobolectricTestRunner::class) class AnalyticsManagerTest { @@ -790,7 +791,7 @@ class AnalyticsManagerTest { @Test fun `pushDisplayUnitClickedEventForID displayController is null`() { - every { coreState.controllerManager.ctDisplayUnitController } returns null + every { coreState.controllerManager.displayUnitCache } returns null analyticsManagerSUT.pushDisplayUnitClickedEventForID("id") verify(exactly = 0) { @@ -802,7 +803,7 @@ class AnalyticsManagerTest { fun `pushDisplayUnitClickedEventForID displayUnit is null`() { val displayController = mockk() every { displayController.getDisplayUnitForID(any()) } returns null - every { coreState.controllerManager.ctDisplayUnitController } returns displayController + every { coreState.controllerManager.displayUnitCache } returns displayController analyticsManagerSUT.pushDisplayUnitClickedEventForID("id") @@ -819,7 +820,7 @@ class AnalyticsManagerTest { @Test fun `pushDisplayUnitViewedEventForID displayController is null`() { - every { coreState.controllerManager.ctDisplayUnitController } returns null + every { coreState.controllerManager.displayUnitCache } returns null analyticsManagerSUT.pushDisplayUnitViewedEventForID("id") verify(exactly = 0) { @@ -831,7 +832,7 @@ class AnalyticsManagerTest { fun `pushDisplayUnitViewedEventForID displayUnit is null`() { val displayController = mockk() every { displayController.getDisplayUnitForID(any()) } returns null - every { coreState.controllerManager.ctDisplayUnitController } returns displayController + every { coreState.controllerManager.displayUnitCache } returns displayController analyticsManagerSUT.pushDisplayUnitViewedEventForID("id") @@ -840,12 +841,111 @@ class AnalyticsManagerTest { } } + @Test + fun `pushDisplayUnitElementClickedEventForID merges additionalProperties including wzrk_element_id`() { + val displayController = mockk() + val unitJson = JSONObject() + .put("wzrk_id", "1234_5678") + .put("wzrk_pivot", "wzrk_default") + every { displayController.getDisplayUnitForID(any()) } returns + CleverTapDisplayUnit.toDisplayUnit(unitJson) + every { coreState.controllerManager.displayUnitCache } returns displayController + mockCleanEventName(Constants.NOTIFICATION_CLICKED_EVENT_NAME) + + val extras = HashMap().apply { + put("wzrk_element_id", "button-1") + put("action_type", "open_url") + put("action_url", "https://example.com") + put("k1", "v1") + } + analyticsManagerSUT.pushDisplayUnitElementClickedEventForID("id", extras) + + verify(exactly = 1) { + eventQueueManager.queueEvent(any(), match { event -> + val evtData = event.getJSONObject(Constants.KEY_EVT_DATA) + event.getString(Constants.KEY_EVT_NAME) == Constants.NOTIFICATION_CLICKED_EVENT_NAME + // wzrk_* enrichment preserved + && evtData.optString("wzrk_id") == "1234_5678" + && evtData.optString("wzrk_pivot") == "wzrk_default" + // element id flows via additionalProperties + && evtData.optString("wzrk_element_id") == "button-1" + // additionalProperties merged + && evtData.optString("action_type") == "open_url" + && evtData.optString("action_url") == "https://example.com" + && evtData.optString("k1") == "v1" + }, Constants.RAISED_EVENT, any()) + } + } + + @Test + fun `pushDisplayUnitElementClickedEventForID cached wzrk_ wins over caller wzrk_ but novel wzrk_ keys pass through`() { + val displayController = mockk() + val unitJson = JSONObject().put("wzrk_id", "real_id") + every { displayController.getDisplayUnitForID(any()) } returns + CleverTapDisplayUnit.toDisplayUnit(unitJson) + every { coreState.controllerManager.displayUnitCache } returns displayController + mockCleanEventName(Constants.NOTIFICATION_CLICKED_EVENT_NAME) + + val extras = HashMap().apply { + put("wzrk_id", "spoofed") // collides with cached wzrk_id — cached wins + put("wzrk_extra", "kept-through") // novel wzrk_ key, not in cached unit — passes through + put("ok_key", "kept") + } + analyticsManagerSUT.pushDisplayUnitElementClickedEventForID("id", extras) + + verify(exactly = 1) { + eventQueueManager.queueEvent(any(), match { event -> + val evtData = event.getJSONObject(Constants.KEY_EVT_DATA) + // Cached wzrk_id wins over the caller-supplied collision. + evtData.optString("wzrk_id") == "real_id" + // Novel wzrk_-prefixed key from caller passes through. + && evtData.optString("wzrk_extra") == "kept-through" + // Non-wzrk caller key kept. + && evtData.optString("ok_key") == "kept" + }, Constants.RAISED_EVENT, any()) + } + } + + @Test + fun `pushDisplayUnitElementClickedEventForID setWzrkParams receives only wzrk_ keys`() { + val displayController = mockk() + val unitJson = JSONObject().put("wzrk_id", "1234") + every { displayController.getDisplayUnitForID(any()) } returns + CleverTapDisplayUnit.toDisplayUnit(unitJson) + every { coreState.controllerManager.displayUnitCache } returns displayController + mockCleanEventName(Constants.NOTIFICATION_CLICKED_EVENT_NAME) + + val extras = HashMap().apply { + put("action_url", "https://x") + put("wzrk_element_id", "btn") + } + analyticsManagerSUT.pushDisplayUnitElementClickedEventForID("id", extras) + + // coreMetaData.setWzrkParams feeds the wzrk_ref batch header — caller-supplied + // non-wzrk extras must NOT ride along. coreMetaData is a real instance (see + // MockCoreStateKotlin), so reading wzrkParams back reflects the latest set. + val wzrkParams = coreState.coreMetaData.wzrkParams + assertNotNull(wzrkParams) + assertFalse(wzrkParams.has("action_url")) + assertEquals("1234", wzrkParams.optString("wzrk_id")) + assertEquals("btn", wzrkParams.optString("wzrk_element_id")) + } + + @Test + fun `pushDisplayUnitElementClickedEventForID displayController is null`() { + every { coreState.controllerManager.displayUnitCache } returns null + analyticsManagerSUT.pushDisplayUnitElementClickedEventForID("id", HashMap()) + verify(exactly = 0) { + eventQueueManager.queueEvent(any(), any(), any(), any()) + } + } + private fun verifyDisplayUnitEventForId(isClicked: Boolean) { val displayController = mockk() val displayUnitJson = JSONObject() val displayUnit = CleverTapDisplayUnit.toDisplayUnit(displayUnitJson) every { displayController.getDisplayUnitForID(any()) } returns displayUnit - every { coreState.controllerManager.ctDisplayUnitController } returns displayController + every { coreState.controllerManager.displayUnitCache } returns displayController val eventName: String if (isClicked) { @@ -1551,8 +1651,8 @@ class AnalyticsManagerTest { @Test fun `raiseEventForGeofences should queue event and set location`() { val eventName = "geofence_event" - val lat = 34.05 - val lng = -118.25 + val lat: Double = 34.05 + val lng: Double = -118.25 val propKey = "prop" val propValue = "value" val geofenceProperties = JSONObject().apply { @@ -1581,8 +1681,8 @@ class AnalyticsManagerTest { } val location = coreState.coreMetaData.locationFromUser - assertEquals(lat, location.latitude) - assertEquals(lng, location.longitude) + assertEquals(lat, location.latitude, 0.0) + assertEquals(lng, location.longitude, 0.0) } @Test diff --git a/clevertap-core/src/test/java/com/clevertap/android/sdk/MockAnalyticsManager.kt b/clevertap-core/src/test/java/com/clevertap/android/sdk/MockAnalyticsManager.kt index b13a3937cf..0d9bbd13bf 100644 --- a/clevertap-core/src/test/java/com/clevertap/android/sdk/MockAnalyticsManager.kt +++ b/clevertap-core/src/test/java/com/clevertap/android/sdk/MockAnalyticsManager.kt @@ -23,6 +23,10 @@ internal class MockAnalyticsManager : BaseAnalyticsManager() { } override fun pushDisplayUnitClickedEventForID(unitID: String) {} + override fun pushDisplayUnitElementClickedEventForID( + unitID: String, + additionalProperties: java.util.HashMap? + ) {} override fun pushDisplayUnitViewedEventForID(unitID: String) {} override fun pushError(errorMessage: String, errorCode: Int) {} override fun pushEvent(eventName: String, eventActions: Map) {} diff --git a/clevertap-core/src/test/java/com/clevertap/android/sdk/displayunits/CTDisplayUnitControllerTest.kt b/clevertap-core/src/test/java/com/clevertap/android/sdk/displayunits/CTDisplayUnitControllerTest.kt index 39a98c509b..fa66739763 100644 --- a/clevertap-core/src/test/java/com/clevertap/android/sdk/displayunits/CTDisplayUnitControllerTest.kt +++ b/clevertap-core/src/test/java/com/clevertap/android/sdk/displayunits/CTDisplayUnitControllerTest.kt @@ -3,8 +3,6 @@ package com.clevertap.android.sdk.displayunits import com.clevertap.android.sdk.displayunits.model.CleverTapDisplayUnit import com.clevertap.android.sdk.displayunits.model.MockCleverTapDisplayUnit import com.clevertap.android.shared.test.BaseTestCase -import io.mockk.every -import io.mockk.mockkStatic import org.junit.Assert import org.junit.Before import org.junit.Test @@ -22,14 +20,28 @@ class CTDisplayUnitControllerTest : BaseTestCase() { ctDisplayUnitController = CTDisplayUnitController() } + private fun mockDisplayUnits(noItems: Int): ArrayList { + val jsonArray = MockCleverTapDisplayUnit().getMockResponse(noItems) + val list = ArrayList() + for (i in 0 until jsonArray.length()) { + list.add(CleverTapDisplayUnit.toDisplayUnit(jsonArray.getJSONObject(i))) + } + return list + } + @Test - fun test_updateDisplayUnits_whenResponseArrayIsNull_returnNullDisplayUnit() { - val list = ctDisplayUnitController.updateDisplayUnits(null) - Assert.assertNull(list) + fun test_updateDisplayUnits_whenListIsNull_cacheIsEmpty() { + ctDisplayUnitController.updateDisplayUnits(null) Assert.assertNull(ctDisplayUnitController.allDisplayUnits) Assert.assertNull(ctDisplayUnitController.getDisplayUnitForID("12121212")) } + @Test + fun test_updateDisplayUnits_whenListIsEmpty_cacheIsEmpty() { + ctDisplayUnitController.updateDisplayUnits(emptyList()) + Assert.assertNull(ctDisplayUnitController.allDisplayUnits) + } + @Test fun test_getDisplayUnitForID_whenEmptyOrNullUnitID_returnNullDisplayUnit() { Assert.assertNull(ctDisplayUnitController.getDisplayUnitForID(null)) @@ -38,36 +50,31 @@ class CTDisplayUnitControllerTest : BaseTestCase() { @Test fun test_reset() { - // first we put non empty response to ensure that after reset the values are cleared or not - - val list = ctDisplayUnitController.updateDisplayUnits(MockCleverTapDisplayUnit().getMockResponse(1)) - Assert.assertNotNull(list) - Assert.assertTrue(list?.size == 1) + ctDisplayUnitController.updateDisplayUnits(mockDisplayUnits(1)) + Assert.assertNotNull(ctDisplayUnitController.allDisplayUnits) + Assert.assertTrue(ctDisplayUnitController.allDisplayUnits!!.size == 1) - //after reset the units should get cleared ctDisplayUnitController.reset() Assert.assertNull(ctDisplayUnitController.allDisplayUnits) - Assert.assertNull(ctDisplayUnitController.getDisplayUnitForID("12121212")) + Assert.assertNull(ctDisplayUnitController.getDisplayUnitForID("mock-notification-id")) } @Test - fun test_updateDisplayUnits_whenAnyException_returnNullDisplayUnit() { - mockkStatic(CleverTapDisplayUnit::class) { - every { CleverTapDisplayUnit.toDisplayUnit(any()) } throws RuntimeException("Something went wrong") - - ctDisplayUnitController.updateDisplayUnits(MockCleverTapDisplayUnit().getMockResponse(1)) - Assert.assertNull(ctDisplayUnitController.getDisplayUnitForID(null)) - Assert.assertNull(ctDisplayUnitController.getDisplayUnitForID("")) - } - } - - @Test - fun test_updateDisplayUnits_validDisplayUnitResponse_shouldReturnValidDisplayUnit() { - ctDisplayUnitController.updateDisplayUnits(MockCleverTapDisplayUnit().getMockResponse(1)) + fun test_updateDisplayUnits_validList_populatesCache() { + ctDisplayUnitController.updateDisplayUnits(mockDisplayUnits(1)) Assert.assertNotNull(ctDisplayUnitController.allDisplayUnits) Assert.assertTrue(ctDisplayUnitController.allDisplayUnits!!.size > 0) val displayUnit = ctDisplayUnitController.getDisplayUnitForID("mock-notification-id") Assert.assertNotNull(displayUnit) Assert.assertTrue(displayUnit is CleverTapDisplayUnit) } + + @Test + fun test_updateDisplayUnits_replacesPreviousCache() { + ctDisplayUnitController.updateDisplayUnits(mockDisplayUnits(1)) + Assert.assertNotNull(ctDisplayUnitController.allDisplayUnits) + + ctDisplayUnitController.updateDisplayUnits(emptyList()) + Assert.assertNull(ctDisplayUnitController.allDisplayUnits) + } } diff --git a/docs/CTCORECHANGELOG.md b/docs/CTCORECHANGELOG.md index 5e6e1389ab..bb19119c6c 100644 --- a/docs/CTCORECHANGELOG.md +++ b/docs/CTCORECHANGELOG.md @@ -1,4 +1,10 @@ ## CleverTap Android SDK CHANGE LOG +### Version 8.3.0 (June 2026) + +#### New Features +* **Native Display Element Click:** New `pushDisplayUnitElementClickedEventForID(String unitID, HashMap additionalProperties)` on `CleverTapAPI` records a `Notification Clicked` event for a specific element within a Display Unit. Caller-supplied `additionalProperties` (including `wzrk_element_id` from the action's `metadata`) are merged first, then enriched with cached `wzrk_*` attribution fields from the unit — giving finer-grained click analytics for Native Display experiences. +* **Display Unit Cache API:** New public interface `DisplayUnitCache` and `setDisplayUnitCache(DisplayUnitCache)` on `CleverTapAPI` let external SDKs (e.g. the Native Display SDK) inject a custom display-unit store. `getAllDisplayUnits()` and `getDisplayUnitForID()` now route through this cache. The default implementation (`CTDisplayUnitController`) remains active when no override is installed. + ### Version 8.2.0 (May 20, 2026) #### New Features diff --git a/docs/CTGEOFENCE.md b/docs/CTGEOFENCE.md index c0c65f339b..88402adc1e 100644 --- a/docs/CTGEOFENCE.md +++ b/docs/CTGEOFENCE.md @@ -17,7 +17,7 @@ Add the following dependencies to the `build.gradle` ```Groovy implementation "com.clevertap.android:clevertap-geofence-sdk:1.4.0" -implementation "com.clevertap.android:clevertap-android-sdk:8.2.0" // 3.9.0 and above +implementation "com.clevertap.android:clevertap-android-sdk:8.3.0" // 3.9.0 and above implementation "com.google.android.gms:play-services-location:21.3.0" implementation "androidx.work:work-runtime:2.10.2" // required for FETCH_LAST_LOCATION_PERIODIC implementation "androidx.concurrent:concurrent-futures:1.2.0" // required for FETCH_LAST_LOCATION_PERIODIC diff --git a/docs/CTPUSHTEMPLATES.md b/docs/CTPUSHTEMPLATES.md index 7fa9b519f2..f52bf700db 100644 --- a/docs/CTPUSHTEMPLATES.md +++ b/docs/CTPUSHTEMPLATES.md @@ -21,7 +21,7 @@ CleverTap Push Templates SDK helps you engage with your users using fancy push n ```groovy implementation "com.clevertap.android:push-templates:2.4.0" -implementation "com.clevertap.android:clevertap-android-sdk:8.2.0" // 4.4.0 and above +implementation "com.clevertap.android:clevertap-android-sdk:8.3.0" // 4.4.0 and above ``` 2. Add the following line to your Application class before the `onCreate()` diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 566b37f314..52e211b9f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -50,7 +50,7 @@ lifecycleRuntimeTesting = "2.9.4" installreferrer = "2.2" #SDK Versions -clevertap_android_sdk = "8.2.0" +clevertap_android_sdk = "8.3.0" clevertap_rendermax_sdk = "1.0.3" clevertap_geofence_sdk = "1.4.0" clevertap_hms_sdk = "1.5.1" diff --git a/sample/src/main/java/com/clevertap/demo/MyApplication.kt b/sample/src/main/java/com/clevertap/demo/MyApplication.kt index 5bdc00d42a..1bbd8a38eb 100644 --- a/sample/src/main/java/com/clevertap/demo/MyApplication.kt +++ b/sample/src/main/java/com/clevertap/demo/MyApplication.kt @@ -23,6 +23,8 @@ import com.clevertap.android.sdk.InboxMessageButtonListener import com.clevertap.android.sdk.InboxMessageListener import com.clevertap.android.sdk.SyncListener import com.clevertap.android.sdk.cryption.EncryptionLevel +import com.clevertap.android.sdk.displayunits.DisplayUnitCache +import com.clevertap.android.sdk.displayunits.model.CleverTapDisplayUnit import com.clevertap.android.sdk.inbox.CTInboxMessage import com.clevertap.android.sdk.interfaces.NotificationHandler import com.clevertap.android.sdk.pushnotification.CTPushNotificationListener @@ -109,6 +111,7 @@ class MyApplication : MultiDexApplication(), CTPushNotificationListener, Activit }) ctInstance = buildCtInstance(useDefaultInstance = true) + ctInstance?.setDisplayUnitCache(SampleDisplayUnitCache()) if (BuildConfig.ENABLE_MULTI_INSTANCE) { ctMultiInstance = buildCustomCtInstance() @@ -370,3 +373,39 @@ class MyApplication : MultiDexApplication(), CTPushNotificationListener, Activit } } } + +/** + * Sample custom [DisplayUnitCache] implementation. + * + * Demonstrates how the Native Display SDK (or any host) can provide its own + * display-unit store via [CleverTapAPI.setDisplayUnitCache]. The default + * [com.clevertap.android.sdk.displayunits.CTDisplayUnitController] is replaced + * by this instance; all lookup and attribution calls route through it. + */ +class SampleDisplayUnitCache : DisplayUnitCache { + + private val lock = Any() + private val items = HashMap() + + override fun getAllDisplayUnits(): ArrayList? { + synchronized(lock) { + return if (items.isEmpty()) null else ArrayList(items.values) + } + } + + override fun getDisplayUnitForID(unitID: String?): CleverTapDisplayUnit? { + if (unitID.isNullOrEmpty()) return null + synchronized(lock) { return items[unitID] } + } + + override fun updateDisplayUnits(displayUnits: List?) { + synchronized(lock) { + items.clear() + displayUnits?.forEach { unit -> unit.unitID?.let { id -> items[id] = unit } } + } + } + + override fun reset() { + synchronized(lock) { items.clear() } + } +} diff --git a/templates/CTCORECHANGELOG.md b/templates/CTCORECHANGELOG.md index 5e6e1389ab..bb19119c6c 100644 --- a/templates/CTCORECHANGELOG.md +++ b/templates/CTCORECHANGELOG.md @@ -1,4 +1,10 @@ ## CleverTap Android SDK CHANGE LOG +### Version 8.3.0 (June 2026) + +#### New Features +* **Native Display Element Click:** New `pushDisplayUnitElementClickedEventForID(String unitID, HashMap additionalProperties)` on `CleverTapAPI` records a `Notification Clicked` event for a specific element within a Display Unit. Caller-supplied `additionalProperties` (including `wzrk_element_id` from the action's `metadata`) are merged first, then enriched with cached `wzrk_*` attribution fields from the unit — giving finer-grained click analytics for Native Display experiences. +* **Display Unit Cache API:** New public interface `DisplayUnitCache` and `setDisplayUnitCache(DisplayUnitCache)` on `CleverTapAPI` let external SDKs (e.g. the Native Display SDK) inject a custom display-unit store. `getAllDisplayUnits()` and `getDisplayUnitForID()` now route through this cache. The default implementation (`CTDisplayUnitController`) remains active when no override is installed. + ### Version 8.2.0 (May 20, 2026) #### New Features