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.
'/lock-screen'
routes to theLockScreen
'/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.