For the application of one of our clients, we had to find and implement a solution to limit the simultaneous screen count in use from each user account. We have an iOS and android application and a website developed with flutter, and we want the solution to apply to these three targets. As the app is subscription-based, we only want a user to be able to use it on two devices simultaneously.
What we are trying to achieve here is, if a user already uses the application on 2 devices:
-
prevent the user from logging in on a new device;
-
disconnect him if he opens the app on a device he’s already connected to.
I decided to present the solution we chose and the implementation steps in this article.
The app on which we implemented this solution already used Firebase authentication and Firestore Database, so using Realtime Database was an obvious choice for us.
How to configure Firebase
Create a Firebase Realtime Database
Firstly, you need to create a Realtime Database on your Firebase console. You can start by putting your rules in test mode, which allows anyone with the URL to read and write data but don’t worry, we’ll change that later.
For now, the goal is to connect our app to this newly created database. You can do this easily with Flutterfire CLI:
-
in the terminal, go to the root of your flutter app;
-
use the
flutterfire configure
command, and follow the instructions.
This generates a firebase_options.dart file which you will use in your main function when initializing Firebase :
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
To do this, you must add the firebase_database dependency to your pubspec.yaml
file.
We thought of the following solution about how we will organize the data in this database:
Users will write under logged_in_users
the number of screens connected to the user account next to the user's unique id.
Set the rules of your database
We will use the rules tab to implement the logic behind the solution. We want the user not to be able to add a connected device if the value is above 2. This is ensured by the following rules. The user will only be able to write on the database if the value he sent is under or equals 2, meaning they can only have 2 simultaneous connections.
{
"rules": {
".read": false,
".write": false,
"logged_in_users":{
"$uid":{
".validate": "newData.isNumber() && newData.val()<=2 && newData.val()>=0",
".read": true,
".write": true
}
}
}
}
Create the Bloc in charge of the logic
Our app state management is handled with Bloc, so our approach for this solution is to create a Bloc named ScreenLimitBloc
that will be in charge of the logic behind this.
Define the states of the bloc
Our app can be in 3 different states that we are going to define in a screen_limit_state.dart
file:
-
a correct device count state:
DeviceCountCorrect
when the user has the permission to log in; -
an over-limit device count state:
DeviceCountOverLimit
when the user already has the maximum count of devices simultaneously connected; -
and an initial state:
ScreenLimitInitial
when the user is not logged in.
Given these explanations, you should have the following file now:
part of 'screen_limit_bloc.dart';
@immutable
abstract class ScreenLimitState {}
class ScreenLimitInitial extends ScreenLimitState {}
class DeviceCountCorrect extends ScreenLimitState {}
class DeviceCountOverLimit extends ScreenLimitState {}
Define the events of the bloc
Now, to put the Bloc in the correct or over limit state, we should have an event called CheckDeviceCount
that will call a function to check whether the user can log in. We will implement that function later. You should have a screen_limit_event.dart
file looking like this :
part of 'screen_limit_bloc.dart';
@immutable
abstract class ScreenLimitEvent {}
class CheckDeviceCount extends ScreenLimitEvent {}
Define the bloc
Now let’s move on to the important part: creating the Bloc. In a new file called screen_limit_bloc.dart
, you can create a Bloc called ScreenLimitBloc
:
class ScreenLimitBloc extends Bloc<ScreenLimitEvent, ScreenLimitState> {
ScreenLimitBloc({}) : super(ScreenlimitInitial()) {
on<CheckDeviceCount>(_onCheckDeviceCount);
}
}
The function _onCheckDeviceCount
is called each time you add the CheckDeviceCount
event to the Bloc. Basically, it sets the Firebase Database reference to tell the function where to read and write on the realtime database, and then check if the user can login. To get the reference, we must recover the actual user’s id.
To read and write data on the database, we must get the DatabaseReference
first, and we can do so with the following command:
final userId = _authenticationRepository.currentUser.id;
ref = FirebaseDatabase.instance.ref('logged_in_users/$userId/');
This allows us to use the get()
method to recover the database value and the set(value)
method to set a new value on the database.
To do that, we add the authentication repository as a dependency of the Bloc:
class ScreenLimitBloc extends Bloc<ScreenLimitEvent, ScreenLimitState> {
ScreenLimitBloc({
required AuthenticationRepository authenticationRepository,
}) : _authenticationRepository = authenticationRepository,
super(ScreenLimitInitial()) {
on<CheckDeviceCount>(_onCheckDeviceCount);
}
}
So we can now write our function :
Future<void> _onCheckDeviceCount(
CheckDeviceCount event,
Emitter<ScreenLimitState> emit,
) async {
if (state is ScreenLimitInitial) {
final userId = _authenticationRepository.currentUser.id;
ref = FirebaseDatabase.instance.ref('logged_in_users/$userId/');
deviceCountExceeded = false;
await ref
.set(ServerValue.increment(1))
.onError<FirebaseException>((error, _) async {
if (error.code == 'permission-denied') {
deviceCountExceeded = true;
return _onScreenNumberExceeded(emit, ref);
}
});
if (!deviceCountExceeded) {
emit(DeviceCountCorrect());
}
}
}
This works well with the realtime database rules that we defined earlier (the validate
line). The trick was to use the .set(ServerValue.increment(1))
so that the front did not handle all the logic. The Bloc has no idea of the server value. Still, if the request is rejected by the database (meaning that the value is above 2), the user already has his maximum simultaneous connections. This is great because if we try to connect 3 devices simultaneously, the request will always be rejected on one of them. Whereas if we were reading the value first and then doing all the logic in the front and not by rules, we could have issues when we launch the application at the same time on multiple devices.
The next thing to handle is decreasing the server value by 1 when the user logs out. We are going to add an event to the screen_limit_event.dart
file :
class DecreaseDeviceCount extends ScreenLimitEvent {}
Let’s add a function to the Bloc that will be called when this event is added:
on<DecreaseDeviceCount>(_onDecreaseDeviceCount);
...
Future<void> _onDecreaseDeviceCount(
DecreaseDeviceCount event,
Emitter<ScreenLimitState> emit,
) async {
if (state is ScreenLimitCorrect) {
await ref.set(ServerValue.increment(-1));
emit(ScreenLimitInitial());
}
}
This function will do the trick if everything goes well, but what if the app crashes or the user kills the app without disconnecting himself? Those are cases you want to handle, and that’s where the onDisconnect
method comes on stage. According to Google’s documentation : the OnDisconnect class is used to manage operations that will be run on the server when this client disconnects. It can be used to add or remove data based on a client's connection status. It is very useful in applications looking for 'presence' functionality. That’s exactly what we are trying to achieve here.
To implement this, just add this line in the _onCheckDeviceCount
function, before emitting the DeviceCountCorrect
state:
await ref.onDisconnect().set(ServerValue.increment(-1));
⚠️ You also want to add a ref.onDisconnect().cancel()
when the user disconnects, otherwise if he kills the app right after being disconnected, his screen count would decrease by 2.
Disconnect the user if he use too much simultaneous screens
We’re almost done here, there are just 2 more functions to write. The first one is to handle the case where the Bloc goes into DeviceCountOverLimit
state, and that’s the function we called earlier _onDeviceCountExceeded
:
Future<void> _onDeviceCountExceeded(
Emitter<ScreenLimitState> emit,
DatabaseReference ref,
) async {
emit(DeviceCountOverLimit());
emit(ScreenLimitInitial());
}
We will later listen for those state changes to react to it to force the user’s disconnection. This way, we don’t handle the disconnection logic in our Bloc.
The last thing to do is to add events to this Bloc depending on the authentication repository changes:
-
if the user changes, we want to add the
CheckDeviceCount
event; -
if the user goes empty, meaning he is disconnected, we want to add the
DecreaseDeviceCount
event.
To do so, we will add the authentication_repository.user
subscription to our ScreenLimitBloc
:
late StreamSubscription userSubscription;
void listenToUserChanges() {
userSubscription = _authenticationRepository.user.listen((user) {
if (user == User.empty) {
add(DecreaseDeviceCount());
} else {
add(CheckDeviceCount());
}
});
}
You have to call this listenToUserChanges
function when you initialize your Bloc (just below the on<DecreaseDeviceCount>(_onDecreaseDeviceCount);
line).
There you go, all you have to do is add your ScreenLimitBloc
with BlocProvider
and wrap your app with a ScreenLimitListener
in your app.dart
file :
class ScreenLimitListener extends StatelessWidget {
const ScreenLimitListener({Key? key, required this.child}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return BlocListener<ScreenLimitBloc, ScreenLimitState>(
listener: (context, state) {
if (state is DeviceCountOverLimit) {
context.read<AppBloc>().add(AppLogoutRequested());
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
const SnackBar(
content: Text('Ooops, too many simultaneous screens'),
),
);
}
},
child: child,
);
}
}
BlocProvider(
create: (_) => ScreenLimitBloc(
authenticationRepository: _authenticationRepository,
),
)
...
class AppView extends StatelessWidget {
const AppView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: theme,
home: ScreenLimitListener(
child: FlowBuilder<AppStatus>(
state: context.select((AppBloc bloc) => bloc.state.status),
onGeneratePages: onGenerateAppViewPages,
),
),
);
}
}
The ScreenLimitListener
is responsible for disconnecting the user if the state of the ScreenLimitBloc
is DeviceCountOverLimit
, and for showing a little snack bar that indicates to the user what was the reason he was disconnected.
If you want to see the concepts we applied in this article, feel free to look at this repository, and, more specifically, at this commit where we added the whole screen limit logic.
Conclusion
This method allowed us to prevent simultaneous login of the same user on multiple devices in a relatively simple way. Indeed, if you check the commit where we added all this logic from a basic authentication app, you will see that it’s not even 200 lines of code. That’s ridiculously low for such an important feature!
You can check this little experiment of the solution in real conditions:
Finally, although I use Firebase authentication in this solution in the listenToUserChanges
function to detect any user changes (via the firebaseAuth.authStateChanges
method), any method like oauth or even a personal authentication method should do the trick if you adapt the listenToUserChanges
to listen to your particular state.