Blog Infos
Author
Published
Topics
, , , ,
Published

Photo by Markus Winkler on Unsplash

 

In the previous article, we implemented the first version of Friendly Captcha in a Jetpack Compose-based Android app.

In this post, we’re diving into Friendly Captcha 2.0 — showing how to set it up with a clean, production-ready architecture using Jetpack Compose and Hilt.

What’s New in Friendly Captcha 2.0?

Before we dive into the implementation, here’s a quick overview of how Friendly Captcha 2.0 improves compared to the earlier version:

  • Integration Flow:
    Version 1 required a more manual setup.
    Version 2.0 offers smoother SDK initialization, making integration easier.
  • Widget Experience:
    In Version 1, the widget sometimes introduced a slight delay.
    Version 2.0 renders faster and offers a more seamless user experience.
  • API Endpoint Support:
    Version 1 had a fixed endpoint.
    Version 2.0 supports customizable endpoints like "global" for better flexibility.
  • Privacy & Security:
    Version 1 already had a strong privacy focus.
    Version 2.0 improves on this with a smarter puzzle engine and the same zero-tracking approach.

TL;DR: Version 2.0 is faster, more flexible, and even more secure — while staying privacy-first and developer-friendly.

Step 1: Add the Friendly Captcha SDK

To get started, you need to add the Friendly Captcha 2.0 SDK to your project.

If you’re using version catalogs (libs.versions.toml), you can define the dependency like this:

# libs.versions.toml
friendlyCaptchaAndroid = "1.0.2"

friendly-captcha-android = {
  module = "com.friendlycaptcha.android:friendly-captcha-android",
  version.ref = "friendlyCaptchaAndroid"
}

Then include it in your app-level build.gradle.kts:

implementation(libs.friendly.captcha.android)

Note: Version 1.0.2 of the SDK is the current stable release that reflects the 2.0 features – naming-wise, it’s a bit confusing, but this is the right one.

Step 2: Create a Captcha Provider with Hilt

To avoid initializing the SDK directly in your UI or ViewModel, let’s create a simple provider class that encapsulates setup and exposes the captcha widget.

@Singleton
class FriendlyCaptchaProvider @Inject constructor(
    @ApplicationContext private val context: Context
) {
    private val sdk by lazy {
        FriendlyCaptchaSDK(
            context = context,
            apiEndpoint = "global" // or use "eu" / custom region if needed
        )
    }

    internal val widget by lazy {
        sdk.createWidget(
            sitekey = context.getString(R.string.friendly_captcha_api_key)
        )
    }
}

What’s happening here:

  • @Singleton ensures only one instance exists app-wide.
  • We’re using @Inject constructor so Hilt can provide this class automatically.
  • sdk.createWidget() creates an instance of FriendlyCaptchaWidgetHandle, which you’ll pass to your UI.

Note: Make sure your API key is stored in strings.xml:

 

<string name="friendly_captcha_api_key">YOUR_SITE_KEY_HERE</string>

 

Step 3: Inject the Captcha Widget into Your ViewModel

We’ll inject the FriendlyCaptchaProvider using Hilt, then expose the widget via a val so it can be passed into your composable screen.

@HiltViewModel
class RegistrationViewModel @Inject constructor(
    private val authService: AuthService,
    private val friendlyCaptchaProvider: FriendlyCaptchaProvider
) : ViewModel() {

    var state by mutableStateOf(RegistrationState())
        private set

    private val _effect = Channel<RegistrationEffect>()
    val effect = _effect.receiveAsFlow()

    // Expose the captcha widget
    val friendlyCaptchaWidget by lazy { friendlyCaptchaProvider.widget }

    fun onAction(action: RegistrationAction) {
        when (action) {
            is RegistrationAction.NextClick -> {
                // Extract captcha response
                val captchaResponse = action.data.captchaSolution

                // Optional: validate or show loading state
                state = state.copy(isLoading = true)

                // Simulate call to backend or your authService
                viewModelScope.launch {
                    val result = authService.registerWithCaptcha(captchaResponse)

                    state = state.copy(
                        isLoading = false,
                        authResult = result
                    )
                }
            }

            // Handle other actions if needed (e.g., field input, back press)
            else -> Unit
        }
    }
}

 

Why this matters:

  • The widget is now part of your UI state, not tightly coupled to your composable.
  • It’s initialized lazily, so it only gets created when needed.
  • Keeping it inside the ViewModel avoids unnecessary recompositions or view-specific issues.

If you’re following an MVI or MVVM pattern, this keeps your architecture clean and testable.

Now that your ViewModel exposes the FriendlyCaptchaWidgetHandle, you can pass it to your screen-level composable:

RegistrationScreen(
    state = viewModel.state,
    onAction = viewModel::onAction,
    captchaWidget = viewModel.friendlyCaptchaWidget
)

Then update your screen function to accept the captchaWidget as a parameter:

 

@Composable
fun RegistrationScreen(
    state: RegistrationState,
    onAction: (RegistrationAction) -> Unit,
    captchaWidget: FriendlyCaptchaWidgetHandle
) {
    when (state.currentStep) {
        RegistrationStep.CAPTCHA -> {
            CaptchaComponent(
                widget = captchaWidget,
                modifier = Modifier.padding(bottom = 40.dp),
                state = state,
                nextClickTrigger = nextClickTrigger.value,
                onNextButtonEnabled = { enabled ->
                    nextButtonEnabled.value = enabled
                },
                onAction = onAction,
                onResetNextTrigger = { nextClickTrigger.value = false }
            )
        }
        // other steps...
    }
}

 

