Skip to content

feat(analytics): add autocapture frustration signals (click, rage click, dead click)#982

Open
rahul-mixpanel wants to merge 56 commits into
masterfrom
feature/autocapture-phase1
Open

feat(analytics): add autocapture frustration signals (click, rage click, dead click)#982
rahul-mixpanel wants to merge 56 commits into
masterfrom
feature/autocapture-phase1

Conversation

@rahul-mixpanel

@rahul-mixpanel rahul-mixpanel commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Implements autocapture Phase 1: $mp_click, $mp_rage_click, and $mp_dead_click event detection for both XML Views and Jetpack Compose UIs
  • Adds AutocaptureOptions configuration (disabled by default, opt-in via MixpanelOptions builder) with per-feature options (ClickOptions, RageClickOptions, DeadClickOptions)
  • Intercepts touch events via Window.Callback wrapping, with WindowSpy for dialog/popup/menu window tracking
  • Full Compose support via native SemanticsNode tree traversal (graceful degradation when Compose is not present)

Privacy

Autocapture does not capture visible text content ($el_text) from tapped elements. Tracking text can be invasive and raise privacy concerns. The complexity of nested view hierarchies can also cause text extraction to capture content from unintended views — for example, tapping a LinearLayout container might extract text from a deeply nested TextView that isn't semantically related to the tap.

The captured properties ($el_id, $el_tag_name, $attr-aria-label, $attr-role, $elements, $x, $y) are purely structural UI metadata with no PII risk.

Captured Properties

Property Description
$x, $y Touch coordinates
$el_id Element identifier (contentDescription → resource ID → fallback hash)
$el_tag_name Element class name (e.g., Button, TextView)
$attr-aria-label Accessibility label (contentDescription)
$attr-role Semantic role (e.g., Button, Switch, Slider)
$elements View hierarchy string (max 5 levels)

Architecture

Component Responsibility
TouchInterceptor Per-window touch interception via Window.Callback
SemanticExtractor Element info extraction (XML View + Compose routing)
ComposeSemanticHelper Compose-specific semantics via RootForTest / SemanticsNode
RageClickTracker 4+ clicks within 1000ms / 44dp radius
DeadClickDetector No UI change within 500ms (XML: view hash, Compose: semantic snapshot)
AutocaptureManager Lifecycle coordination, event dispatch
WindowSpy Root view tracking for dialogs, popups, menus

Tests

  • 7 XML autocapture instrumented tests (AutocaptureInstrumentedTest)
  • 7 Compose autocapture instrumented tests (ComposeAutocaptureInstrumentedTest)
  • All 14 autocapture tests pass on emulator (API 35)

Demo app

  • XML and Compose test screens added to mixpaneldemo for manual validation

Hardening (code review fixes)

  • Depth-limited recursion (max 20) on all tree traversals to prevent StackOverflowError
  • Fixed WeakHashMap iteration in stop() to avoid ConcurrentModificationException
  • Fixed WindowSpy.getRootViews() returning stale data
  • Fixed ClickEvent.isComposeClick() unreliability from WeakReference GC
  • Fixed AccessibilityNodeInfo leak on exception

Test plan

  • Run XML tests: ./gradlew :analytics:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.mixpanel.android.mpmetrics.AutocaptureInstrumentedTest
  • Run Compose tests: ./gradlew :analytics:connectedAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.mixpanel.android.mpmetrics.ComposeAutocaptureInstrumentedTest
  • Manual validation using demo app XML and Compose test screens
  • Verify existing SDK tests still pass: ./gradlew :analytics:connectedAndroidTest

rahul-mixpanel and others added 13 commits June 11, 2026 23:33
…ead 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…erent feedback

Privacy filtering:
- Return null (block all events) for views marked mp-sensitive or mp-no-track
- Add same check for Compose via AccessibilityNodeInfo
- Add debug logging when skipping sensitive elements

Dead click exclusions:
- Exclude EditText (keyboard appears, cursor shows)
- Exclude CompoundButton (Switch, CheckBox, RadioButton - toggle animation)
- Exclude SeekBar (thumb moves with drag)

These controls have inherent visual feedback and should not trigger $mp_dead_click.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…ose APIs

- Add ComposeSemanticHelper to extract semantics from Jetpack Compose views
  using Compose's SemanticsNode API directly (no reflection)
- Add compileOnly dependency on Compose UI for graceful degradation
- Update SemanticExtractor to detect Compose roots and use ComposeSemanticHelper
- Implement $el_id resolution: contentDescription > testTag > hash fallback
- Implement sensitive element detection (mp-sensitive/mp-no-track) with
  ancestor checking - returns SENSITIVE_BLOCKED to prevent accessibility fallback
