Build an Android Biometric Turbo Module

Build a Kotlin Turbo Module for Android biometric prompts in React Native.

Introduction

30 Second Summary

A React Native screen can look complete while its Android biometric feature remains unreachable. The hidden native boundary only works when every layer follows the same contract.

In this project, you will build an Android authentication lab that calls a custom Kotlin Turbo Native Module from React Native. The app launches the system biometric prompt before returning the native result to a TypeScript status panel.

What You'll Build

Picture your status panel changing to Authentication succeeded after Android's system prompt sends a valid fingerprint result through your Kotlin module.

By the end of this project, you'll have:

  • A biometric availability check that tells you whether strong authentication is ready before you open the system prompt.
  • A native result panel that makes each boundary state visible from missing registration through success or a terminal error.
  • A typed call path you can explain from TypeScript through JSI. You can show where Codegen bindings hand control to Kotlin. You can show how Promise settlement updates the screen.
  • Secret Mission: Deliver a 60 to 90 second architecture defense plus a 100 to 150 word portfolio write-up that names one Turbo Module trade-off.

Are there any prerequisites?

Strong React Native plus TypeScript experience is required. Previous Kotlin experience is optional because constrained TODOs introduce the syntax you need.

You'll also need a Windows computer with Android Studio, the Android SDK, the Android Emulator, Node.js, an editor, and a physical Android device.

Before We Start

At this checkpoint, you are committing to build a React Native Android authentication lab that traces a typed TypeScript call through TurboModuleRegistry, JSI, Codegen-generated NativeBiometricAuthSpec bindings, Kotlin, AndroidX Biometric callbacks, and Promise-based React state. The learning goal is to manually complete the Kotlin module behavior, package metadata, module lookup, and MainApplication.kt registration.

Scaffold and Run the Android App

Native Android integration adds several moving parts to every debugging session. A broken starter build can masquerade as a native-module problem.

A clean React Native 0.87.1 baseline gives you a known working foundation. This step scaffolds the TypeScript app before proving the untouched template runs on an Android emulator.

In this step, get ready to:
  • Scaffold the pinned React Native project.
  • Start an Android emulator that Android Studio recognizes.
  • Build the untouched starter app on the emulator.
Create the pinned React Native project

The React Native Community CLI creates the native Android project. It also installs the TypeScript starter files.

You will run the scaffold from Windows PowerShell. This keeps every project path consistent with the commands in this project.

  • Press the Windows key to open Windows search.
  • Type PowerShell into the search field.
  • Press Enter to open Windows PowerShell.
  • Move to your Desktop by running this command:
cd ~/Desktop

What does this command do?

The cd command changes PowerShell's current location. The ~/Desktop path places the generated project somewhere easy to find.

The scaffold downloads the template plus its dependencies, so it can take several minutes. A quiet terminal during parts of the installation is normal.

  • Confirm the download if PowerShell asks for permission to install the CLI package.
  • Create BiometricTurbo with the Android package name com.biometricturbo by running this command:
npx @react-native-community/cli@latest init BiometricTurbo --version 0.87.1 --package-name com.biometricturbo

What does this command create?

  • The init BiometricTurbo portion names the generated project.
  • The --version 0.87.1 portion pins the React Native release used by the starter app.
  • The --package-name com.biometricturbo portion configures the Android application package.
  • Confirm the project folder exists by running:
ls

What does this command show?

The ls command lists the contents of your Desktop. It gives you a quick check that the scaffold created the project folder.

You should see a BiometricTurbo folder in the output.

Project folder missing?

  • Confirm PowerShell was on your Desktop when you started the scaffold.
  • Scroll to the first reported failure in the scaffold output.
  • Run the scaffold command again after PowerShell returns to a prompt.

Help me troubleshoot the React Native scaffold.

  • Enter the generated BiometricTurbo directory by running this command:
cd BiometricTurbo

What does this command do?

This changes PowerShell's current location to the generated BiometricTurbo directory. The next build command uses the project manifest from this location.

Your PowerShell prompt should now show that you are inside BiometricTurbo.

Start the Android emulator

A running emulator gives the Android build a target for installation. Android Studio confirms that target through its device controls.

  • Press the Windows key to open Windows search.
  • Type Android Studio into the search field.
  • Press Enter to open Android Studio.
  • Select View from the main menu bar.
  • Select Tool Windows.
  • Select Device Manager.

The emulator may take a few minutes to finish booting. A short wait at this point is normal.

  • Select the Virtual tab in Device Manager.
  • Click the Launch icon beside your existing virtual device.
  • Wait for the emulator to reach the Android home screen.
  • Select the running emulator from the target device menu in the main toolbar.

You should see the running emulator listed as an available target. This confirms Android Studio recognizes it.

  • Set your physical Android device aside for the final cross-device test.

Emulator missing from Android Studio?

  • Confirm the virtual device reaches the Android home screen.
  • Return to Device Manager to check that the device shows a running state.
  • Check the target device menu again after the emulator finishes booting.

Help me get the Android emulator recognized.

Run the untouched starter app

The generated Gradle project can now build against the running emulator. The android package script starts that build from the project directory.

  • Return to the Windows PowerShell window from earlier.
  • Confirm the prompt still shows the BiometricTurbo directory.

The first Android build can take several minutes while Gradle prepares the project. Longer output here usually means the native app is still compiling.

Before you run the build, predict whether the untouched starter template reaches the emulator on its first attempt.

  • Build the Android app on the running emulator by running:
