← Back to blog

How to use native APIs in Flutter: iOS and Android Biometric Authentication [Part 2]

Mais Alheraki

Open Source Engineer

7th July, 2022

Need a custom tool? Invertase can help

Tired of investing in off-the-shelf software that doesn't quite fit your business? Invertase can build solutions tailored to your exact needs. Tell us what you're looking for here and we'll be in touch.

Introduction

This is the second part in our series on using platform channels in Flutter to communicate with the host platform. If you have not read the first part, it is recommended that you read it first.

How often do you find yourself in a situation where you need to integrate a new native iOS/Android API, or a third-party SDK, and you think for a second it would have been better if I’m using native instead of Flutter? This does not happen very often, but when it does, it could be critical to the experience of your app.

In this part, you will get hands-on using native code from Swift and Kotlin in Dart.

Get the full code

Before diving in, you can get the full project and code used in this tutorial from this repository. The code is runnable, you can clone and play around with it on Android and iOS devices.

Preparing the project

Let’s start with creating a new Flutter project.

flutter create flutter_biometrics_authentication

By default, the newly created project will use Kotlin for Android and Swift for iOS.

💡 You can create a new project that is using Java/ObjC for the Android/iOS side, if you have experience with them.

To do that, run the command with additional parameters:

flutter create -a java -i objc flutter_biometrics_authentication

Once the project is created, and if you are on the stable channel, as of the time of writing, it is Flutter 3.0.2. You can notice you got 6 folders with the following platform names: ios, android, web, macos, windows, and linux. In this tutorial, we are only interested in ios and android folders.

If you flutter run, you can see the usual Flutter counter application. You won’t learn how to increment the counter with a platform channel, instead, you will look into a real use-case.

What are you building?

Before going further into code, let’s break down the sample you’re going to build in simple English.

The use-case

Some apps hold sensitive information about a user, such as financial, and health apps. It is recommended that you use biometrics as an option to protect the user’s privacy.

About Biometrics Native APIs

Both iOS and Android exposes an easy-to-use API that allows developers to authenticate users locally using their configured biometrics such as Face and Fingerprint, or fallback to passcode.

On iOS, you can communicate with the system to ask the user to authenticate with biometrics using LocalAuthentication framework. On Android, this can be achieved using BiometricPrompt package.

iOSAndroid

The Dart and Flutter side

You will start from main.dart. Open the file and delete the MyHomePage widget.

UI part

The app has a single view only, let’s name it AuthView.

class AuthView extends StatefulWidget {
  const AuthView({Key? key}) : super(key: key);

  @override
  State<AuthView> createState() => _AuthViewState();
}

class _AuthViewState extends State<AuthView> {
/// The current authentication status.
  AuthStatus authStatus = AuthStatus.idle;

  /// If not null, the error message coming from platform.
  String? error;

    Future<void> authenticateWithBiometrics() async {}

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Biometrics Sample')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text('Authentication status'),
            Chip(
              label: Text(
                authStatus.value.text,
                style: const TextStyle(color: Colors.white),
              ),
              backgroundColor: authStatus.value.color,
            ),
            TextButton(
              onPressed: authenticateWithBiometrics,
              child: const Text("Sign in"),
            ),
            if (error != null) Text(error!)
          ],
        ),
      ),
    );
  }
}

The AuthStatus type is not yet defined, so let’s create it.

/// This code using the supercharged enums from Dart 2.17

class AuthStatusItem {
  const AuthStatusItem({
    required this.color,
    required this.text,
  });
  final Color color;
  final String text;
}

enum AuthStatus {
  idle(AuthStatusItem(color: Colors.blueGrey, text: 'Not started')),
  success(AuthStatusItem(color: Colors.green, text: 'Successful!')),
  failed(AuthStatusItem(color: Colors.red, text: 'Failed!'));

  final AuthStatusItem value;
  const AuthStatus(this.value);
}

Lastly, add the new widget AuthView as the home in MaterialApp.

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const AuthView(),
    );
  }
}

MethodChannel part

The very first step in defining a MethodChannel to use to communicate with the native side.

You will need flutter/services package, go to the very top of your file and add this import:

import 'package:flutter/services.dart';

Next, under _AuthViewState, add the following.