- Disable dead click detection for Compose (ViewTreeObserver cannot detect
  Compose UI changes) - click and rage click work correctly

Note: Dead click for Compose requires semantic tree comparison (future enhancement)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Reorder property checks so EditableText is checked before OnClick.
TextFields in Compose are clickable (to gain focus) but should be
identified as TextField/textbox, not Button/button.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
ViewTreeObserver cannot detect Compose UI changes since Compose renders
inside a single AndroidComposeView. This implements semantic tree comparison
for Compose clicks: captures semantic hash at click position (text, states,
bounds, children), then compares after timeout to detect UI changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
…snapshot comparison

Use semantic tree snapshot comparison for Compose dead click detection:
- Capture snapshot immediately at click (nodeCount + contentHash)
- At timeout, detect changes via content hash or significant node count change
- Navigation detected by node count increase (new screen composables added)
- Ripples don't affect semantic tree, so dead clicks are correctly detected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add complete integration guide for autocapture feature covering click,
rage click, and dead click tracking.

- Add analytics/docs/autocapture.md (620 lines)
  - Overview and quick start with Kotlin/Java examples
  - Configuration options for all event types
  - Complete event properties reference
  - Element identification best practices
  - Full Jetpack Compose support documentation
  - Multi-window tracking explanation (WindowSpy)
  - Privacy considerations and sensitive data handling
  - Troubleshooting guide with debug logging
  - ProGuard configuration notes
  - Migration guide from manual tracking
  - FAQ section

- Update analytics/README.md
  - Add Features section with Autocapture overview
  - Add quick setup examples (Kotlin + Java)
  - Link to detailed autocapture guide
  - Update table of contents

Documentation provides comprehensive coverage exceeding iOS guide,
with dual-language examples and complete API reference.
Add 9 instrumented tests covering:
- Click event capture and property verification
- Element ID resolution (contentDescription, resource ID, hash fallback)
- Rage click detection with rapid touch injection
- Dead click detection for unresponsive elements
- Privacy filtering for mp-sensitive elements
- Multiple click event generation
- Token and distinct_id verification
- Add testElementIdResolutionRule3HashFallback: verifies hash fallback
  format (Button_view_<hex>) when both contentDescription and resource
  ID are absent
- Add testClickEventCapturesElText: verifies $el_text captures the
  button's visible text content via SemanticExtractor.extractText
Add 9 instrumented tests for Jetpack Compose autocapture, mirroring
the XML test suite:
- Click event capture with contentDescription element ID
- Element ID from testTag (Compose Rule 2 fallback)
- Element ID hash fallback (no contentDescription, no testTag)
- Rage click detection with rapid sendPointerSync injection
- Dead click detection (onClick={} with no UI change)
- Privacy filtering for mp-sensitive composables
- Multiple click event generation
- $el_text capture from Text composable
- Token and distinct_id verification

Uses sendPointerSync for touch injection because compose-ui-test's
performTouchInput bypasses Window.Callback where TouchInterceptor
lives. Compose test rule is used only to find element bounds.
Remove testClickEventPropertiesComplete and testElementIdResolutionRule1
as they duplicate coverage already provided by testXmlClickEventBasic.
- Add MAX_RECURSION_DEPTH (20) to prevent StackOverflowError in
  countViews, computeContentHash, computeTreeHash,
  findNodeAtPositionRecursive, and collectTextFromChildren
- Fix WeakHashMap iteration in stop() to avoid ConcurrentModificationException
- Fix WindowSpy.getRootViews() returning stale data by pointing to the
  live delegating list instead of the replaced original
- Fix ClickEvent.isComposeClick() unreliability by using a boolean flag
  set at construction time instead of checking WeakReference at read time
- Fix AccessibilityNodeInfo leak on exception in findNodeAtPosition
@rahul-mixpanel rahul-mixpanel requested review from a team and tylerjroach June 29, 2026 16:06
@rahul-mixpanel rahul-mixpanel changed the title feat(autocapture): implement Phase 1 - click, rage click, dead click feat(analytics): add frustration signals - click, rage click, dead click Jun 29, 2026
@rahul-mixpanel rahul-mixpanel changed the title feat(analytics): add frustration signals - click, rage click, dead click feat(analytics): add autocapture frustration signals (click, rage click, dead click) Jun 29, 2026
@rahul-mixpanel rahul-mixpanel marked this pull request as draft June 29, 2026 16:36
@rahul-mixpanel rahul-mixpanel self-assigned this Jun 29, 2026