What’s happening here:

  • The widget is now fully integrated with your screen.
  • You can control how it’s shown, when it resets, and how it feeds back to the form.
  • The CaptchaComponent will handle the actual rendering and logic.
Step 5: Create the CaptchaComponent Composable

This component is responsible for rendering the captcha widget inside your screen, handling its events, and triggering actions like form submission when the captcha is completed.

@Composable
fun CaptchaComponent(
    widget: FriendlyCaptchaWidgetHandle,
    modifier: Modifier = Modifier,
    state: RegistrationState,
    nextClickTrigger: Boolean?,
    onResetNextTrigger: () -> Unit,
    onNextButtonEnabled: (enabled: Boolean) -> Unit,
    onAction: (RegistrationAction) -> Unit
) {
    val captchaResponse = remember { mutableStateOf(state.registrationData.captchaSolution) }
    var buttonEnabled by remember { mutableStateOf(!captchaResponse.value.isNullOrEmpty()) }

    // React to external "Next" clicks
    LaunchedEffect(nextClickTrigger) {
        if (nextClickTrigger == true) {
            onAction(
                RegistrationAction.NextClick(
                    RegistrationDataTransfer.CaptchaDataTransfer(
                        captchaSolution = captchaResponse.value.orEmpty()
                    )
                )
            )
            onResetNextTrigger()
            widget.reset()
        }
    }

    // Enable or disable the Next button based on captcha state
    LaunchedEffect(buttonEnabled) {
        onNextButtonEnabled(buttonEnabled)
    }

    Captcha(
        modifier = modifier,
        widget = widget,
        onWidgetClick = { eventState, eventResponse ->
            when (eventState) {
                CaptchaEventState.COMPLETED.type -> {
                    captchaResponse.value = eventResponse
                    buttonEnabled = true
                }
                CaptchaEventState.EXPIRED.type,
                CaptchaEventState.ERROR.type,
                CaptchaEventState.RESET.type -> {
                    captchaResponse.value = eventResponse
                    buttonEnabled = false
                }
            }
        }
    )

    // Optional: show error message below the widget
    if (state.authResult.authStatus == AuthStatus.Error) {
        Text(
            modifier = Modifier
                .padding(20.dp)
                .fillMaxWidth(),
            text = state.authResult.errorMessage ?: stringResource(R.string.error),
            textAlign = TextAlign.Center,
            style = MaterialTheme.typography.titleSmall.copy(
                color = MaterialTheme.colorScheme.error
            )
        )
    }
}

What’s happening here:

  • Captcha state is tracked locally inside the composable using remember.
  • Events from the widget update the button state and captcha response.
  • It listens for an external trigger (nextClickTrigger) to submit the captcha.
  • After submission, the widget is reset and ready to use again.

This makes the component reusable, testable, and reactive to both internal and external events.

Step 6: Create the Captcha() Composable Using AndroidView

Jetpack Compose doesn’t support direct rendering of native views, so we use AndroidView to embed the Captcha SDK’s view inside our Compose layout.

 

@Composable
private fun Captcha(
    modifier: Modifier,
    widget: FriendlyCaptchaWidgetHandle,
    onWidgetClick: (eventState: String, eventResponse: String) -> Unit
) {
    // Set the listener for captcha events
    widget.setOnStateChangeListener { event ->
        onWidgetClick(event.state, event.response)
    }

    // Render the widget using AndroidView
    AndroidView(
        factory = { widget.view },
        modifier = modifier
            .fillMaxWidth()
            .height(72.dp)
    )
}

 

Why this is needed:

  • The SDK exposes the Captcha as a traditional Android View.
  • AndroidView is a bridge that allows you to embed classic Views inside Compose.
  • The setOnStateChangeListener lets you react to events like "completed""error", or "expired" — all of which are passed back to your CaptchaComponent.

Bonus: Define Captcha Event States for Clarity

For clean and readable logic, define the event states like this:

private enum class CaptchaEventState(val type: String) {
    COMPLETED("completed"),
    EXPIRED("expired"),
    ERROR("error"),
    RESET("reset")
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

With the advent of Android 15, edge-to-edge design has become the default configuration. Consequently, applications must be capable of accommodating window insets, including the system status bar and navigation bar, as well as supporting drawing…
Watch Video

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility GmbH

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android develop ...
Seven Principles Mob ...

Cutting-Edge-to-Edge in Android 15: Using Previews and Testing in Jetpack Compose to Manage Insets.

Timo Drick
Lead Android developer
Seven Principles Mobility ...

Jobs

Conclusion

In this article, we walked through how to integrate Friendly Captcha 2.0 into a Jetpack Compose-based Android app using Hilt and ViewModel-based state management.

Compared to the older version, 2.0 brings:

  • Faster widget rendering
  • Smarter, adaptive puzzle logic
  • Cleaner SDK initialization
  • Strong privacy and zero tracking

By combining Compose, Hilt, and a well-structured UI flow, we’ve created a seamless and secure captcha experience that’s ready for production.

If you haven’t already, you can check out Implementing Friendly Captcha in Jetpack Compose: A Ready-to-Use Solution, where we implemented the first version of Friendly Captcha and established the foundation for this setup.

Found this helpful?

If this article helped you get up and running with Friendly Captcha 2.0 — or saved you a few hours of trial and error — drop a few claps 👏 to help others discover it too!

Have questions, feedback, or your own approach to integrating captcha in Jetpack Compose?
💬 Leave a comment — I’d love to hear your thoughts.

If you’re into Android development, Jetpack Compose, or Kotlin tips, follow me here on Medium — I regularly share practical content based on real projects, not just docs.

This article was previously published on proandroiddev.com.

Menu