How to implement in-app lock in Flutter Applications

How to implement in-app lock in Flutter Applications

Introduction

In today's digital age, we rely on our mobile devices more than ever before. Our smartphones contain a lot of personal information that must be kept secure, from banking and e-commerce to social media. One way to do this is through in-app lock.

In this article, we will explore what in-app lock is, how it works, why it is important, and how to add in-app lock to Flutter apps.

What's in-app lock

In-app lock is a security feature that allows users to restrict access to specific content within an app. This is typically achieved through the use of a passcode, password, fingerprint, or facial recognition. Once enabled, the user must provide the correct authentication method to gain access to the app's content.

How does in-app lock works?

In-app lock works by restricting a user's access to the content of an app. When a user enables in-app lock, the content of the app becomes inaccessible the first time the user opens the app, and each time the user leaves the app to open another app and comes back to the app after a specific period until the user provides the correct authentication method.

Why is in-app lock important?

In-app lock is important because it provides an additional layer of security to protect sensitive information from prying eyes. For example, if you are using a bank app or a mobile wallet app on your phone, in-app lock can protect the funds in your wallet from unauthorized access if your phone is misplaced or stolen.

How to add in-app lock to Flutter apps

To implement in-app lock feature, you need to have an understanding of how the Flutter Application Lifecycle works.

Flutter Application Lifecycle

Application lifecycle monitors or manages the state that an application can be in.

In Flutter, the WidgetsBindingObserver class monitors or manages the life cycle of an app. This class has a method called didChangeAppLifecycleState that takes the AppLifeCycleState object as an argument, which is an enumeration that contains the following lifecycle states:

inactive: The application is in an inactive state and is not receiving user input.

Paused: The application is not currently visible to the user, is not responding to user input, and is running in the background.

resumed: The application is visible and responding to user input.

detached: The application is still hosted on a Flutter engine but is detached from any host views.

Now that we know what AppLifecycle is, let's see how we can work with the AppLifecycle state.

Although Flutter behaves identically on both Android and iOS there is an actual difference when it comes to AppLifeCycleState's.

Let's create a stateful widget AppLock and add the WidgetsBindingObserver class as a mixin to it, as shown below.

Mixins are a way of reusing a class’s code in multiple class hierarchies. In simple terms, mixins allow us to reuse a class's properties and methods without inheriting or sub-classing since Dart doesn't support multiple inheritance. The with keyword is used to denote mixins.

import 'package:flutter/material.dart';
class AppLock extends StatefulWidget {
  const AppLock({super.key});
  @override
  State<AppLock> createState() => _AppLockState();
}
class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
  @override
  Widget build(BuildContext context) {
    ///...
  }
}

Stateful widget gives us access to the initState and dispose method. The WidgetsBindingObserver will be added in the initState and removed in the dispose method as seen below.

class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
  }
  @override
  void dispose() {
    super.dispose();
    WidgetsBinding.instance.removeObserver(this);
  }
  @override
  Widget build(BuildContext context) {
    ///...
  }
}

The didChangeAppLifecycleState method from the WidgetsBindingObserver class exposes the various lifecycle states that we discussed earlier, and we are going to perform various actions based on the state of our app.

class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
  //...
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {}
}

Let's create two variables.

DateTime? _dateTimeBeforeAppWasInactive will be used to keep track of the time when the application became inactive.

int _lockDurationSeconds will store the number of seconds the app is allowed to stay in the background before we show a lock screen.

class _AppLockState extends State<AppLock> with WidgetsBindingObserver {
  DateTime? _dateTimeBeforeAppWasInactive;
  int _lockDurationSeconds = 60;
  //...
}

Then, inside the didChangeAppLifecycleState method, we can check the current AppLifecycleState of the app.

After successful access to the app the first time, if we minimize the app or leave the app for another app, the app transitions to an inactive state. We set the value of _dateTimeBeforeAppWasInactive to DateTime.now() then the app transitions to a paused state.

When we come back to the app after some time on Android, the app transitions to a resumed state, and we call the _showLock method.

class _AppLockState extends State<AppLock> with WidgetsBindingObserver {  
  //...
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      _showLock();
    } else if (state == AppLifecycleState.inactive) {
      _dateTimeBeforeAppWasInactive = DateTime.now();
    }
  }
}

This works pretty well on Android, but on iOS it doesn't.

When we come back to the app after some time on iOS, the app transitions to an inactive state, and the previously set value of _dateTimeBeforeAppWasInactive is overwritten. Then the app transitions to a resumed state.

The time difference between the DateTime.now() and _dateTimeBeforeAppWasInactive will be 0 seconds. As a result, no matter how long the app spends running in the background, it will never ask for a password or passcode on iOS.

Let's create a variable called _isInactive to keep track of the inactive state of the app and set its initial value to false.

If the value of _isInactive is false, the code inside the curly braces will be executed.