@tylerjroach tylerjroach left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I haven't finished reviewing the code, but had these questions on autocapture doc

Comment thread analytics/docs/autocapture.md Outdated
Comment thread analytics/docs/autocapture.md
Comment thread analytics/docs/autocapture.md
Comment thread analytics/docs/autocapture.md Outdated
Comment thread analytics/docs/autocapture.md
Comment thread analytics/docs/autocapture.md Outdated
Comment thread analytics/README.md Outdated
…tent

$el_text (visible text content of tapped elements) is no longer collected
by default. Users must explicitly enable it by setting captureTextContent
to true in AutocaptureOptions, protecting user privacy by default.
Simplify element exclusion to a single marker. Both mp-sensitive and
mp-no-track had identical behavior (full block), so only mp-no-track
is needed.
Comment thread analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java Outdated
Comment thread analytics/src/main/java/com/mixpanel/android/autocapture/WindowSpy.java Outdated
Comment thread analytics/src/main/java/com/mixpanel/android/autocapture/WindowSpy.java Outdated
Comment thread analytics/src/main/java/com/mixpanel/android/autocapture/TouchInterceptor.java Outdated
…xclusion

Remove text content capture entirely from autocapture. Tracking visible
text can be invasive and raise privacy concerns. The complexity of nested
view hierarchies can also cause text extraction to capture content from
unintended views — for example, tapping a container layout might extract
text from a deeply nested label unrelated to the tap.

Without text capture, the mp-no-track element exclusion marker is no
longer needed since the remaining properties ($el_id, $el_tag_name,
$attr-aria-label, $attr-role, $elements) are purely structural UI
metadata with no PII risk.

Removed: captureTextContent config, $el_text property, text extraction
and sanitization (CC/SSN regex), sensitive input detection, mp-no-track
marker checks, and all related tests and demo elements.
@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Confidence Score: 3/5

Several correctness issues from previous review rounds remain unresolved, including manager teardown not wired into any MixpanelAPI cleanup path and touch listeners for dialog/popup windows not removed on dismissal.

The core detection pipeline works correctly in the happy path, multi-touch handling was fixed, and null guards were added to AutocaptureOptions.Builder. However, AutocaptureManager.stop() is still never called from MixpanelAPI, so the manager and its lifecycle callbacks leak on any cleanup path. Dialog/popup window touch listeners registered via onRootViewChanged are not cleaned up on dismissal. The accessibility fallback still emits visible text as $el_id and omits $attr-aria-label when a resource ID is present, contradicting the documented privacy and property contracts.

AutocaptureManager.java (stop() not wired to any MixpanelAPI cleanup), SemanticExtractor.java (accessibility fallback text capture + ariaLabel omission), MixpanelAPI.java (no teardown path for mAutocaptureManager).

Important Files Changed