npm run android

What does this command do?

The npm command runs the project's android script. That script builds the generated Android application before installing it on the selected emulator.

That's your clean baseline proven. You should see the untouched React Native starter screen running on the Android emulator.

Starter screen not opening?

  • Keep the emulator running on the Android home screen.
  • Confirm Android Studio still lists the emulator in the target device menu.
  • Confirm PowerShell is still inside the BiometricTurbo directory.
  • Check that package.json exists in the current directory.

Help me debug the Android starter build.

✔️ Awesome, I've got everything!

Your untouched starter app is running on the emulator. Keep the project files unchanged for the next step.

ⓧ I'd like to double check the full code

This step uses the generated starter project without editing its code. Compare your project state with these checkpoints:

  • The BiometricTurbo/ directory contains the React Native 0.87.1 starter project created with package name com.biometricturbo.
  • The BiometricTurbo/App.tsx file remains the untouched React Native starter application screen that you displayed on the Android emulator.
  • The BiometricTurbo/android/ directory contains the generated Android Gradle project configured for the com.biometricturbo application.
  • The BiometricTurbo/package.json file remains the generated React Native project manifest with its android script available.

Your pinned Android baseline now builds cleanly. Next, you'll define the typed native boundary before making the missing module visible.

Define the Typed Boundary

Your working React Native starter screen proves the Android baseline can build. The app now needs a precise contract for calls that cross from TypeScript into native code.

This step defines that typed boundary before a native implementation is available. The first lookup tests whether the runtime can satisfy the contract on its own.

In this step, get ready to:
  • Configure Codegen for a native module specification.
  • Define the typed biometric module contract.
  • Replace the starter screen with controls that expose the runtime lookup result.
Add the Codegen configuration

Codegen reads a typed specification to generate the native interface and runtime bindings. The configuration tells it where the specification lives.

  • In your editor, open package.json from the BiometricTurbo folder.
  • Locate the end of the existing scripts object.
  • Insert this configuration before the dependencies object:
"codegenConfig": {
    "name": "NativeBiometricAuthSpec",
    "type": "modules",
    "jsSrcsDir": "specs",
    "android": {
      "javaPackageName": "com.biometricturbo"
    }
  }

What does this configuration do?

  • The name value identifies the generated native specification.
  • The modules value limits generation to native module code.
  • The specs value points Codegen to the folder that holds the typed contract.
  • The javaPackageName value places the generated Android interface under com.biometricturbo.
  • Save package.json.
  • Confirm the new codegenConfig object sits between scripts and dependencies.

You should see the four Codegen values grouped inside one codegenConfig object.

Does the manifest show a JSON error?

Check that the closing brace before codegenConfig has a comma. Check that the closing brace after codegenConfig also has a comma.

Ask for help with the exact punctuation around this object: help me fix the codegenConfig JSON structure

✔️ Awesome, I've got everything!

Your manifest now points Codegen at the typed specification you create next.

ⓧ I'd like to double check the full code

Compare your complete package.json file with this reference:

{
  "name": "BiometricTurbo",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "android": "react-native run-android",
    "ios": "react-native run-ios",
    "lint": "eslint .",
    "start": "react-native start",
    "test": "jest"
  },
  "codegenConfig": {
    "name": "NativeBiometricAuthSpec",
    "type": "modules",
    "jsSrcsDir": "specs",
    "android": {
      "javaPackageName": "com.biometricturbo"
    }
  },
  "dependencies": {
    "react": "19.2.3",
    "react-native": "0.87.1",
    "@react-native/new-app-screen": "0.87.1",
    "react-native-safe-area-context": "^5.5.2"
  },
  "devDependencies": {
    "@babel/core": "^7.25.2",
    "@babel/preset-env": "^7.25.3",
    "@babel/runtime": "^7.25.0",
    "@react-native-community/cli": "20.2.0",
    "@react-native-community/cli-platform-android": "20.2.0",
    "@react-native-community/cli-platform-ios": "20.2.0",
    "@react-native/babel-preset": "0.87.1",
    "@react-native/eslint-config": "0.87.1",
    "@react-native/jest-preset": "0.87.1",
    "@react-native/metro-config": "0.87.1",
    "@react-native/typescript-config": "0.87.1",
    "@types/jest": "^29.5.13",
    "@types/react": "^19.2.0",
    "@types/react-test-renderer": "^19.1.0",
    "eslint": "^8.19.0",
    "jest": "^29.6.3",
    "prettier": "2.8.8",
    "react-test-renderer": "19.2.3",
    "typescript": "^6.0.3"
  },
  "engines": {
    "node": ">= 22.11.0"
  }
}

What should match?

The codegenConfig object should appear after scripts. Its names and values must match the reference exactly.

Create the typed module specification

A Turbo Native Module specification owns the public contract that JavaScript can call. Promise return types tell Codegen that native Android methods settle their results asynchronously.

  • In your editor's file sidebar, create a folder named specs inside BiometricTurbo.

You should see specs beside android in the project tree.

  • Create NativeBiometricAuth.ts inside the new specs folder.
  • Paste this contract into NativeBiometricAuth.ts.
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

export interface Spec extends TurboModule {
  isAvailable(): Promise<boolean>;
  authenticate(reason: string): Promise<boolean>;
}

export default TurboModuleRegistry.get<Spec>('NativeBiometricAuth');