if _isInactive is true, this indicates that the application is now considered to be inactive. Hence, the value of _dateTimeBeforeAppWasInactive won't be overwritten the second time the app transitions to an inactive state on iOS. As a result, our app behaves as expected on both Android and iOS.

  bool _isInactive = false;
  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
      ///...
    else if (state == AppLifecycleState.inactive) {
      if (!_isInactive) {
        _isInactive = true;
        _dateTimeBeforeAppWasInactive = DateTime.now();
      }
    }
  }

Below is the implementation of the _showLock method. If the time difference between DateTime.now() and _dateTimeBeforeAppWasInactiveexceeds exceeds _lockDurationSeconds, we navigate to a lock screen by calling _showLockScreen method.

  void _showLock() async {
    if (_dateTimeBeforeAppWasInactive != null) {
      var difference =
          DateTime.now().difference(_dateTimeBeforeAppWasInactive!);
      if (difference.inSeconds >= _lockDurationSeconds) {
          //Navigate to the lock screen
          _showLockScreen();
      } else {
        _dateTimeBeforeAppWasInactive = null;
      }
    }
  }

Below is the implementation of the _showLockScreen method.

  final GlobalKey<NavigatorState> _navigatorKey = GlobalKey();
  Future<Object?> _showLockScreen() {
    return _navigatorKey.currentState!.pushNamed<Object?>('/lock-    screen');
  }
  ///...

To prevent the app from navigating to the lock screen multiple times, we are going to create a variable called _isLocked and set its initial value to false. When the _showLock method is called, we check if _isLocked is false, we set _isLocked to true and navigate to the lock screen; otherwise, we have already navigated to the lock screen and don't need to navigate again.

  bool _isLocked = false;
  void _showLock() async {
    // implementation clipped for brevity
    if (!_isLocked) {
      _isLocked = true;
      _showLockScreen();
    }
  }

If the app is currently running, calling didUnlock will cause AppLock to pop the lockScreen, otherwise, it will create the widget returned by the [builder] function.

  bool _didUnlockForAppLaunch = true;

  void didUnlock(Object? args) {
    if (_didUnlockForAppLaunch) {
      _didUnlockOnAppLaunch(true);
    } else {
      _didUnlockOnAppPaused(true);
    }
  }

  void _didUnlockOnAppLaunch(Object? args) {
    _didUnlockForAppLaunch = false;
    _navigatorKey.currentState!
        .pushReplacementNamed('/unlocked', arguments: args);
  }

  void _didUnlockOnAppPaused(Object? args) {
    _isLocked = false;
    _dateTimeBeforeAppWasInactive = null;
    _navigatorKey.currentState!.pop(args);
  }

The code below is used to get the State object of the nearest ancestor of the AppLock Stateful widget, which is an instance of type AppLockState.

findAncestorStateOfType method describes in its name what its purpose is - to find a Widget that is above in the Widget tree whose State matches the ScaffoldState

//...  
static AppLockState? of(BuildContext context) =>
      context.findAncestorStateOfType<AppLockState>();
  @override
  State<AppLock> createState() => AppLockState();
//...

After navigating to the lock screen, we can call didUnlock method from the LockScreen widget to unlock the app.

AppLock.of(context)?.didUnlock(true);

To enable or disable the app-lock feature, we are going to create a variable called _enabled with three other methods to conveniently update the _enabled value.

If _enabled is true, AppLock displays the lockScreen on subsequent app pauses; otherwise, AppLock does not display it on subsequent app pauses.

bool _enabled = true;
void setEnabled(bool enabled) {
  if (enabled) {
    enable();
  } else {
    disable();
  }
}

void enable() {
  setState(() {
    _enabled = true;
  });
}

void disable() {
  setState(() {
    _enabled = false;
  });
}

The AppLock stateful widget is going to return a MaterialApp widget with two routes.

  1. '/lock-screen' routes to the LockScreen

  2. '/unlocked' routes to the unlocked screen

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: widget.enabled ? _lockScreen : widget.builder(null),
      navigatorKey: _navigatorKey,
      routes: <String, WidgetBuilder>{
        '/lock-screen': (context) => _lockScreen,
        '/unlocked': (context) =>
            widget.builder(ModalRoute.of(context)!.settings.arguments)
      },
    );
  }

The _lockScreen method returns the LockScreen widget that is wrapped with the WillPopScope widget.

The WillPopScope widget comes with the Flutter framework. It allows us to control the back button navigation. This is achieved using a callback, which the widget takes in as one of its parameters.

  Widget get _lockScreen {
    return WillPopScope(
      child: LockScreen(),
      onWillPop: () => Future.value(false),
    );
  }

Wrap the MyApp (or similar widget) initialization in a function and pass it to the builder property of AppLock widget.

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return AppLock(
      builder: (args) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          routes: <String, WidgetBuilder>{
            '/': (context) => const Home(),
          },
        );
      },
    );
  }
}

Conclusion

In this article, we have seen that in-app lock is a valuable security feature that can help protect your personal information and prevent unauthorized access to sensitive data. We have also seen how we can add in-app lock to Flutter apps.

The complete code can be found on Github

Thank you for reading.