Filename Overview
analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureManager.java Core lifecycle coordinator; stop() is never called from MixpanelAPI (open issue from previous review), and dialog/popup window touch listeners added via onRootViewChanged are not removed on dismissal.
analytics/src/main/java/com/mixpanel/android/autocapture/SemanticExtractor.java Handles View + Compose semantics extraction; two open issues from previous review: node.getText() used as $el_id (contradicts privacy claim), and $attr-aria-label dropped when viewIdResourceName is present.
analytics/src/main/java/com/mixpanel/android/autocapture/CurtainsHelper.kt Touch interception; multi-touch false-tap fix applied (ACTION_POINTER_DOWN resets downTime), single-tap filtering looks correct.
analytics/src/main/java/com/mixpanel/android/autocapture/DeadClickDetector.java Baseline/hash based change detection; depth-limited tree walk for XML; Compose monitors structural hash. AccessibilityNodeInfo lifecycle appears correct.
analytics/src/main/java/com/mixpanel/android/autocapture/ComposeSemanticHelper.java Compose-specific semantic extraction and snapshot hashing; coordinate conversion to window-relative space is correct; no text captured as element ID (unlike the accessibility fallback path).
analytics/src/main/java/com/mixpanel/android/autocapture/RageClickTracker.java Time-window + spatial-radius rage click detection; iterator-safe pruning; injectable TimeProvider for testability. No issues found.
analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelAPI.java trackAutocaptureEvent correctly uses 2-arg track() with isAutomaticEvent=false, so events are not gated by trackAutomaticEvents. mAutocaptureManager.stop() still not wired to any cleanup path.
analytics/src/main/java/com/mixpanel/android/mpmetrics/AutocaptureOptions.java Null guards added to all three Builder setters; defaults all sub-options to enabled. Clean.
analytics/src/main/java/com/mixpanel/android/autocapture/WindowSpy.java Curtains singleton; static listener list with CopyOnWriteArrayList is thread-safe. uninstall() exists but is never called from AutocaptureManager.stop(), leaving the Curtains hook as a permanent singleton (benign but intentional).
analytics/src/main/java/com/mixpanel/android/autocapture/ClickEvent.java isCompose flag is now a primitive boolean set at construction (not from WeakReference), fixing the GC reliability issue called out in a previous review thread.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant CurtainsHelper
    participant AutocaptureManager
    participant SemanticExtractor
    participant RageClickTracker
    participant DeadClickDetector
    participant MixpanelAPI

    User->>CurtainsHelper: finger down / up (MotionEvent)
    CurtainsHelper->>CurtainsHelper: "filter: single-pointer, slop, <500ms"
    CurtainsHelper->>AutocaptureManager: onTap(x, y, decorView)
    AutocaptureManager->>SemanticExtractor: extract(decorView, x, y)
    SemanticExtractor-->>AutocaptureManager: ClickEvent.Builder
    AutocaptureManager->>MixpanelAPI: trackClick(clickEvent)
    AutocaptureManager->>RageClickTracker: recordClick(clickEvent)
    RageClickTracker-->>AutocaptureManager: rageClick? (4+ in window)
    AutocaptureManager->>MixpanelAPI: trackRageClick(rageClick)
    AutocaptureManager->>DeadClickDetector: startDetection(clickEvent, rootView)
    DeadClickDetector->>DeadClickDetector: captureBaseline (view hash / semantic snapshot)
    Note over DeadClickDetector: wait 500ms
    DeadClickDetector->>DeadClickDetector: hasChanged()?
    DeadClickDetector-->>AutocaptureManager: onDeadClickDetected(clickEvent)
    AutocaptureManager->>MixpanelAPI: trackDeadClick(clickEvent)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant CurtainsHelper
    participant AutocaptureManager
    participant SemanticExtractor
    participant RageClickTracker
    participant DeadClickDetector
    participant MixpanelAPI

    User->>CurtainsHelper: finger down / up (MotionEvent)
    CurtainsHelper->>CurtainsHelper: "filter: single-pointer, slop, <500ms"
    CurtainsHelper->>AutocaptureManager: onTap(x, y, decorView)
    AutocaptureManager->>SemanticExtractor: extract(decorView, x, y)
    SemanticExtractor-->>AutocaptureManager: ClickEvent.Builder
    AutocaptureManager->>MixpanelAPI: trackClick(clickEvent)
    AutocaptureManager->>RageClickTracker: recordClick(clickEvent)
    RageClickTracker-->>AutocaptureManager: rageClick? (4+ in window)
    AutocaptureManager->>MixpanelAPI: trackRageClick(rageClick)
    AutocaptureManager->>DeadClickDetector: startDetection(clickEvent, rootView)
    DeadClickDetector->>DeadClickDetector: captureBaseline (view hash / semantic snapshot)
    Note over DeadClickDetector: wait 500ms
    DeadClickDetector->>DeadClickDetector: hasChanged()?
    DeadClickDetector-->>AutocaptureManager: onDeadClickDetected(clickEvent)
    AutocaptureManager->>MixpanelAPI: trackDeadClick(clickEvent)
Loading

Reviews (29): Last reviewed commit: "Align rage click distance with iOS/JS SD..." | Re-trigger Greptile

Comment thread analytics/src/main/java/com/mixpanel/android/mpmetrics/MixpanelOptions.java Outdated
- Remove redundant API 19 check in WindowSpy (minSdk is 21)
- Guard autocaptureOptions(null) with warning instead of NPE
- Add depth limit to findViewAtPosition for parity with Compose path
- Expand dead click excluded controls docs with full platform breakdown
- Add Javadoc to AutocaptureOptions.Builder clarifying defaults
@tylerjroach

Copy link
Copy Markdown
Contributor

Not to overwhelm here but some notable findings to look into from Fable.

1. Autocapture events silently dropped when trackAutomaticEvents=false

MixpanelAPI.java:2204 — The emit callback calls track(eventName, properties, /*isAutomaticEvent=*/true), and track() (line 2805) early-returns on (isAutomaticEvent && !mTrackAutomaticEvents). An app created with getInstance(context, token, false, optionsWithAutocaptureEnabled) starts the full autocapture machinery (interceptors, WindowSpy, timers) yet emits zero events — "Autocapture initialized" is logged and nothing hints at the drop. Autocapture opt-in should gate itself rather than piggyback on the automatic-events flag.

