Skip to content
Merged
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 @@ -362,6 +362,7 @@ The following list contains configuration properties to allows customization of
| `hideDialog` | Boolean | No | False | **DEPRECATED**. Use `renderMode=HEADLESS` instead. |
| `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 @@ -444,6 +445,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()` and `destroy()` stop tracking and 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
1 change: 1 addition & 0 deletions compose-sdk/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ android {
dependencies {
api project(':sdk')
implementation "androidx.compose.foundation:foundation:$compose_version"
testImplementation 'junit:junit:4.13.2'
}

project.afterEvaluate {
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package com.hcaptcha.sdk.journeylitics

import androidx.compose.ui.unit.Density
import org.junit.Test

class PressGestureScopeImplTest {
private fun newInstance(): Any {
val classNames = listOf(
"com.hcaptcha.sdk.journeylitics.PressGestureScopeImpl",
"com.hcaptcha.sdk.journeylitics.ComposeAnalyticsKt\$PressGestureScopeImpl"
)
var clazz: Class<*>? = null
for (name in classNames) {
try {
clazz = Class.forName(name)
break
} catch (_: ClassNotFoundException) {
// Try next name
}
}
requireNotNull(clazz) { "PressGestureScopeImpl class not found" }

val ctor = clazz.getDeclaredConstructor(Density::class.java)
ctor.isAccessible = true
val density = object : Density {
override val density: Float = 1f
override val fontScale: Float = 1f
}
return ctor.newInstance(density)
}

@Test
fun cancelWithoutReset_doesNotThrow() {
val instance = newInstance()
val cancel = instance.javaClass.getDeclaredMethod("cancel")
cancel.isAccessible = true
cancel.invoke(instance)
}

@Test
fun releaseWithoutReset_doesNotThrow() {
val instance = newInstance()
val release = instance.javaClass.getDeclaredMethod("release")
release.isAccessible = true
release.invoke(instance)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public class MainActivity extends AppCompatActivity {
private CheckBox loading;
private CheckBox disableHardwareAccel;
private CheckBox themeDark;
private CheckBox userJourney;
private CheckBox webViewDebug;
private TextInputLayout phoneInputLayout;
private TextInputEditText phonePrefixInput;
Expand All @@ -80,7 +81,6 @@ public class MainActivity extends AppCompatActivity {
private TabLayout topTabs;
private int selectedTab = TAB_CONFIGURATION;
private final List<Integer> visibleTopTabs = new ArrayList<>();

private HCaptcha hCaptcha;
private HCaptchaTokenResponse tokenResponse;
private String lastToken;
Expand All @@ -102,6 +102,7 @@ protected void onCreate(final Bundle savedInstanceState) {
loading = findViewById(R.id.loading);
disableHardwareAccel = findViewById(R.id.hwAccel);
themeDark = findViewById(R.id.themeDark);
userJourney = findViewById(R.id.userJourney);
webViewDebug = findViewById(R.id.webViewDebug);
phoneInputLayout = findViewById(R.id.phoneInputLayout);
phonePrefixInput = findViewById(R.id.phonePrefix);
Expand Down Expand Up @@ -335,6 +336,7 @@ private HCaptchaConfig getConfig() {
.disableHardwareAcceleration(disableHardwareAccel.isChecked())
.theme(isDark ? HCaptchaTheme.DARK : HCaptchaTheme.LIGHT)
.tokenExpiration(120)
.userJourney(userJourney.isChecked())
.diagnosticLog(true)
.retryPredicate((config, exception) -> exception.getHCaptchaError() == HCaptchaError.SESSION_TIMEOUT)
.build();
Expand Down
24 changes: 24 additions & 0 deletions example-app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,30 @@
android:text="" />
</LinearLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:gravity="center_vertical"
android:orientation="horizontal">

<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/user_journey"
android:textColor="@color/hc_text_primary" />

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

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
Expand Down
1 change: 1 addition & 0 deletions example-app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
<string name="hide_dialog">Hide Dialog</string>
<string name="hit_test">Hit test</string>
<string name="theme_dark">Dark Theme</string>
<string name="user_journey">Journey</string>
<string name="phone_mode">Phone mode</string>
<string name="phone_mode_prefix">Use phone prefix</string>
<string name="phone_mode_number">Use full phone number</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import com.hcaptcha.sdk.HCaptchaEvent
import com.hcaptcha.sdk.HCaptchaRenderMode
import com.hcaptcha.sdk.HCaptchaResponse
import com.hcaptcha.sdk.HCaptchaSize
import com.hcaptcha.sdk.journeylitics.AnalyticsScreen
import com.hcaptcha.sdk.HCaptchaTheme
import java.text.SimpleDateFormat
import java.util.Date
Expand All @@ -78,15 +79,16 @@ class ComposeActivity : ComponentActivity() {
super.onCreate(savedInstanceState)
actionBar?.hide()
setContent {
val compactTypography = Typography(
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontSize = 13.sp),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 12.sp),
titleMedium = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp)
)
MaterialTheme(typography = compactTypography) {
val context = LocalContext.current
val formatter = remember { SimpleDateFormat("HH:mm:ss", Locale.US) }
AnalyticsScreen("ComposeActivity") {
val compactTypography = Typography(
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontSize = 13.sp),
bodyMedium = MaterialTheme.typography.bodyMedium.copy(fontSize = 12.sp),
titleMedium = MaterialTheme.typography.titleMedium.copy(fontSize = 15.sp),
labelMedium = MaterialTheme.typography.labelMedium.copy(fontSize = 11.sp)
)
MaterialTheme(typography = compactTypography) {
val context = LocalContext.current
val formatter = remember { SimpleDateFormat("HH:mm:ss", Locale.US) }

val hcBgApp = Color(0xFF211238)
val hcSurface = Color(0xFFFFFFFF)
Expand All @@ -107,6 +109,7 @@ class ComposeActivity : ComponentActivity() {
var webDebugEnabled by remember { mutableStateOf(false) }
var disableHwAccel by remember { mutableStateOf(true) }
var darkTheme by remember { mutableStateOf(false) }
var userJourney by remember { mutableStateOf(false) }

var resultState by remember { mutableStateOf(ResultState.Idle) }
var resultMessage by remember { mutableStateOf("-") }
Expand Down Expand Up @@ -157,6 +160,7 @@ class ComposeActivity : ComponentActivity() {
loadingForConfig,
disableHwAccel,
darkTheme,
userJourney,
captchaRenderKey
) {
HCaptchaConfig.builder()
Expand All @@ -166,6 +170,7 @@ class ComposeActivity : ComponentActivity() {
.loading(loadingForConfig)
.disableHardwareAcceleration(disableHwAccel)
.theme(if (darkTheme) HCaptchaTheme.DARK else HCaptchaTheme.LIGHT)
.userJourney(userJourney)
.tokenExpiration(120)
.diagnosticLog(true)
.build()
Expand Down Expand Up @@ -353,6 +358,7 @@ class ComposeActivity : ComponentActivity() {
LabeledCheckbox("Web Debug", webDebugEnabled) { webDebugEnabled = it }
LabeledCheckbox("Disable HW Accel", disableHwAccel) { disableHwAccel = it }
LabeledCheckbox("Dark Theme", darkTheme) { darkTheme = it }
LabeledCheckbox("User Journey", userJourney) { userJourney = it }
}
}
}
Expand Down Expand Up @@ -529,7 +535,6 @@ class ComposeActivity : ComponentActivity() {
}
}
}

if (captchaVisible && selectedMode != HCaptchaRenderMode.EMBEDDED) {
HCaptchaCompose(config = config) { response ->
when (response) {
Expand Down Expand Up @@ -559,6 +564,7 @@ class ComposeActivity : ComponentActivity() {
}
}
}
}
}
}
}
Expand Down
61 changes: 60 additions & 1 deletion sdk/src/main/java/com/hcaptcha/sdk/HCaptcha.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@
import androidx.annotation.Nullable;
import androidx.fragment.app.FragmentActivity;

import com.hcaptcha.sdk.journeylitics.InMemorySink;
import com.hcaptcha.sdk.journeylitics.JLConfig;
import com.hcaptcha.sdk.journeylitics.JLEvent;
import com.hcaptcha.sdk.journeylitics.Journeylitics;
import com.hcaptcha.sdk.tasks.Task;

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 All @@ -30,6 +37,9 @@ public final class HCaptcha extends Task<HCaptchaTokenResponse> implements IHCap
@NonNull
private final HCaptchaInternalConfig internalConfig;

@Nullable
private InMemorySink journeySink;

private HCaptcha(@NonNull final Activity activity, @NonNull final HCaptchaInternalConfig internalConfig) {
this.activity = activity;
this.internalConfig = internalConfig;
Expand Down Expand Up @@ -94,6 +104,9 @@ void onOpen() {
@Override
void onSuccess(final String token) {
HCaptchaLog.d("HCaptcha.onSuccess");
if (journeySink != null) {
journeySink.clearEvents();
}
scheduleCaptchaExpired(inputConfig.getTokenExpiration());
setResult(new HCaptchaTokenResponse(token, HCaptcha.this.handler));
}
Expand All @@ -105,6 +118,24 @@ void onFailure(final HCaptchaException exception) {
}
};
try {
// Initialize or disable user journey tracking if enabled/disabled.
if (Boolean.TRUE.equals(inputConfig.getUserJourney())) {
if (journeySink == null) {
journeySink = new InMemorySink();
}
if (Journeylitics.isStarted()) {
Journeylitics.addSink(journeySink);
} else {
final JLConfig jlConfig = new JLConfig(journeySink);
Journeylitics.start(activity, jlConfig);
}
} else if (journeySink != null) {
if (Journeylitics.isStarted()) {
Journeylitics.removeSink(journeySink);
}
journeySink = null;
}

final boolean headlessMode = inputConfig.isHeadlessMode();
if (Boolean.TRUE.equals(inputConfig.getHideDialog())) {
HCaptchaLog.w("Config.hideDialog is deprecated. Use renderMode=HEADLESS instead.");
Expand Down Expand Up @@ -209,6 +240,7 @@ public void reset() {
captchaVerifier.reset();
captchaVerifier = null;
}
stopEvents();
}

@Override
Expand All @@ -217,6 +249,18 @@ public void destroy() {
captchaVerifier.destroy();
captchaVerifier = null;
}
stopEvents();
}

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

private HCaptcha startVerification() {
Expand All @@ -230,7 +274,22 @@ private HCaptcha startVerification(@Nullable final HCaptchaVerifyParams verifyPa
if (captchaVerifier == null) {
setException(new HCaptchaException(HCaptchaError.ERROR));
} else {
captchaVerifier.startVerification(activity, verifyParams);
HCaptchaVerifyParams finalParams = verifyParams;
if (journeySink != null) {
final List<JLEvent> events = journeySink.getEvents();
if (!events.isEmpty()) {
if (finalParams == null) {
finalParams = HCaptchaVerifyParams.builder()
.userJourney(events)
.build();
} else {
finalParams = finalParams.toBuilder()
.userJourney(events)
.build();
}
}
}
captchaVerifier.startVerification(activity, finalParams);
}
return this;
}
Expand Down
6 changes: 6 additions & 0 deletions sdk/src/main/java/com/hcaptcha/sdk/HCaptchaConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ public class HCaptchaConfig implements Serializable {
@NonNull
private Boolean disableHardwareAcceleration = true;

/**
* Enable / Disable user journey analytics tracking.
*/
@Builder.Default
private Boolean userJourney = false;

/**
* @deprecated use {@link #getJsSrc()} getter instead
*/
Expand Down
7 changes: 7 additions & 0 deletions sdk/src/main/java/com/hcaptcha/sdk/HCaptchaVerifyParams.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,11 @@ public class HCaptchaVerifyParams implements Serializable {
*/
@JsonProperty("rqdata")
private String rqdata;

/**
* Optional user journey events to be passed to hCaptcha.
* Contains user interaction events for analytics.
*/
@JsonProperty("userjourney")
private Object userJourney;
}
Loading
Loading