diff --git a/README.md b/README.md index 92f72d4a..9dcbfe19 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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()` 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)` diff --git a/compose-sdk/build.gradle b/compose-sdk/build.gradle index f3e5af0c..6ae5e965 100644 --- a/compose-sdk/build.gradle +++ b/compose-sdk/build.gradle @@ -64,6 +64,7 @@ android { dependencies { api project(':sdk') implementation "androidx.compose.foundation:foundation:$compose_version" + testImplementation 'junit:junit:4.13.2' } project.afterEvaluate { diff --git a/compose-sdk/src/main/java/com/hcaptcha/sdk/journeylitics/ComposeAnalytics.kt b/compose-sdk/src/main/java/com/hcaptcha/sdk/journeylitics/ComposeAnalytics.kt new file mode 100644 index 00000000..eb54d993 --- /dev/null +++ b/compose-sdk/src/main/java/com/hcaptcha/sdk/journeylitics/ComposeAnalytics.kt @@ -0,0 +1,403 @@ +package com.hcaptcha.sdk.journeylitics + +import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.* +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.Density +import kotlin.math.abs +import java.util.AbstractMap +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex + +/** + * Helper function to create field map with screen name + */ +private fun createFieldMapWithScreen( + screenName: String?, + vararg pairs: Map.Entry +): MutableMap { + val eventData = MetaMapHelper.createMetaMap(*pairs).toMutableMap() + screenName?.let { eventData[FieldKey.SCREEN.getJsonKey()] = it } + return eventData +} + +/** + * Analytics screen wrapper that provides screen context + * Usage: AnalyticsScreen("HomeScreen") { content } + */ +@Composable +fun AnalyticsScreen( + screenName: String, + content: @Composable () -> Unit +) { + // Track screen appearance + DisposableEffect(screenName) { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.SCREEN, screenName), + AbstractMap.SimpleEntry(FieldKey.ID, "screen"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "appear"), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + + Journeylitics.emit( + EventKind.screen, + "Screen", + eventData + ) + + onDispose { + val disposeEventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.SCREEN, screenName), + AbstractMap.SimpleEntry(FieldKey.ID, "screen"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "disappear"), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + + Journeylitics.emit( + EventKind.screen, + "Screen", + disposeEventData + ) + } + } + + // Top-level pointer interceptor that catches ALL interactions from children + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var lastPosition = down.position + var isDragging = false + var isScrolling = false + var totalDragDistance = 0f + var pressed = true + + do { + val event = awaitPointerEvent() + val change = event.changes.first() + pressed = change.pressed + + when { + change.pressed && change.previousPressed -> { + // Movement detected + val currentPosition = change.position + val delta = currentPosition - lastPosition + val distance = kotlin.math.sqrt(delta.x * delta.x + delta.y * delta.y) + + if (distance > 2f) { // Small threshold to avoid noise + totalDragDistance += distance + + if (!isDragging) { + isDragging = true + // Determine if this is likely scrolling or dragging + val isLikelyScroll = distance > 10f && ( + abs(delta.x) > abs(delta.y) * 2 || // Horizontal scroll + abs(delta.y) > abs(delta.x) * 2 // Vertical scroll + ) + + if (isLikelyScroll) { + isScrolling = true + val direction = if (abs(delta.x) > abs(delta.y)) "horizontal" else "vertical" + val scrollDirection = when { + abs(delta.x) > abs(delta.y) -> if (delta.x > 0) "right" else "left" + else -> if (delta.y > 0) "down" else "up" + } + + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, "screen_interaction"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "scroll_start"), + AbstractMap.SimpleEntry(FieldKey.X, delta.x), + AbstractMap.SimpleEntry(FieldKey.Y, delta.y), + AbstractMap.SimpleEntry(FieldKey.VALUE, "$direction:$scrollDirection"), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "ScrollView", eventData) + } else { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, "screen_interaction"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "drag_start"), + AbstractMap.SimpleEntry(FieldKey.X, delta.x), + AbstractMap.SimpleEntry(FieldKey.Y, delta.y), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "View", eventData) + } + } else { + // Continue tracking movement + if (isScrolling) { + val direction = if (abs(delta.x) > abs(delta.y)) "horizontal" else "vertical" + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, "screen_interaction"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "scroll"), + AbstractMap.SimpleEntry(FieldKey.X, delta.x), + AbstractMap.SimpleEntry(FieldKey.Y, delta.y), + AbstractMap.SimpleEntry(FieldKey.VALUE, direction), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "ScrollView", eventData) + } else { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, "screen_interaction"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "drag"), + AbstractMap.SimpleEntry(FieldKey.X, delta.x), + AbstractMap.SimpleEntry(FieldKey.Y, delta.y), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "View", eventData) + } + } + } + + lastPosition = currentPosition + } + } + } while (pressed) + + // Gesture ended + if (isDragging) { + if (isScrolling) { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, "screen_interaction"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "scroll_end"), + AbstractMap.SimpleEntry(FieldKey.VALUE, totalDragDistance), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "ScrollView", eventData) + } else { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, "screen_interaction"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "drag_end"), + AbstractMap.SimpleEntry(FieldKey.VALUE, totalDragDistance), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "View", eventData) + } + } else { + // Simple tap - could be button, text field, or any other component + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, "screen_interaction"), + AbstractMap.SimpleEntry(FieldKey.ACTION, "tap"), + AbstractMap.SimpleEntry(FieldKey.X, down.position.x), + AbstractMap.SimpleEntry(FieldKey.Y, down.position.y), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.click, "View", eventData) + } + } + } + ) { + content() + } +} + +/** + * Universal analytics modifier that detects all interactions without interfering with existing logic + * Usage: Modifier.analytics("component_name", "ScreenName") + */ +@Composable +fun Modifier.analytics( + contentType: String, + screenName: String? = null, +): Modifier = composed( + inspectorInfo = debugInspectorInfo { + name = "analytics" + properties["contentType"] = contentType + properties["screenName"] = screenName + } +) { + this.then( + Modifier.pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var lastPosition = down.position + var isDragging = false + var isScrolling = false + var totalDragDistance = 0f + var pressed = true + + do { + val event = awaitPointerEvent() + val change = event.changes.first() + pressed = change.pressed + + when { + change.pressed && change.previousPressed -> { + // Movement detected + val currentPosition = change.position + val delta = currentPosition - lastPosition + val distance = kotlin.math.sqrt(delta.x * delta.x + delta.y * delta.y) + + if (distance > 2f) { // Small threshold to avoid noise + totalDragDistance += distance + + if (!isDragging) { + isDragging = true + // Determine if this is likely scrolling or dragging + val isLikelyScroll = distance > 10f && ( + abs(delta.x) > abs(delta.y) * 2 || // Horizontal scroll + abs(delta.y) > abs(delta.x) * 2 // Vertical scroll + ) + + if (isLikelyScroll) { + isScrolling = true + val direction = if (abs(delta.x) > abs(delta.y)) "horizontal" else "vertical" + val scrollDirection = when { + abs(delta.x) > abs(delta.y) -> if (delta.x > 0) "right" else "left" + else -> if (delta.y > 0) "down" else "up" + } + + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, contentType), + AbstractMap.SimpleEntry(FieldKey.ACTION, "scroll_start"), + AbstractMap.SimpleEntry(FieldKey.X, delta.x), + AbstractMap.SimpleEntry(FieldKey.Y, delta.y), + AbstractMap.SimpleEntry(FieldKey.VALUE, "$direction:$scrollDirection"), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "ScrollView", eventData) + } else { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, contentType), + AbstractMap.SimpleEntry(FieldKey.ACTION, "drag_start"), + AbstractMap.SimpleEntry(FieldKey.X, delta.x), + AbstractMap.SimpleEntry(FieldKey.Y, delta.y), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "View", eventData) + } + } else { + // Continue tracking movement + if (isScrolling) { + val direction = if (abs(delta.x) > abs(delta.y)) "horizontal" else "vertical" + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, contentType), + AbstractMap.SimpleEntry(FieldKey.ACTION, "scroll"), + AbstractMap.SimpleEntry(FieldKey.X, delta.x), + AbstractMap.SimpleEntry(FieldKey.Y, delta.y), + AbstractMap.SimpleEntry(FieldKey.VALUE, direction), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "ScrollView", eventData) + } else { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, contentType), + AbstractMap.SimpleEntry(FieldKey.ACTION, "drag"), + AbstractMap.SimpleEntry(FieldKey.X, delta.x), + AbstractMap.SimpleEntry(FieldKey.Y, delta.y), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "View", eventData) + } + } + } + + lastPosition = currentPosition + } + } + } while (pressed) + + // Gesture ended + if (isDragging) { + if (isScrolling) { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, contentType), + AbstractMap.SimpleEntry(FieldKey.ACTION, "scroll_end"), + AbstractMap.SimpleEntry(FieldKey.VALUE, totalDragDistance), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "ScrollView", eventData) + } else { + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, contentType), + AbstractMap.SimpleEntry(FieldKey.ACTION, "drag_end"), + AbstractMap.SimpleEntry(FieldKey.VALUE, totalDragDistance), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.drag, "View", eventData) + } + } else { + // Simple tap + val eventData = createFieldMapWithScreen( + screenName, + AbstractMap.SimpleEntry(FieldKey.ID, contentType), + AbstractMap.SimpleEntry(FieldKey.ACTION, "tap"), + AbstractMap.SimpleEntry(FieldKey.X, down.position.x), + AbstractMap.SimpleEntry(FieldKey.Y, down.position.y), + AbstractMap.SimpleEntry(FieldKey.COMPOSE, "true") + ) + Journeylitics.emit(EventKind.click, "View", eventData) + } + } + } + ) +} + +/** + * PressGestureScope implementation for the workaround + */ +private class PressGestureScopeImpl( + density: Density, +) : PressGestureScope, Density by density { + + private var isReleased = false + private var isCanceled = false + private val mutex = Mutex(locked = false) + + fun cancel() { + isCanceled = true + if (mutex.isLocked) { + mutex.unlock() + } + } + + fun release() { + isReleased = true + if (mutex.isLocked) { + mutex.unlock() + } + } + + suspend fun reset() { + mutex.lock() + isReleased = false + isCanceled = false + } + + override suspend fun awaitRelease() { + if (!tryAwaitRelease()) { + throw GestureCancellationException("The press gesture was canceled.") + } + } + + override suspend fun tryAwaitRelease(): Boolean { + if (!isReleased && !isCanceled) { + mutex.lock() + mutex.unlock() + } + return isReleased + } +} diff --git a/compose-sdk/src/test/java/com/hcaptcha/sdk/journeylitics/PressGestureScopeImplTest.kt b/compose-sdk/src/test/java/com/hcaptcha/sdk/journeylitics/PressGestureScopeImplTest.kt new file mode 100644 index 00000000..4cc63257 --- /dev/null +++ b/compose-sdk/src/test/java/com/hcaptcha/sdk/journeylitics/PressGestureScopeImplTest.kt @@ -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) + } +} diff --git a/example-app/src/main/java/com/hcaptcha/example/MainActivity.java b/example-app/src/main/java/com/hcaptcha/example/MainActivity.java index a225ce7d..0a298cfd 100644 --- a/example-app/src/main/java/com/hcaptcha/example/MainActivity.java +++ b/example-app/src/main/java/com/hcaptcha/example/MainActivity.java @@ -29,6 +29,7 @@ public class MainActivity extends AppCompatActivity { private CheckBox loading; private CheckBox disableHardwareAccel; private CheckBox themeDark; + private CheckBox userJourney; private TextView tokenTextView; private TextView errorTextView; private TextView phonePrefixInput; @@ -48,6 +49,7 @@ protected void onCreate(Bundle savedInstanceState) { loading = findViewById(R.id.loading); disableHardwareAccel = findViewById(R.id.hwAccel); themeDark = findViewById(R.id.themeDark); + userJourney = findViewById(R.id.userJourney); phonePrefixInput = findViewById(R.id.phonePrefix); phoneModeSwitch = findViewById(R.id.phoneModeSwitch); rqdataInput = findViewById(R.id.rqdataInput); @@ -91,6 +93,7 @@ private HCaptchaConfig getConfig() { .hideDialog(hideDialog.isChecked()) .theme(isDark ? HCaptchaTheme.DARK : HCaptchaTheme.LIGHT) .disableHardwareAcceleration(disableHardwareAccel.isChecked()) + .userJourney(userJourney.isChecked()) .tokenExpiration(10) .diagnosticLog(true) .retryPredicate((config, exception) -> exception.getHCaptchaError() == HCaptchaError.SESSION_TIMEOUT) diff --git a/example-app/src/main/res/layout/activity_main.xml b/example-app/src/main/res/layout/activity_main.xml index e0e75709..dfc5b0e2 100644 --- a/example-app/src/main/res/layout/activity_main.xml +++ b/example-app/src/main/res/layout/activity_main.xml @@ -11,61 +11,78 @@ android:paddingTop="60dp" tools:context=".MainActivity"> - - - - - - + android:layout_height="50dp" + android:fillViewport="false" + android:fadingEdgeLength="64dp" + android:requiresFadingEdge="horizontal"> - - + android:layout_height="match_parent" + android:orientation="horizontal" + android:gravity="center_vertical"> + + + + + + + + + + + + Hide Dialog Hit test Dark Theme + Journey Copy Token copied to clipboard No token to copy diff --git a/example-compose-app/src/main/java/com/hcaptcha/example/compose/ComposeActivity.kt b/example-compose-app/src/main/java/com/hcaptcha/example/compose/ComposeActivity.kt index b7fb83ad..7817f09c 100644 --- a/example-compose-app/src/main/java/com/hcaptcha/example/compose/ComposeActivity.kt +++ b/example-compose-app/src/main/java/com/hcaptcha/example/compose/ComposeActivity.kt @@ -21,6 +21,7 @@ import com.hcaptcha.sdk.HCaptchaConfig import com.hcaptcha.sdk.HCaptchaEvent import com.hcaptcha.sdk.HCaptchaResponse import com.hcaptcha.sdk.HCaptchaSize +import com.hcaptcha.sdk.journeylitics.AnalyticsScreen class ComposeActivity : ComponentActivity() { @@ -29,52 +30,58 @@ class ComposeActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - var hideDialog by remember { mutableStateOf(false) } - var captchaState by remember { mutableStateOf(CaptchaState.Idle) } - var text by remember { mutableStateOf("") } + AnalyticsScreen("ComposeActivity") { + var hideDialog by remember { mutableStateOf(false) } + var userJourney by remember { mutableStateOf(false) } + var captchaState by remember { mutableStateOf(CaptchaState.Idle) } + var text by remember { mutableStateOf("") } - val hCaptchaConfig = remember(hideDialog) { - HCaptchaConfig.builder() - .siteKey("10000000-ffff-ffff-ffff-000000000001") - .size(if (hideDialog) HCaptchaSize.INVISIBLE else HCaptchaSize.NORMAL) - .hideDialog(hideDialog) - .diagnosticLog(true) - .build() - } + val hCaptchaConfig = remember(hideDialog, userJourney) { + HCaptchaConfig.builder() + .siteKey("10000000-ffff-ffff-ffff-000000000001") + .size(if (hideDialog) HCaptchaSize.INVISIBLE else HCaptchaSize.NORMAL) + .hideDialog(hideDialog) + .userJourney(userJourney) + .diagnosticLog(true) + .build() + } - if (captchaState != CaptchaState.Idle) { - HCaptchaCompose(hCaptchaConfig) { result -> - val message = when (result) { - is HCaptchaResponse.Success -> { - captchaState = CaptchaState.Idle - "Success: ${result.token}" - } - is HCaptchaResponse.Failure -> { - captchaState = CaptchaState.Idle - "Failure: ${result.error.message}" - } - is HCaptchaResponse.Event -> { - if (result.event == HCaptchaEvent.Opened) { - captchaState = CaptchaState.Loaded + if (captchaState != CaptchaState.Idle) { + HCaptchaCompose(hCaptchaConfig) { result -> + val message = when (result) { + is HCaptchaResponse.Success -> { + captchaState = CaptchaState.Idle + "Success: ${result.token}" + } + is HCaptchaResponse.Failure -> { + captchaState = CaptchaState.Idle + "Failure: ${result.error.message}" + } + is HCaptchaResponse.Event -> { + if (result.event == HCaptchaEvent.Opened) { + captchaState = CaptchaState.Loaded + } + "Event: ${result.event}" } - "Event: ${result.event}" } + text += "\n${message}" + println(message) } - text += "\n${message}" - println(message) } - } - CaptchaControlUI( - hideDialog = hideDialog, - onHideDialogChanged = { hideDialog = it }, - text = text, - onVerifyClick = { - captchaState = CaptchaState.Started - text = "" - }, - showProgress = captchaState == CaptchaState.Started - ) + CaptchaControlUI( + hideDialog = hideDialog, + onHideDialogChanged = { hideDialog = it }, + userJourney = userJourney, + onUserJourneyChanged = { userJourney = it }, + text = text, + onVerifyClick = { + captchaState = CaptchaState.Started + text = "" + }, + showProgress = captchaState == CaptchaState.Started + ) + } } } @@ -82,6 +89,8 @@ class ComposeActivity : ComponentActivity() { private fun CaptchaControlUI( hideDialog: Boolean, onHideDialogChanged: (Boolean) -> Unit, + userJourney: Boolean, + onUserJourneyChanged: (Boolean) -> Unit, text: String, onVerifyClick: () -> Unit, showProgress: Boolean @@ -114,6 +123,14 @@ class ComposeActivity : ComponentActivity() { Text(text = "Hide Dialog (Passive Site Key)") } + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = userJourney, + onCheckedChange = onUserJourneyChanged + ) + Text(text = "User Journey") + } + Button( onClick = onVerifyClick, modifier = Modifier diff --git a/sdk/src/main/java/com/hcaptcha/sdk/HCaptcha.java b/sdk/src/main/java/com/hcaptcha/sdk/HCaptcha.java index 8ce547d6..d09f3ca0 100644 --- a/sdk/src/main/java/com/hcaptcha/sdk/HCaptcha.java +++ b/sdk/src/main/java/com/hcaptcha/sdk/HCaptcha.java @@ -9,8 +9,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 implements IHCaptcha { public static final String META_SITE_KEY = "com.hcaptcha.sdk.site-key"; @@ -26,6 +33,9 @@ public final class HCaptcha extends Task 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; @@ -85,6 +95,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)); } @@ -96,6 +109,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; + } + if (Boolean.TRUE.equals(inputConfig.getHideDialog())) { // Overwrite certain config values in case the dialog is hidden to avoid behavior collision this.config = inputConfig.toBuilder() @@ -175,6 +206,7 @@ public void reset() { captchaVerifier.reset(); captchaVerifier = null; } + stopEvents(); } @Override @@ -183,6 +215,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() { @@ -196,7 +240,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 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; } diff --git a/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaConfig.java b/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaConfig.java index 185b56cb..bd67b5d2 100644 --- a/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaConfig.java +++ b/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaConfig.java @@ -166,6 +166,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 */ diff --git a/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaVerifyParams.java b/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaVerifyParams.java index 1f9f5022..fb6f2b60 100644 --- a/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaVerifyParams.java +++ b/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaVerifyParams.java @@ -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; } diff --git a/sdk/src/main/java/com/hcaptcha/sdk/IHCaptcha.java b/sdk/src/main/java/com/hcaptcha/sdk/IHCaptcha.java index 8ae0c75e..ab19b9db 100644 --- a/sdk/src/main/java/com/hcaptcha/sdk/IHCaptcha.java +++ b/sdk/src/main/java/com/hcaptcha/sdk/IHCaptcha.java @@ -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(); } diff --git a/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/EventKind.java b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/EventKind.java new file mode 100644 index 00000000..821e71f8 --- /dev/null +++ b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/EventKind.java @@ -0,0 +1,26 @@ +package com.hcaptcha.sdk.journeylitics; + +import com.fasterxml.jackson.annotation.JsonValue; + +/** + * Event kinds observed by the library + */ +enum EventKind { + screen("screen"), + click("click"), + drag("drag"), + gesture("gesture"), + edit("edit"); + + private final String value; + + EventKind(String value) { + this.value = value; + } + + @JsonValue + String getValue() { + return value; + } +} + diff --git a/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/FieldKey.java b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/FieldKey.java new file mode 100644 index 00000000..755b098d --- /dev/null +++ b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/FieldKey.java @@ -0,0 +1,43 @@ +package com.hcaptcha.sdk.journeylitics; + +/** + * Serialization enum for consistent field mapping across platforms + * Maps readable field names to short JSON keys for minification + */ +enum FieldKey { + // Top-level fields (always present) + KIND("k"), + VIEW("v"), + TIMESTAMP("ts"), + META("m"), + + // Meta fields (nested under meta object) + ID("id"), + SCREEN("sc"), + ACTION("ac"), + VALUE("val"), + X("x"), + Y("y"), + INDEX("idx"), + SECTION("sct"), + ITEM("it"), + TARGET("tt"), + CONTROL("ct"), + GESTURE("gt"), + STATE("gs"), + TAPS("tap"), + CONTAINER_VIEW("cv"), + LENGTH("ln"), + COMPOSE("comp"); + + private final String jsonKey; + + FieldKey(String jsonKey) { + this.jsonKey = jsonKey; + } + + String getJsonKey() { + return jsonKey; + } +} + diff --git a/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/InMemorySink.java b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/InMemorySink.java new file mode 100644 index 00000000..b672fad3 --- /dev/null +++ b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/InMemorySink.java @@ -0,0 +1,69 @@ +package com.hcaptcha.sdk.journeylitics; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.locks.ReentrantReadWriteLock; + +/** + * In-memory sink that keeps the last 50 events for user journey tracking + */ +public class InMemorySink implements JLSink { + private static final int MAX_EVENTS = 50; + private final List events = new ArrayList<>(); + private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + + @Override + public void emit(JLEvent event) { + lock.writeLock().lock(); + try { + events.add(event); + // Keep only the last MAX_EVENTS events + if (events.size() > MAX_EVENTS) { + events.remove(0); + } + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Gets a snapshot of all current events and clears the list + * @return List of events (may be empty, never null) + */ + public List getAndClearEvents() { + lock.writeLock().lock(); + try { + final List snapshot = new ArrayList<>(events); + events.clear(); + return snapshot; + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Gets a snapshot of all current events without clearing the list. + * @return List of events (may be empty, never null) + */ + public List getEvents() { + lock.writeLock().lock(); + try { + return new ArrayList<>(events); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Clears all buffered events. + */ + public void clearEvents() { + lock.writeLock().lock(); + try { + events.clear(); + } finally { + lock.writeLock().unlock(); + } + } + +} diff --git a/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLConfig.java b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLConfig.java new file mode 100644 index 00000000..e47c1063 --- /dev/null +++ b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLConfig.java @@ -0,0 +1,79 @@ +package com.hcaptcha.sdk.journeylitics; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Runtime configuration + */ +public class JLConfig { + static final JLConfig DEFAULT = new JLConfig(); + + private final boolean enableScreens; + private final boolean enableClicks; + private final boolean enableToggles; + private final boolean enableSliders; + private final boolean enableScrolls; + private final boolean enableSearch; + private final boolean enableTextInputs; + private final List sinks; + + JLConfig(boolean enableScreens, boolean enableClicks, boolean enableToggles, + boolean enableSliders, boolean enableScrolls, boolean enableSearch, + boolean enableTextInputs, List sinks) { + this.enableScreens = enableScreens; + this.enableClicks = enableClicks; + this.enableToggles = enableToggles; + this.enableSliders = enableSliders; + this.enableScrolls = enableScrolls; + this.enableSearch = enableSearch; + this.enableTextInputs = enableTextInputs; + this.sinks = sinks; + } + + JLConfig() { + this(true, true, true, true, true, true, true, Collections.emptyList()); + } + + /** + * Creates a config with all features enabled and a single sink + * @param sink The sink to use for event emission + */ + public JLConfig(JLSink sink) { + this(true, true, true, true, true, true, true, Arrays.asList(sink)); + } + + boolean isEnableScreens() { + return enableScreens; + } + + boolean isEnableClicks() { + return enableClicks; + } + + boolean isEnableToggles() { + return enableToggles; + } + + boolean isEnableSliders() { + return enableSliders; + } + + boolean isEnableScrolls() { + return enableScrolls; + } + + boolean isEnableSearch() { + return enableSearch; + } + + boolean isEnableTextInputs() { + return enableTextInputs; + } + + List getSinks() { + return sinks; + } +} + diff --git a/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLEvent.java b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLEvent.java new file mode 100644 index 00000000..8effed91 --- /dev/null +++ b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLEvent.java @@ -0,0 +1,52 @@ +package com.hcaptcha.sdk.journeylitics; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class JLEvent { + private final long timestamp; + private final EventKind kind; + private final String view; + private final Object metadata; + + JLEvent(long timestamp, EventKind kind, String view, Object metadata) { + this.timestamp = timestamp; + this.kind = kind; + this.view = view; + this.metadata = metadata; + } + + JLEvent(EventKind kind, String view, Map metadata) { + this(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()), kind, view, metadata); + } + + JLEvent(EventKind kind, String view) { + this(kind, view, new java.util.HashMap<>()); + } + + /** + * UNIX timestamp (seconds). + */ + @JsonProperty("ts") + long getTimestamp() { + return timestamp; + } + + @JsonProperty("k") + EventKind getKind() { + return kind; + } + + @JsonProperty("v") + String getView() { + return view; + } + + @JsonProperty("m") + Object getMetadata() { + return metadata; + } +} + diff --git a/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLSink.java b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLSink.java new file mode 100644 index 00000000..7377268c --- /dev/null +++ b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/JLSink.java @@ -0,0 +1,10 @@ +package com.hcaptcha.sdk.journeylitics; + +/** + * Sink interface receiving events + */ +@FunctionalInterface +public interface JLSink { + void emit(JLEvent event); +} + diff --git a/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/Journeylitics.java b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/Journeylitics.java new file mode 100644 index 00000000..5a5507d1 --- /dev/null +++ b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/Journeylitics.java @@ -0,0 +1,651 @@ +package com.hcaptcha.sdk.journeylitics; + +import android.app.Activity; +import android.app.Application; +import android.os.Bundle; +import android.os.SystemClock; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.ViewTreeObserver; +import android.widget.AutoCompleteTextView; +import android.widget.Button; +import android.widget.CheckedTextView; +import android.widget.CompoundButton; +import android.widget.EditText; +import android.widget.HorizontalScrollView; +import android.widget.ImageButton; +import android.widget.MultiAutoCompleteTextView; +import android.widget.ScrollView; +import android.widget.SearchView; +import android.widget.SeekBar; +import android.widget.TextView; +import androidx.annotation.MainThread; + +import java.util.AbstractMap; +import java.util.HashMap; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Main entry + * @SuppressWarnings("PMD.GodClass") - This class intentionally handles multiple responsibilities + * for view instrumentation and event tracking + * @SuppressWarnings("PMD.UseUtilityClass") - This class maintains static state and lifecycle + */ +@SuppressWarnings({"PMD.GodClass", "PMD.UseUtilityClass"}) +public class Journeylitics { + private static final AtomicBoolean STARTED = new AtomicBoolean(false); + private static Application sApp; + private static JLConfig sConfig = JLConfig.DEFAULT; + private static final CopyOnWriteArrayList SINKS = new CopyOnWriteArrayList<>(); + private static final WeakHashMap INSTRUMENTED = new WeakHashMap<>(); + private static final WeakHashMap LAST_SCROLL_EVENT_AT = new WeakHashMap<>(); + private static final long SCROLL_EVENT_MIN_INTERVAL_MS = 250; + + private static final class ListenerLookup { + private final T listener; + private final boolean success; + + private ListenerLookup(T listener, boolean success) { + this.listener = listener; + this.success = success; + } + } + + private static final Application.ActivityLifecycleCallbacks LIFECYCLE_CALLBACKS = + new Application.ActivityLifecycleCallbacks() { + @Override + public void onActivityResumed(Activity activity) { + if (sConfig.isEnableScreens()) { + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.SCREEN, + activity.getClass().getSimpleName()), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "appear") + ); + emit(EventKind.screen, activity.getClass().getSimpleName(), meta); + } + // Install view hooks lazily when screen is visible + // Use a small delay to ensure the view hierarchy is fully established + final ViewGroup content = activity.findViewById(android.R.id.content); + if (content != null) { + content.post(new Runnable() { + @Override + public void run() { + instrumentActivityViews(activity); + } + }); + } + } + + @Override + public void onActivityPaused(Activity activity) { + if (sConfig.isEnableScreens()) { + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.SCREEN, + activity.getClass().getSimpleName()), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "disappear") + ); + emit(EventKind.screen, activity.getClass().getSimpleName(), meta); + } + } + + @Override + public void onActivityCreated(Activity activity, Bundle savedInstanceState) { + // No-op: not needed for analytics + } + + @Override + public void onActivityStarted(Activity activity) { + // No-op: not needed for analytics + } + + @Override + public void onActivityStopped(Activity activity) { + // No-op: not needed for analytics + } + + @Override + public void onActivitySaveInstanceState(Activity activity, Bundle outState) { + // No-op: not needed for analytics + } + + @Override + public void onActivityDestroyed(Activity activity) { + // No-op: not needed for analytics + } + }; + + @MainThread + public static void start(Activity activity) { + start(activity, JLConfig.DEFAULT); + } + + @MainThread + public static void start(Activity activity, JLConfig configuration) { + if (!STARTED.compareAndSet(false, true)) { + return; + } + sApp = activity.getApplication(); + sConfig = configuration; + SINKS.clear(); + SINKS.addAll(configuration.getSinks()); + sApp.registerActivityLifecycleCallbacks(LIFECYCLE_CALLBACKS); + instrumentViews(activity); + } + + public static boolean isStarted() { + return STARTED.get(); + } + + public static void addSink(JLSink sink) { + if (sink == null || SINKS.contains(sink)) { + return; + } + SINKS.add(sink); + } + + public static void removeSink(JLSink sink) { + SINKS.remove(sink); + } + + static void instrumentViews(Activity activity) { + instrumentActivityViews(activity); + } + + public static void emit(EventKind kind, String view, Map metadata) { + final JLEvent event = new JLEvent(kind, view, new HashMap<>(metadata)); + for (JLSink sink : SINKS) { + try { + sink.emit(event); + } catch (Exception e) { + // Ignore sink errors + } + } + } + + public static void emit(EventKind kind, String view) { + emit(kind, view, new HashMap<>()); + } + + // --- View instrumentation ------------------------------------------------------------- + + private static void instrumentActivityViews(Activity activity) { + final ViewGroup root = activity.findViewById(android.R.id.content); + if (root == null) { + return; + } + traverseAndHook(root); + } + + private static void traverseAndHook(View view) { + if (Boolean.TRUE.equals(INSTRUMENTED.put(view, true))) { + return; + } + + if (view instanceof Button) { + hookClick(view); + } else if (view instanceof ImageButton) { + hookClick(view); + } else if (view instanceof EditText) { + hookTextInput((EditText) view); + } else if (view instanceof AutoCompleteTextView) { + hookTextInput((EditText) view); + } else if (view instanceof MultiAutoCompleteTextView) { + hookTextInput((EditText) view); + } else if (view instanceof CheckedTextView) { + hookCheckedTextView((CheckedTextView) view); + } else if (view instanceof TextView && view.isClickable()) { + hookClick(view); + } else if (view instanceof CompoundButton) { + hookToggle((CompoundButton) view); + } else if (view instanceof SeekBar) { + hookSeek((SeekBar) view); + } else if (view instanceof SearchView) { + hookSearch((SearchView) view); + } else if (view instanceof ScrollView) { + hookScrollView(view); + } else if (view instanceof HorizontalScrollView) { + hookScrollView(view); + } + + if (view instanceof ViewGroup) { + final ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + traverseAndHook(viewGroup.getChildAt(i)); + } + } + } + + private static String viewIdName(View view) { + final int id = view.getId(); + if (id != View.NO_ID) { + try { + return view.getResources().getResourceEntryName(id); + } catch (Exception e) { + return String.valueOf(id); + } + } + + // Try to get meaningful identifier from various sources + if (view instanceof EditText) { + final EditText editText = (EditText) view; + if (editText.getHint() != null && !editText.getHint().toString().isEmpty()) { + return editText.getHint().toString(); + } + if (editText.getTag() != null && !editText.getTag().toString().isEmpty()) { + return editText.getTag().toString(); + } + if (editText.getContentDescription() != null && !editText.getContentDescription().toString().isEmpty()) { + return editText.getContentDescription().toString(); + } + } else if (view instanceof TextView) { + final TextView textView = (TextView) view; + if (textView.getHint() != null && !textView.getHint().toString().isEmpty()) { + return textView.getHint().toString(); + } + if (textView.getTag() != null && !textView.getTag().toString().isEmpty()) { + return textView.getTag().toString(); + } + if (textView.getContentDescription() != null && !textView.getContentDescription().toString().isEmpty()) { + return textView.getContentDescription().toString(); + } + } else if (view instanceof Button) { + final Button button = (Button) view; + if (button.getText() != null && !button.getText().toString().isEmpty()) { + return button.getText().toString(); + } + if (button.getTag() != null && !button.getTag().toString().isEmpty()) { + return button.getTag().toString(); + } + if (button.getContentDescription() != null && !button.getContentDescription().toString().isEmpty()) { + return button.getContentDescription().toString(); + } + } + + // Fallback to class name with position if available + final ViewParent parent = view.getParent(); + if (parent instanceof ViewGroup) { + final ViewGroup parentGroup = (ViewGroup) parent; + final int index = parentGroup.indexOfChild(view); + if (index >= 0) { + return view.getClass().getSimpleName() + "_" + index; + } + } + + final int magicNumber = 1000; + return view.getClass().getSimpleName() + "_" + (view.hashCode() % magicNumber); + } + + private static void hookClick(View view) { + final ListenerLookup lookup = getOnClickListener(view); + if (!lookup.success) { + return; + } + final View.OnClickListener original = lookup.listener; + view.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View clickedView) { + if (original != null) { + original.onClick(clickedView); + } + if (sConfig.isEnableClicks()) { + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(clickedView)) + ); + emit(EventKind.click, clickedView.getClass().getSimpleName(), meta); + } + } + }); + } + + private static void hookCheckedTextView(CheckedTextView checkedTextView) { + // CheckedTextView doesn't have an OnCheckedChangeListener like CompoundButton, + // so we hook the click listener and track the checked state change + final ListenerLookup lookup = getOnClickListener(checkedTextView); + if (!lookup.success) { + return; + } + final View.OnClickListener original = lookup.listener; + checkedTextView.setOnClickListener(new View.OnClickListener() { + @Override + public void onClick(View view) { + if (original != null) { + original.onClick(view); + } + if (sConfig.isEnableToggles() && view instanceof CheckedTextView) { + final CheckedTextView checkedView = (CheckedTextView) view; + // Post to get the state after the click has been processed + view.post(new Runnable() { + @Override + public void run() { + final boolean isChecked = checkedView.isChecked(); + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(checkedView)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "toggle"), + new AbstractMap.SimpleEntry<>(FieldKey.VALUE, String.valueOf(isChecked)) + ); + emit(EventKind.click, checkedView.getClass().getSimpleName(), meta); + } + }); + } + } + }); + } + + private static void hookToggle(CompoundButton compoundButton) { + final ListenerLookup lookup = + getOnCheckedChangeListener(compoundButton); + if (!lookup.success) { + return; + } + final CompoundButton.OnCheckedChangeListener original = lookup.listener; + compoundButton.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() { + @Override + public void onCheckedChanged(CompoundButton button, boolean isChecked) { + if (original != null) { + original.onCheckedChanged(button, isChecked); + } + if (sConfig.isEnableToggles()) { + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(button)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "toggle"), + new AbstractMap.SimpleEntry<>(FieldKey.VALUE, String.valueOf(isChecked)) + ); + emit(EventKind.click, button.getClass().getSimpleName(), meta); + } + } + }); + } + + private static void hookSeek(SeekBar seekBar) { + final ListenerLookup lookup = + getOnSeekBarChangeListener(seekBar); + if (!lookup.success) { + return; + } + final SeekBar.OnSeekBarChangeListener original = lookup.listener; + seekBar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + @Override + public void onProgressChanged(SeekBar bar, int progress, boolean fromUser) { + if (original != null) { + original.onProgressChanged(bar, progress, fromUser); + } + if (fromUser && sConfig.isEnableSliders()) { + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(seekBar)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "change"), + new AbstractMap.SimpleEntry<>(FieldKey.VALUE, progress) + ); + emit(EventKind.drag, seekBar.getClass().getSimpleName(), meta); + } + } + + @Override + public void onStartTrackingTouch(SeekBar bar) { + if (original != null) { + original.onStartTrackingTouch(bar); + } + } + + @Override + public void onStopTrackingTouch(SeekBar bar) { + if (original != null) { + original.onStopTrackingTouch(bar); + } + } + }); + } + + private static void hookSearch(SearchView searchView) { + final ListenerLookup lookup = + getOnQueryTextListener(searchView); + if (!lookup.success) { + return; + } + final SearchView.OnQueryTextListener original = lookup.listener; + searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() { + @Override + public boolean onQueryTextSubmit(String query) { + final boolean handled = original != null && original.onQueryTextSubmit(query); + if (sConfig.isEnableSearch()) { + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(searchView)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "submit"), + new AbstractMap.SimpleEntry<>(FieldKey.VALUE, + query != null ? query.length() : 0) + ); + emit(EventKind.click, searchView.getClass().getSimpleName(), meta); + } + return handled; + } + + @Override + public boolean onQueryTextChange(String newText) { + final boolean handled = original != null && original.onQueryTextChange(newText); + if (sConfig.isEnableSearch()) { + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(searchView)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "change") + ); + emit(EventKind.edit, searchView.getClass().getSimpleName(), meta); + } + return handled; + } + }); + } + + private static void hookTextInput(EditText editText) { + if (!sConfig.isEnableTextInputs()) { + return; + } + + // Track text changes + editText.addTextChangedListener(new android.text.TextWatcher() { + private int previousLength = editText.getText() != null ? editText.getText().length() : 0; + + @Override + public void beforeTextChanged(CharSequence sequence, int start, int count, int after) { + // Not needed for analytics + } + + @Override + public void onTextChanged(CharSequence sequence, int start, int before, int count) { + // Not needed for analytics + } + + @Override + public void afterTextChanged(android.text.Editable editable) { + final int currentLength = editable != null ? editable.length() : 0; + if (currentLength != previousLength) { + final int delta = currentLength - previousLength; + final String action = delta > 0 ? "add" : "remove"; + + // Use 'edit' event kind for text input changes + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(editText)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, action), + new AbstractMap.SimpleEntry<>(FieldKey.VALUE, delta) + ); + emit(EventKind.edit, editText.getClass().getSimpleName(), meta); + + previousLength = currentLength; + } + } + }); + + // Track focus changes while preserving existing listener + final ListenerLookup focusLookup = + getOnFocusChangeListener(editText); + if (focusLookup.success) { + final View.OnFocusChangeListener originalFocusListener = focusLookup.listener; + editText.setOnFocusChangeListener(new View.OnFocusChangeListener() { + @Override + public void onFocusChange(View view, boolean hasFocus) { + if (originalFocusListener != null) { + originalFocusListener.onFocusChange(view, hasFocus); + } + final String action = hasFocus ? "focus" : "blur"; + + // Use 'edit' event kind for text input focus changes + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(editText)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, action) + ); + emit(EventKind.edit, editText.getClass().getSimpleName(), meta); + } + }); + } + + // Track text input submission + final ListenerLookup editorLookup = + getOnEditorActionListener(editText); + if (editorLookup.success) { + final TextView.OnEditorActionListener originalEditorActionListener = + editorLookup.listener; + editText.setOnEditorActionListener(new TextView.OnEditorActionListener() { + @Override + public boolean onEditorAction(TextView textView, int actionId, + android.view.KeyEvent event) { + final boolean handled = originalEditorActionListener != null + && originalEditorActionListener.onEditorAction( + textView, actionId, event); + if (actionId == android.view.inputmethod.EditorInfo.IME_ACTION_DONE + || actionId == android.view.inputmethod.EditorInfo.IME_ACTION_GO + || actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEARCH + || actionId == android.view.inputmethod.EditorInfo.IME_ACTION_SEND + || actionId == android.view.inputmethod.EditorInfo.IME_ACTION_NEXT) { + + // Use 'edit' event kind for text input submission + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(editText)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "submit"), + new AbstractMap.SimpleEntry<>(FieldKey.VALUE, + editText.getText() != null ? editText.getText().length() : 0) + ); + emit(EventKind.edit, editText.getClass().getSimpleName(), meta); + } + return handled; + } + }); + } + } + + 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 meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, viewIdName(scrollView)), + new AbstractMap.SimpleEntry<>(FieldKey.ACTION, "scroll") + ); + emit(EventKind.drag, scrollView.getClass().getSimpleName(), meta); + } + } + }); + } + + // ------- Reflection helpers to preserve existing listeners ----------------------------- + + private static ListenerLookup getOnClickListener(View view) { + try { + // Try to get the listener through reflection, but handle failures gracefully + final java.lang.reflect.Field infoField = View.class.getDeclaredField("mListenerInfo"); + infoField.setAccessible(true); + final Object info = infoField.get(view); + if (info == null) { + return new ListenerLookup<>(null, true); + } + + // Use a more robust approach to get the listener info class + Class listenerInfoClass; + try { + listenerInfoClass = Class.forName("android.view.View$ListenerInfo"); + } catch (ClassNotFoundException e) { + // Fallback: try to get the class from the info object + listenerInfoClass = info.getClass(); + } + + final java.lang.reflect.Field field = + listenerInfoClass.getDeclaredField("mOnClickListener"); + field.setAccessible(true); + return new ListenerLookup<>((View.OnClickListener) field.get(info), true); + } catch (Throwable e) { + return new ListenerLookup<>(null, false); + } + } + + private static ListenerLookup + getOnCheckedChangeListener(CompoundButton view) { + try { + final java.lang.reflect.Field field = + CompoundButton.class.getDeclaredField("mOnCheckedChangeListener"); + field.setAccessible(true); + return new ListenerLookup<>( + (CompoundButton.OnCheckedChangeListener) field.get(view), true); + } catch (Throwable e) { + return new ListenerLookup<>(null, false); + } + } + + private static ListenerLookup + getOnSeekBarChangeListener(SeekBar view) { + try { + final java.lang.reflect.Field field = + SeekBar.class.getDeclaredField("mOnSeekBarChangeListener"); + field.setAccessible(true); + return new ListenerLookup<>( + (SeekBar.OnSeekBarChangeListener) field.get(view), true); + } catch (Throwable e) { + return new ListenerLookup<>(null, false); + } + } + + private static ListenerLookup + getOnQueryTextListener(SearchView view) { + try { + final java.lang.reflect.Field field = + SearchView.class.getDeclaredField("mOnQueryChangeListener"); + field.setAccessible(true); + return new ListenerLookup<>( + (SearchView.OnQueryTextListener) field.get(view), true); + } catch (Throwable e) { + return new ListenerLookup<>(null, false); + } + } + + private static ListenerLookup + getOnFocusChangeListener(View view) { + try { + final java.lang.reflect.Field field = + View.class.getDeclaredField("mOnFocusChangeListener"); + field.setAccessible(true); + return new ListenerLookup<>((View.OnFocusChangeListener) field.get(view), true); + } catch (Throwable e) { + return new ListenerLookup<>(null, false); + } + } + + private static ListenerLookup + getOnEditorActionListener(EditText view) { + try { + final java.lang.reflect.Field field = + EditText.class.getDeclaredField("mEditorActionListener"); + field.setAccessible(true); + return new ListenerLookup<>((TextView.OnEditorActionListener) field.get(view), true); + } catch (Throwable e) { + return new ListenerLookup<>(null, false); + } + } +} diff --git a/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/MetaMapHelper.java b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/MetaMapHelper.java new file mode 100644 index 00000000..23df39dc --- /dev/null +++ b/sdk/src/main/java/com/hcaptcha/sdk/journeylitics/MetaMapHelper.java @@ -0,0 +1,33 @@ +package com.hcaptcha.sdk.journeylitics; + +import java.util.HashMap; +import java.util.Map; + +/** + * Helper class to create meta field mappings (O(1) - no iteration) + */ +@SuppressWarnings("PMD.UseUtilityClass") +final class MetaMapHelper { + private MetaMapHelper() { + // Utility class - prevent instantiation + } + + @SafeVarargs + static Map createMetaMap(Map.Entry... pairs) { + final Map map = new HashMap<>(); + for (Map.Entry pair : pairs) { + map.put(pair.getKey().getJsonKey(), pair.getValue()); + } + return map; + } + + @SafeVarargs + static Map createFieldMap(Map.Entry... pairs) { + final Map map = new HashMap<>(); + for (Map.Entry pair : pairs) { + map.put(pair.getKey(), pair.getValue()); + } + return map; + } +} + diff --git a/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaJSInterfaceTest.java b/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaJSInterfaceTest.java index 7c721d90..fb4a2a64 100644 --- a/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaJSInterfaceTest.java +++ b/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaJSInterfaceTest.java @@ -78,6 +78,7 @@ public void full_config_serialization() throws JSONException { .tokenExpiration(timeout) .diagnosticLog(true) .disableHardwareAcceleration(false) + .userJourney(true) .build(); final HCaptchaJSInterface jsInterface = new HCaptchaJSInterface(handler, config, captchaVerifier); @@ -102,6 +103,7 @@ public void full_config_serialization() throws JSONException { expected.put("tokenExpiration", timeout); expected.put("diagnosticLog", true); expected.put("disableHardwareAcceleration", false); + expected.put("userJourney", true); JSONAssert.assertEquals(jsInterface.getConfig(), expected, false); } @@ -145,6 +147,7 @@ public void subset_config_serialization() throws JSONException { expected.put("tokenExpiration", defaultTimeout); expected.put("diagnosticLog", false); expected.put("disableHardwareAcceleration", true); + expected.put("userJourney", false); JSONAssert.assertEquals(jsInterface.getConfig(), expected, false); } diff --git a/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaJourneyLifecycleTest.java b/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaJourneyLifecycleTest.java new file mode 100644 index 00000000..0e472b6f --- /dev/null +++ b/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaJourneyLifecycleTest.java @@ -0,0 +1,245 @@ +package com.hcaptcha.sdk; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import android.app.Activity; +import android.app.Application; +import android.content.Context; +import androidx.fragment.app.FragmentActivity; + +import com.hcaptcha.sdk.journeylitics.InMemorySink; +import com.hcaptcha.sdk.journeylitics.Journeylitics; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +public class HCaptchaJourneyLifecycleTest { + private static final int LISTENER_ARG_INDEX = 3; + + private static void resetJourneyliticsState() throws Exception { + final Field startedField = Journeylitics.class.getDeclaredField("STARTED"); + startedField.setAccessible(true); + ((AtomicBoolean) startedField.get(null)).set(false); + + final Field appField = Journeylitics.class.getDeclaredField("sApp"); + appField.setAccessible(true); + appField.set(null, null); + + final Field defaultConfigField = Class.forName("com.hcaptcha.sdk.journeylitics.JLConfig") + .getDeclaredField("DEFAULT"); + defaultConfigField.setAccessible(true); + final Field configField = Journeylitics.class.getDeclaredField("sConfig"); + configField.setAccessible(true); + configField.set(null, defaultConfigField.get(null)); + + final Field sinksField = Journeylitics.class.getDeclaredField("SINKS"); + sinksField.setAccessible(true); + ((List) sinksField.get(null)).clear(); + + final Field instrumentedField = Journeylitics.class.getDeclaredField("INSTRUMENTED"); + instrumentedField.setAccessible(true); + ((Map) instrumentedField.get(null)).clear(); + + final Field scrollEventField = Journeylitics.class.getDeclaredField("LAST_SCROLL_EVENT_AT"); + scrollEventField.setAccessible(true); + ((Map) scrollEventField.get(null)).clear(); + } + + private static FragmentActivity createActivity() { + final FragmentActivity activity = mock(FragmentActivity.class); + final Application app = mock(Application.class); + when(app.getApplicationContext()).thenReturn(app); + when(activity.getApplicationContext()).thenReturn(app); + when(activity.getApplication()).thenReturn(app); + return activity; + } + + private static InMemorySink getJourneySink(HCaptcha hcaptchaClient) throws Exception { + final Field journeySinkField = HCaptcha.class.getDeclaredField("journeySink"); + journeySinkField.setAccessible(true); + return (InMemorySink) journeySinkField.get(hcaptchaClient); + } + + @SuppressWarnings("unchecked") + private static void emitClickEvent() throws Exception { + final Class eventKindClass = Class.forName("com.hcaptcha.sdk.journeylitics.EventKind"); + final Object clickKind = Enum.valueOf((Class) eventKindClass, "click"); + final Method emit = Journeylitics.class.getMethod( + "emit", eventKindClass, String.class, Map.class); + emit.invoke(null, clickKind, "Button", new HashMap()); + } + + @Test + public void clearOnSuccess_clearsBufferedEvents() throws Exception { + resetJourneyliticsState(); + final FragmentActivity activity = createActivity(); + final HCaptchaConfig config = HCaptchaConfig.builder() + .siteKey(HCaptchaConfigTest.MOCK_SITE_KEY) + .userJourney(true) + .build(); + + final AtomicReference listenerRef = new AtomicReference<>(); + final HCaptchaDialogFragment verifier = mock(HCaptchaDialogFragment.class); + doAnswer(invocation -> { + final HCaptchaStateListener listener = listenerRef.get(); + if (listener != null) { + listener.onSuccess("token-1"); + } + return null; + }).when(verifier).startVerification(any(Activity.class), any(HCaptchaVerifyParams.class)); + + final HCaptcha hCaptcha = HCaptcha.getClient(activity); + try (MockedStatic dialogFragmentMock = mockStatic(HCaptchaDialogFragment.class)) { + dialogFragmentMock + .when(() -> HCaptchaDialogFragment.newInstance( + any(Context.class), + any(HCaptchaConfig.class), + any(HCaptchaInternalConfig.class), + any(HCaptchaStateListener.class))) + .thenAnswer(invocation -> { + listenerRef.set(invocation.getArgument(LISTENER_ARG_INDEX)); + return verifier; + }); + hCaptcha.setup(config); + } + + emitClickEvent(); + final InMemorySink sink = getJourneySink(hCaptcha); + assertNotNull(sink); + assertEquals(1, sink.getEvents().size()); + + hCaptcha.verifyWithHCaptcha(); + assertTrue(sink.getEvents().isEmpty()); + } + + @Test + public void sequence_withoutDestroy_keepsTrackingBetweenTokens() throws Exception { + resetJourneyliticsState(); + final FragmentActivity activity = createActivity(); + final HCaptchaConfig config = HCaptchaConfig.builder() + .siteKey(HCaptchaConfigTest.MOCK_SITE_KEY) + .userJourney(true) + .build(); + + final AtomicReference listenerRef = new AtomicReference<>(); + final HCaptchaDialogFragment verifier = mock(HCaptchaDialogFragment.class); + doAnswer(invocation -> { + final HCaptchaStateListener listener = listenerRef.get(); + if (listener != null) { + listener.onSuccess("token"); + } + return null; + }).when(verifier).startVerification(any(Activity.class), any(HCaptchaVerifyParams.class)); + + final HCaptcha hCaptcha = HCaptcha.getClient(activity); + try (MockedStatic dialogFragmentMock = mockStatic(HCaptchaDialogFragment.class)) { + dialogFragmentMock + .when(() -> HCaptchaDialogFragment.newInstance( + any(Context.class), + any(HCaptchaConfig.class), + any(HCaptchaInternalConfig.class), + any(HCaptchaStateListener.class))) + .thenAnswer(invocation -> { + listenerRef.set(invocation.getArgument(LISTENER_ARG_INDEX)); + return verifier; + }); + hCaptcha.setup(config); + } + + final InMemorySink sink = getJourneySink(hCaptcha); + assertNotNull(sink); + + emitClickEvent(); + assertEquals(1, sink.getEvents().size()); + hCaptcha.verifyWithHCaptcha(); + assertTrue(sink.getEvents().isEmpty()); + + emitClickEvent(); + assertEquals(1, sink.getEvents().size()); + hCaptcha.verifyWithHCaptcha(); + assertTrue(sink.getEvents().isEmpty()); + } + + @Test + public void sequence_withDestroy_requiresRestartToCaptureEvents() throws Exception { + resetJourneyliticsState(); + final FragmentActivity activity = createActivity(); + final HCaptchaConfig config = HCaptchaConfig.builder() + .siteKey(HCaptchaConfigTest.MOCK_SITE_KEY) + .userJourney(true) + .build(); + + final AtomicReference listenerRef = new AtomicReference<>(); + final HCaptchaDialogFragment verifier = mock(HCaptchaDialogFragment.class); + doAnswer(invocation -> { + final HCaptchaStateListener listener = listenerRef.get(); + if (listener != null) { + listener.onSuccess("token"); + } + return null; + }).when(verifier).startVerification(any(Activity.class), any(HCaptchaVerifyParams.class)); + + final HCaptcha hCaptcha = HCaptcha.getClient(activity); + try (MockedStatic dialogFragmentMock = mockStatic(HCaptchaDialogFragment.class)) { + dialogFragmentMock + .when(() -> HCaptchaDialogFragment.newInstance( + any(Context.class), + any(HCaptchaConfig.class), + any(HCaptchaInternalConfig.class), + any(HCaptchaStateListener.class))) + .thenAnswer(invocation -> { + listenerRef.set(invocation.getArgument(LISTENER_ARG_INDEX)); + return verifier; + }); + hCaptcha.setup(config); + } + + final InMemorySink firstSink = getJourneySink(hCaptcha); + assertNotNull(firstSink); + emitClickEvent(); + assertEquals(1, firstSink.getEvents().size()); + hCaptcha.verifyWithHCaptcha(); + assertTrue(firstSink.getEvents().isEmpty()); + + hCaptcha.destroy(); + emitClickEvent(); + assertTrue(firstSink.getEvents().isEmpty()); + + try (MockedStatic dialogFragmentMock = mockStatic(HCaptchaDialogFragment.class)) { + dialogFragmentMock + .when(() -> HCaptchaDialogFragment.newInstance( + any(Context.class), + any(HCaptchaConfig.class), + any(HCaptchaInternalConfig.class), + any(HCaptchaStateListener.class))) + .thenAnswer(invocation -> { + listenerRef.set(invocation.getArgument(LISTENER_ARG_INDEX)); + return verifier; + }); + hCaptcha.setup(config); + } + + final InMemorySink secondSink = getJourneySink(hCaptcha); + assertNotNull(secondSink); + assertNotSame(firstSink, secondSink); + emitClickEvent(); + assertEquals(1, secondSink.getEvents().size()); + hCaptcha.verifyWithHCaptcha(); + assertTrue(secondSink.getEvents().isEmpty()); + } +} diff --git a/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaStopEventsTest.java b/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaStopEventsTest.java new file mode 100644 index 00000000..3e139c81 --- /dev/null +++ b/sdk/src/test/java/com/hcaptcha/sdk/HCaptchaStopEventsTest.java @@ -0,0 +1,94 @@ +package com.hcaptcha.sdk; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import android.app.Application; +import android.content.Context; +import androidx.fragment.app.FragmentActivity; + +import com.hcaptcha.sdk.journeylitics.Journeylitics; +import org.junit.Test; +import org.mockito.MockedStatic; + +import java.lang.reflect.Field; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class HCaptchaStopEventsTest { + + private static void resetJourneyliticsState() throws Exception { + final Field startedField = Journeylitics.class.getDeclaredField("STARTED"); + startedField.setAccessible(true); + ((AtomicBoolean) startedField.get(null)).set(false); + + final Field appField = Journeylitics.class.getDeclaredField("sApp"); + appField.setAccessible(true); + appField.set(null, null); + + final Field defaultConfigField = Class.forName("com.hcaptcha.sdk.journeylitics.JLConfig") + .getDeclaredField("DEFAULT"); + defaultConfigField.setAccessible(true); + final Field configField = Journeylitics.class.getDeclaredField("sConfig"); + configField.setAccessible(true); + configField.set(null, defaultConfigField.get(null)); + + final Field sinksField = Journeylitics.class.getDeclaredField("SINKS"); + sinksField.setAccessible(true); + ((List) sinksField.get(null)).clear(); + + final Field instrumentedField = Journeylitics.class.getDeclaredField("INSTRUMENTED"); + instrumentedField.setAccessible(true); + ((Map) instrumentedField.get(null)).clear(); + + final Field scrollEventField = Journeylitics.class.getDeclaredField("LAST_SCROLL_EVENT_AT"); + scrollEventField.setAccessible(true); + ((Map) scrollEventField.get(null)).clear(); + } + + @Test + public void stopEvents_unregisters_sink() throws Exception { + resetJourneyliticsState(); + + final FragmentActivity activity = mock(FragmentActivity.class); + final Application app = mock(Application.class); + when(app.getApplicationContext()).thenReturn(app); + when(activity.getApplicationContext()).thenReturn(app); + when(activity.getApplication()).thenReturn(app); + + final HCaptchaConfig config = HCaptchaConfig.builder() + .siteKey(HCaptchaConfigTest.MOCK_SITE_KEY) + .userJourney(true) + .build(); + + final HCaptcha hCaptcha = HCaptcha.getClient(activity); + try (MockedStatic dialogFragmentMock = mockStatic(HCaptchaDialogFragment.class)) { + dialogFragmentMock + .when(() -> HCaptchaDialogFragment.newInstance( + any(Context.class), + any(HCaptchaConfig.class), + any(HCaptchaInternalConfig.class), + any(HCaptchaStateListener.class))) + .thenReturn(mock(HCaptchaDialogFragment.class)); + + hCaptcha.setup(config); + } + + final Field sinksField = Journeylitics.class.getDeclaredField("SINKS"); + sinksField.setAccessible(true); + final List sinks = (List) sinksField.get(null); + assertEquals(1, sinks.size()); + + hCaptcha.stopEvents(); + + assertEquals(0, sinks.size()); + final Field journeySinkField = HCaptcha.class.getDeclaredField("journeySink"); + journeySinkField.setAccessible(true); + assertNull(journeySinkField.get(hCaptcha)); + } +} diff --git a/sdk/src/test/java/com/hcaptcha/sdk/journeylitics/JourneyliticsTest.java b/sdk/src/test/java/com/hcaptcha/sdk/journeylitics/JourneyliticsTest.java new file mode 100644 index 00000000..64f2c2ac --- /dev/null +++ b/sdk/src/test/java/com/hcaptcha/sdk/journeylitics/JourneyliticsTest.java @@ -0,0 +1,123 @@ +package com.hcaptcha.sdk.journeylitics; + +import android.app.Activity; +import android.app.Application; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; +import java.util.AbstractMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class JourneyliticsTest { + private static final String VIEW_BUTTON = "Button"; + private final List captured = new ArrayList<>(); + + private static void resetJourneyliticsState() throws Exception { + final Field startedField = Journeylitics.class.getDeclaredField("STARTED"); + startedField.setAccessible(true); + ((AtomicBoolean) startedField.get(null)).set(false); + + final Field appField = Journeylitics.class.getDeclaredField("sApp"); + appField.setAccessible(true); + appField.set(null, null); + + final Field configField = Journeylitics.class.getDeclaredField("sConfig"); + configField.setAccessible(true); + configField.set(null, JLConfig.DEFAULT); + + final Field sinksField = Journeylitics.class.getDeclaredField("SINKS"); + sinksField.setAccessible(true); + ((List) sinksField.get(null)).clear(); + + final Field instrumentedField = Journeylitics.class.getDeclaredField("INSTRUMENTED"); + instrumentedField.setAccessible(true); + ((Map) instrumentedField.get(null)).clear(); + + final Field scrollEventField = Journeylitics.class.getDeclaredField("LAST_SCROLL_EVENT_AT"); + scrollEventField.setAccessible(true); + ((Map) scrollEventField.get(null)).clear(); + } + + @Test + public void sink_emits_event() { + // This test verifies that the sink pipeline works correctly + final int before = captured.size(); + final JLSink sink = new JLSink() { + @Override + public void emit(JLEvent event) { + captured.add(event); + } + }; + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, "test-button") + ); + sink.emit(new JLEvent(EventKind.click, VIEW_BUTTON, new HashMap<>(meta))); + Assert.assertTrue(captured.size() == before + 1); + } + + @Test + public void metadata_serializes_as_string() throws Exception { + final ObjectMapper mapper = new ObjectMapper(); + final JLEvent event = new JLEvent(1234567890L, EventKind.click, VIEW_BUTTON, "meta-string"); + final JsonNode node = mapper.readTree(mapper.writeValueAsString(event)); + Assert.assertEquals("meta-string", node.get("m").asText()); + } + + @Test + public void metadata_serializes_as_object() throws Exception { + final ObjectMapper mapper = new ObjectMapper(); + final Map meta = new HashMap<>(); + meta.put("id", "submit-btn"); + final JLEvent event = new JLEvent(1234567890L, EventKind.click, VIEW_BUTTON, meta); + final JsonNode node = mapper.readTree(mapper.writeValueAsString(event)); + Assert.assertEquals("submit-btn", node.get("m").get("id").asText()); + } + + @Test + public void addSink_afterStart_receivesEvents() throws Exception { + resetJourneyliticsState(); + final Application app = Mockito.mock(Application.class); + final Activity activity = Mockito.mock(Activity.class); + Mockito.when(activity.getApplication()).thenReturn(app); + Journeylitics.start(activity, new JLConfig()); + + final List events = new ArrayList<>(); + final JLSink sink = events::add; + Journeylitics.addSink(sink); + + final Map meta = MetaMapHelper.createMetaMap( + new AbstractMap.SimpleEntry<>(FieldKey.ID, "test-button") + ); + Journeylitics.emit(EventKind.click, VIEW_BUTTON, meta); + Assert.assertEquals(1, events.size()); + } + + @Test + public void removeSink_stopsEvents() throws Exception { + resetJourneyliticsState(); + final Application app = Mockito.mock(Application.class); + final Activity activity = Mockito.mock(Activity.class); + Mockito.when(activity.getApplication()).thenReturn(app); + Journeylitics.start(activity, new JLConfig()); + + final List events = new ArrayList<>(); + final JLSink sink = events::add; + Journeylitics.addSink(sink); + + Journeylitics.emit(EventKind.click, VIEW_BUTTON, new HashMap<>()); + Assert.assertEquals(1, events.size()); + + Journeylitics.removeSink(sink); + Journeylitics.emit(EventKind.click, VIEW_BUTTON, new HashMap<>()); + Assert.assertEquals(1, events.size()); + } +}