2. WindowSpy mViews swap is an unsynchronized race that can crash the host app

WindowSpy.java:80-84install() copies WindowManagerGlobal.mViews into a new list and writes the field back while holding only WindowSpy's own lock (not the framework's mLock), running synchronously on whatever thread calls getInstance(). A root view added by the main thread between the copy and the field write vanishes from mViews; its later removeView throws IllegalArgumentException("View not attached to window manager") in the host app — violating the never-crash rule. This swap technique is only safe when performed on the main thread.

3. Every single-pointer ACTION_UP is treated as a click — scrolls and swipes emit $mp_click

TouchInterceptor.java:107-125ACTION_DOWN is never recorded, so there is no touch-slop or duration check, and ACTION_CANCEL/ACTION_MOVE are ignored (no mitigation exists anywhere downstream). Every scroll stroke on a RecyclerView/LazyColumn produces a spurious click on whatever was under the finger at lift-off, and a few quick strokes in one region synthesize a false $mp_rage_click. A slop/duration check at this layer is standard.

4. Responsive buttons falsely reported as dead clicks (XML path)

DeadClickDetector.java:201-224 — The baseline snapshot is taken 150ms after the click, and onGlobalLayout/onScrollChanged explicitly ignore anything before mBaselineCaptured ("expected settling"). A click handler that updates the UI within a frame or two — the common case — has its response absorbed into the baseline; at the 500ms check the tree matches and a false $mp_dead_click fires. The Compose path does this correctly by snapshotting synchronously at click time.

5. No retroactive attach — deferred SDK init captures nothing on the current screen

AutocaptureManager.java:101-122start() only registers forward-looking hooks: registerActivityLifecycleCallbacks doesn't replay onActivityResumed for an already-resumed activity, and DelegatingViewList's copy-constructor bulk copy bypasses the overridden add(), so existing root views never fire onRootViewChanged. With deferred init (e.g., after a consent dialog), the foreground activity gets no interceptor until navigation or a config change.

6. Dialogs, popups, and menus are never captured — WindowSpy attaches to nothing

AutocaptureManager.java:339-357getWindowFromView() only handles context instanceof Activity directly (no ContextWrapper unwrapping; the "reflection fallback" comment at line 341 has no code behind it). Dialog decor views carry a DecorContext/ContextThemeWrapper, so the method returns null and no interceptor attaches. When the context is the Activity (popups), it returns the activity's already-registered window, so the containsKey guard skips attachment. Net effect: the entire WindowSpy mechanism currently attaches interceptors to nothing.

7. Compose hit-testing mixes coordinate spaces

ComposeSemanticHelper.java:226TouchInterceptor captures getRawX()/getRawY() (screen space) and passes them unconverted to findNodeAtPositionRecursive, which tests against SemanticsNode.getBoundsInWindow() (window space). The XML and accessibility paths correctly use screen space (getLocationOnScreen, getBoundsInScreen). For any Compose window not at the screen origin — dialogs, popups, multi-window — the tap misses or attributes to the wrong node.

8. Two Window.Callback methods are not delegated

TouchInterceptor.java:128-281onProvideKeyboardShortcuts (API 24) and onPointerCaptureChanged (API 26) are not overridden. Both are default no-ops in the interface, so this compiles silently, but once wrapped those framework notifications never reach the host Activity — breaking the keyboard-shortcuts sheet (Meta+/) and pointer-capture notifications. The existing Api23Helper + animalsniffer-ignore pattern covers this cleanly.

9 $tap_count documented but never emitted

RageClickTracker.java:92-95autocapture.md:150 documents $tap_count ("Number of taps in the rage click sequence") on $mp_rage_click, but recordClick clears the click history and returns the triggering event unchanged, and ClickEvent.toProperties() has no tap-count field. The documented property never appears in any emitted event, and once cleared the actual count is unrecoverable.

10. stop() / uninstall() latent defects

TouchInterceptor.java:67-76uninstall() only restores the callback when getCallback() == this; if another SDK wrapped the callback after Mixpanel, it silently no-ops and the stale interceptor keeps emitting clicks forever — there is no detached/stopped flag anywhere on the event path. install() is also not idempotent (no check for an
already-installed TouchInterceptor), so a stop/start cycle would double-wrap and double-emit every click. Currently unreachable — nothing in the SDK calls the public stop() — but worth fixing before it becomes reachable.