/// The platform channel used to communicate with the native code.
/// The  of the method channel MUST match the name of the channel
/// defined on the native side.
final platform = const MethodChannel('samples.invertase.io/biometrics');

Next, you will implement authenticateWithBiometrics.

Future<void> authenticateWithBiometrics() async {
  AuthStatus authStatus = AuthStatus.idle;

  try {
    await platform.invokeMethod('authenticateWithBiometrics');

    /// Handle the method calls coming from the native side.
    platform.setMethodCallHandler((call) async {
      if (call.method == 'authenticationResult') {
        if (call.arguments ?? false) {
          authStatus = AuthStatus.success;
        } else {
          authStatus = AuthStatus.failed;
        }

        setState(() {
          this.authStatus = authStatus;
        });
      }
    });
  } on PlatformException catch (e) {
    // The type of error coming from native is always [PlatformException].
    setState(() {
      this.authStatus = AuthStatus.failed;
      error = e.message;
    });
  }
}

To break down what’s happening inside this function:

  1. You’re invoking a method named authenticateWithBiometrics on the samples.invertase.io/biometrics channel.
  2. Then, you’re setting up a handler that will listen to any method calls on this channel that is coming from the native side of the app.
  3. Finally, you’re catching any exception of type PlatformException that might be thrown from the native side.

Results and data validation

After invoking a method on a channel, you might get a response back from the native side. The type returned by invokeMethod is Future<dynamic>, meaning you need to await it, and for the fact that the value of the future is dynamic, you will need to validate the result before going any further in your code.

In this example, you won’t store the result, rather just await for the invocation to finish then proceed, since there’s no expected data to be returned from authenticateWithBiometrics as you will see in the following sections.

The native side

In this section, you will add the native implementation for authenticateWithBiometrics method. Even if you don’t have any native experience, don’t let this part scare you. The goal is to walk you through writing native code and debugging it step by step.

Writing the iOS implementation

The iOS part will be written in Swift language using Xcode, to make use of the native debugging, analyzing and formatting capabilities. If you’re using VS Code, right-click on the iOS folder, you can see Open in Xcode, click on it. If you’re using Android Studio, you should see a similar option, right-click on iOS folder, then Flutter > Open iOS module in Xcode.

Once Xcode window is open, expand Runner folder, and open AppDelegate.

A screenshot from the AppDelegate.swift in the source code.

The AppDelegate class is the entry point of the Flutter app on iOS. You can override some of its methods to prepare Swift to handle calls from Dart.

First step is to setup the channel. We need 1 constant channelName and 1 variable biometricsChannel, add them inside the AppDelegate class.

/**
 * The channel name which communicates back and forth with Flutter.
 * This name **MUST** match exactly the one on the Flutter side.
 */
let channelName = "samples.invertase.io/biometrics"
var biometricsChannel: FlutterMethodChannel?

Next, paste the following code inside application method.