How does the contract work?

  • The Spec interface extends the base native module type that Codegen expects.
  • The isAvailable() method requests a Boolean capability result.
  • The authenticate(reason) method sends a reason string to native code and waits for a Boolean result.
  • The get lookup can return a module or null. This gives the screen control over the missing-module state.
  • Save specs/NativeBiometricAuth.ts.
  • Confirm NativeBiometricAuth.ts appears inside specs in your editor's file sidebar.

Does the specification show type errors?

Check that the file is named NativeBiometricAuth.ts with the same capitalization. Confirm both imports come from react-native.

Use this prompt to inspect any remaining error: help me debug the TurboModule specification

✔️ Awesome, I've got everything!

Your typed contract now defines the two calls that the Android implementation must support.

ⓧ I'd like to double check the full code

Compare your complete specs/NativeBiometricAuth.ts file with this reference:

import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

export interface Spec extends TurboModule {
  isAvailable(): Promise<boolean>;
  authenticate(reason: string): Promise<boolean>;
}

export default TurboModuleRegistry.get<Spec>('NativeBiometricAuth');

What should match?

Both Promise method signatures must match the reference. The lookup string must be exactly NativeBiometricAuth.

Replace the starter screen

The test screen turns the runtime lookup into a visible result. It also prepares both biometric actions without hiding errors from the native boundary.

  • Open App.tsx from the BiometricTurbo folder in your editor.
  • Delete the existing starter screen code.
  • Assemble the replacement from the following sections in order.

The sections form one file. Keep App.tsx unsaved until you have added the final section.

import {useState} from 'react';
import {Button, StyleSheet, Text, View} from 'react-native';
import NativeBiometricAuth from './specs/NativeBiometricAuth';

type NativeError = Error & {code?: string};

function errorMessage(error: unknown): string {
  if (error instanceof Error) {
    const nativeError = error as NativeError;
    return nativeError.code
      ? `${nativeError.code}: ${nativeError.message}`
      : nativeError.message;
  }

  return String(error);
}

What does this section do?

The imports bring in React state, the screen components, and the typed module lookup. The errorMessage() helper preserves a native error code when one is available.

  • Append the availability logic directly below errorMessage().
function App() {
  const [status, setStatus] = useState('Ready to inspect the native boundary');

  async function checkAvailability() {
    if (!NativeBiometricAuth) {
      setStatus('NativeBiometricAuth is not registered');
      return;
    }

    try {
      const available = await NativeBiometricAuth.isAvailable();
      setStatus(
        available
          ? 'Strong biometrics are available'
          : 'Strong biometrics are unavailable',
      );
    } catch (error) {
      setStatus(errorMessage(error));
    }
  }

How does the availability check work?

The screen checks the nullable registry result before calling isAvailable(). Every result becomes readable status text instead of an uncaught exception.

  • Append the authentication logic below checkAvailability().
  async function authenticate() {
    if (!NativeBiometricAuth) {
      setStatus('NativeBiometricAuth is not registered');
      return;
    }

    try {
      setStatus('Waiting for Android biometric authentication');
      const authenticated = await NativeBiometricAuth.authenticate(
        'Confirm access to the native module demo',
      );
      setStatus(
        authenticated ? 'Authentication succeeded' : 'Authentication failed',
      );
    } catch (error) {
      setStatus(errorMessage(error));
    }
  }

How does authentication update the screen?

The authenticate() handler records the waiting state before it awaits native code. It converts either a resolved Boolean or a rejected native error into visible text.

  • Append the screen layout below authenticate().
  return (
    <View style={styles.screen}>
      <Text style={styles.title}>Biometric Turbo Module</Text>
      <Text style={styles.flow}>
        TypeScript → JSI and Codegen → Kotlin → AndroidX → Promise
      </Text>
      <View style={styles.statusCard}>
        <Text style={styles.statusLabel}>Status</Text>
        <Text style={styles.status}>{status}</Text>
      </View>
      <View style={styles.button}>
        <Button title="Check availability" onPress={checkAvailability} />
      </View>
      <View style={styles.button}>
        <Button title="Authenticate" onPress={authenticate} />
      </View>
    </View>
  );
}

What does the layout expose?

The status card shows each boundary result. The two buttons invoke the exact methods declared in the typed specification.

  • Append the first style section below App().
const styles = StyleSheet.create({
  screen: {
    flex: 1,
    justifyContent: 'center',
    padding: 24,
    backgroundColor: '#f4f6f8',
  },
  title: {
    marginBottom: 8,
    fontSize: 28,
    fontWeight: '700',
    color: '#17202a',
  },
  flow: {
    marginBottom: 24,
    fontSize: 14,
    color: '#52616b',
  },
  statusCard: {
    marginBottom: 20,
    padding: 16,
    borderRadius: 10,
    backgroundColor: '#ffffff',
  },

What does this style section control?

These styles center the test screen and separate the call path from the result panel. The status card gives every lookup result a consistent place to appear.

  • Append the remaining styles and export below statusCard.
  statusLabel: {
    marginBottom: 6,
    fontSize: 12,
    fontWeight: '700',
    textTransform: 'uppercase',
    color: '#607d8b',
  },
  status: {
    fontSize: 16,
    color: '#17202a',
  },
  button: {
    marginBottom: 12,
  },
});

export default App;

What completes the screen?

The final styles make the status label readable and space the controls. The default export gives React Native the completed App component.

  • Save App.tsx.

✔️ Awesome, I've got everything!

Your test screen is ready. Double check that App.tsx is saved before rebuilding.

ⓧ I'd like to double check the full code