Autocapture PR Review — Additional Findings (design, performance, cleanup)

Design / architecture

D1. "Disabled by default" is a hand-built template, not a real off switch

MixpanelOptions.java:147-152 — The default is implemented by building an AutocaptureOptions that individually disables each of the three current sub-options, and AutocaptureOptions.isEnabled() is an OR over sub-options. When Phase 2 adds a new sub-option
whose builder defaults to enabled=true (the existing convention in ClickOptions/RageClickOptions/DeadClickOptions), every app that never called autocaptureOptions() silently gets that capture enabled. Fix: a master enabled flag or an
AutocaptureOptions.disabled() factory so "off" is defined once, in the class that owns the sub-options. (flagged independently by 2 agents)

D2. Dead-click detection is two divergent mechanisms instead of one abstraction

DeadClickDetector.java:188DetectionSession branches on mIsComposeClick throughout start/captureBaseline/checkResult/cleanup, and the paths already disagree: baselineDelayMs silently doesn't apply to Compose, layout/scroll cancellation signals only exist
for XML, and onWindowFocusChanged() (line 124) is never called by anyone — dead code. Every new change signal or Phase 2 surface must be added to both branches. Fix: a UI-change-monitor interface (captureBaseline / hasChanged / attach / detach) chosen once per
click.

D3. Compose change-detection sensitivity is a hardcoded heuristic

ComposeSemanticHelper.java:136 — The Compose path classifies a click as dead unless the node count changes by more than ±5, bypassing both AutocaptureDefaults and DeadClickOptions, while the XML path uses exact count/hash equality. A Compose click that
adds/removes 1-4 nodes (toggling an icon, revealing a badge) is a false dead click, and there's no option to tune it. Sensitivity should be defined once and honored by both snapshot implementations.

D4. MAX_ACCESSIBILITY_NODES doesn't enforce its documented contract

SemanticExtractor.java:215 — The constant (500, documented as "maximum number of accessibility nodes to probe") is actually used as a recursion depth bound in findNodeAtPosition. A broad-but-shallow tree probes unbounded nodes per tap, while 500 as a depth
limit is far too deep to prevent the StackOverflowError it presumably targets (other traversals cap depth at 20). Fix: a shared visited-node counter for total work, with depth reusing MAX_RECURSION_DEPTH.

D5. WindowSpy install is irreversible and failure is silent-permanent

WindowSpy.java:118 — There is no uninstall path (stop() removes listeners but the swapped list stays forever), sInstalled is set true even when the reflection fails, so degradation is permanent and undetectable by the caller, and DelegatingViewList only
intercepts add/remove — not addAll/clear/set — so coverage depends on OS internals. If another library uses the same mViews-swap technique, whichever installs second wraps the other and neither can be removed. Fix: keep the original list for restore-on-stop,
and surface install failure.

D6. Duplicate ActivityLifecycleCallbacks registration

AutocaptureManager.java:109 — The SDK already registers MixpanelActivityLifecycleCallbacks on the same Application (MixpanelAPI.java:2178); AutocaptureManager registers a second, and with multiple Mixpanel instances each adds another. Dispatching
autocapture's resume/pause/destroy hooks from the existing callbacks would keep one lifecycle path to maintain.

D7. XML hit-testing picks the deepest view, not the clickable target

SemanticExtractor.java:506findViewAtPosition returns the deepest visible view with no preference for clickable views or walk-up to a clickable ancestor (unlike the accessibility path). A tap on a clickable container built as ViewGroup + TextView is
attributed to the inner TextView: wrong $el_id, role text instead of button, and isInteractive=false — which also silently skips dead-click detection for that element.

Performance (tap hot path — all on the main thread)

P1. Extraction runs before the app receives the touch event

TouchInterceptor.java:90 — On every ACTION_UP, the full hit test, hierarchy string build, and (for Compose) semantics traversal + snapshot run synchronously before mOriginalCallback.dispatchTouchEvent(event), adding latency between finger-up and the app's

Performance (tap hot path — all on the main thread)

P1. Extraction runs before the app receives the touch event

TouchInterceptor.java:90 — On every ACTION_UP, the full hit test, hierarchy string build, and (for Compose) semantics traversal + snapshot run synchronously before mOriginalCallback.dispatchTouchEvent(event), adding latency between finger-up and the app's click handling. Nothing in the extraction depends on running first — forward the event,
then extract (or post it).

P2. Per-node getLocationOnScreen during hit-test DFS

