Cross-platform frameworks can't expose every native API. When you need camera access, Bluetooth, biometrics, or a platform-specific SDK, you write a bridge between the framework and native code.
React Native: Native Modules
// Android side: TurboModule (new architecture) or legacy NativeModule
@ReactModule(name = BiometricModule.NAME)
class BiometricModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
companion object { const val NAME = "BiometricModule" }
override fun getName(): String = NAME
@ReactMethod
fun authenticate(reason: String, promise: Promise) {
val activity = currentActivity ?: run {
promise.reject("NO_ACTIVITY", "No current activity")
return
}
val executor = ContextCompat.getMainExecutor(reactApplicationContext)
val biometricPrompt = BiometricPrompt(activity as FragmentActivity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
promise.resolve("success")
}
override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
promise.reject("AUTH_ERROR_$errorCode", errString.toString())
}
override fun onAuthenticationFailed() {
promise.reject("AUTH_FAILED", "Authentication failed")
}
})
val promptInfo = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate")
.setSubtitle(reason)
.setNegativeButtonText("Cancel")
.build()
biometricPrompt.authenticate(promptInfo)
}
}
// Register in Package
class BiometricPackage : ReactPackage {
override fun createNativeModules(context: ReactApplicationContext) =
listOf(BiometricModule(context))
override fun createViewManagers(context: ReactApplicationContext) = emptyList<ViewManager<*, *>>()
}
// JavaScript side
import { NativeModules } from 'react-native';
const { BiometricModule } = NativeModules;
async function authenticate() {
try {
const result = await BiometricModule.authenticate('Confirm your identity');
console.log('Authenticated:', result);
} catch (e) {
console.error('Auth failed:', e.message);
}
}
Flutter: Platform Channels
// Android side: MethodChannel handler
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.myapp/biometric"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"authenticate" -> {
val reason = call.argument<String>("reason") ?: "Authenticate"
authenticate(reason, result)
}
else -> result.notImplemented()
}
}
}
private fun authenticate(reason: String, result: MethodChannel.Result) {
val executor = ContextCompat.getMainExecutor(this)
val prompt = BiometricPrompt(this, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(r: BiometricPrompt.AuthenticationResult) {
result.success("success")
}
override fun onAuthenticationError(code: Int, msg: CharSequence) {
result.error("AUTH_ERROR", msg.toString(), null)
}
override fun onAuthenticationFailed() {
result.error("AUTH_FAILED", "Authentication failed", null)
}
})
prompt.authenticate(BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate")
.setSubtitle(reason)
.setNegativeButtonText("Cancel")
.build())
}
}
// Flutter/Dart side
class BiometricService {
static const _channel = MethodChannel('com.myapp/biometric');
Future<bool> authenticate(String reason) async {
try {
final result = await _channel.invokeMethod<String>('authenticate', {'reason': reason});
return result == 'success';
} on PlatformException catch (e) {
debugPrint('Biometric error: ${e.message}');
return false;
}
}
}
KMM: expect/actual for Native SDKs
// commonMain: declare expected platform function
expect class PlatformBiometric {
suspend fun authenticate(reason: String): Boolean
}
// androidMain: actual implementation
actual class PlatformBiometric(private val activity: FragmentActivity) {
actual suspend fun authenticate(reason: String): Boolean {
return suspendCancellableCoroutine { cont ->
val executor = ContextCompat.getMainExecutor(activity)
val prompt = BiometricPrompt(activity, executor,
object : BiometricPrompt.AuthenticationCallback() {
override fun onAuthenticationSucceeded(r: BiometricPrompt.AuthenticationResult) {
cont.resume(true)
}
override fun onAuthenticationError(code: Int, msg: CharSequence) {
cont.resume(false)
}
override fun onAuthenticationFailed() { cont.resume(false) }
})
val info = BiometricPrompt.PromptInfo.Builder()
.setTitle("Authenticate").setSubtitle(reason).setNegativeButtonText("Cancel").build()
prompt.authenticate(info)
}
}
}
// iosMain: actual implementation using LocalAuthentication
actual class PlatformBiometric {
actual suspend fun authenticate(reason: String): Boolean {
return suspendCancellableCoroutine { cont ->
val context = LAContext()
context.evaluatePolicy(
LAPolicy.DeviceOwnerAuthenticationWithBiometrics,
localizedReason = reason
) { success, _ -> cont.resume(success) }
}
}
}
Avoiding Bridge Bottlenecks
- Batch calls: don't call native 60 times/second; aggregate updates
- Return serializable types:
Map<String, Any>,List, primitives only — no custom objects across the bridge - Async everything: bridge calls are async; never block the JS/Dart thread waiting for native
- EventChannel (Flutter) / EventEmitter (RN): for streaming data (BLE scan results, sensor updates) use event channels, not polling
Key Takeaways
| Framework | Bridge Type | Use For |
|---|---|---|
| React Native | NativeModule | One-shot calls; EventEmitter for streams |
| Flutter | MethodChannel | Calls; EventChannel for streams |
| KMM | expect/actual | Any platform API; keeps shared code clean |
| All | Batch + async | Never block the UI thread or hammer the bridge per-frame |