override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
    // Get the root controller for the Flutter view.
    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController

    // Initialize the method channel once the Flutter engines is attached.s
    biometricsChannel = FlutterMethodChannel(name: channelName,
                                             binaryMessenger: controller.binaryMessenger)

    // Set a method call handler, whenever we invoke a method from Flutter on "samples.invertase.io/biometrics" channel,
        // this handler will be triggered.
    biometricsChannel?.setMethodCallHandler({
        (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
        // This method is invoked on the UI thread.
        // Handle calls to biometrics channel.
        guard call.method == "authenticateWithBiometrics" else {
            // If the requested method is not `authenticate`, throw.
            result(FlutterMethodNotImplemented)
            return
        }

        self.authenticateWithBiometrics(result: result)
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}

Xcode will complain that authenticateWithBiometrics does not exist yet, so let’s create it. inside the AppDelegate class, paste this new method.

/**
 * Show a prompt that asks the user to sign in with biometrics (either Face or Fingerprint).
 * If neither is configured by the user on their iOS device, it will prompt for the passcode.
 */
private func authenticateWithBiometrics(result: @escaping FlutterResult) -> Void  {
    let context = LAContext()

    if #available(iOS 10.0, *) {
        context.localizedCancelTitle = "Use Password"
    }

    var error: NSError?

    // Check for biometric authentication permissions
    let permissions = context.canEvaluatePolicy(
        .deviceOwnerAuthentication,
        error: &error
    )

    if permissions {
        let reason = "Log in with Face ID"
        context.evaluatePolicy(
            // .deviceOwnerAuthentication allows biometric or passcode authentication
            .deviceOwnerAuthentication,
            localizedReason: reason
        ) { success, error in
            // Send the authentication result to Flutter, either true or false.
            result(nil)
            self.biometricsChannel?.invokeMethod("authenticationResult", arguments: success)
        }
    } else {
        // If the biometrics permissions failed, throw a PlatformException to Flutter.
        let platformError = FlutterError(code: "AUTHFAILED",
                                         message: "(error?.localizedDescription ?? "The authentication process has failed for an unknown reason").",
                                         details: nil)
        result(platformError)
    }

    return
}

The last important part is to import LocalAuthentication framework at the top of the file.

import UIKit
import Flutter

// Add this
import LocalAuthentication

If you got lost, you can refer to the AppDelegat.swift file from the source code.

Sending results to Flutter

To send a result to Flutter from Swift, you will pass in result that you got from setMethodCallHandler to authenticateWithBiometrics method. This object holds all the necessary information needed to send back a response to Flutter. You can send 2 types of responses:

Success with data:

result("Some value")

Error with message, which is received by Dart as PlatformException:

result(FlutterError(code: "ERR_CODE",
                                        message: "Error message",
                                        details: "More details"))

Run and debug

The reason it’s easier to write Swift using Xcode is because it allows us to debug it. In the following snippet, you can see how to run and start a debugging session of the app.

iOS Debugging GIF

Writing the Android implementation

The Android part will be written in Kotlin using Android Studio.

Before writing the implementation, you need to add biometric package in app/build.gradle.

implementation 'androidx.biometric:biometric:1.2.0-alpha04'
Gradle package location

Then you need to tell Android Studio to fetch the package. Go to File > Sync with Gradle Files, then wait for it to finish syncing.

Gradle sync

You also need to add the permission to access biometric fingerprint on the user’s phone. Open android/app/src/main/AndroidManifest.xml, and add the following under application tag:

<uses-permission android:name="android.permission.USE_FINGERPRINT"/>

If you’re using VS Code, right-click on the Android folder, then click on Open in Android Studio. If you’re using Android Studio, you should see a similar option, right-click on Android folder, then Flutter > Open Android module in Android Studio.

Once Android Studio window is open, expand app/java folder, then open the folder named after your application ID, usually this would start with com.example. Finally, the Android native code will be written inside MainActivity.

A screenshot from the MainActivity.kt in the source code.

In your case, the MainActivity class would look like this:

class MainActivity : FlutterActivity () {

}

It’s empty, you will start adding code inside of it.

First, add necessary imports.

import androidx.annotation.NonNull
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricManager.Authenticators.DEVICE_CREDENTIAL
import androidx.biometric.BiometricPrompt
import androidx.biometric.BiometricPrompt.AUTHENTICATION_RESULT_TYPE_UNKNOWN
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

Next, add the channel name constant and method channel variable inside MainActivity class.

/**
 * The channel name which communicates back and forth with Flutter.
 * This name MUST match exactly the one on the Flutter side.
 */
private val channelName = "samples.invertase.io/biometrics"
private lateinit var biometricsChannel: MethodChannel

Next, override configureFlutterEngine.

override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)

    // Initialize the method channel once the Flutter engines is attached.s
    biometricsChannel = MethodChannel(
        flutterEngine.dartExecutor.binaryMessenger,
        channelName
    );

    // Set a method call handler, whenever we invoke a method from Flutter
    // on "samples.invertase.io/biometrics" channel, this handler will be triggered.
    biometricsChannel.setMethodCallHandler { call, result ->
        if (call.method == "authenticateWithBiometrics") {
            authenticateWithBiometrics(result)
        } else {
            result.notImplemented()
        }
    }
}

This method is overridden from the inherited class FlutterActivity, this method is the counterpart of application method in AppDelegate in Swift. You can notice that the implementation of the MethodChannel here has a very similar API to Swift.

Next, paste the implementation of the method authenticateWithBiometrics under configureFlutterEngine.

/**
 * Show a prompt that asks the user to sign in with biometrics (either Face or Fingerprint).
 * If neither is configured by the user on their Android device, it will prompt for the passcode.
 */