SemanticExtractor.java:483 — Each visited view allocates an int[2] and calls getLocationOnScreen (which walks the parent chain), making the hit test O(visited × depth) per tap. Converting the tap to root coordinates once and translating incrementally while descending (as ViewGroup.dispatchTouchEvent does) eliminates both costs.

P3. Dead-click snapshots walk the tree twice, twice

DeadClickDetector.java:250captureBaseline() and checkResult() each traverse the full hierarchy twice (countViews + computeContentHash) — 4 full traversals per interactive click where 1-2 suffice. Compute count and hash in a single pass (as ComposeSemanticHelper.computeTreeHash already does), or drop countViews since the hash
already reflects count changes.

P4. Per-node allocation in Compose snapshots

ComposeSemanticHelper.java:207computeTreeHash allocates an int[2] per semantics node, and the snapshot runs at click time and again at timeout — hundreds of short-lived arrays per interactive click on large screens. Pack count+hash into a single long return, or use one holder per snapshot.

P5. Hot-path debug logs build strings regardless of log level

SemanticExtractor.java:60 (also ComposeSemanticHelper.extract, captureSnapshot, AutocaptureManager.emitEvent) — MPLog.d checks the level inside the call, so concatenation and getSimpleName() run on every tap even at the default WARN level. Guard with a level check or remove per-tap logs.

Dead code / simplification

  • AutocaptureManager.java:66mCurrentActivityRef is write-only: assigned every onActivityResumed, never read. Delete. (flagged independently by 3 agents)
  • WindowSpy.java:128getRootViews() and the sOriginalViews field exist only to serve it; zero callers. Delete both. (flagged independently by 2 agents)
  • ClickEvent.java:49timestamp is set, threaded through the 10-arg constructor, and never serialized or read (RageClickTracker keeps its own timestamps). Drop it.
  • DeadClickDetector.java:167DetectionSession caches mIsComposeClick/mComposeRootRef duplicating state already available from the mClickEvent it holds (double-wrapping an already-weak reference). Use the ClickEvent accessors.
  • ComposeSemanticHelper.java:46ExtractResult wrapper + two-value ExtractionResult enum encode exactly "nullable builder". Return @Nullable ClickEvent.Builder and delete both types.
  • SemanticExtractor.java:158,183,293 — Dead SDK-version guards for API 16/18; minSdk is 21, so the early-return is unreachable and the gates are always true.
  • SemanticExtractor.java:516 / ComposeSemanticHelper.java:303 — The $el_id priority chain (contentDescription > id/testTag > fallback hash) and role mapping are copy-pasted across the XML, accessibility, and Compose paths — three places to edit, already drifting. Extract one shared resolver.

Conventions (per CLAUDE.md)

  • AutocaptureOptions.java:91 — Builder setters (clickOptions/rageClickOptions/deadClickOptions) store @NonNull params with no null guard; a Java caller passing null NPEs later in AutocaptureOptions.isEnabled(), which runs in the MixpanelAPI constructor outside any try/catch — reachable host-app crash during getInstance(). The
    sibling MixpanelOptions.Builder.autocaptureOptions added in this PR does guard; mirror it.
  • AutocaptureManager.java:57,59mRageClickTracker and mDeadClickDetector are assigned only in the constructor but declared non-final ("Final fields for immutability").
  • ClickEvent.java:23 — Instance fields (x, y, elementId, …) omit the m prefix used by every other new class in this PR ("Member variables prefixed with 'm'").

