From 281b8a86f1513e94058d9fd8f7b0bf742310fdf7 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Thu, 11 Jun 2026 23:33:38 +0530 Subject: [PATCH 01/65] feat(analytics): implement autocapture phase 1 - click, rage click, dead click Add autocapture functionality for automatic event tracking: - Click tracking ($mp_click): Captures all touch events with semantic info - Rage click detection ($mp_rage_click): 4+ clicks within 1000ms in 44dp radius - Dead click detection ($mp_dead_click): No UI change within 500ms after click Key components: - AutocaptureManager: Main coordinator with activity lifecycle integration - TouchInterceptor: Window.Callback wrapper for touch interception - WindowSpy: Tracks all windows (dialogs, popups) via reflection - SemanticExtractor: Extracts element info from View hierarchy - RageClickTracker: Click pattern detection with spatial/temporal thresholds - DeadClickDetector: UI change monitoring with ViewTreeObserver Configuration via MixpanelOptions.Builder.autocaptureOptions() Co-Authored-By: Claude Opus 4.5 --- analytics/build.gradle | 2 + analytics/proguard.txt | 5 +- .../autocapture/AutocaptureDefaults.java | 106 +++ .../autocapture/AutocaptureManager.java | 357 +++++++++++ .../android/autocapture/ClickEvent.java | 189 ++++++ .../autocapture/DeadClickDetector.java | 319 +++++++++ .../android/autocapture/RageClickTracker.java | 156 +++++ .../autocapture/SemanticExtractor.java | 605 ++++++++++++++++++ .../android/autocapture/TouchInterceptor.java | 282 ++++++++ .../android/autocapture/WindowSpy.java | 204 ++++++ .../android/mpmetrics/AutocaptureOptions.java | 165 +++++ .../android/mpmetrics/ClickOptions.java | 77 +++ .../android/mpmetrics/DeadClickOptions.java | 135 ++++ .../android/mpmetrics/MixpanelAPI.java | 35 + .../android/mpmetrics/MixpanelOptions.java | 52 ++ .../android/mpmetrics/RageClickOptions.java | 162 +++++ 16 files changed, 2850 insertions(+), 1 deletion(-) create mode 100644 analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureDefaults.java create mode 100644 analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureManager.java create mode 100644 analytics/src/main/java/com/mixpanel/android/autocapture/ClickEvent.java create mode 100644 analytics/src/main/java/com/mixpanel/android/autocapture/DeadClickDetector.java create mode 100644 analytics/src/main/java/com/mixpanel/android/autocapture/RageClickTracker.java create mode 100644 analytics/src/main/java/com/mixpanel/android/autocapture/SemanticExtractor.java create mode 100644 analytics/src/main/java/com/mixpanel/android/autocapture/TouchInterceptor.java create mode 100644 analytics/src/main/java/com/mixpanel/android/autocapture/WindowSpy.java create mode 100644 analytics/src/main/java/com/mixpanel/android/mpmetrics/AutocaptureOptions.java create mode 100644 analytics/src/main/java/com/mixpanel/android/mpmetrics/ClickOptions.java create mode 100644 analytics/src/main/java/com/mixpanel/android/mpmetrics/DeadClickOptions.java create mode 100644 analytics/src/main/java/com/mixpanel/android/mpmetrics/RageClickOptions.java diff --git a/analytics/build.gradle b/analytics/build.gradle index 781525b3a..5d5904e0e 100644 --- a/analytics/build.gradle +++ b/analytics/build.gradle @@ -12,6 +12,8 @@ animalsniffer { ignoreFailures = false // Runtime-gated by SDK_INT >= TIRAMISU; animalsniffer can't see the version check. ignore 'android.content.pm.PackageManager' + // Autocapture TouchInterceptor: API 23+ Window.Callback methods, runtime-gated by SDK_INT >= M + ignore 'android.view.Window$Callback' } diff --git a/analytics/proguard.txt b/analytics/proguard.txt index f81203f97..5cdca2a50 100644 --- a/analytics/proguard.txt +++ b/analytics/proguard.txt @@ -1 +1,4 @@ --dontwarn com.mixpanel.** \ No newline at end of file +-dontwarn com.mixpanel.** + +# Autocapture WindowSpy reflection +-keep class android.view.WindowManagerGlobal { *; } \ No newline at end of file diff --git a/analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureDefaults.java b/analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureDefaults.java new file mode 100644 index 000000000..435d52eb7 --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureDefaults.java @@ -0,0 +1,106 @@ +package com.mixpanel.android.autocapture; + +/** + * Internal constants for autocapture functionality. + * + *

These values are not exposed in the public API but can be adjusted internally if needed. + */ +final class AutocaptureDefaults { + + /** + * Maximum length for text content captured in {@code $el_text}. + * Text exceeding this length will be truncated. + */ + static final int MAX_TEXT_LENGTH = 100; + + /** + * Maximum depth of the view hierarchy captured in {@code $elements}. + * Limits the number of ancestor elements included in the hierarchy string. + */ + static final int MAX_HIERARCHY_DEPTH = 5; + + /** + * Maximum number of accessibility nodes to probe when searching for an element. + * Prevents excessive traversal in complex UI hierarchies. + */ + static final int MAX_ACCESSIBILITY_NODES = 500; + + /** + * Event name for click events. + */ + static final String EVENT_CLICK = "$mp_click"; + + /** + * Event name for rage click events. + */ + static final String EVENT_RAGE_CLICK = "$mp_rage_click"; + + /** + * Event name for dead click events. + */ + static final String EVENT_DEAD_CLICK = "$mp_dead_click"; + + /** + * Property name for X coordinate. + */ + static final String PROP_X = "$x"; + + /** + * Property name for Y coordinate. + */ + static final String PROP_Y = "$y"; + + /** + * Property name for element ID. + */ + static final String PROP_EL_ID = "$el_id"; + + /** + * Property name for element tag name (class name). + */ + static final String PROP_EL_TAG_NAME = "$el_tag_name"; + + /** + * Property name for element text content. + */ + static final String PROP_EL_TEXT = "$el_text"; + + /** + * Property name for accessibility label (contentDescription). + */ + static final String PROP_ARIA_LABEL = "$attr-aria-label"; + + /** + * Property name for element role. + */ + static final String PROP_ROLE = "$attr-role"; + + /** + * Property name for view hierarchy. + */ + static final String PROP_ELEMENTS = "$elements"; + + /** + * Separator used in view hierarchy string. + */ + static final String HIERARCHY_SEPARATOR = " > "; + + /** + * Placeholder for redacted sensitive content. + */ + static final String REDACTED_PLACEHOLDER = "[REDACTED]"; + + /** + * Tag value used to mark views as sensitive (excluded from autocapture). + */ + static final String SENSITIVE_TAG = "mp-sensitive"; + + /** + * Alternative tag value used to mark views as excluded from autocapture. + */ + static final String NO_TRACK_TAG = "mp-no-track"; + + private AutocaptureDefaults() { + // Prevent instantiation + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureManager.java b/analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureManager.java new file mode 100644 index 000000000..de1ae2783 --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureManager.java @@ -0,0 +1,357 @@ +package com.mixpanel.android.autocapture; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import android.os.Bundle; +import android.view.View; +import android.view.Window; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.mixpanel.android.mpmetrics.AutocaptureOptions; +import com.mixpanel.android.util.MPLog; + +import org.json.JSONObject; + +import java.lang.ref.WeakReference; +import java.util.Map; +import java.util.WeakHashMap; + +/** + * Main coordinator for autocapture functionality. + * + *

Manages the lifecycle of touch interception, semantic extraction, and event detection + * (click, rage click, dead click). Integrates with the application lifecycle to attach + * and detach interceptors appropriately. + * + *

Thread safety: All public methods must be called from the main thread. + */ +public final class AutocaptureManager implements + Application.ActivityLifecycleCallbacks, + WindowSpy.OnRootViewChangedListener, + TouchInterceptor.TouchListener, + DeadClickDetector.DeadClickListener { + + private static final String TAG = "MP.AutocaptureManager"; + + /** + * Interface for emitting tracked events. + */ + public interface EventEmitter { + /** + * Emits a tracked event. + * + * @param eventName The event name (e.g., "$mp_click"). + * @param properties The event properties. + */ + void emit(@NonNull String eventName, @NonNull JSONObject properties); + } + + private final Context mContext; + private final AutocaptureOptions mOptions; + private final EventEmitter mEmitter; + + @Nullable + private RageClickTracker mRageClickTracker; + @Nullable + private DeadClickDetector mDeadClickDetector; + + // Track interceptors per window to avoid duplicate installations + private final Map mWindowInterceptors = new WeakHashMap<>(); + + // Track the current activity for lifecycle management + @Nullable + private WeakReference mCurrentActivityRef; + + private boolean mStarted = false; + + /** + * Creates an AutocaptureManager. + * + * @param context The application context. + * @param options The autocapture configuration options. + * @param emitter The event emitter for tracked events. + */ + public AutocaptureManager( + @NonNull Context context, + @NonNull AutocaptureOptions options, + @NonNull EventEmitter emitter) { + mContext = context.getApplicationContext(); + mOptions = options; + mEmitter = emitter; + + // Initialize trackers based on options + if (mOptions.getRageClickOptions().isEnabled()) { + mRageClickTracker = new RageClickTracker(mOptions.getRageClickOptions(), mContext); + } + + if (mOptions.getDeadClickOptions().isEnabled()) { + mDeadClickDetector = new DeadClickDetector(mOptions.getDeadClickOptions(), this); + } + } + + /** + * Starts autocapture. + * + *

Registers lifecycle callbacks and installs window tracking. + * This should be called once during SDK initialization. + */ + public void start() { + if (mStarted) { + return; + } + + try { + // Register activity lifecycle callbacks + if (mContext instanceof Application) { + ((Application) mContext).registerActivityLifecycleCallbacks(this); + } + + // Install WindowSpy for dialog/popup tracking + WindowSpy.install(); + WindowSpy.addListener(this); + + mStarted = true; + MPLog.d(TAG, "Autocapture started"); + + } catch (Exception e) { + MPLog.e(TAG, "Failed to start autocapture", e); + } + } + + /** + * Stops autocapture. + * + *

Unregisters lifecycle callbacks and removes all interceptors. + */ + public void stop() { + if (!mStarted) { + return; + } + + try { + // Unregister activity lifecycle callbacks + if (mContext instanceof Application) { + ((Application) mContext).unregisterActivityLifecycleCallbacks(this); + } + + // Remove WindowSpy listener + WindowSpy.removeListener(this); + + // Uninstall all interceptors + for (TouchInterceptor interceptor : mWindowInterceptors.values()) { + if (interceptor != null) { + interceptor.uninstall(); + } + } + mWindowInterceptors.clear(); + + // Cancel pending detection + if (mDeadClickDetector != null) { + mDeadClickDetector.cancelDetection(); + } + + // Clear rage click history + if (mRageClickTracker != null) { + mRageClickTracker.clear(); + } + + mStarted = false; + MPLog.d(TAG, "Autocapture stopped"); + + } catch (Exception e) { + MPLog.e(TAG, "Error stopping autocapture", e); + } + } + + // ==================== TouchInterceptor.TouchListener ==================== + + @Override + public void onTouchUp(float x, float y, @NonNull View decorView) { + try { + processTouchEvent(x, y, decorView); + } catch (Exception e) { + MPLog.e(TAG, "Error processing touch event", e); + } + } + + private void processTouchEvent(float x, float y, @NonNull View decorView) { + // Extract semantics from the touched view + ClickEvent.Builder builder = SemanticExtractor.extract(decorView, x, y); + if (builder == null) { + // No view found at position + return; + } + + ClickEvent clickEvent = builder.build(); + + // Track basic click + if (mOptions.getClickOptions().isEnabled()) { + emitEvent(AutocaptureDefaults.EVENT_CLICK, clickEvent); + } + + // Check for rage click + if (mRageClickTracker != null) { + ClickEvent rageClick = mRageClickTracker.recordClick(clickEvent); + if (rageClick != null) { + emitEvent(AutocaptureDefaults.EVENT_RAGE_CLICK, rageClick); + } + } + + // Start dead click detection + if (mDeadClickDetector != null) { + mDeadClickDetector.startDetection(clickEvent, decorView); + } + } + + // ==================== DeadClickDetector.DeadClickListener ==================== + + @Override + public void onDeadClickDetected(@NonNull ClickEvent clickEvent) { + try { + emitEvent(AutocaptureDefaults.EVENT_DEAD_CLICK, clickEvent); + } catch (Exception e) { + MPLog.e(TAG, "Error emitting dead click event", e); + } + } + + // ==================== WindowSpy.OnRootViewChangedListener ==================== + + @Override + public void onRootViewChanged(@NonNull View view, boolean added) { + try { + if (added) { + // Notify dead click detector - new window is a UI change + if (mDeadClickDetector != null) { + mDeadClickDetector.onWindowAdded(); + } + + // Try to attach interceptor to the new window + Window window = getWindowFromView(view); + if (window != null && !mWindowInterceptors.containsKey(window)) { + attachInterceptor(window); + } + } + } catch (Exception e) { + MPLog.e(TAG, "Error handling root view change", e); + } + } + + // ==================== Activity Lifecycle Callbacks ==================== + + @Override + public void onActivityCreated(@NonNull Activity activity, @Nullable Bundle savedInstanceState) { + // No action needed + } + + @Override + public void onActivityStarted(@NonNull Activity activity) { + // No action needed + } + + @Override + public void onActivityResumed(@NonNull Activity activity) { + try { + mCurrentActivityRef = new WeakReference<>(activity); + + // Attach interceptor to activity window + Window window = activity.getWindow(); + if (window != null && !mWindowInterceptors.containsKey(window)) { + attachInterceptor(window); + } + } catch (Exception e) { + MPLog.e(TAG, "Error in onActivityResumed", e); + } + } + + @Override + public void onActivityPaused(@NonNull Activity activity) { + try { + // Cancel dead click detection - activity transition is a UI change + if (mDeadClickDetector != null) { + mDeadClickDetector.cancelDetection(); + } + + // Clear rage click history on activity change + if (mRageClickTracker != null) { + mRageClickTracker.clear(); + } + } catch (Exception e) { + MPLog.e(TAG, "Error in onActivityPaused", e); + } + } + + @Override + public void onActivityStopped(@NonNull Activity activity) { + // No action needed + } + + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) { + // No action needed + } + + @Override + public void onActivityDestroyed(@NonNull Activity activity) { + try { + // Clean up interceptor for this activity's window + Window window = activity.getWindow(); + if (window != null) { + TouchInterceptor interceptor = mWindowInterceptors.remove(window); + if (interceptor != null) { + interceptor.uninstall(); + } + } + } catch (Exception e) { + MPLog.e(TAG, "Error in onActivityDestroyed", e); + } + } + + // ==================== Private Helpers ==================== + + private void attachInterceptor(@NonNull Window window) { + TouchInterceptor interceptor = TouchInterceptor.install(window, this); + if (interceptor != null) { + mWindowInterceptors.put(window, interceptor); + MPLog.d(TAG, "Attached interceptor to window: " + window); + } + } + + private void emitEvent(@NonNull String eventName, @NonNull ClickEvent clickEvent) { + try { + JSONObject properties = clickEvent.toProperties(); + mEmitter.emit(eventName, properties); + MPLog.d(TAG, "Emitted " + eventName + " event"); + } catch (Exception e) { + MPLog.e(TAG, "Error emitting event: " + eventName, e); + } + } + + /** + * Attempts to get the Window from a View. + * + *

Works for decor views that have a PhoneWindow attached. + */ + @Nullable + private Window getWindowFromView(@NonNull View view) { + try { + // Try to get window via reflection (decor view has mWindow field on some versions) + // For most cases, we rely on WindowSpy notifying us when the Activity is resumed + // and we attach via activity.getWindow() directly + + // Check if this is a decor view from an Activity + Context context = view.getContext(); + if (context instanceof Activity) { + return ((Activity) context).getWindow(); + } + + // For dialogs and other windows, we get notified via ActivityLifecycleCallbacks + // or WindowSpy when the root view is added + } catch (Exception e) { + MPLog.d(TAG, "Could not get window from view", e); + } + return null; + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/autocapture/ClickEvent.java b/analytics/src/main/java/com/mixpanel/android/autocapture/ClickEvent.java new file mode 100644 index 000000000..20ac15eb0 --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/autocapture/ClickEvent.java @@ -0,0 +1,189 @@ +package com.mixpanel.android.autocapture; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.json.JSONException; +import org.json.JSONObject; + +/** + * Immutable data class representing a captured click event. + * + *

Contains all semantic information about the clicked element and its context. + * This snapshot is captured immediately on click to ensure accurate attribution + * even when views are recycled (e.g., RecyclerView, LazyColumn). + */ +final class ClickEvent { + + /** Screen X coordinate of the click. */ + public final float x; + + /** Screen Y coordinate of the click. */ + public final float y; + + /** Primary element identifier (contentDescription, resource ID, or fallback). */ + @Nullable + public final String elementId; + + /** Element class name (e.g., "Button", "TextView"). */ + @Nullable + public final String tagName; + + /** Visible text content of the element (max 100 chars, sensitive data redacted). */ + @Nullable + public final String text; + + /** Accessibility label (contentDescription). */ + @Nullable + public final String ariaLabel; + + /** Semantic role of the element (e.g., "button", "switch", "checkbox"). */ + @Nullable + public final String role; + + /** View hierarchy string (max 5 levels, ">" separated). */ + @Nullable + public final String elements; + + /** Timestamp when the click was captured. */ + public final long timestamp; + + /** Whether the clicked view is considered interactive (clickable/longClickable). */ + public final boolean isInteractive; + + /** + * Creates a new ClickEvent. + */ + ClickEvent( + float x, + float y, + @Nullable String elementId, + @Nullable String tagName, + @Nullable String text, + @Nullable String ariaLabel, + @Nullable String role, + @Nullable String elements, + long timestamp, + boolean isInteractive) { + this.x = x; + this.y = y; + this.elementId = elementId; + this.tagName = tagName; + this.text = text; + this.ariaLabel = ariaLabel; + this.role = role; + this.elements = elements; + this.timestamp = timestamp; + this.isInteractive = isInteractive; + } + + /** + * Converts this ClickEvent to a JSONObject for event tracking. + * + * @return A JSONObject containing all non-null properties. + */ + @NonNull + JSONObject toProperties() { + JSONObject props = new JSONObject(); + try { + props.put(AutocaptureDefaults.PROP_X, (int) x); + props.put(AutocaptureDefaults.PROP_Y, (int) y); + + if (elementId != null) { + props.put(AutocaptureDefaults.PROP_EL_ID, elementId); + } + if (tagName != null) { + props.put(AutocaptureDefaults.PROP_EL_TAG_NAME, tagName); + } + if (text != null) { + props.put(AutocaptureDefaults.PROP_EL_TEXT, text); + } + if (ariaLabel != null) { + props.put(AutocaptureDefaults.PROP_ARIA_LABEL, ariaLabel); + } + if (role != null) { + props.put(AutocaptureDefaults.PROP_ROLE, role); + } + if (elements != null) { + props.put(AutocaptureDefaults.PROP_ELEMENTS, elements); + } + } catch (JSONException e) { + // Should not happen with these simple types + } + return props; + } + + /** + * Builder for creating {@link ClickEvent} instances. + */ + static class Builder { + private float x; + private float y; + private String elementId; + private String tagName; + private String text; + private String ariaLabel; + private String role; + private String elements; + private long timestamp; + private boolean isInteractive; + + Builder() { + this.timestamp = System.currentTimeMillis(); + } + + Builder x(float x) { + this.x = x; + return this; + } + + Builder y(float y) { + this.y = y; + return this; + } + + Builder elementId(@Nullable String elementId) { + this.elementId = elementId; + return this; + } + + Builder tagName(@Nullable String tagName) { + this.tagName = tagName; + return this; + } + + Builder text(@Nullable String text) { + this.text = text; + return this; + } + + Builder ariaLabel(@Nullable String ariaLabel) { + this.ariaLabel = ariaLabel; + return this; + } + + Builder role(@Nullable String role) { + this.role = role; + return this; + } + + Builder elements(@Nullable String elements) { + this.elements = elements; + return this; + } + + Builder timestamp(long timestamp) { + this.timestamp = timestamp; + return this; + } + + Builder isInteractive(boolean isInteractive) { + this.isInteractive = isInteractive; + return this; + } + + ClickEvent build() { + return new ClickEvent(x, y, elementId, tagName, text, ariaLabel, role, elements, timestamp, isInteractive); + } + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/autocapture/DeadClickDetector.java b/analytics/src/main/java/com/mixpanel/android/autocapture/DeadClickDetector.java new file mode 100644 index 000000000..aba4e766d --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/autocapture/DeadClickDetector.java @@ -0,0 +1,319 @@ +package com.mixpanel.android.autocapture; + +import android.os.Handler; +import android.os.Looper; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewTreeObserver; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.mixpanel.android.mpmetrics.DeadClickOptions; +import com.mixpanel.android.util.MPLog; + +import java.lang.ref.WeakReference; + +/** + * Detects dead clicks by monitoring UI changes after a click. + * + *

A dead click is detected when a user clicks on an element that appears to be interactive + * but produces no UI response within the configured timeout period. + * + *

Detection strategy: + *

    + *
  1. On click detected, wait for baseline delay (default 150ms) for UI to settle
  2. + *
  3. Capture baseline snapshot (view count, content hash)
  4. + *
  5. Attach listeners for UI changes (layout, scroll, window focus, new windows)
  6. + *
  7. After timeout (default 500ms total), compare final state to baseline
  8. + *
  9. If no change detected, emit dead click event
  10. + *
+ * + *

Thread safety: This class is NOT thread-safe and should only be called from the main thread. + */ +final class DeadClickDetector { + + private static final String TAG = "MP.DeadClickDetector"; + + /** + * Listener for dead click detection results. + */ + interface DeadClickListener { + /** + * Called when a dead click is detected. + * + * @param clickEvent The original click event that had no UI response. + */ + void onDeadClickDetected(@NonNull ClickEvent clickEvent); + } + + private final long mTimeoutMs; + private final long mBaselineDelayMs; + private final Handler mHandler; + private final DeadClickListener mListener; + + // Current detection state + @Nullable + private DetectionSession mCurrentSession; + + /** + * Creates a DeadClickDetector with the given options. + * + * @param options The dead click configuration options. + * @param listener The listener to receive dead click events. + */ + DeadClickDetector(@NonNull DeadClickOptions options, @NonNull DeadClickListener listener) { + mTimeoutMs = options.getTimeoutMs(); + mBaselineDelayMs = options.getBaselineDelayMs(); + mHandler = new Handler(Looper.getMainLooper()); + mListener = listener; + } + + /** + * Starts detection for a click event. + * + *

Only monitors clicks on interactive elements (clickable, long-clickable, + * or known interactive types). + * + * @param clickEvent The click event to monitor. + * @param rootView The root view to monitor for changes. + */ + void startDetection(@NonNull ClickEvent clickEvent, @NonNull View rootView) { + // Cancel any existing detection + cancelDetection(); + + // Only monitor interactive elements + if (!clickEvent.isInteractive) { + return; + } + + try { + mCurrentSession = new DetectionSession(clickEvent, rootView); + mCurrentSession.start(); + } catch (Exception e) { + MPLog.e(TAG, "Error starting dead click detection", e); + mCurrentSession = null; + } + } + + /** + * Cancels any ongoing detection. + * Call this when the activity is paused or a navigation occurs. + */ + void cancelDetection() { + if (mCurrentSession != null) { + mCurrentSession.cancel(); + mCurrentSession = null; + } + } + + /** + * Notifies the detector that a new window was added. + * This is a UI change signal that cancels dead click detection. + */ + void onWindowAdded() { + if (mCurrentSession != null) { + mCurrentSession.onUiChange("window_added"); + } + } + + /** + * Notifies the detector that window focus changed. + * This is a UI change signal that cancels dead click detection. + */ + void onWindowFocusChanged() { + if (mCurrentSession != null) { + mCurrentSession.onUiChange("focus_changed"); + } + } + + /** + * Manages the detection lifecycle for a single click. + */ + private class DetectionSession implements + ViewTreeObserver.OnGlobalLayoutListener, + ViewTreeObserver.OnScrollChangedListener { + + private final ClickEvent mClickEvent; + private final WeakReference mRootViewRef; + + private boolean mCancelled = false; + private boolean mBaselineCaptured = false; + private int mBaselineViewCount; + private int mBaselineContentHash; + + private final Runnable mCaptureBaselineRunnable = this::captureBaseline; + private final Runnable mCheckResultRunnable = this::checkResult; + + DetectionSession(@NonNull ClickEvent clickEvent, @NonNull View rootView) { + mClickEvent = clickEvent; + mRootViewRef = new WeakReference<>(rootView); + } + + void start() { + // Schedule baseline capture after delay + mHandler.postDelayed(mCaptureBaselineRunnable, mBaselineDelayMs); + + // Schedule final check + mHandler.postDelayed(mCheckResultRunnable, mTimeoutMs); + + // Attach listeners + View rootView = mRootViewRef.get(); + if (rootView != null) { + ViewTreeObserver observer = rootView.getViewTreeObserver(); + if (observer.isAlive()) { + observer.addOnGlobalLayoutListener(this); + observer.addOnScrollChangedListener(this); + } + } + } + + void cancel() { + if (mCancelled) return; + mCancelled = true; + cleanup(); + } + + void onUiChange(String reason) { + if (mCancelled) return; + MPLog.d(TAG, "UI change detected: " + reason + ", cancelling dead click detection"); + cancel(); + } + + @Override + public void onGlobalLayout() { + if (!mBaselineCaptured) { + // UI change before baseline - expected settling, ignore + return; + } + onUiChange("layout"); + } + + @Override + public void onScrollChanged() { + if (!mBaselineCaptured) { + return; + } + onUiChange("scroll"); + } + + private void captureBaseline() { + if (mCancelled) return; + + View rootView = mRootViewRef.get(); + if (rootView == null) { + cancel(); + return; + } + + try { + mBaselineViewCount = countViews(rootView); + mBaselineContentHash = computeContentHash(rootView); + mBaselineCaptured = true; + } catch (Exception e) { + MPLog.e(TAG, "Error capturing baseline", e); + cancel(); + } + } + + private void checkResult() { + if (mCancelled) return; + + try { + View rootView = mRootViewRef.get(); + if (rootView == null) { + cleanup(); + return; + } + + // Compare current state to baseline + int currentViewCount = countViews(rootView); + int currentContentHash = computeContentHash(rootView); + + boolean noChange = (currentViewCount == mBaselineViewCount) && + (currentContentHash == mBaselineContentHash); + + if (noChange) { + // Dead click detected! + mListener.onDeadClickDetected(mClickEvent); + } + } catch (Exception e) { + MPLog.e(TAG, "Error checking dead click result", e); + } finally { + cleanup(); + } + } + + private void cleanup() { + mHandler.removeCallbacks(mCaptureBaselineRunnable); + mHandler.removeCallbacks(mCheckResultRunnable); + + View rootView = mRootViewRef.get(); + if (rootView != null) { + ViewTreeObserver observer = rootView.getViewTreeObserver(); + if (observer.isAlive()) { + observer.removeOnGlobalLayoutListener(this); + observer.removeOnScrollChangedListener(this); + } + } + + if (mCurrentSession == this) { + mCurrentSession = null; + } + } + + /** + * Counts the total number of views in the hierarchy. + */ + private int countViews(@NonNull View view) { + if (view.getVisibility() != View.VISIBLE) { + return 0; + } + + int count = 1; + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + count += countViews(group.getChildAt(i)); + } + } + return count; + } + + /** + * Computes a hash of the visible content for change detection. + * + *

This is a lightweight approximation that considers: + * - View visibility states + * - View bounds + * - Text content (if TextView) + */ + private int computeContentHash(@NonNull View view) { + if (view.getVisibility() != View.VISIBLE) { + return 0; + } + + int hash = 17; + hash = 31 * hash + view.getLeft(); + hash = 31 * hash + view.getTop(); + hash = 31 * hash + view.getWidth(); + hash = 31 * hash + view.getHeight(); + + if (view instanceof android.widget.TextView) { + CharSequence text = ((android.widget.TextView) view).getText(); + if (text != null) { + hash = 31 * hash + text.hashCode(); + } + } + + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = 0; i < group.getChildCount(); i++) { + hash = 31 * hash + computeContentHash(group.getChildAt(i)); + } + } + + return hash; + } + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/autocapture/RageClickTracker.java b/analytics/src/main/java/com/mixpanel/android/autocapture/RageClickTracker.java new file mode 100644 index 000000000..73b7d0af8 --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/autocapture/RageClickTracker.java @@ -0,0 +1,156 @@ +package com.mixpanel.android.autocapture; + +import android.content.Context; +import android.util.DisplayMetrics; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.mixpanel.android.mpmetrics.RageClickOptions; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +/** + * Tracks click patterns to detect rage clicks. + * + *

A rage click is detected when a user rapidly clicks multiple times in the same area, + * indicating frustration with an unresponsive UI element. + * + *

Detection criteria (all configurable): + *

+ * + *

Thread safety: This class is NOT thread-safe and should only be called from the main thread. + */ +final class RageClickTracker { + + /** + * Interface for providing current time (for testability). + */ + interface TimeProvider { + long currentTimeMillis(); + } + + private final int mClickThreshold; + private final long mTimeWindowMs; + private final float mRadiusPx; + private final TimeProvider mTimeProvider; + + private final List mRecentClicks = new ArrayList<>(); + + /** + * Creates a RageClickTracker with the given options and screen density. + * + * @param options The rage click configuration options. + * @param context The context to get screen density. + */ + RageClickTracker(@NonNull RageClickOptions options, @NonNull Context context) { + this(options, context, System::currentTimeMillis); + } + + /** + * Creates a RageClickTracker with injectable time provider (for testing). + */ + @VisibleForTesting + RageClickTracker(@NonNull RageClickOptions options, @NonNull Context context, @NonNull TimeProvider timeProvider) { + mClickThreshold = options.getClickThreshold(); + mTimeWindowMs = options.getTimeWindowMs(); + mTimeProvider = timeProvider; + + // Convert dp to pixels + DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + mRadiusPx = options.getRadius() * metrics.density; + } + + /** + * Records a click and checks if it triggers a rage click. + * + * @param clickEvent The click event to record. + * @return The ClickEvent that triggered the rage click (same as input), or null if no rage click detected. + */ + @Nullable + ClickEvent recordClick(@NonNull ClickEvent clickEvent) { + long now = mTimeProvider.currentTimeMillis(); + float x = clickEvent.x; + float y = clickEvent.y; + + // Remove expired clicks + pruneExpiredClicks(now); + + // Add new click + mRecentClicks.add(new ClickRecord(x, y, now)); + + // Count clicks within radius of the current click + int clicksInRadius = countClicksInRadius(x, y); + + // Check if threshold reached + if (clicksInRadius >= mClickThreshold) { + // Clear tracked clicks to avoid multiple rage click events for the same sequence + mRecentClicks.clear(); + return clickEvent; + } + + return null; + } + + /** + * Clears all tracked clicks. + * Call this on activity changes or when the user navigates away. + */ + void clear() { + mRecentClicks.clear(); + } + + /** + * Removes clicks that are outside the time window. + */ + private void pruneExpiredClicks(long now) { + long cutoff = now - mTimeWindowMs; + Iterator iterator = mRecentClicks.iterator(); + while (iterator.hasNext()) { + if (iterator.next().timestamp < cutoff) { + iterator.remove(); + } + } + } + + /** + * Counts clicks within the spatial radius of the given position. + */ + private int countClicksInRadius(float x, float y) { + int count = 0; + float radiusSquared = mRadiusPx * mRadiusPx; + + for (ClickRecord record : mRecentClicks) { + float dx = record.x - x; + float dy = record.y - y; + float distanceSquared = dx * dx + dy * dy; + + if (distanceSquared <= radiusSquared) { + count++; + } + } + + return count; + } + + /** + * Simple record of a click's position and time. + */ + private static class ClickRecord { + final float x; + final float y; + final long timestamp; + + ClickRecord(float x, float y, long timestamp) { + this.x = x; + this.y = y; + this.timestamp = timestamp; + } + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/autocapture/SemanticExtractor.java b/analytics/src/main/java/com/mixpanel/android/autocapture/SemanticExtractor.java new file mode 100644 index 000000000..9f6de807e --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/autocapture/SemanticExtractor.java @@ -0,0 +1,605 @@ +package com.mixpanel.android.autocapture; + +import android.graphics.Rect; +import android.os.Build; +import android.text.InputType; +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityNodeInfo; +import android.view.accessibility.AccessibilityNodeProvider; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.ImageButton; +import android.widget.ImageView; +import android.widget.RadioButton; +import android.widget.SeekBar; +import android.widget.Spinner; +import android.widget.Switch; +import android.widget.TextView; +import android.widget.ToggleButton; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.mixpanel.android.util.MPLog; + +import java.util.regex.Pattern; + +/** + * Extracts semantic information from Views and AccessibilityNodeInfo for autocapture. + * + *

Handles both traditional XML Views and Jetpack Compose views (via AccessibilityNodeProvider). + * Includes privacy protection by filtering sensitive fields and redacting PII patterns. + */ +final class SemanticExtractor { + + private static final String TAG = "MP.SemanticExtractor"; + + // Regex patterns for sensitive data detection + private static final Pattern CREDIT_CARD_PATTERN = Pattern.compile( + "\\b(?:4[0-9]{12}(?:[0-9]{3})?|" + // Visa + "5[1-5][0-9]{14}|" + // Mastercard + "3[47][0-9]{13}|" + // Amex + "6(?:011|5[0-9]{2})[0-9]{12}|" + // Discover + "(?:2131|1800|35\\d{3})\\d{11})\\b" // JCB + ); + + private static final Pattern SSN_PATTERN = Pattern.compile( + "\\b\\d{3}-\\d{2}-\\d{4}\\b" + ); + + // Sensitive input types that should not have text captured + private static final int SENSITIVE_INPUT_MASK = + InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD | + InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | + InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD | + InputType.TYPE_CLASS_NUMBER | InputType.TYPE_NUMBER_VARIATION_PASSWORD; + + private static final int PASSWORD_MASK = + InputType.TYPE_TEXT_VARIATION_PASSWORD | + InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD | + InputType.TYPE_TEXT_VARIATION_WEB_PASSWORD | + InputType.TYPE_NUMBER_VARIATION_PASSWORD; + + /** + * Extracts semantic information from a view at the given coordinates. + * + * @param rootView The root view to search within. + * @param x Screen X coordinate. + * @param y Screen Y coordinate. + * @return A ClickEvent.Builder with extracted semantics, or null if no view found. + */ + @Nullable + static ClickEvent.Builder extract(@NonNull View rootView, float x, float y) { + try { + // First try to find view via accessibility (works for Compose) + ClickEvent.Builder result = extractFromAccessibility(rootView, x, y); + if (result != null) { + return result; + } + + // Fall back to direct view traversal (XML views) + View targetView = findViewAtPosition(rootView, (int) x, (int) y); + if (targetView != null) { + return extractFromView(targetView, x, y); + } + } catch (Exception e) { + MPLog.e(TAG, "Error extracting semantics", e); + } + + return null; + } + + /** + * Extracts semantics using AccessibilityNodeProvider (for Compose views). + */ + @Nullable + private static ClickEvent.Builder extractFromAccessibility(@NonNull View rootView, float x, float y) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { + return null; + } + + AccessibilityNodeProvider provider = rootView.getAccessibilityNodeProvider(); + if (provider == null) { + return null; + } + + try { + AccessibilityNodeInfo rootNode = provider.createAccessibilityNodeInfo(View.NO_ID); + if (rootNode == null) { + return null; + } + + AccessibilityNodeInfo targetNode = findNodeAtPosition(rootNode, (int) x, (int) y, 0); + if (targetNode != null) { + ClickEvent.Builder builder = extractFromNode(targetNode, x, y); + targetNode.recycle(); + rootNode.recycle(); + return builder; + } + + rootNode.recycle(); + } catch (Exception e) { + MPLog.d(TAG, "Error extracting from accessibility", e); + } + + return null; + } + + /** + * Recursively finds the deepest AccessibilityNodeInfo at the given position. + */ + @Nullable + private static AccessibilityNodeInfo findNodeAtPosition( + @NonNull AccessibilityNodeInfo node, int x, int y, int depth) { + + if (depth > AutocaptureDefaults.MAX_ACCESSIBILITY_NODES) { + return null; + } + + Rect bounds = new Rect(); + node.getBoundsInScreen(bounds); + + if (!bounds.contains(x, y)) { + return null; + } + + // Check children for a more specific match + int childCount = node.getChildCount(); + for (int i = childCount - 1; i >= 0; i--) { + AccessibilityNodeInfo child = node.getChild(i); + if (child != null) { + AccessibilityNodeInfo result = findNodeAtPosition(child, x, y, depth + 1); + if (result != null) { + child.recycle(); + return result; + } + child.recycle(); + } + } + + // This node contains the point but no child does + // Return a copy since we need to recycle the original during traversal + return AccessibilityNodeInfo.obtain(node); + } + + /** + * Extracts semantics from an AccessibilityNodeInfo. + */ + @NonNull + private static ClickEvent.Builder extractFromNode(@NonNull AccessibilityNodeInfo node, float x, float y) { + ClickEvent.Builder builder = new ClickEvent.Builder() + .x(x) + .y(y); + + // Element ID (contentDescription or class name fallback) + CharSequence contentDesc = node.getContentDescription(); + if (contentDesc != null && contentDesc.length() > 0) { + builder.elementId(contentDesc.toString()); + builder.ariaLabel(contentDesc.toString()); + } else { + CharSequence className = node.getClassName(); + if (className != null) { + String simpleName = getSimpleClassName(className.toString()); + builder.elementId(simpleName + "_" + node.hashCode()); + } + } + + // Tag name (class simple name) + CharSequence className = node.getClassName(); + if (className != null) { + builder.tagName(getSimpleClassName(className.toString())); + } + + // Text content (with privacy filtering) + CharSequence text = node.getText(); + if (text != null && text.length() > 0 && !isPasswordNode(node)) { + builder.text(sanitizeText(text.toString())); + } + + // Role + builder.role(inferRoleFromNode(node)); + + // Interactive check + builder.isInteractive(node.isClickable() || node.isLongClickable() || node.isCheckable()); + + return builder; + } + + /** + * Extracts semantics from a traditional View. + */ + @NonNull + private static ClickEvent.Builder extractFromView(@NonNull View view, float x, float y) { + ClickEvent.Builder builder = new ClickEvent.Builder() + .x(x) + .y(y); + + // Check if view or ancestors are marked as sensitive + if (isSensitiveView(view)) { + builder.tagName(view.getClass().getSimpleName()); + builder.isInteractive(isInteractive(view)); + return builder; + } + + // Element ID resolution: contentDescription > resource ID > fallback + String elementId = resolveElementId(view); + builder.elementId(elementId); + + // Tag name + builder.tagName(view.getClass().getSimpleName()); + + // Content description (aria-label) + CharSequence contentDesc = view.getContentDescription(); + if (contentDesc != null && contentDesc.length() > 0) { + builder.ariaLabel(contentDesc.toString()); + } + + // Text content (with privacy filtering) + String text = extractText(view); + if (text != null && !isSensitiveInput(view)) { + builder.text(sanitizeText(text)); + } + + // Role + builder.role(inferRoleFromView(view)); + + // View hierarchy + builder.elements(buildHierarchyString(view)); + + // Interactive check + builder.isInteractive(isInteractive(view)); + + return builder; + } + + /** + * Finds the deepest view at the given screen coordinates. + */ + @Nullable + private static View findViewAtPosition(@NonNull View view, int x, int y) { + if (!isViewVisible(view)) { + return null; + } + + int[] location = new int[2]; + view.getLocationOnScreen(location); + int left = location[0]; + int top = location[1]; + int right = left + view.getWidth(); + int bottom = top + view.getHeight(); + + if (x < left || x > right || y < top || y > bottom) { + return null; + } + + // Check children in reverse order (top-most first) + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int i = group.getChildCount() - 1; i >= 0; i--) { + View child = group.getChildAt(i); + View result = findViewAtPosition(child, x, y); + if (result != null) { + return result; + } + } + } + + return view; + } + + /** + * Resolves the element ID according to the priority: + * 1. contentDescription (if non-empty) + * 2. Resource ID name (R.id.xxx) + * 3. ClassName_view_ + */ + @NonNull + private static String resolveElementId(@NonNull View view) { + // 1. Try contentDescription + CharSequence contentDesc = view.getContentDescription(); + if (contentDesc != null && contentDesc.length() > 0) { + return contentDesc.toString(); + } + + // 2. Try resource ID name + int id = view.getId(); + if (id != View.NO_ID) { + try { + String resourceName = view.getResources().getResourceEntryName(id); + if (resourceName != null && !resourceName.isEmpty()) { + return resourceName; + } + } catch (Exception ignored) { + // Resource not found, use fallback + } + } + + // 3. Fallback: ClassName_view_ + return view.getClass().getSimpleName() + "_view_" + Integer.toHexString(view.hashCode()); + } + + /** + * Extracts visible text from a view, walking children if needed. + */ + @Nullable + private static String extractText(@NonNull View view) { + // Direct text from TextView + if (view instanceof TextView) { + CharSequence text = ((TextView) view).getText(); + if (text != null && text.length() > 0) { + return text.toString(); + } + } + + // For containers (e.g., Button with child TextView), walk children + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + StringBuilder sb = new StringBuilder(); + collectTextFromChildren(group, sb); + if (sb.length() > 0) { + return sb.toString(); + } + } + + return null; + } + + /** + * Recursively collects text from child TextViews. + */ + private static void collectTextFromChildren(@NonNull ViewGroup group, @NonNull StringBuilder sb) { + for (int i = 0; i < group.getChildCount(); i++) { + View child = group.getChildAt(i); + if (child instanceof TextView) { + CharSequence text = ((TextView) child).getText(); + if (text != null && text.length() > 0) { + if (sb.length() > 0) { + sb.append(" "); + } + sb.append(text); + } + } else if (child instanceof ViewGroup) { + collectTextFromChildren((ViewGroup) child, sb); + } + } + } + + /** + * Builds a hierarchy string of ancestor views (max depth). + */ + @NonNull + private static String buildHierarchyString(@NonNull View view) { + StringBuilder sb = new StringBuilder(); + View current = view; + int depth = 0; + + while (current != null && depth < AutocaptureDefaults.MAX_HIERARCHY_DEPTH) { + if (sb.length() > 0) { + sb.insert(0, AutocaptureDefaults.HIERARCHY_SEPARATOR); + } + sb.insert(0, current.getClass().getSimpleName()); + + if (current.getParent() instanceof View) { + current = (View) current.getParent(); + } else { + break; + } + depth++; + } + + return sb.toString(); + } + + /** + * Infers the semantic role from a View's class. + */ + @NonNull + private static String inferRoleFromView(@NonNull View view) { + if (view instanceof Button || view instanceof ImageButton) { + return "button"; + } + if (view instanceof Switch || view instanceof ToggleButton) { + return "switch"; + } + if (view instanceof CheckBox) { + return "checkbox"; + } + if (view instanceof RadioButton) { + return "radio"; + } + if (view instanceof SeekBar) { + return "slider"; + } + if (view instanceof Spinner) { + return "combobox"; + } + if (view instanceof EditText) { + return "textbox"; + } + if (view instanceof ImageView) { + return "img"; + } + if (view instanceof TextView) { + return "text"; + } + if (view.isClickable() || view.isLongClickable()) { + return "button"; + } + return "none"; + } + + /** + * Infers the semantic role from an AccessibilityNodeInfo. + */ + @NonNull + private static String inferRoleFromNode(@NonNull AccessibilityNodeInfo node) { + CharSequence className = node.getClassName(); + if (className == null) { + return "none"; + } + + String name = className.toString(); + if (name.contains("Button")) { + return "button"; + } + if (name.contains("Switch") || name.contains("Toggle")) { + return "switch"; + } + if (name.contains("CheckBox")) { + return "checkbox"; + } + if (name.contains("RadioButton")) { + return "radio"; + } + if (name.contains("SeekBar") || name.contains("Slider")) { + return "slider"; + } + if (name.contains("Spinner")) { + return "combobox"; + } + if (name.contains("EditText")) { + return "textbox"; + } + if (name.contains("Image")) { + return "img"; + } + if (name.contains("Text")) { + return "text"; + } + if (node.isClickable() || node.isLongClickable()) { + return "button"; + } + return "none"; + } + + /** + * Checks if a view is interactive (clickable or long-clickable). + */ + private static boolean isInteractive(@NonNull View view) { + if (view.hasOnClickListeners()) { + return true; + } + if (view.isClickable() || view.isLongClickable()) { + return true; + } + // Known interactive types + return view instanceof Button || + view instanceof CompoundButton || + view instanceof EditText || + view instanceof SeekBar || + view instanceof Spinner; + } + + /** + * Checks if a view is visible (not gone/invisible and has positive dimensions). + */ + private static boolean isViewVisible(@NonNull View view) { + return view.getVisibility() == View.VISIBLE && + view.getWidth() > 0 && + view.getHeight() > 0; + } + + /** + * Checks if a view or its ancestors are marked as sensitive. + */ + private static boolean isSensitiveView(@Nullable View view) { + View current = view; + while (current != null) { + // Check tag + Object tag = current.getTag(); + if (tag instanceof String) { + String tagStr = (String) tag; + if (tagStr.contains(AutocaptureDefaults.SENSITIVE_TAG) || + tagStr.contains(AutocaptureDefaults.NO_TRACK_TAG)) { + return true; + } + } + + // Check contentDescription + CharSequence contentDesc = current.getContentDescription(); + if (contentDesc != null) { + String desc = contentDesc.toString(); + if (desc.contains(AutocaptureDefaults.SENSITIVE_TAG) || + desc.contains(AutocaptureDefaults.NO_TRACK_TAG)) { + return true; + } + } + + // Walk up the hierarchy + if (current.getParent() instanceof View) { + current = (View) current.getParent(); + } else { + break; + } + } + return false; + } + + /** + * Checks if an EditText has a sensitive input type (password, email, phone). + */ + private static boolean isSensitiveInput(@NonNull View view) { + if (!(view instanceof EditText)) { + return false; + } + + EditText editText = (EditText) view; + int inputType = editText.getInputType(); + + // Check for password types + if ((inputType & PASSWORD_MASK) != 0) { + return true; + } + + // Check for email and phone (optionally sensitive) + int variation = inputType & InputType.TYPE_MASK_VARIATION; + return variation == InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS || + variation == InputType.TYPE_TEXT_VARIATION_WEB_EMAIL_ADDRESS || + (inputType & InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_PHONE; + } + + /** + * Checks if an AccessibilityNodeInfo represents a password field. + */ + private static boolean isPasswordNode(@NonNull AccessibilityNodeInfo node) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) { + return node.isPassword(); + } + return false; + } + + /** + * Sanitizes text by truncating and redacting sensitive patterns. + */ + @NonNull + private static String sanitizeText(@NonNull String text) { + // Truncate to max length + String result = text.length() > AutocaptureDefaults.MAX_TEXT_LENGTH + ? text.substring(0, AutocaptureDefaults.MAX_TEXT_LENGTH) + : text; + + // Redact credit card numbers + result = CREDIT_CARD_PATTERN.matcher(result).replaceAll(AutocaptureDefaults.REDACTED_PLACEHOLDER); + + // Redact SSN patterns + result = SSN_PATTERN.matcher(result).replaceAll(AutocaptureDefaults.REDACTED_PLACEHOLDER); + + return result; + } + + /** + * Extracts the simple class name from a fully qualified class name. + */ + @NonNull + private static String getSimpleClassName(@NonNull String fullyQualifiedName) { + int lastDot = fullyQualifiedName.lastIndexOf('.'); + return lastDot >= 0 ? fullyQualifiedName.substring(lastDot + 1) : fullyQualifiedName; + } + + private SemanticExtractor() { + // Prevent instantiation + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/autocapture/TouchInterceptor.java b/analytics/src/main/java/com/mixpanel/android/autocapture/TouchInterceptor.java new file mode 100644 index 000000000..f980fd246 --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/autocapture/TouchInterceptor.java @@ -0,0 +1,282 @@ +package com.mixpanel.android.autocapture; + +import android.annotation.TargetApi; +import android.os.Build; +import android.view.KeyEvent; +import android.view.MotionEvent; +import android.view.View; +import android.view.Window; +import android.view.accessibility.AccessibilityEvent; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.mixpanel.android.util.MPLog; + +/** + * Intercepts touch events from a Window by wrapping its Window.Callback. + * + *

Only processes ACTION_UP events with a single pointer (no multi-touch gestures). + * Touch interception is non-blocking; the original callback always receives the event. + */ +final class TouchInterceptor implements Window.Callback { + + private static final String TAG = "MP.TouchInterceptor"; + + private final Window mWindow; + private final Window.Callback mOriginalCallback; + private final TouchListener mTouchListener; + + /** + * Listener interface for processed touch events. + */ + interface TouchListener { + /** + * Called when a valid click is detected (single-pointer ACTION_UP). + * + * @param x Screen X coordinate. + * @param y Screen Y coordinate. + * @param decorView The window's decor view. + */ + void onTouchUp(float x, float y, @NonNull View decorView); + } + + /** + * Creates a TouchInterceptor and installs it on the window. + * + * @param window The window to intercept touches from. + * @param touchListener The listener to receive touch events. + * @return The installed TouchInterceptor, or null if installation failed. + */ + @Nullable + static TouchInterceptor install(@NonNull Window window, @NonNull TouchListener touchListener) { + try { + Window.Callback originalCallback = window.getCallback(); + TouchInterceptor interceptor = new TouchInterceptor(window, originalCallback, touchListener); + window.setCallback(interceptor); + return interceptor; + } catch (Exception e) { + MPLog.e(TAG, "Failed to install TouchInterceptor", e); + return null; + } + } + + /** + * Uninstalls this interceptor from the window, restoring the original callback. + */ + void uninstall() { + try { + // Only restore if we're still the current callback + if (mWindow.getCallback() == this) { + mWindow.setCallback(mOriginalCallback); + } + } catch (Exception e) { + MPLog.e(TAG, "Failed to uninstall TouchInterceptor", e); + } + } + + private TouchInterceptor( + @NonNull Window window, + @Nullable Window.Callback originalCallback, + @NonNull TouchListener touchListener) { + mWindow = window; + mOriginalCallback = originalCallback; + mTouchListener = touchListener; + } + + @Override + public boolean dispatchTouchEvent(MotionEvent event) { + try { + processTouchEvent(event); + } catch (Exception e) { + MPLog.e(TAG, "Error processing touch event", e); + } + + // Always forward to original callback + if (mOriginalCallback != null) { + return mOriginalCallback.dispatchTouchEvent(event); + } + return false; + } + + /** + * Processes the touch event and notifies the listener if it's a valid click. + */ + private void processTouchEvent(@NonNull MotionEvent event) { + // Only process ACTION_UP events + if (event.getActionMasked() != MotionEvent.ACTION_UP) { + return; + } + + // Only process single-pointer events (filter out multi-touch gestures) + if (event.getPointerCount() != 1) { + return; + } + + View decorView = mWindow.getDecorView(); + if (decorView == null) { + return; + } + + // Use raw coordinates (screen space) + float x = event.getRawX(); + float y = event.getRawY(); + + mTouchListener.onTouchUp(x, y, decorView); + } + + // Delegate all other Window.Callback methods to the original callback + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + return mOriginalCallback != null && mOriginalCallback.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchKeyShortcutEvent(KeyEvent event) { + return mOriginalCallback != null && mOriginalCallback.dispatchKeyShortcutEvent(event); + } + + @Override + public boolean dispatchTrackballEvent(MotionEvent event) { + return mOriginalCallback != null && mOriginalCallback.dispatchTrackballEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + return mOriginalCallback != null && mOriginalCallback.dispatchGenericMotionEvent(event); + } + + @Override + public boolean dispatchPopulateAccessibilityEvent(AccessibilityEvent event) { + return mOriginalCallback != null && mOriginalCallback.dispatchPopulateAccessibilityEvent(event); + } + + @Nullable + @Override + public View onCreatePanelView(int featureId) { + return mOriginalCallback != null ? mOriginalCallback.onCreatePanelView(featureId) : null; + } + + @Override + public boolean onCreatePanelMenu(int featureId, @NonNull android.view.Menu menu) { + return mOriginalCallback != null && mOriginalCallback.onCreatePanelMenu(featureId, menu); + } + + @Override + public boolean onPreparePanel(int featureId, @Nullable View view, @NonNull android.view.Menu menu) { + return mOriginalCallback != null && mOriginalCallback.onPreparePanel(featureId, view, menu); + } + + @Override + public boolean onMenuOpened(int featureId, @NonNull android.view.Menu menu) { + return mOriginalCallback != null && mOriginalCallback.onMenuOpened(featureId, menu); + } + + @Override + public boolean onMenuItemSelected(int featureId, @NonNull android.view.MenuItem item) { + return mOriginalCallback != null && mOriginalCallback.onMenuItemSelected(featureId, item); + } + + @Override + public void onWindowAttributesChanged(android.view.WindowManager.LayoutParams attrs) { + if (mOriginalCallback != null) { + mOriginalCallback.onWindowAttributesChanged(attrs); + } + } + + @Override + public void onContentChanged() { + if (mOriginalCallback != null) { + mOriginalCallback.onContentChanged(); + } + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + if (mOriginalCallback != null) { + mOriginalCallback.onWindowFocusChanged(hasFocus); + } + } + + @Override + public void onAttachedToWindow() { + if (mOriginalCallback != null) { + mOriginalCallback.onAttachedToWindow(); + } + } + + @Override + public void onDetachedFromWindow() { + if (mOriginalCallback != null) { + mOriginalCallback.onDetachedFromWindow(); + } + } + + @Override + public void onPanelClosed(int featureId, @NonNull android.view.Menu menu) { + if (mOriginalCallback != null) { + mOriginalCallback.onPanelClosed(featureId, menu); + } + } + + @Override + public boolean onSearchRequested() { + return mOriginalCallback != null && mOriginalCallback.onSearchRequested(); + } + + // API 23+ method - delegate via helper class to avoid AnimalSniffer violation + @Override + public boolean onSearchRequested(android.view.SearchEvent searchEvent) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && mOriginalCallback != null) { + return Api23Helper.onSearchRequested(mOriginalCallback, searchEvent); + } + return false; + } + + @Nullable + @Override + public android.view.ActionMode onWindowStartingActionMode(android.view.ActionMode.Callback callback) { + return mOriginalCallback != null ? mOriginalCallback.onWindowStartingActionMode(callback) : null; + } + + // API 23+ method - delegate via helper class to avoid AnimalSniffer violation + @Nullable + @Override + public android.view.ActionMode onWindowStartingActionMode(android.view.ActionMode.Callback callback, int type) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && mOriginalCallback != null) { + return Api23Helper.onWindowStartingActionMode(mOriginalCallback, callback, type); + } + return null; + } + + /** + * Helper class to isolate API 23+ method calls. + * This class is only loaded on API 23+ devices, avoiding verification errors on older APIs. + */ + @TargetApi(Build.VERSION_CODES.M) + private static class Api23Helper { + static boolean onSearchRequested(Window.Callback callback, android.view.SearchEvent searchEvent) { + return callback.onSearchRequested(searchEvent); + } + + static android.view.ActionMode onWindowStartingActionMode( + Window.Callback callback, android.view.ActionMode.Callback actionModeCallback, int type) { + return callback.onWindowStartingActionMode(actionModeCallback, type); + } + } + + @Override + public void onActionModeStarted(android.view.ActionMode mode) { + if (mOriginalCallback != null) { + mOriginalCallback.onActionModeStarted(mode); + } + } + + @Override + public void onActionModeFinished(android.view.ActionMode mode) { + if (mOriginalCallback != null) { + mOriginalCallback.onActionModeFinished(mode); + } + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/autocapture/WindowSpy.java b/analytics/src/main/java/com/mixpanel/android/autocapture/WindowSpy.java new file mode 100644 index 000000000..0964b4f8b --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/autocapture/WindowSpy.java @@ -0,0 +1,204 @@ +package com.mixpanel.android.autocapture; + +import android.os.Build; +import android.view.View; + +import androidx.annotation.NonNull; + +import com.mixpanel.android.util.MPLog; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Utility class for tracking root views (windows) in the application. + * + *

WindowSpy hooks into Android's internal WindowManagerGlobal to detect when + * windows are added or removed. This enables autocapture to intercept touch events + * on all windows including dialogs, popups, menus, and spinners. + * + *

Without WindowSpy, only Activity windows would be captured via ActivityLifecycleCallbacks. + * + *

This implementation is inspired by Square's Curtains library but kept minimal + * to avoid external dependencies and version conflicts. + */ +final class WindowSpy { + + private static final String TAG = "MP.WindowSpy"; + + private static volatile boolean sInstalled = false; + private static final Object sLock = new Object(); + private static final List sListeners = new CopyOnWriteArrayList<>(); + + // Cached references to WindowManagerGlobal internals + private static Object sWmgInstance; + private static ArrayList sOriginalViews; + + /** + * Listener interface for root view changes. + */ + interface OnRootViewChangedListener { + /** + * Called when a root view is added or removed. + * + * @param view The root view that changed. + * @param added {@code true} if the view was added, {@code false} if removed. + */ + void onRootViewChanged(@NonNull View view, boolean added); + } + + /** + * Installs the WindowSpy hook into WindowManagerGlobal. + * + *

This method is safe to call multiple times; subsequent calls are no-ops. + * If installation fails (e.g., due to reflection restrictions), the spy + * gracefully degrades and only Activity windows will be tracked. + */ + static void install() { + if (sInstalled) { + return; + } + + synchronized (sLock) { + if (sInstalled) { + return; + } + + // Requires API 19+ for WindowManagerGlobal + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) { + MPLog.d(TAG, "WindowSpy not supported below API 19"); + sInstalled = true; // Mark as installed to avoid retries + return; + } + + try { + // Access WindowManagerGlobal singleton + Class wmgClass = Class.forName("android.view.WindowManagerGlobal"); + Method getInstance = wmgClass.getMethod("getInstance"); + sWmgInstance = getInstance.invoke(null); + + // Get the mViews field (holds all root views) + Field mViewsField = wmgClass.getDeclaredField("mViews"); + mViewsField.setAccessible(true); + + @SuppressWarnings("unchecked") + ArrayList originalList = (ArrayList) mViewsField.get(sWmgInstance); + sOriginalViews = originalList; + + // Replace with delegating list that notifies on add/remove + ArrayList delegatingList = new DelegatingViewList(originalList); + mViewsField.set(sWmgInstance, delegatingList); + + sInstalled = true; + MPLog.d(TAG, "WindowSpy installed successfully"); + + } catch (Exception e) { + MPLog.e(TAG, "Failed to install WindowSpy, only Activity windows will be tracked", e); + sInstalled = true; // Mark as installed to avoid retries + } + } + } + + /** + * Adds a listener to be notified of root view changes. + * + * @param listener The listener to add. + */ + static void addListener(@NonNull OnRootViewChangedListener listener) { + if (!sListeners.contains(listener)) { + sListeners.add(listener); + } + } + + /** + * Removes a previously added listener. + * + * @param listener The listener to remove. + */ + static void removeListener(@NonNull OnRootViewChangedListener listener) { + sListeners.remove(listener); + } + + /** + * Returns a copy of the current root views. + * + *

The returned list is a snapshot; changes to the actual root views + * will not be reflected in this list. + * + * @return A list of current root views, or an empty list if WindowSpy is not installed. + */ + @NonNull + static List getRootViews() { + if (sOriginalViews != null) { + synchronized (sOriginalViews) { + return new ArrayList<>(sOriginalViews); + } + } + return new ArrayList<>(); + } + + /** + * Notifies all registered listeners of a root view change. + */ + private static void notifyListeners(@NonNull View view, boolean added) { + for (OnRootViewChangedListener listener : sListeners) { + try { + listener.onRootViewChanged(view, added); + } catch (Exception e) { + MPLog.e(TAG, "Error notifying WindowSpy listener", e); + } + } + } + + /** + * ArrayList subclass that intercepts add/remove operations to notify listeners. + */ + private static class DelegatingViewList extends ArrayList { + + DelegatingViewList(ArrayList source) { + super(source); + } + + @Override + public boolean add(View view) { + boolean result = super.add(view); + if (result && view != null) { + notifyListeners(view, true); + } + return result; + } + + @Override + public void add(int index, View view) { + super.add(index, view); + if (view != null) { + notifyListeners(view, true); + } + } + + @Override + public View remove(int index) { + View view = super.remove(index); + if (view != null) { + notifyListeners(view, false); + } + return view; + } + + @Override + public boolean remove(Object o) { + boolean result = super.remove(o); + if (result && o instanceof View) { + notifyListeners((View) o, false); + } + return result; + } + } + + private WindowSpy() { + // Prevent instantiation + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/mpmetrics/AutocaptureOptions.java b/analytics/src/main/java/com/mixpanel/android/mpmetrics/AutocaptureOptions.java new file mode 100644 index 000000000..cfad13c8d --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/mpmetrics/AutocaptureOptions.java @@ -0,0 +1,165 @@ +package com.mixpanel.android.mpmetrics; + +import androidx.annotation.NonNull; + +/** + * Configuration options for Mixpanel autocapture. + * + *

Autocapture automatically tracks user interactions without requiring manual instrumentation. + * Phase 1 supports click events, rage clicks, and dead clicks. + * + *

Autocapture is disabled by default. To enable, create an AutocaptureOptions instance + * and pass it to {@link MixpanelOptions.Builder#autocaptureOptions(AutocaptureOptions)}. + * + *

{@code
+ * // Enable all autocapture with defaults
+ * AutocaptureOptions autocaptureOptions = new AutocaptureOptions.Builder().build();
+ *
+ * // Or customize individual event types
+ * AutocaptureOptions autocaptureOptions = new AutocaptureOptions.Builder()
+ *     .clickOptions(new ClickOptions.Builder().enabled(true).build())
+ *     .rageClickOptions(new RageClickOptions.Builder()
+ *         .enabled(true)
+ *         .clickThreshold(5)
+ *         .build())
+ *     .deadClickOptions(new DeadClickOptions.Builder()
+ *         .enabled(false)  // Disable dead click detection
+ *         .build())
+ *     .build();
+ *
+ * MixpanelOptions options = new MixpanelOptions.Builder()
+ *     .autocaptureOptions(autocaptureOptions)
+ *     .build();
+ *
+ * MixpanelAPI mixpanel = MixpanelAPI.getInstance(context, "YOUR_TOKEN", true, options);
+ * }
+ * + * @see MixpanelOptions.Builder#autocaptureOptions(AutocaptureOptions) + * @see ClickOptions + * @see RageClickOptions + * @see DeadClickOptions + */ +public class AutocaptureOptions { + + private final ClickOptions mClickOptions; + private final RageClickOptions mRageClickOptions; + private final DeadClickOptions mDeadClickOptions; + + private AutocaptureOptions(Builder builder) { + this.mClickOptions = builder.mClickOptions; + this.mRageClickOptions = builder.mRageClickOptions; + this.mDeadClickOptions = builder.mDeadClickOptions; + } + + /** + * Returns whether any autocapture feature is enabled. + * + *

This returns {@code true} if at least one of click, rage click, or dead click + * detection is enabled. + * + * @return {@code true} if any autocapture feature is enabled, {@code false} otherwise. + */ + public boolean isEnabled() { + return mClickOptions.isEnabled() || + mRageClickOptions.isEnabled() || + mDeadClickOptions.isEnabled(); + } + + /** + * Returns the click options configuration. + * + * @return The {@link ClickOptions} for click event tracking. + */ + @NonNull + public ClickOptions getClickOptions() { + return mClickOptions; + } + + /** + * Returns the rage click options configuration. + * + * @return The {@link RageClickOptions} for rage click detection. + */ + @NonNull + public RageClickOptions getRageClickOptions() { + return mRageClickOptions; + } + + /** + * Returns the dead click options configuration. + * + * @return The {@link DeadClickOptions} for dead click detection. + */ + @NonNull + public DeadClickOptions getDeadClickOptions() { + return mDeadClickOptions; + } + + /** + * Builder for creating {@link AutocaptureOptions} instances. + * + *

When built, all event types are enabled by default with their respective default settings. + * Use the individual options builders to customize or disable specific event types. + */ + public static class Builder { + private ClickOptions mClickOptions = new ClickOptions.Builder().build(); + private RageClickOptions mRageClickOptions = new RageClickOptions.Builder().build(); + private DeadClickOptions mDeadClickOptions = new DeadClickOptions.Builder().build(); + + public Builder() { + } + + /** + * Creates a Builder pre-populated with values from an existing {@link AutocaptureOptions}. + * + * @param source The AutocaptureOptions to copy values from. + */ + public Builder(AutocaptureOptions source) { + this.mClickOptions = source.mClickOptions; + this.mRageClickOptions = source.mRageClickOptions; + this.mDeadClickOptions = source.mDeadClickOptions; + } + + /** + * Sets the click options for autocapture. + * + * @param clickOptions The {@link ClickOptions} configuration. + * @return This Builder instance for chaining. + */ + public Builder clickOptions(@NonNull ClickOptions clickOptions) { + this.mClickOptions = clickOptions; + return this; + } + + /** + * Sets the rage click options for autocapture. + * + * @param rageClickOptions The {@link RageClickOptions} configuration. + * @return This Builder instance for chaining. + */ + public Builder rageClickOptions(@NonNull RageClickOptions rageClickOptions) { + this.mRageClickOptions = rageClickOptions; + return this; + } + + /** + * Sets the dead click options for autocapture. + * + * @param deadClickOptions The {@link DeadClickOptions} configuration. + * @return This Builder instance for chaining. + */ + public Builder deadClickOptions(@NonNull DeadClickOptions deadClickOptions) { + this.mDeadClickOptions = deadClickOptions; + return this; + } + + /** + * Builds and returns an {@link AutocaptureOptions} instance with the configured settings. + * + * @return A new {@link AutocaptureOptions} instance. + */ + public AutocaptureOptions build() { + return new AutocaptureOptions(this); + } + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/mpmetrics/ClickOptions.java b/analytics/src/main/java/com/mixpanel/android/mpmetrics/ClickOptions.java new file mode 100644 index 000000000..5cc5c429f --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/mpmetrics/ClickOptions.java @@ -0,0 +1,77 @@ +package com.mixpanel.android.mpmetrics; + +/** + * Configuration options for click event autocapture. + * + *

Use this class to enable or disable click event tracking when autocapture is enabled. + * + *

{@code
+ * ClickOptions clickOptions = new ClickOptions.Builder()
+ *     .enabled(true)
+ *     .build();
+ * }
+ * + * @see AutocaptureOptions.Builder#clickOptions(ClickOptions) + */ +public class ClickOptions { + + private final boolean mEnabled; + + private ClickOptions(Builder builder) { + this.mEnabled = builder.mEnabled; + } + + /** + * Returns whether click event tracking is enabled. + * + * @return {@code true} if click events are tracked, {@code false} otherwise. + * Defaults to {@code true} when autocapture is enabled. + */ + public boolean isEnabled() { + return mEnabled; + } + + /** + * Builder for creating {@link ClickOptions} instances. + * + *

Default values: + *

+ */ + public static class Builder { + private boolean mEnabled = true; + + public Builder() { + } + + /** + * Creates a Builder pre-populated with values from an existing {@link ClickOptions}. + * + * @param source The ClickOptions to copy values from. + */ + public Builder(ClickOptions source) { + this.mEnabled = source.mEnabled; + } + + /** + * Enables or disables click event tracking. + * + * @param enabled {@code true} to track click events, {@code false} to disable. + * @return This Builder instance for chaining. + */ + public Builder enabled(boolean enabled) { + this.mEnabled = enabled; + return this; + } + + /** + * Builds and returns a {@link ClickOptions} instance with the configured settings. + * + * @return A new {@link ClickOptions} instance. + */ + public ClickOptions build() { + return new ClickOptions(this); + } + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/mpmetrics/DeadClickOptions.java b/analytics/src/main/java/com/mixpanel/android/mpmetrics/DeadClickOptions.java new file mode 100644 index 000000000..2766e3d97 --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/mpmetrics/DeadClickOptions.java @@ -0,0 +1,135 @@ +package com.mixpanel.android.mpmetrics; + +/** + * Configuration options for dead click detection in autocapture. + * + *

Dead clicks are detected when a user clicks on an element that appears to be interactive + * but produces no UI response within a configured timeout period. + * + *

{@code
+ * DeadClickOptions deadClickOptions = new DeadClickOptions.Builder()
+ *     .enabled(true)
+ *     .timeoutMs(600)         // Wait 600ms for UI response
+ *     .baselineDelayMs(200)   // Wait 200ms before taking baseline snapshot
+ *     .build();
+ * }
+ * + * @see AutocaptureOptions.Builder#deadClickOptions(DeadClickOptions) + */ +public class DeadClickOptions { + + private final boolean mEnabled; + private final long mTimeoutMs; + private final long mBaselineDelayMs; + + private DeadClickOptions(Builder builder) { + this.mEnabled = builder.mEnabled; + this.mTimeoutMs = builder.mTimeoutMs; + this.mBaselineDelayMs = builder.mBaselineDelayMs; + } + + /** + * Returns whether dead click detection is enabled. + * + * @return {@code true} if dead clicks are tracked, {@code false} otherwise. + * Defaults to {@code true} when autocapture is enabled. + */ + public boolean isEnabled() { + return mEnabled; + } + + /** + * Returns the timeout in milliseconds to wait for a UI response after a click. + * + *

If no UI change is detected within this time, a dead click event is emitted. + * + * @return The timeout in milliseconds. Defaults to 500. + */ + public long getTimeoutMs() { + return mTimeoutMs; + } + + /** + * Returns the delay in milliseconds before capturing the baseline UI snapshot. + * + *

This allows initial UI settling (e.g., ripple effects) before establishing + * the baseline state against which changes are detected. + * + * @return The baseline delay in milliseconds. Defaults to 150. + */ + public long getBaselineDelayMs() { + return mBaselineDelayMs; + } + + /** + * Builder for creating {@link DeadClickOptions} instances. + * + *

Default values: + *

+ */ + public static class Builder { + private boolean mEnabled = true; + private long mTimeoutMs = 500; + private long mBaselineDelayMs = 150; + + public Builder() { + } + + /** + * Creates a Builder pre-populated with values from an existing {@link DeadClickOptions}. + * + * @param source The DeadClickOptions to copy values from. + */ + public Builder(DeadClickOptions source) { + this.mEnabled = source.mEnabled; + this.mTimeoutMs = source.mTimeoutMs; + this.mBaselineDelayMs = source.mBaselineDelayMs; + } + + /** + * Enables or disables dead click detection. + * + * @param enabled {@code true} to detect dead clicks, {@code false} to disable. + * @return This Builder instance for chaining. + */ + public Builder enabled(boolean enabled) { + this.mEnabled = enabled; + return this; + } + + /** + * Sets the timeout in milliseconds to wait for a UI response. + * + * @param timeoutMs The timeout in milliseconds. Must be positive. + * @return This Builder instance for chaining. + */ + public Builder timeoutMs(long timeoutMs) { + this.mTimeoutMs = Math.max(1, timeoutMs); + return this; + } + + /** + * Sets the delay in milliseconds before capturing the baseline UI snapshot. + * + * @param baselineDelayMs The baseline delay in milliseconds. Must be non-negative. + * @return This Builder instance for chaining. + */ + public Builder baselineDelayMs(long baselineDelayMs) { + this.mBaselineDelayMs = Math.max(0, baselineDelayMs); + return this; + } + + /** + * Builds and returns a {@link DeadClickOptions} instance with the configured settings. + * + * @return A new {@link DeadClickOptions} instance. + */ + public DeadClickOptions build() { + return new DeadClickOptions(this); + } + } +} diff --git a/analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java b/analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java index b6e9c2f68..81831cb68 100644 --- a/analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java +++ b/analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java @@ -14,6 +14,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; + import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; @@ -21,6 +22,8 @@ import androidx.lifecycle.Lifecycle; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.ProcessLifecycleOwner; + +import com.mixpanel.android.autocapture.AutocaptureManager; import com.mixpanel.android.util.HttpService; import com.mixpanel.android.util.MPLog; import com.mixpanel.android.util.MixpanelNetworkErrorListener; @@ -309,6 +312,11 @@ public class MixpanelAPI implements FeatureFlagDelegate { SessionReplayBroadcastReceiver.INTENT_FILTER, ContextCompat.RECEIVER_NOT_EXPORTED); } + + // Initialize autocapture if enabled + if (options.getAutocaptureOptions().isEnabled()) { + initializeAutocapture(options.getAutocaptureOptions()); + } } /** @@ -2177,6 +2185,32 @@ void isEnabled( } } + /** + * Initializes the autocapture functionality with the provided options. + * + *

Autocapture automatically tracks user interactions (clicks, rage clicks, dead clicks) + * without requiring manual instrumentation. + * + * @param options The autocapture configuration options. + */ + private void initializeAutocapture(AutocaptureOptions options) { + try { + mAutocaptureManager = new AutocaptureManager( + mContext, + options, + (eventName, properties) -> { + // Use track() to emit autocapture events + // These are internal events so we use the internal tracking method + track(eventName, properties, true); + } + ); + mAutocaptureManager.start(); + MPLog.d(LOGTAG, "Autocapture initialized"); + } catch (Exception e) { + MPLog.e(LOGTAG, "Failed to initialize autocapture", e); + } + } + /** * Based on the application's event lifecycle this method will determine whether the app is * running in the foreground or not. @@ -3018,6 +3052,7 @@ RemoteService getHttpService() { private final FeatureFlagOptions mFeatureFlagOptions; private final Set mExcludeProperties; private FeatureFlagManager mFeatureFlagManager; + private AutocaptureManager mAutocaptureManager; private RemoteService mHttpService; // Flag to track if app has entered foreground private final AtomicBoolean mHasAppForegrounded = new AtomicBoolean(false); diff --git a/analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java b/analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java index 04d1759a2..8ed536c13 100644 --- a/analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java +++ b/analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java @@ -31,6 +31,7 @@ public class MixpanelOptions { private final boolean optOutTrackingDefault; private final JSONObject superProperties; private final FeatureFlagOptions mFeatureFlagOptions; + private final AutocaptureOptions mAutocaptureOptions; private final DeviceIdProvider deviceIdProvider; private final String serverURL; private final ProxyServerInteractor proxyServerInteractor; @@ -44,6 +45,7 @@ private MixpanelOptions(Builder builder) { this.serverURL = builder.serverURL; this.proxyServerInteractor = builder.proxyServerInteractor; this.mFeatureFlagOptions = builder.mFeatureFlagOptions; + this.mAutocaptureOptions = builder.mAutocaptureOptions; this.excludeProperties = builder.excludeProperties; } @@ -84,6 +86,19 @@ public FeatureFlagOptions getFeatureFlagOptions() { return mFeatureFlagOptions; } + /** + * Returns the autocapture options for this Mixpanel instance. + * + *

Autocapture is disabled by default. When no AutocaptureOptions are explicitly + * configured, this returns a disabled configuration. + * + * @return The {@link AutocaptureOptions} configuration, never null. + */ + @NonNull + public AutocaptureOptions getAutocaptureOptions() { + return mAutocaptureOptions; + } + /** * Returns the custom device ID provider, or null if not set. * @@ -129,6 +144,12 @@ public static class Builder { private boolean optOutTrackingDefault = false; private JSONObject superProperties; private FeatureFlagOptions mFeatureFlagOptions = new FeatureFlagOptions.Builder().build(); + // Autocapture is disabled by default - all event types disabled + private AutocaptureOptions mAutocaptureOptions = new AutocaptureOptions.Builder() + .clickOptions(new ClickOptions.Builder().enabled(false).build()) + .rageClickOptions(new RageClickOptions.Builder().enabled(false).build()) + .deadClickOptions(new DeadClickOptions.Builder().enabled(false).build()) + .build(); private DeviceIdProvider deviceIdProvider = null; private String serverURL; private ProxyServerInteractor proxyServerInteractor; @@ -226,6 +247,37 @@ public Builder featureFlagOptions(FeatureFlagOptions featureFlagOptions) { return this; } + /** + * Sets the autocapture options for this Mixpanel instance. + * + *

Autocapture is disabled by default. Passing an {@link AutocaptureOptions} instance + * enables autocapture with the specified configuration. When enabled, the SDK automatically + * tracks user interactions (clicks, rage clicks, dead clicks) without manual instrumentation. + * + *

{@code
+         * // Enable all autocapture with defaults
+         * .autocaptureOptions(new AutocaptureOptions.Builder().build())
+         *
+         * // Enable with custom configuration
+         * .autocaptureOptions(new AutocaptureOptions.Builder()
+         *     .rageClickOptions(new RageClickOptions.Builder()
+         *         .clickThreshold(5)
+         *         .build())
+         *     .deadClickOptions(new DeadClickOptions.Builder()
+         *         .enabled(false)
+         *         .build())
+         *     .build())
+         * }
+ * + * @param autocaptureOptions The AutocaptureOptions configuration. + * @return This Builder instance for chaining. + * @see AutocaptureOptions + */ + public Builder autocaptureOptions(AutocaptureOptions autocaptureOptions) { + this.mAutocaptureOptions = autocaptureOptions; + return this; + } + /** * Sets a custom device ID provider. * diff --git a/analytics/src/main/java/com/mixpanel/android/mpmetrics/RageClickOptions.java b/analytics/src/main/java/com/mixpanel/android/mpmetrics/RageClickOptions.java new file mode 100644 index 000000000..956c48421 --- /dev/null +++ b/analytics/src/main/java/com/mixpanel/android/mpmetrics/RageClickOptions.java @@ -0,0 +1,162 @@ +package com.mixpanel.android.mpmetrics; + +/** + * Configuration options for rage click detection in autocapture. + * + *

Rage clicks are detected when a user rapidly clicks multiple times in the same area, + * indicating frustration with an unresponsive UI element. + * + *

{@code
+ * RageClickOptions rageClickOptions = new RageClickOptions.Builder()
+ *     .enabled(true)
+ *     .clickThreshold(5)      // Require 5 clicks instead of default 4
+ *     .timeWindowMs(800)      // Shorter time window
+ *     .radius(50f)            // Larger click radius
+ *     .build();
+ * }
+ * + * @see AutocaptureOptions.Builder#rageClickOptions(RageClickOptions) + */ +public class RageClickOptions { + + private final boolean mEnabled; + private final int mClickThreshold; + private final long mTimeWindowMs; + private final float mRadius; + + private RageClickOptions(Builder builder) { + this.mEnabled = builder.mEnabled; + this.mClickThreshold = builder.mClickThreshold; + this.mTimeWindowMs = builder.mTimeWindowMs; + this.mRadius = builder.mRadius; + } + + /** + * Returns whether rage click detection is enabled. + * + * @return {@code true} if rage clicks are tracked, {@code false} otherwise. + * Defaults to {@code true} when autocapture is enabled. + */ + public boolean isEnabled() { + return mEnabled; + } + + /** + * Returns the number of clicks required to trigger a rage click event. + * + * @return The click threshold. Defaults to 4. + */ + public int getClickThreshold() { + return mClickThreshold; + } + + /** + * Returns the time window in milliseconds within which clicks must occur + * to be considered a rage click. + * + * @return The time window in milliseconds. Defaults to 1000. + */ + public long getTimeWindowMs() { + return mTimeWindowMs; + } + + /** + * Returns the spatial threshold for rage click detection. + * + *

Clicks within this radius (in dp) are considered to be in the "same location". + * + * @return The radius in density-independent pixels (dp). Defaults to 44. + */ + public float getRadius() { + return mRadius; + } + + /** + * Builder for creating {@link RageClickOptions} instances. + * + *

Default values: + *

+ */ + public static class Builder { + private boolean mEnabled = true; + private int mClickThreshold = 4; + private long mTimeWindowMs = 1000; + private float mRadius = 44f; + + public Builder() { + } + + /** + * Creates a Builder pre-populated with values from an existing {@link RageClickOptions}. + * + * @param source The RageClickOptions to copy values from. + */ + public Builder(RageClickOptions source) { + this.mEnabled = source.mEnabled; + this.mClickThreshold = source.mClickThreshold; + this.mTimeWindowMs = source.mTimeWindowMs; + this.mRadius = source.mRadius; + } + + /** + * Enables or disables rage click detection. + * + * @param enabled {@code true} to detect rage clicks, {@code false} to disable. + * @return This Builder instance for chaining. + */ + public Builder enabled(boolean enabled) { + this.mEnabled = enabled; + return this; + } + + /** + * Sets the number of clicks required to trigger a rage click event. + * + * @param clickThreshold The number of clicks required. Must be at least 2. + * @return This Builder instance for chaining. + */ + public Builder clickThreshold(int clickThreshold) { + this.mClickThreshold = Math.max(2, clickThreshold); + return this; + } + + /** + * Sets the time window in milliseconds within which clicks must occur. + * + * @param timeWindowMs The time window in milliseconds. Must be positive. + * @return This Builder instance for chaining. + */ + public Builder timeWindowMs(long timeWindowMs) { + this.mTimeWindowMs = Math.max(1, timeWindowMs); + return this; + } + + /** + * Sets the spatial threshold for rage click detection. + * + *

Clicks within this radius are considered to be in the "same location". + * Unit: dp (density-independent pixels) on Android. + * + * @param radius The radius in dp within which clicks are considered "same location". + * @return This Builder instance for chaining. + */ + public Builder radius(float radius) { + this.mRadius = Math.max(0, radius); + return this; + } + + /** + * Builds and returns a {@link RageClickOptions} instance with the configured settings. + * + * @return A new {@link RageClickOptions} instance. + */ + public RageClickOptions build() { + return new RageClickOptions(this); + } + } +} From b28b87cf7b1403c700d728323bb0002f4f214f07 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Fri, 12 Jun 2026 12:21:39 +0530 Subject: [PATCH 02/65] feat(mixpaneldemo): add autocapture test screens and enable SDK init Add test screens for validating autocapture functionality: - ComposeAutocaptureTestScreen: Jetpack Compose test screen - XmlAutocaptureTestActivity: XML Views test screen - DemoApplication: Enables autocapture during SDK initialization Test coverage includes: - $el_id resolution (contentDescription, resource ID, hash fallback) - Dead click detection (elements with no UI response) - Rage click detection (4+ rapid taps) - Excluded controls (Switch, EditText, SeekBar) - Privacy filtering (mp-sensitive, mp-no-track) Note: XML views autocapture working; Compose needs improvement. Co-Authored-By: Claude Opus 4.5 --- analytics/mixpaneldemo/build.gradle.kts | 1 + .../mixpaneldemo/src/main/AndroidManifest.xml | 7 + .../ComposeAutocaptureTestScreen.kt | 264 ++++++++++++++++++ .../mixpanel/mixpaneldemo/DemoApplication.kt | 21 ++ .../com/mixpanel/mixpaneldemo/LandingPage.kt | 9 + .../com/mixpanel/mixpaneldemo/NavGraph.kt | 1 + .../XmlAutocaptureTestActivity.kt | 100 +++++++ .../layout/activity_xml_autocapture_test.xml | 251 +++++++++++++++++ 8 files changed, 654 insertions(+) create mode 100644 analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/ComposeAutocaptureTestScreen.kt create mode 100644 analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/DemoApplication.kt create mode 100644 analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/XmlAutocaptureTestActivity.kt create mode 100644 analytics/mixpaneldemo/src/main/res/layout/activity_xml_autocapture_test.xml diff --git a/analytics/mixpaneldemo/build.gradle.kts b/analytics/mixpaneldemo/build.gradle.kts index aeeb5f1e9..ede17dee9 100644 --- a/analytics/mixpaneldemo/build.gradle.kts +++ b/analytics/mixpaneldemo/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation("androidx.core:core-ktx:1.13.1") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.4") implementation("androidx.activity:activity-compose:1.9.1") + implementation("androidx.appcompat:appcompat:1.7.0") implementation(platform("androidx.compose:compose-bom:2024.12.01")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui-graphics") diff --git a/analytics/mixpaneldemo/src/main/AndroidManifest.xml b/analytics/mixpaneldemo/src/main/AndroidManifest.xml index 1b081d496..6c4386391 100644 --- a/analytics/mixpaneldemo/src/main/AndroidManifest.xml +++ b/analytics/mixpaneldemo/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + \ No newline at end of file diff --git a/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/ComposeAutocaptureTestScreen.kt b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/ComposeAutocaptureTestScreen.kt new file mode 100644 index 000000000..14a756db6 --- /dev/null +++ b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/ComposeAutocaptureTestScreen.kt @@ -0,0 +1,264 @@ +package com.mixpanel.mixpaneldemo + +import android.content.Intent +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController + +/** + * Jetpack Compose Autocapture Test Screen + * + * Tests $mp_click, $mp_rage_click, and $mp_dead_click on Compose elements, + * and verifies $el_id resolution via Compose's accessibility semantics: + * 1. semantics { contentDescription } (if non-empty) + * 2. testTag / resource ID mapping + * 3. ClassName_view_ + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ComposeAutocaptureTestScreen(navController: NavHostController) { + val context = LocalContext.current + var tapCount by remember { mutableIntStateOf(0) } + var switchOn by remember { mutableStateOf(false) } + var textValue by remember { mutableStateOf("") } + var passwordValue by remember { mutableStateOf("") } + var sliderValue by remember { mutableFloatStateOf(0.5f) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Compose Autocapture Test") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + ) + } + ) { innerPadding -> + LazyColumn( + contentPadding = PaddingValues( + start = 16.dp, + end = 16.dp, + top = innerPadding.calculateTopPadding() + 16.dp, + bottom = 32.dp + ), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + // $el_id Resolution + item { SectionHeader("\$el_id Resolution") } + + item { + Button( + onClick = { }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "compose_rule1" } + ) { + Text("Rule 1 - semantics contentDescription") + } + } + + item { + Button( + onClick = { }, + modifier = Modifier + .fillMaxWidth() + .testTag("compose_rule2_btn") + // No contentDescription + ) { + Text("Rule 2 - testTag (no contentDescription)") + } + } + + item { + Button( + onClick = { }, + modifier = Modifier.fillMaxWidth() + // No semantics at all + ) { + Text("Rule 3 - No semantics (hash fallback)") + } + } + + item { + Button( + onClick = { }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "compose_desc_wins" } + .testTag("ignored_tag") + ) { + Text("Rule 1 Wins - contentDescription + testTag") + } + } + + item { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .background(Color.Blue.copy(alpha = 0.1f)) + .clickable { } + .semantics { contentDescription = "compose_box" }, + contentAlignment = Alignment.Center + ) { + Text("Clickable Box (non-Button)") + } + } + + // Dead Click + item { SectionHeader("Dead Click") } + + item { + Button( + onClick = { }, // Empty - no UI change + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "compose_dead_btn" } + ) { + Text("Dead Button (no UI change) -> \$mp_dead_click") + } + } + + // Rage Click + item { SectionHeader("Rage Click - tap 4+ times") } + + item { + Button( + onClick = { }, // Does nothing visible + modifier = Modifier + .fillMaxWidth() + .height(80.dp) + .semantics { contentDescription = "compose_rage_btn" }, + colors = ButtonDefaults.buttonColors( + containerColor = Color.Red.copy(alpha = 0.3f) + ) + ) { + Text("Rage Zone - tap rapidly") + } + } + + item { + Button( + onClick = { tapCount++ }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "compose_rage_click_btn" } + ) { + Text("Click + Rage - tap 4x (both events): ${tapCount}x") + } + } + + // Excluded Controls + item { SectionHeader("Excluded Controls (no dead click)") } + + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Switch - no dead click") + Switch(checked = switchOn, onCheckedChange = { switchOn = it }) + } + } + + item { + OutlinedTextField( + value = textValue, + onValueChange = { textValue = it }, + label = { Text("TextField - no dead click, text not captured") }, + modifier = Modifier.fillMaxWidth() + ) + } + + item { + OutlinedTextField( + value = passwordValue, + onValueChange = { passwordValue = it }, + label = { Text("Password - \$el_text absent") }, + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + } + + item { + Column(modifier = Modifier.fillMaxWidth()) { + Slider(value = sliderValue, onValueChange = { sliderValue = it }) + Text( + "Slider - no dead click (value: ${(sliderValue * 100).toInt()}%)", + style = MaterialTheme.typography.bodySmall + ) + } + } + + // Privacy + item { SectionHeader("Privacy - zero events captured") } + + item { + Button( + onClick = { }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "mp-sensitive" } + ) { + Text("mp-sensitive (no events)") + } + } + + item { + Button( + onClick = { }, + modifier = Modifier + .fillMaxWidth() + .semantics { contentDescription = "mp-no-track" } + ) { + Text("mp-no-track (no events)") + } + } + + // Navigate to XML Test + item { SectionHeader("XML Views Test") } + + item { + Button( + onClick = { + context.startActivity(Intent(context, XmlAutocaptureTestActivity::class.java)) + }, + modifier = Modifier.fillMaxWidth(), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) + ) { + Text("Open XML Test Activity") + } + } + } + } +} + +@Composable +private fun SectionHeader(text: String) { + Column(modifier = Modifier.padding(top = 8.dp)) { + Text( + text = text.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary + ) + HorizontalDivider(modifier = Modifier.padding(top = 4.dp)) + } +} diff --git a/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/DemoApplication.kt b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/DemoApplication.kt new file mode 100644 index 000000000..01b2518f0 --- /dev/null +++ b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/DemoApplication.kt @@ -0,0 +1,21 @@ +package com.mixpanel.mixpaneldemo + +import android.app.Application +import com.mixpanel.android.mpmetrics.AutocaptureOptions +import com.mixpanel.android.mpmetrics.MixpanelAPI +import com.mixpanel.android.mpmetrics.MixpanelOptions + +class DemoApplication : Application() { + override fun onCreate() { + super.onCreate() + + // Initialize Mixpanel with autocapture enabled + val autocaptureOptions = AutocaptureOptions.Builder().build() + + val options = MixpanelOptions.Builder() + .autocaptureOptions(autocaptureOptions) + .build() + + MixpanelAPI.getInstance(this, MIXPANEL_PROJECT_TOKEN, true, options) + } +} diff --git a/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/LandingPage.kt b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/LandingPage.kt index b4f67659a..dbdf36d41 100644 --- a/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/LandingPage.kt +++ b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/LandingPage.kt @@ -74,6 +74,15 @@ fun LandingPage(navController: NavHostController) { ) { Text("Groups") } + Button( + onClick = { navController.navigate("autocaptureTestPage") }, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(red = 76, green = 175, blue = 80)), + ) { + Text("Autocapture Test") + } } } } \ No newline at end of file diff --git a/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/NavGraph.kt b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/NavGraph.kt index 96550d9be..77f18d4f4 100644 --- a/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/NavGraph.kt +++ b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/NavGraph.kt @@ -19,5 +19,6 @@ fun NavGraph(navController: NavHostController, modifier: Modifier = Modifier) { composable("utilityPage") { UtilityPage(navController) } composable("gdprPage") { GDPRPage(navController) } composable("groupsPage") { GroupsPage(navController) } + composable("autocaptureTestPage") { ComposeAutocaptureTestScreen(navController) } } } diff --git a/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/XmlAutocaptureTestActivity.kt b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/XmlAutocaptureTestActivity.kt new file mode 100644 index 000000000..07b89f9fa --- /dev/null +++ b/analytics/mixpaneldemo/src/main/java/com/mixpanel/mixpaneldemo/XmlAutocaptureTestActivity.kt @@ -0,0 +1,100 @@ +package com.mixpanel.mixpaneldemo + +import android.os.Bundle +import android.view.View +import android.widget.Button +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.TextView +import androidx.appcompat.app.AppCompatActivity + +/** + * XML Views Autocapture Test Screen + * + * Tests $mp_click, $mp_rage_click, and $mp_dead_click on traditional XML View elements, + * and verifies $el_id resolution across all three Android rules: + * 1. contentDescription (if non-empty) + * 2. Resource ID name (R.id.xxx) + * 3. ClassName_view_ + */ +class XmlAutocaptureTestActivity : AppCompatActivity() { + + private var tapCount = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_xml_autocapture_test) + title = "XML Autocapture Test" + + setupElIdResolutionTests() + setupRageClickTests() + setupPrivacyTests() + } + + private fun setupElIdResolutionTests() { + // Rule 1 - contentDescription set in XML (rule1_btn) + findViewById