Compare your complete App.tsx file with this reference:

import {useState} from 'react';
import {Button, StyleSheet, Text, View} from 'react-native';
import NativeBiometricAuth from './specs/NativeBiometricAuth';

type NativeError = Error & {code?: string};

function errorMessage(error: unknown): string {
  if (error instanceof Error) {
    const nativeError = error as NativeError;
    return nativeError.code
      ? `${nativeError.code}: ${nativeError.message}`
      : nativeError.message;
  }

  return String(error);
}

function App() {
  const [status, setStatus] = useState('Ready to inspect the native boundary');

  async function checkAvailability() {
    if (!NativeBiometricAuth) {
      setStatus('NativeBiometricAuth is not registered');
      return;
    }

    try {
      const available = await NativeBiometricAuth.isAvailable();
      setStatus(
        available
          ? 'Strong biometrics are available'
          : 'Strong biometrics are unavailable',
      );
    } catch (error) {
      setStatus(errorMessage(error));
    }
  }

  async function authenticate() {
    if (!NativeBiometricAuth) {
      setStatus('NativeBiometricAuth is not registered');
      return;
    }

    try {
      setStatus('Waiting for Android biometric authentication');
      const authenticated = await NativeBiometricAuth.authenticate(
        'Confirm access to the native module demo',
      );
      setStatus(
        authenticated ? 'Authentication succeeded' : 'Authentication failed',
      );
    } catch (error) {
      setStatus(errorMessage(error));
    }
  }

  return (
    <View style={styles.screen}>
      <Text style={styles.title}>Biometric Turbo Module</Text>
      <Text style={styles.flow}>
        TypeScript → JSI and Codegen → Kotlin → AndroidX → Promise
      </Text>
      <View style={styles.statusCard}>
        <Text style={styles.statusLabel}>Status</Text>
        <Text style={styles.status}>{status}</Text>
      </View>
      <View style={styles.button}>
        <Button title="Check availability" onPress={checkAvailability} />
      </View>
      <View style={styles.button}>
        <Button title="Authenticate" onPress={authenticate} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: {
    flex: 1,
    justifyContent: 'center',
    padding: 24,
    backgroundColor: '#f4f6f8',
  },
  title: {
    marginBottom: 8,
    fontSize: 28,
    fontWeight: '700',
    color: '#17202a',
  },
  flow: {
    marginBottom: 24,
    fontSize: 14,
    color: '#52616b',
  },
  statusCard: {
    marginBottom: 20,
    padding: 16,
    borderRadius: 10,
    backgroundColor: '#ffffff',
  },
  statusLabel: {
    marginBottom: 6,
    fontSize: 12,
    fontWeight: '700',
    textTransform: 'uppercase',
    color: '#607d8b',
  },
  status: {
    fontSize: 16,
    color: '#17202a',
  },
  button: {
    marginBottom: 12,
  },
});

export default App;

What should match?

The full file should contain both button handlers and one shared status state. The import path must point to ./specs/NativeBiometricAuth.

  • Switch back to Windows PowerShell from earlier.
  • Confirm the prompt is inside the BiometricTurbo folder.
  • Rebuild the Android app by running:
npm run android

What does this command do?

The project script rebuilds the Android app with the new TypeScript files. It then loads the updated screen on the running emulator.

You should see the title Biometric Turbo Module. The status card should initially read Ready to inspect the native boundary.

Before you test the runtime lookup, do you think the typed contract alone is enough for the runtime to return a module?

  • Press Check availability on the emulator.

The status panel reads NativeBiometricAuth is not registered. That shortfall is intentional because the runtime has no registered implementation to return.

  • Press Authenticate on the emulator.

The status panel again reads NativeBiometricAuth is not registered instead of crashing. You have made the runtime lookup responsibility visible from the screen.

Still seeing the starter screen?

Confirm you saved App.tsx in the BiometricTurbo folder. Check the PowerShell output for the first TypeScript syntax error if the rebuild stopped.

Share the visible build error with this prompt: help me rebuild the biometric test screen

Your TypeScript boundary now fails safely and visibly. Next, you will generate its native contract and connect the runtime lookup to a Kotlin scaffold.

Generate and Register the Scaffold

Your TypeScript screen now asks for NativeBiometricAuth. The React Native runtime still has no native implementation to create.

This step turns the typed specification into a generated Android contract. You will register a compiling Kotlin scaffold so the same button reaches native code.

In this step, get ready to:
  • Add AndroidX Biometric and generate the native contract.
  • Create the Kotlin module and package scaffolds.
  • Register the package and prove that React Native reaches Kotlin.
Add the dependency and generate the contract

Codegen reads your typed specification and produces the abstract Android class that Kotlin must implement. AndroidX Biometric provides the platform-facing APIs used by the next two steps.

  • In your editor from earlier, select android/app/build.gradle from the BiometricTurbo folder.
  • Find this existing dependency line:
    implementation("com.facebook.react:react-android")

Why find this line?

This line marks the start of the Android application dependencies. The biometric dependency belongs directly below it.

  • Add the biometric dependency below the existing React Native dependency so both lines look like this:
    implementation("com.facebook.react:react-android")
    implementation("androidx.biometric:biometric:1.1.0")

What does this dependency add?

The AndroidX Biometric library supplies the capability manager and system prompt APIs. Keeping it in the app module makes those APIs available to your Kotlin implementation.

  • Save android/app/build.gradle.
  • Switch Windows PowerShell from BiometricTurbo to its android folder by running:
cd android

What does this command do?

PowerShell changes its current location to the Android project. This is where the Gradle Wrapper lives.

The first Codegen run may pause while Gradle configures the Android project. That setup work is expected.

  • Generate the Android contract from your TypeScript specification by running:
gradlew.bat generateCodegenArtifactsFromSchema

What does this task generate?

Codegen reads specs/NativeBiometricAuth.ts through the settings in package.json. It produces the abstract native contract plus the bindings used by the React Native runtime.

The generated files are build artifacts. Your handwritten Kotlin module extends the generated contract.

  • In your editor, select android/app/build/generated/source/codegen/java/com/biometricturbo/NativeBiometricAuthSpec.java.
  • Confirm the generated class includes abstract methods corresponding to isAvailable() and authenticate(reason).

You have turned the TypeScript contract into an Android interface that Kotlin can implement. The native boundary is now visible in generated code.

Codegen task failed?

Confirm Windows PowerShell is inside the BiometricTurbo/android folder. Also check that package.json still points Codegen at the specs directory.

If the generated class is missing, confirm the specification filename begins with Native.

Help me diagnose the failed Codegen task.

Create and complete the Kotlin scaffolds

The generated class defines the required method signatures. Your handwritten module owns the temporary behavior that proves calls have reached Kotlin.

  • In your editor from earlier, select the android/app/src/main/java/com/biometricturbo folder.
  • Create NativeBiometricAuthModule.kt in that folder.
  • Paste this compiling module scaffold into the new file:
package com.biometricturbo

import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext

class NativeBiometricAuthModule(
  reactContext: ReactApplicationContext,
) : NativeBiometricAuthSpec(reactContext) {

  override fun getName(): String = NAME

  override fun isAvailable(promise: Promise) {
    // TODO: Query BiometricManager with BIOMETRIC_STRONG and settle the Promise.
    promise.reject(ERROR_NOT_IMPLEMENTED, "TODO: implement isAvailable")
  }

  override fun authenticate(reason: String, promise: Promise) {
    // TODO: Validate the current FragmentActivity, construct BiometricPrompt,
    // map terminal callbacks to one Promise settlement, and start authentication.
    promise.reject(ERROR_NOT_IMPLEMENTED, "TODO: implement authenticate")
  }

  companion object {
    const val NAME = "NativeBiometricAuth"
    const val ERROR_NOT_IMPLEMENTED = "E_NOT_IMPLEMENTED"
  }
}

What does this module do?

  • Extending NativeBiometricAuthSpec connects the handwritten class to the generated contract.
  • The NAME value matches the lookup string used by TurboModuleRegistry.
  • Both methods reject their Promise with E_NOT_IMPLEMENTED. This temporary result proves the call reached Kotlin.
  • Save NativeBiometricAuthModule.kt.
  • Confirm the file appears inside android/app/src/main/java/com/biometricturbo in your editor.

Module scaffold showing errors?

Confirm the file is inside the com/biometricturbo package folder. The first line must match that package path.

If NativeBiometricAuthSpec is unresolved, confirm the Codegen task completed before creating this file.

Help me fix the Kotlin module scaffold.

A BaseReactPackage controls native module lookup. It also supplies metadata that identifies the module as a Turbo Module.

  • Create NativeBiometricAuthPackage.kt inside android/app/src/main/java/com/biometricturbo.
  • Paste this package scaffold into the new file:
package com.biometricturbo

import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfoProvider

class NativeBiometricAuthPackage : BaseReactPackage() {
  override fun getModule(
    name: String,
    reactContext: ReactApplicationContext,
  ): NativeModule? {
    // TODO: Return NativeBiometricAuthModule only for its exact NAME.
    return null
  }

  override fun getReactModuleInfoProvider(): ReactModuleInfoProvider =
    ReactModuleInfoProvider {
      // TODO: Return one ReactModuleInfo entry with isTurboModule set to true.
      emptyMap()
    }
}

What does this package control?

  • The getModule() method decides which native implementation belongs to a requested module name.
  • The metadata provider tells React Native how the module should be loaded.
  • The current TODO results compile as a scaffold. They cannot expose the module until you complete both lookup responsibilities.
  • Save NativeBiometricAuthPackage.kt.
  • Confirm both Kotlin scaffold files appear in the com/biometricturbo folder.

Package scaffold not recognized?

Check that the filename is exactly NativeBiometricAuthPackage.kt. Confirm its package declaration matches com.biometricturbo.

Help me correct the native package scaffold.

✔️ Awesome, I've got everything!

Both Kotlin scaffolds are saved in android/app/src/main/java/com/biometricturbo. You are ready to complete the package TODOs.

ⓧ I'd like to double check the full code

Compare your module scaffold with this complete file:

package com.biometricturbo

import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext

class NativeBiometricAuthModule(
  reactContext: ReactApplicationContext,
) : NativeBiometricAuthSpec(reactContext) {

  override fun getName(): String = NAME

  override fun isAvailable(promise: Promise) {
    // TODO: Query BiometricManager with BIOMETRIC_STRONG and settle the Promise.
    promise.reject(ERROR_NOT_IMPLEMENTED, "TODO: implement isAvailable")
  }

  override fun authenticate(reason: String, promise: Promise) {
    // TODO: Validate the current FragmentActivity, construct BiometricPrompt,
    // map terminal callbacks to one Promise settlement, and start authentication.
    promise.reject(ERROR_NOT_IMPLEMENTED, "TODO: implement authenticate")
  }

  companion object {
    const val NAME = "NativeBiometricAuth"
    const val ERROR_NOT_IMPLEMENTED = "E_NOT_IMPLEMENTED"
  }
}

Compare your package scaffold with this complete file:

package com.biometricturbo

import com.facebook.react.BaseReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.module.model.ReactModuleInfoProvider

class NativeBiometricAuthPackage : BaseReactPackage() {
  override fun getModule(
    name: String,
    reactContext: ReactApplicationContext,
  ): NativeModule? {
    // TODO: Return NativeBiometricAuthModule only for its exact NAME.
    return null
  }

  override fun getReactModuleInfoProvider(): ReactModuleInfoProvider =
    ReactModuleInfoProvider {
      // TODO: Return one ReactModuleInfo entry with isTurboModule set to true.
      emptyMap()
    }
}

The two package TODOs are your native registration checkpoint. The name lookup must be exact because JavaScript requests one specific module string.

The metadata booleans are fiddly because several values sit together. Keep the override flag, eager initialization flag, and C++ module flag disabled.

  • In NativeBiometricAuthPackage.kt, replace the getModule() TODO with an exact-name lookup.
  • Return a new NativeBiometricAuthModule when name equals NativeBiometricAuthModule.NAME.
  • Return null for every other module name.
  • Replace the metadata TODO with one entry keyed by NativeBiometricAuthModule.NAME.
  • Use NativeBiometricAuthModule.NAME for both metadata name fields.
  • Set the override flag, eager initialization flag, and C++ module flag to false.
  • Set the Turbo Module flag to true.
  • Save NativeBiometricAuthPackage.kt.
Register the package and reach Kotlin

A completed package can describe the module. MainApplication.kt must still add that package to the list used by the React host.

  • In your editor, select android/app/src/main/java/com/biometricturbo/MainApplication.kt.
  • Find this package-list block:
        PackageList(this).packages.apply {
          // TODO: Register NativeBiometricAuthPackage() manually.
        },

Why register here?

The React host receives this package list when the application starts. A package outside this list cannot answer the runtime lookup.

  • Replace the TODO comment with this package registration:
          NativeBiometricAuthPackage()

What does this line connect?

This adds your custom package to the React host. The runtime can now ask it to create NativeBiometricAuthModule.

  • Save MainApplication.kt.
  • Return Windows PowerShell to the BiometricTurbo folder by running:
cd ..

What does this command do?

PowerShell moves from BiometricTurbo/android back to the main BiometricTurbo folder. The app script runs from this location.

Native edits trigger a full Android rebuild. Expect this run to take a few minutes.

Before you rebuild, do you think the status will still report a missing registration or reach the Kotlin rejection?

  • Rebuild the Android app by running:
npm run android

What does this rebuild prove?

The rebuild compiles the generated contract with your Kotlin files. It also starts the updated application on the available emulator.

  • Press Check availability on the rebuilt app.

You should see a status beginning with E_NOT_IMPLEMENTED instead of NativeBiometricAuth is not registered. The message proves that React Native found the package and invoked your Kotlin override.

Still seeing the registration message?

Confirm NativeBiometricAuthPackage() sits inside PackageList(this).packages.apply.

Check that getModule() compares the requested name with NativeBiometricAuthModule.NAME.

Help me trace why React Native still cannot discover my module.

Your JavaScript call now crosses the generated boundary and reaches Kotlin. Next, you will replace the availability TODO with a real biometric capability check.

Implement Biometric Availability

Your manual package registration now carries the TypeScript availability call into Kotlin. The E_NOT_IMPLEMENTED result proves React Native can discover your module.

Discovery only proves that the route exists. This step uses AndroidX Biometric to distinguish a device that supports strong biometric authentication from one with missing capability or enrollment.

In this step, get ready to:
  • Implement the strong biometric capability query inside the Kotlin module.
  • Map the native capability result to the Boolean TypeScript contract.
  • Compare availability across enrolled and unavailable targets.
Implement the availability check

A BiometricManager capability check asks Android whether the requested authenticator is ready. The returned Boolean lets TypeScript choose the wording shown in the status panel.

Build the Boolean contract

  • The BiometricManager instance comes from reactApplicationContext.
  • The local authenticators value holds BiometricManager.Authenticators.BIOMETRIC_STRONG.
  • The canAuthenticate(authenticators) call performs the capability query.
  • The Promise resolves with true only when the result equals BIOMETRIC_SUCCESS.
  • Every other capability status resolves with false.
  • The AndroidX class import makes BiometricManager available to Kotlin.
  • The authenticate(reason, promise) scaffold remains unchanged.
  • The Spec in specs/NativeBiometricAuth.ts remains unchanged.
  • In your editor, return to android/app/src/main/java/com/biometricturbo/NativeBiometricAuthModule.kt from the previous step.
  • Find override fun isAvailable(promise: Promise).
  • Replace the entire TODO scaffold inside isAvailable with a Kotlin implementation that matches the Boolean contract above.
  • Save NativeBiometricAuthModule.kt.
Rebuild and test a ready target

The Kotlin change must be compiled into the Android app. A native rebuild packages your completed availability method into the installed application.

  • Switch back to the Windows PowerShell panel in BiometricTurbo from earlier.
  • Rebuild the Android app by running this command:
npm run android

What does this rebuild do?

  • The npm script asks React Native to rebuild the Android application.
  • Gradle compiles the updated Kotlin module.
  • Android installs the rebuilt application on the active emulator.
  • Wait for the Android build to complete.