try {
// Choose the appropriate monitor based on click type
UiChangeMonitor monitor;
if (clickEvent.isComposeClick()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many apps are mixed between compose and xml. Will the change monitor detect a click that goes from a compose view to an xml view since these monitors look to be specific for the view type that came from the click?

Or a better example would be if a compose view is added to an xml view. If a button came from xml but changed the contents inside compose (or vise versa) would we detect this?

Addresses Tyler's review comment about synchronization. Since all
public methods require the main thread (view/window operations),
the @mainthread annotation enforces the contract via lint. No lock
needed — the main thread is single-threaded. Updated Javadoc to
document idempotency. Added Tyler's and Greptile's review findings
to the PR review report.
contentHash already catches all structural changes — computeTreeHash
folds child hashes sequentially, so any node add/remove produces a
different hash. The ±5 threshold was redundant and caused false dead
clicks for 1-4 node changes (icon toggles, badges).

Also adds defensive comment to WindowSpy Curtains try-catch explaining
graceful degradation if reflection breaks on future Android versions.
The timestamp field was set in the constructor but never serialized
in toProperties() or read anywhere. RageClickTracker maintains its
own timestamps via ClickRecord. Removes field, constructor parameter,
and Builder setter.
Replace ExtractResult wrapper and ExtractionResult enum with a plain
@nullable ClickEvent.Builder return. NOT_FOUND with null builder is
equivalent to returning null — the wrapper added no information.
Also includes defensive comment on WindowSpy Curtains try-catch.
Nodes from AccessibilityNodeProvider.createAccessibilityNodeInfo()
(Compose path) are not sealed. Calling getParent() on them throws
IllegalStateException. Catch it and return the hierarchy built so
far — the ancestor chain is best-effort for the $elements property.
Comment thread analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureManager.java Outdated
Move rootNode/targetNode recycling into a finally block so both are
always recycled — even when findNodeAtPosition or extractFromNode
throws. Previously the catch block leaked both nodes, exhausting the
AccessibilityNodeInfo pool on pre-API-28 devices.
rahul-mixpanel and others added 2 commits July 6, 2026 18:11
Compose screen embeds an XML TextView updated by a Compose button;
XML screen embeds a ComposeView button that updates an XML TextView.
Both reproduce the false dead click from cross-framework UI changes.
ComposeUiChangeMonitor now also monitors the XML view hierarchy so that
a Compose button click which only modifies XML views (e.g. TextView text)
is correctly detected as a UI change instead of a false dead click.

Changes:
- ComposeUiChangeMonitor takes rootView, captures XML content hash baseline,
  checks both Compose semantic tree and XML hash at timeout
- Attach ViewTreeObserver listeners for early cancellation on XML layout/scroll
- Extract computeContentHash as shared method on DeadClickDetector
- Add instrumented test (AutocaptureMixedInstrumentedTest) and test activity
- Remove cross-framework section from demo XmlAutocaptureTestActivity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Jul 7, 2026

Copy link
Copy Markdown

SDK-27

@rahul-mixpanel rahul-mixpanel marked this pull request as ready for review July 7, 2026 07:34
rahul-mixpanel and others added 2 commits July 7, 2026 16:08
Reset downTime on ACTION_POINTER_DOWN and ACTION_CANCEL so the
existing downTime > 0 guard rejects the subsequent ACTION_UP.
Without this, two-finger gestures (pinch, rotate) produce a
spurious $mp_click when the first finger lifts.

Applied in both CurtainsHelper and AutocaptureManager fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Passing null to clickOptions(), rageClickOptions(), or deadClickOptions()
would set the backing field to null and NPE on the first isEnabled() call.
Added defensive null checks matching the existing MixpanelOptions.Builder pattern.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread analytics/src/main/java/com/mixpanel/android/autocapture/AutocaptureManager.java Outdated
rahul-mixpanel and others added 10 commits July 7, 2026 16:43
Convert screen coordinates to window-relative coordinates before
matching against Compose's getBoundsInWindow(). In split-screen or
multi-window the window origin is offset from screen (0,0), causing
node lookups to miss or pick the wrong element.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
attachRootViewTouchListener was a best-effort fallback for views without
a PhoneWindow (Toasts, PopupWindows). In practice it captured zero real
interactions: Toasts aren't clickable, and PopupWindow child views
consume touches before reaching the root listener. Removing it
eliminates an untracked listener leak and OnTouchListener clobbering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Combines countViews() and computeContentHash() into a single snapshotViewTree()
method that captures both view count and content hash in one pass, halving the
number of tree traversals per dead click check.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
nodeCount was computed but never used by hasChanged(), which only compares
contentHash. Removing it simplifies computeTreeHash to return a plain int
instead of int[], eliminating per-node array allocations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Tap duration filter: 800ms → 500ms to match iOS and platform long-press
  conventions
- Dead click snapshot: add class name, CompoundButton checked state, and
  enabled state to hash; add alpha > 0 visibility check

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Route all autocapture events through the Autocapture class instead of
direct track() calls. This ensures $mp_autocapture=true is set on all
click events (was missing before) and exposes public methods for host
apps to emit click events using ClickEvent + Builder.

Key changes:
- Add trackClick/trackRageClick/trackDeadClick accepting ClickEvent
- Make ClickEvent, Builder, and toProperties() public for host apps
- Require x, y, elementId in Builder constructor (mandatory fields)
- Add null checks on all public method parameters
- Replace EventEmitter interface with Autocapture reference
- Extract trackAutocaptureEvent/mergeProperties shared helpers
- Update AutocaptureManager and SemanticExtractor call sites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace squared distance comparison with Math.sqrt() to match the
Euclidean distance calculation used by iOS and JS SDKs. Functionally
equivalent but improves cross-platform code consistency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants