Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,7 @@ The following list contains configuration properties to allows customization of
| `hideDialog` | Boolean | No | False | To be used in combination with a passive sitekey when no user interaction is required. See Enterprise docs. |
| `tokenExpiration` | long | No | 120 | hCaptcha token expiration timeout (seconds). |
| `diagnosticLog` | Boolean | No | False | Emit detailed console logs for debugging |
| `userJourney` | Boolean | No | False |Enable user journeys; SDK captures interaction events (Enterprise). |
| `disableHardwareAcceleration` | Boolean | No | True | Disable WebView hardware acceleration |

## Verify Params
Expand Down Expand Up @@ -389,6 +390,45 @@ The `retryPredicate` is part of `HCaptchaConfig` that may get persist during app
So pay attention to this aspect and make sure that `retryPredicate` is serializable to avoid
`android.os.BadParcelableException` in run-time.

### User Journeys (Enterprise)

You can optionally enable user journeys to send recent interaction events alongside your verification request.

```java
HCaptchaConfig config = HCaptchaConfig.builder()
.siteKey("10000000-ffff-ffff-ffff-000000000001")
.userJourney(true)
.build();

HCaptcha.getClient(this)
.setup(config)
.verifyWithHCaptcha();
```

For Jetpack Compose, wrap your screen (and optionally specific components) to capture interactions:

```kotlin
import com.hcaptcha.sdk.journeylitics.AnalyticsScreen
import com.hcaptcha.sdk.journeylitics.analytics

AnalyticsScreen("Checkout") {
Button(
modifier = Modifier.analytics("submit_button", "Checkout")
) {
Text("Submit")
}
}
```

Notes:

- Events start at `setup()` (including pre-warm) and continue until the same `HCaptcha` instance is reconfigured with `userJourney(false)`. `reset()` does not clear the event buffer.
- Only the most recent 50 events are kept; they are cleared after `verifyWithHCaptcha` starts.
- Events include component identifiers, coordinates, and text-length deltas (never full text). This should avoid collecting any personal or sensitive data, but ensure your component IDs do not include any PII.
- If you set `HCaptchaVerifyParams.userJourney` manually while `userJourney` is enabled, the SDK may overwrite it with captured events.
- Use `stopEvents()` if you need to unregister the user-journey sink, for example before reusing a client without analytics.


## Debugging Tips

Useful error messages are often rendered on the hCaptcha checkbox. For example, if the sitekey within your config is invalid, you'll see a message there. To quickly debug your local instance using this tool, set `.size(HCaptchaSize.NORMAL)`
Expand Down
129 changes: 69 additions & 60 deletions example-app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,69 +11,78 @@
android:paddingTop="60dp"
tools:context=".MainActivity">

<com.google.android.flexbox.FlexboxLayout
<HorizontalScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:flexDirection="row"
app:flexWrap="wrap"
app:alignItems="center">
<Spinner
android:id="@+id/sizes"
android:theme="@style/ThemeOverlay.AppCompat.Light"
android:spinnerMode="dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginVertical="12dp" />
<CheckBox
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="true"
android:text="@string/loading"
style="@style/CheckBoxText" />
<CheckBox
android:id="@+id/hide_dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="false"
android:text="@string/hide_dialog"
style="@style/CheckBoxText" />
<CheckBox
android:id="@+id/webViewDebug"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="false"
android:text="@string/web_view_debug"
style="@style/CheckBoxText" />
<CheckBox
android:id="@+id/hwAccel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="true"
android:text="@string/hw_accel"
style="@style/CheckBoxText" />
android:layout_height="50dp"
android:fillViewport="false"
android:fadingEdgeLength="64dp"
android:requiresFadingEdge="horizontal">

<CheckBox
android:id="@+id/themeDark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="false"
android:text="@string/theme_dark"
style="@style/CheckBoxText" />
<CheckBox
android:id="@+id/userJourney"
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="false"
android:text="@string/user_journey"
style="@style/CheckBoxText" />
</com.google.android.flexbox.FlexboxLayout>
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical">

<Spinner
android:id="@+id/sizes"
android:theme="@style/ThemeOverlay.AppCompat.Light"
android:spinnerMode="dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
<CheckBox
android:id="@+id/loading"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:minHeight="48dp"
android:checked="true"
android:text="@string/loading"
style="@style/CheckBoxText" />
<CheckBox
android:id="@+id/hide_dialog"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="false"
android:text="@string/hide_dialog"
style="@style/CheckBoxText" />
<CheckBox
android:id="@+id/webViewDebug"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="false"
android:text="@string/web_view_debug"
style="@style/CheckBoxText" />
<CheckBox
android:id="@+id/hwAccel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="true"
android:text="@string/hw_accel"
style="@style/CheckBoxText" />

<CheckBox
android:id="@+id/themeDark"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="false"
android:text="@string/theme_dark"
style="@style/CheckBoxText" />
<CheckBox
android:id="@+id/userJourney"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:checked="false"
android:text="@string/user_journey"
style="@style/CheckBoxText" />
</LinearLayout>

</HorizontalScrollView>