You should see the app relaunch on the emulator after the build completes. That rebuild now carries your first working native capability method.

Native rebuild failing?

  • Confirm that NativeBiometricAuthModule.kt is saved.
  • Use your editor's import suggestion if Kotlin cannot resolve BiometricManager.
  • Check that the Promise receives a Boolean produced by comparing the capability result with BIOMETRIC_SUCCESS.

help me debug my Kotlin biometric availability implementation.

Before you press the button, do you expect the previous implementation error or a biometric capability message?

  • On your enrolled emulator, press Check availability.

You should see Strong biometrics are available in the status panel on a ready emulator.

Compare an unavailable target

Capability status is separate from a completed biometric scan. Your method treats every non-success capability result as unavailable without changing the TypeScript contract.

  • Use Android Studio to run BiometricTurbo on the physical device from earlier.
  • Choose a target with no eligible strong biometric enrolled for the unavailable comparison.

Before you check each target, do you expect both devices to return the same Boolean?

  • Press Check availability on the physical device.
  • Switch to the target without an eligible strong biometric.
  • Press Check availability on the unavailable target.

An enrolled ready target shows Strong biometrics are available. A target without eligible strong biometric capability shows Strong biometrics are unavailable.

What does this comparison prove?

The native module reports capability as a Boolean across different Android targets. TypeScript owns the visible wording for each result.

Capability failures resolve with false because they are valid answers to the availability query. The authentication method remains responsible for reporting terminal prompt errors in the next step.

Your module can now distinguish a ready biometric target from an unavailable one. Next, you will open the Android system prompt and carry its terminal callbacks back to TypeScript.

Complete the Prompt Flow

Your React Native screen already proves that Kotlin can query strong biometric availability. Authentication now needs to move from a capability result to a live system prompt.

AndroidX Biometric needs the current Activity lifecycle to display that prompt. The terminal callback must settle the TypeScript Promise exactly once.

A successful scan resolves the Promise. Cancellation or another terminal error rejects it. A failed scan leaves it pending because the user can try again.

In this step, get ready to:
  • Validate the current Activity before creating the prompt.
  • Implement terminal callback settlement for the native Promise.
  • Prove authentication behavior on both Android targets.
Map the callback outcomes

A biometric attempt can report success or a terminal error. It can also report a failed scan that still allows another attempt.

Which callbacks are terminal?

  • The onAuthenticationSucceeded() callback is terminal. It resolves promise with true.
  • The onAuthenticationError() callback is terminal. It rejects promise with a stable module-specific code plus the callback message.
  • The onAuthenticationFailed() callback is nonterminal. It leaves promise pending because another scan may follow.
  • Every terminal path settles the same Promise once. This prevents duplicate results from reaching the TypeScript screen.
  • Return to NativeBiometricAuthModule.kt from the previous step.
  • Locate override fun authenticate(reason: String, promise: Promise).
  • Trace how reason supplies prompt context while promise carries the terminal result.
Implement the native prompt

BiometricPrompt needs the current FragmentActivity. It also needs a main-thread Executor for its callbacks.

BiometricPrompt.PromptInfo carries the strong authenticator policy. It also passes the TypeScript reason into the system subtitle.

What must the method guarantee?

  • The method reads currentActivity when authentication starts.
  • A missing Activity rejects promise with a stable module-specific code plus a clear message.
  • The Activity must be a FragmentActivity. Every other Activity produces a stable rejection.
  • The main-thread Executor comes from ContextCompat.getMainExecutor.
  • The BiometricPrompt callback implements all three authentication outcomes.
  • The onAuthenticationSucceeded() callback resolves promise with true.
  • The onAuthenticationError() callback rejects promise with a stable module-specific code plus errString.
  • The onAuthenticationFailed() callback contains no settlement call. A concise comment explains that another scan may follow.
  • The prompt allows BIOMETRIC_STRONG authentication.
  • The prompt has a visible title.
  • The prompt uses reason as its subtitle.
  • The prompt has a negative button.
  • The method ends by calling authenticate(promptInfo) on the biometric prompt.

The scaffold deliberately leaves this implementation to you. Choose how much support you want before replacing the TODO.

✔️ I'll try it first

  • Replace the TODO rejection in authenticate(reason, promise) with one complete implementation that satisfies the contract above.

ⓧ Let me check my work

Structure your implementation around four responsibilities. Keep every terminal settlement inside its matching branch.

  • The host layer validates currentActivity as a FragmentActivity before prompt construction.
  • The execution layer creates the main-thread Executor before constructing BiometricPrompt.
  • The callback layer resolves success once. It rejects terminal errors once. It leaves failed scans pending.
  • The prompt layer builds PromptInfo with the strong authenticator policy plus the visible prompt copy.
  • The final statement starts authentication with authenticate(promptInfo).
  • Write the implementation after checking each responsibility against this list.
  • Save NativeBiometricAuthModule.kt.
  • Switch back to Windows PowerShell in the BiometricTurbo folder.
  • Rebuild the native app by running this command:
npm run android

What does this rebuild prove?

Native compilation checks that your Kotlin imports match the AndroidX APIs. It also checks that your callback signatures match the generated spec.

A successful launch proves the registered package can still create the completed module. The next tap tests its runtime behavior.

You will see the Biometric Turbo Module screen reopen on the emulator.