private fun authenticateWithBiometrics(@NonNull result: MethodChannel.Result) {
    var resultSent = false;
    val executor = ContextCompat.getMainExecutor(this)
    val biometricPrompt = BiometricPrompt(
        this as FragmentActivity, executor,
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationError(
                errorCode: Int,
                errString: CharSequence
            ) {
                super.onAuthenticationError(errorCode, errString)
                if (errorCode != 10 && !resultSent) {
                    result.error("AUTHFAILED", errString.toString(), null)
                    resultSent = true;
                }
            }

            override fun onAuthenticationSucceeded(
                authResult: BiometricPrompt.AuthenticationResult
            ) {
                super.onAuthenticationSucceeded(authResult)
                if (!resultSent) {
                    result.success(null)
                    resultSent = true;
                }
                biometricsChannel.invokeMethod(
                    "authenticationResult",
                    authResult.authenticationType != AUTHENTICATION_RESULT_TYPE_UNKNOWN
                )
            }

            override fun onAuthenticationFailed() {
                super.onAuthenticationFailed()
                if (!resultSent) {
                    result.error("AUTHFAILED", "Unknown", null)
                    resultSent = true;
                }
            }
        })

    val promptInfo = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Biometric login")
        .setSubtitle("Log in using your biometric credential")
        .setConfirmationRequired(false)
        .setAllowedAuthenticators(BIOMETRIC_STRONG or DEVICE_CREDENTIAL)
        .build()

    biometricPrompt.authenticate(promptInfo)
}

At this point, Android Studio should show a similar warning about casting this to FragmentActivity.

FragmentActivity cast warning

The reason is that this class is a FlutterActivity, not a FlutterFragmentctivity. To fix it, go up to the class definition, and change FlutterActivity to FlutterFragmentActivity. You will also need to update the imports.

// Remove this
import io.flutter.embedding.android.FlutterActivity

// Add this
import io.flutter.embedding.android.FlutterFragmentActivity

Sending results to Flutter

To send a result to Flutter from Kotlin, you will pass in result that you got from setMethodCallHandler to authenticateWithBiometrics method. This object holds all the necessary information needed to send back a response to Flutter. You can send 2 types of responses:

Success with data:

result.success("Any value")

Error with message, which is received by Dart as PlatformException:

result.error("ERR_CODE", "Error message", "More details")

Run and debug

For the same reason Xcode was used to write the iOS code, you can run and debug the Android side of your Flutter app in Android Studio.

Android Debugging Gif

Putting it all together

Let’s break up the actual steps you need when creating any platform channel regardless of your use-case.

  1. Pick a String name for your channel, make sure it’s unique enough not to collide with other plugins in your project.
    In our example, the channel name was samples.invertase.io/biometrics.
  2. Setup a MethodChannel in both sides, Dart and native, using the same name.
  3. From the native side, call setMethodCallHandler on the newly created MethodChannel.
  4. Inside the handler, you can read the name of the method once it’s triggered from Flutter, if it matches the expected name, put your implementation, if the method name isn’t expected, throw a Not Implemented exception.
  5. From Dart, invoke the method by calling platform.invokeMethod("methodName"). The method name should match exactly the one which the native side is expecting.
  6. To call Dart methods from native, on your Dart code, set a similar handler to the one in native by calling platform.setMethodCallHandler.
  7. From the native side, invoke any method by calling methodChannel.invokeMethod(”methodName”).

Final remarks

It is highly important as a Flutter developer to learn how to implement platform channels. In fact, you’re not going to use it on a daily basis, you might actually never use it. However, it is important to understand how it works, since most of the plugins in the ecosystem are built over native APIs and third-party SDKs.

If you ever came across a case where you had to implement your own platform channels, it is advised that you take your implementation out of you project to a separated plugin project, check this guide from the official docs on how to start your own plugin. Moreover, you should consider publishing your plugin to share it with the community, which is good since more people can try it out and help you improve it!

Finally, this series is still going on! in part 3, we will see how platform channels enable using native UI elements as Flutter widgets.

Follow us on Twitter, LinkedIn, and Youtube, and subscribe to our monthly newsletter to always stay up-to-date.

Acknowledgment

Special thanks to our interns in the period between August and June 2022, Çağla Loğoğlu and Fatimah Dagriri, for helping in polishing this tutorial.

Mais Alheraki

Open Source Engineer

Categories

Tags