<!-- Phone input row -->
<LinearLayout
Expand Down
13 changes: 13 additions & 0 deletions sdk/src/main/java/com/hcaptcha/sdk/HCaptcha.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import java.util.List;

@SuppressWarnings("PMD.GodClass")
public final class HCaptcha extends Task<HCaptchaTokenResponse> implements IHCaptcha {
public static final String META_SITE_KEY = "com.hcaptcha.sdk.site-key";

Expand Down Expand Up @@ -202,6 +203,7 @@ public void reset() {
captchaVerifier.reset();
captchaVerifier = null;
}
stopEvents();
}

@Override
Expand All @@ -210,6 +212,17 @@ public void destroy() {
captchaVerifier.destroy();
captchaVerifier = null;
}
stopEvents();
}

@Override
public void stopEvents() {
if (journeySink != null) {
if (Journeylitics.isStarted()) {
Journeylitics.removeSink(journeySink);
}
journeySink = null;
}
}

private HCaptcha startVerification() {
Expand Down
5 changes: 5 additions & 0 deletions sdk/src/main/java/com/hcaptcha/sdk/IHCaptcha.java
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,9 @@ public interface IHCaptcha {
* Use this in Activity/Fragment teardown to prevent retaining the host context.
*/
void destroy();

/**
* Stop user journey event tracking for this client instance.
*/
void stopEvents();
}
46 changes: 18 additions & 28 deletions sdk/src/main/java/com/hcaptcha/sdk/journeylitics/Journeylitics.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

import android.app.Activity;
import android.app.Application;
import android.content.Context;
import android.os.Bundle;
import android.os.SystemClock;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
Expand Down Expand Up @@ -42,6 +42,8 @@ public class Journeylitics {
private static JLConfig sConfig = JLConfig.DEFAULT;
private static final CopyOnWriteArrayList<JLSink> SINKS = new CopyOnWriteArrayList<>();
private static final WeakHashMap<View, Boolean> INSTRUMENTED = new WeakHashMap<>();
private static final WeakHashMap<View, Long> LAST_SCROLL_EVENT_AT = new WeakHashMap<>();
private static final long SCROLL_EVENT_MIN_INTERVAL_MS = 250;

private static final class ListenerLookup<T> {
private final T listener;
Expand Down Expand Up @@ -117,24 +119,21 @@ public void onActivityDestroyed(Activity activity) {
};

@MainThread
public static void start(Context context) {
start(context, JLConfig.DEFAULT);
public static void start(Activity activity) {
start(activity, JLConfig.DEFAULT);
}

@MainThread
public static void start(Context context, JLConfig configuration) {
public static void start(Activity activity, JLConfig configuration) {
if (!STARTED.compareAndSet(false, true)) {
return;
}
final Context appCtx = context.getApplicationContext();
if (!(appCtx instanceof Application)) {
throw new IllegalArgumentException("context must be Application or provide applicationContext");
}
sApp = (Application) appCtx;
sApp = activity.getApplication();
sConfig = configuration;
SINKS.clear();
SINKS.addAll(configuration.getSinks());
sApp.registerActivityLifecycleCallbacks(LIFECYCLE_CALLBACKS);
instrumentViews(activity);
}

public static boolean isStarted() {
Expand Down Expand Up @@ -207,9 +206,9 @@ private static void traverseAndHook(View view) {
} else if (view instanceof SearchView) {
hookSearch((SearchView) view);
} else if (view instanceof ScrollView) {
hookScrollView((ScrollView) view);
hookScrollView(view);
} else if (view instanceof HorizontalScrollView) {
hookHScrollView((HorizontalScrollView) view);
hookScrollView(view);
}

if (view instanceof ViewGroup) {
Expand Down Expand Up @@ -534,28 +533,19 @@ public boolean onEditorAction(TextView textView, int actionId,
}
}

private static void hookScrollView(ScrollView scrollView) {
scrollView.getViewTreeObserver().addOnScrollChangedListener(
new ViewTreeObserver.OnScrollChangedListener() {
@Override
public void onScrollChanged() {
if (sConfig.isEnableScrolls()) {
final Map<String, Object> meta = MetaMapHelper.createMetaMap(
new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(scrollView)),
new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "scroll")
);
emit(EventKind.drag, scrollView.getClass().getSimpleName(), meta);
}
}
});
}

private static void hookHScrollView(HorizontalScrollView scrollView) {
private static void hookScrollView(View scrollView) {
scrollView.getViewTreeObserver().addOnScrollChangedListener(
new ViewTreeObserver.OnScrollChangedListener() {
@Override
public void onScrollChanged() {
if (sConfig.isEnableScrolls()) {
final long now = SystemClock.uptimeMillis();
final Long lastEventAt = LAST_SCROLL_EVENT_AT.get(scrollView);
if (lastEventAt != null
&& now - lastEventAt < SCROLL_EVENT_MIN_INTERVAL_MS) {
return;
}
LAST_SCROLL_EVENT_AT.put(scrollView, now);
final Map<String, Object> meta = MetaMapHelper.createMetaMap(
new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(scrollView)),
new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "scroll")
Expand Down
Loading