Kotlin build failed?

  • Use your editor's import quick fix if compilation reports unresolved references for the AndroidX classes.
  • Remove the old ERROR_NOT_IMPLEMENTED rejection if the app still reports the scaffold result.
  • Confirm every overridden callback uses the official parameter types shown by your editor.

Ask for help with the exact compiler output: help me debug the Kotlin biometric prompt implementation.

Before you press Authenticate, do you expect an in-app status alone or the Android system prompt?

  • Press Authenticate on the emulator.

You will see Waiting for Android biometric authentication in the status panel. The Android system biometric prompt opens with your title plus the TypeScript reason.

Test every terminal path

The Android Emulator can simulate an enrolled fingerprint while the prompt is open. This supplies a successful scan without changing the TypeScript contract.

  • Open Extended controls from the Android Emulator toolbar.
  • Select a fingerprint value.

Before you trigger the fingerprint, do you expect the pending Promise to resolve or remain open?

  • Click Touch Sensor while the biometric prompt is open.

You will see Authentication succeeded in the status panel. The successful native callback has now crossed back into React state.

You have completed the full emulator boundary. The typed call now reaches Kotlin before returning through the same Promise.

Before you cancel the next attempt, do you expect the Promise to resolve, reject, or remain pending?

  • Press Authenticate again.
  • Activate the negative button you configured in the system prompt.

You will see your stable native error code plus the terminal callback message in the status panel. This proves cancellation rejects the Promise instead of leaving the screen waiting.

A physical device checks the same flow against real biometric hardware. It also confirms that the module reads the active Activity when each attempt starts.

  • Switch the deployment target to the physical device from earlier in Android Studio.
  • Deploy BiometricTurbo to that device from Android Studio.

You will see the Biometric Turbo Module screen open on the physical device.

  • Press Authenticate on the physical device.

Before you scan, do you expect the physical device to settle the same Promise path as the emulator?

  • Complete a valid biometric scan on the physical device.

You will see Authentication succeeded on the physical device. Both Android targets now complete the native prompt flow.

Prompt stays open?

  • Try another enrolled scan when onAuthenticationFailed() fires. That callback deliberately keeps the Promise pending.
  • Keep the emulator prompt open before clicking Touch Sensor.
  • Confirm the physical device has an eligible strong biometric enrolled.

Get targeted help here: help me diagnose a biometric prompt that stays pending.

You closed the native loop. A typed call now reaches the Android system prompt on both targets before returning a terminal result to the screen.

Secret mission

Defend the Architecture

Make the working prompt prove more than implementation skill. Deliver a 60-to-90-second architecture defense that traces the typed native call path without reading implementation code. Turn the same reasoning into a 100-to-150-word portfolio summary with one explicit Turbo Module trade-off.

Clean Up Your Resources

Clean Up Your Resources

Every resource from this project stays on your local machine, so there are no ongoing costs. Choose whether to keep the files, pause the active tools, or delete the local artifacts.

Resources you used:

  • Any React Native development process for BiometricTurbo that is still running in Windows PowerShell.
  • The BiometricTurbo folder with your TypeScript source, Kotlin implementation, Codegen output, and Android build artifacts.
  • The portfolio/biometric-turbo-module-summary.txt summary file.

Keep everything running

Your local work can stay in place without creating ongoing costs. This option keeps the authentication lab ready for more testing.

  • Keep the active BiometricTurbo process running if you want to test another biometric result immediately.
  • Keep the BiometricTurbo folder to preserve the working app.
  • Keep portfolio/biometric-turbo-module-summary.txt to preserve your architecture summary.

Pause - I'll come back to this later

Pausing ends the active test session while preserving your work. Your source files and architecture summary stay on disk.

  • Press Ctrl+C in any Windows PowerShell terminal that is still running the app.
  • Close the Android Emulator window to free its memory.
  • Disconnect the physical Android device from your computer.
  • Return to BiometricTurbo when you are ready to repeat the Android run check from Step 1.

Delete - I don't want to use this again

Permanent deletion removes your app and written summary. Your Android Emulator configuration and physical device remain untouched.

  • Press Ctrl+C in any Windows PowerShell terminal that is still running the app.
  • Use your Windows file browser to locate BiometricTurbo.
  • Permanently delete the BiometricTurbo folder.
  • Use your Windows file browser to locate portfolio/biometric-turbo-module-summary.txt.
  • Permanently delete portfolio/biometric-turbo-module-summary.txt.
  • Confirm that BiometricTurbo no longer appears in your Windows file browser.
  • Confirm that portfolio/biometric-turbo-module-summary.txt no longer appears in your Windows file browser.

That clears the local project footprint. The app folder and architecture summary are gone.

Nice Work!

Nice Work!

You made it! Your React Native Android app now opens the system biometric prompt through a custom Kotlin Turbo Native Module backed by a typed Codegen contract.

You've learned how to:

  • Define a typed TypeScript contract for NativeBiometricAuth. Codegen turns that contract into the Android interface plus JNI glue.
  • Make NativeBiometricAuth discoverable through package metadata plus manual registration. Implement BIOMETRIC_STRONG availability checks in Kotlin.
  • Map AndroidX Biometric callbacks to one settled Promise. Verify success plus terminal error behavior on an enrolled emulator. Repeat the complete flow on a physical Android device.
  • Secret Mission: Defend the full TypeScript-to-Kotlin call path in a 60-to-90-second explanation. Capture one Turbo Module trade-off in a 100-to-150-word portfolio summary. Explain why local biometric success does not prove server-side identity or authorization.

Ready to quiz yourself?