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 ofFriendlyCaptchaWidgetHandle
, 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.
Step 4: Pass the Widget to Your Composable Screen
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 yourCaptchaComponent
.
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
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.