1. Introduction
Last Updated: 2022-03-14
FlutterFire for cross device communication
As we witness a large number of home automation, wearable and personal health technology devices coming online, cross device communication becomes an increasingly important part of building mobile applications. Setting up cross device communication such as controlling a browser from a phone app, or controlling what plays on your TV from your phone, is traditionally more complex than building a normal mobile app .
Firebase's Realtime Database provides the Presence API which allows users to see their device online/offline status; you'll use it with the Firebase Installations Service to track and connect all the devices where the same user has signed in. You'll use Flutter to quickly create applications for multiple platforms, and then you'll build a cross device prototype that plays music on one device and controls the music on another!
What you'll build
In this codelab, you'll build a simple music player remote controller. Your app will:
- Have a simple music player on Android, iOS and web, built with Flutter.
- Allow users to sign in.
- Connect devices when the same user is signed in on multiple devices.
- Allow users to control music playback on one device from another device.
What you'll learn
- How to build and run a Flutter music player app.
- How to allow users to sign in with Firebase Auth.
- How to use the Firebase RTDB Presence API and Firebase Installation Service to connect devices.
What you'll need
- A Flutter development environment. Follow the instructions in the Flutter installation guide to set it up.
- A minimum Flutter version of 2.10 or higher is required. If you have a lower version, run
flutter upgrade.
- A Firebase account.
2. Getting set up
Get the starter code
We have created a music player app in Flutter. The starter code is located in a Git repo. To get started, on the command line, clone the repo, move into the folder with the starting state, and install dependencies:
git clone https://github.com/FirebaseExtended/cross-device-controller.git
cd cross-device-controller/starter_code
flutter pub get
Build the app
You can work with your favorite IDE to build the app, or use the command line.
In your app directory, build the app for web with the command flutter run -d web-server.
You should be able to see the following prompt.
lib/main.dart is being served at http://localhost:<port>
Visit http://localhost:<port>
to see the music player.
If you're familiar with the Android emulator or iOS simulator, you can build the app for those platforms and install it with the command flutter run -d <device_name>
.
The web app should show a basic standalone music player. Make sure the player features are working as intended. This is a simple music player app designed for this codelab. It can only play a Firebase song, Better Together.
Set up an Android emulator or an iOS simulator
If you already have an Android device or iOS device for development, you can skip this step.
To create an Android emulator, download Android Studio which also supports Flutter development, and follow the instructions in Create and manage virtual devices.
To create an iOS simulator, you will need a Mac environment. Download XCode, and follow the instructions in Simulator Overview > Use Simulator > Open and close a simulator.
3. Setting up Firebase
Create a Firebase project
Open a browser to http://console.firebase.google.com/.
- Sign in to Firebase.
- In the Firebase console, click Add Project (or Create a project), and name your Firebase project Firebase-Cross-Device-Codelab.
- Click through the project creation options. Accept the Firebase terms if prompted. Skip setting up Google Analytics, because you won't be using Analytics for this app.
You don't need to download the files mentioned or change build.gradle files. You will configure them when you initialize FlutterFire.
Install Firebase SDK
Back on the command line, in the project directory, run the following command to install Firebase:
flutter pub add firebase_core
In the pubspec.yaml
file, edit the version for firebase_core
to be at least 1.13.1, or run flutter upgrade
Initialize FlutterFire
- If you don't have the Firebase CLI installed, you can install it by running
curl -sL https://firebase.tools | bash
. - Log in by running
firebase login
and following the prompts. - Install the FlutterFire CLI by running
dart pub global activate flutterfire_cli
. - Configure the FlutterFire CLI by running
flutterfire configure
. - At the prompt, choose the project you've just created for this codelab, something like Firebase-Cross-Device-Codelab.
- Select iOS, Android and Web when you are prompted to choose configuration support.
- When prompted for the Apple bundle ID, type in a unique domain, or enter
com.example.appname
, which is fine for the purpose of this codelab.
Once configured, a firebase_options.dart
file will be generated for you containing all the options required for initialization.
In your editor, add the following code to your main.dart file to initialize Flutter and Firebase:
lib/main.dart
import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(const MyMusicBoxApp());
}
Compile the app with the command:
flutter run
You haven't changed any UI elements yet, so the app's look and behavior haven't changed. But now you have a Firebase app, and can start using Firebase products, including:
- Firebase Authentication, which allows your users to sign in to your app.
- Firebase Realtime Database(RTDB); you'll use the presence API to track device online/offline status
- Firebase Security Rules will let you secure the database.
- Firebase Installations Service to identify the devices that a single user has signed into.
4. Add Firebase Auth
Enable email sign-in for Firebase Authentication
To allow users to sign in to the web app, you'll use the Email/Password sign-in method:
- In the Firebase console, expand the Build menu in the left panel.
- Click Authentication, and then click the Get Started button, then the Sign-in method tab.
- Click Email/Password in the Sign-in providers list, set the Enable switch to the on position, and then click Save.
Configure Firebase Authentication in Flutter
On the command line, run the following commands to install the necessary flutter packages:
flutter pub add firebase_auth
flutter pub add provider
With this configuration, you can now create the sign-in and sign-out flow. Since auth state should not change from screen to screen, you will create an application_state.dart
class to keep track of app level state changes, such as log in and log out. Learn more about this in the Flutter state management documentation.
Paste the following into the new application_state.dart
file:
lib/src/application_state.dart
import 'package:firebase_auth/firebase_auth.dart'; // new
import 'package:firebase_core/firebase_core.dart'; // new
import 'package:flutter/material.dart';
import '../firebase_options.dart';
import 'authentication.dart';
class ApplicationState extends ChangeNotifier {
ApplicationState() {
init();
}
Future<void> init() async {
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
FirebaseAuth.instance.userChanges().listen((user) {
if (user != null) {
_loginState = ApplicationLoginState.loggedIn;
} else {
_loginState = ApplicationLoginState.loggedOut;
}
notifyListeners();
});
}
ApplicationLoginState _loginState = ApplicationLoginState.loggedOut;
ApplicationLoginState get loginState => _loginState;
String? _email;
String? get email => _email;
void startLoginFlow() {
_loginState = ApplicationLoginState.emailAddress;
notifyListeners();
}
Future<void> verifyEmail(
String email,
void Function(FirebaseAuthException e) errorCallback,
) async {
try {
var methods =
await FirebaseAuth.instance.fetchSignInMethodsForEmail(email);
if (methods.contains('password')) {
_loginState = ApplicationLoginState.password;
} else {
_loginState = ApplicationLoginState.register;
}
_email = email;
notifyListeners();
} on FirebaseAuthException catch (e) {
errorCallback(e);
}
}
Future<void> signInWithEmailAndPassword(
String email,
String password,
void Function(FirebaseAuthException e) errorCallback,
) async {
try {
await FirebaseAuth.instance.signInWithEmailAndPassword(
email: email,
password: password,
);
} on FirebaseAuthException catch (e) {
errorCallback(e);
}
}
void cancelRegistration() {
_loginState = ApplicationLoginState.emailAddress;
notifyListeners();
}
Future<void> registerAccount(
String email,
String displayName,
String password,
void Function(FirebaseAuthException e) errorCallback) async {
try {
var credential = await FirebaseAuth.instance
.createUserWithEmailAndPassword(email: email, password: password);
await credential.user!.updateDisplayName(displayName);
} on FirebaseAuthException catch (e) {
errorCallback(e);
}
}
void signOut() {
FirebaseAuth.instance.signOut();
}
}
To make sure ApplicationState
will be initialized when the app starts, you'll add an initialization step to main.dart
:
lib/main.dart
import 'src/application_state.dart';
import 'package:provider/provider.dart';
void main() async {
...
runApp(ChangeNotifierProvider(
create: (context) => ApplicationState(),
builder: (context, _) => const MyMusicBoxApp(),
));
}
Again, the application UI should have remained the same, but now you can let users sign in and save app states.
Create a sign in flow
In this step, you will work on the sign in and sign out flow. Here is what the flow will look like:
- A logged out user will initiate the sign-in flow by clicking on the context menu on the right hand side of the app bar.
- The sign-in flow will be displayed in a dialog.
- If the user has never signed in before, they will be prompted to create an account using a valid email address and a password.
- If the user has signed in before, they will be prompted to enter their password.
- Once the user is signed in, clicking on the context menu will show a Sign out option.
Adding sign-in flow requires three steps.
First of all, create an AppBarMenuButton
widget. This widget will control the context menu popup depending on a user's loginState
. Add the imports
lib/src/widgets.dart
import 'application_state.dart';
import 'package:provider/provider.dart';
import 'authentication.dart';
Append the following code to widgets.dart.
lib/src/widgets.dart
class AppBarMenuButton extends StatelessWidget {
const AppBarMenuButton({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Consumer<ApplicationState>(
builder: (context, appState, child) {
if (appState.loginState == ApplicationLoginState.loggedIn) {
return SignedInMenuButton(buildContext: context);
}
return SignInMenuButton(buildContext: context);
},
);
}
}
class SignedInMenuButton extends StatelessWidget {
const SignedInMenuButton({Key? key, required this.buildContext})
: super(key: key);
final BuildContext buildContext;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
onSelected: _handleSignedInMenu,
color: Colors.deepPurple.shade300,
itemBuilder: (context) => _getMenuItemBuilder(),
);
}
List<PopupMenuEntry<String>> _getMenuItemBuilder() {
return [
const PopupMenuItem<String>(
value: 'Sign out',
child: Text(
'Sign out',
style: TextStyle(color: Colors.white),
),
)
];
}
Future<void> _handleSignedInMenu(String value) async {
switch (value) {
case 'Sign out':
Provider.of<ApplicationState>(buildContext, listen: false).signOut();
break;
}
}
}
class SignInMenuButton extends StatelessWidget {
const SignInMenuButton({Key? key, required this.buildContext})
: super(key: key);
final BuildContext buildContext;
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
onSelected: _signIn,
color: Colors.deepPurple.shade300,
itemBuilder: (context) => _getMenuItemBuilder(context),
);
}
Future<void> _signIn(String value) async {
return showDialog<void>(
context: buildContext,
builder: (context) => const SignInDialog(),
);
}
List<PopupMenuEntry<String>> _getMenuItemBuilder(BuildContext context) {
return [
const PopupMenuItem<String>(
value: 'Sign in',
child: Text(
'Sign in',
style: TextStyle(color: Colors.white),
),
),
];
}
}
Second, in the same widgets.dart
class, create the SignInDialog
widget.
lib/src/widgets.dart
class SignInDialog extends AlertDialog {
const SignInDialog({Key? key}) : super(key: key);
@override
AlertDialog build(BuildContext context) {
return AlertDialog(
content: Column(mainAxisSize: MainAxisSize.min, children: [
Consumer<ApplicationState>(
builder: (context, appState, _) => Authentication(
email: appState.email,
loginState: appState.loginState,
startLoginFlow: appState.startLoginFlow,
verifyEmail: appState.verifyEmail,
signInWithEmailAndPassword: appState.signInWithEmailAndPassword,
cancelRegistration: appState.cancelRegistration,
registerAccount: appState.registerAccount,
signOut: appState.signOut,
),
),
]),
);
}
}
Third, find the existing appBar widget in main.dart.
Add the AppBarMenuButton
to display the Sign in or Sign out option.
lib/main.dart
import 'src/widgets.dart';
appBar: AppBar(
title: const Text('Music Box'),
backgroundColor: Colors.deepPurple.shade400,
actions: const <Widget>[
AppBarMenuButton(),
],
),
Run the command flutter run
to restart the app with these changes. You should be able to see the context menu on the right hand side of the app bar. Clicking on it will take you to a sign-in dialog.
Once you sign in with a valid email address and a password, you should be able to see a Sign out option in the context menu.
In the Firebase console, under Authentication, you should be able to see the email address listed as a new user.
Congratulations! Users can now sign into the app!
5. Add database connection
Now you are ready to move on to device registration using the Firebase Presence API.
On the command line, run the following commands to add the necessary dependencies:
flutter pub add firebase_app_installations
flutter pub add firebase_database
Create a database
In the Firebase console,
- Navigate to the Realtime Database section of the Firebase console. Click Create Database.
- If prompted to select a starting mode for your security rules, select Test Mode for now**.** (Test Mode creates Security Rules that allows all requests through. You'll add Security Rules later. It's important to never go to production with your Security Rules still in Test Mode.)
The database is empty for now. Locate your databaseURL
in Project settings, under the General tab. Scroll down to the Web apps section.
Add your databaseURL
to the firebase_options.dart
file:
lib/firebase_options.dart
static const FirebaseOptions web = FirebaseOptions(
apiKey: yourApiKey,
...
databaseURL: 'https://<YOUR_DATABASE_URL>,
...
);
Register devices using the RTDB Presence API
You want to register a user's devices when they appear online. To do this, you'll take advantage of Firebase Installations and the Firebase RTDB Presence API to keep track of a list of online devices from a single user. The following code will help accomplish this goal:
lib/src/application_state.dart
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:firebase_database/firebase_database.dart';
import 'package:firebase_app_installations/firebase_app_installations.dart';
class ApplicationState extends ChangeNotifier {
String? _deviceId;
String? _uid;
Future<void> init() async {
...
FirebaseAuth.instance.userChanges().listen((user) {
if (user != null) {
_loginState = ApplicationLoginState.loggedIn;
_uid = user.uid;
_addUserDevice();
}
...
});
}
Future<void> _addUserDevice() async {
_uid = FirebaseAuth.instance.currentUser?.uid;
String deviceType = _getDevicePlatform();
// Create two objects which we will write to the
// Realtime database when this device is offline or online
var isOfflineForDatabase = {
'type': deviceType,
'state': 'offline',
'last_changed': ServerValue.timestamp,
};
var isOnlineForDatabase = {
'type': deviceType,
'state': 'online',
'last_changed': ServerValue.timestamp,
};
var devicesRef =
FirebaseDatabase.instance.ref().child('/users/$_uid/devices');
FirebaseInstallations.instance
.getId()
.then((id) => _deviceId = id)
.then((_) {
// Use the semi-persistent Firebase Installation Id to key devices
var deviceStatusRef = devicesRef.child('$_deviceId');
// RTDB Presence API
FirebaseDatabase.instance
.ref()
.child('.info/connected')
.onValue
.listen((data) {
if (data.snapshot.value == false) {
return;
}
deviceStatusRef.onDisconnect().set(isOfflineForDatabase).then((_) {
deviceStatusRef.set(isOnlineForDatabase);
});
});
});
}
String _getDevicePlatform() {
if (kIsWeb) {
return 'Web';
} else if (Platform.isIOS) {
return 'iOS';
} else if (Platform.isAndroid) {
return 'Android';
}
return 'Unknown';
}
Back on the command line, build and run the app on your device or in a browser with flutter run.
In your app, sign in as a user. Remember to sign in as the same user on different platforms.
In the Firebase console, you should see your devices showing up under one user ID in your database.
6. Sync device state
Select a lead device
To sync states between devices, designate one device as the leader, or the controller. The lead device will dictate the states on the follower devices.
Create a setLeadDevice
method in application_state.dart
, and track this device with the key active_device
in RTDB:
lib/src/application_state.dart
bool _isLeadDevice = false;
String? leadDeviceType;
Future<void> setLeadDevice() async {
if (_uid != null && _deviceId != null) {
var playerRef =
FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
await playerRef
.update({'id': _deviceId, 'type': _getDevicePlatform()}).then((_) {
_isLeadDevice = true;
});
}
}
To add this functionality to the app bar context menu, create a PopupMenuItem
called Controller
by modifying the SignedInMenuButton
widget. This menu will allow users to set the lead device.
lib/src/widgets.dart
class SignedInMenuButton extends StatelessWidget {
const SignedInMenuButton({Key? key, required this.buildContext})
: super(key: key);
final BuildContext buildContext;
List<PopupMenuEntry<String>> _getMenuItemBuilder() {
return [
const PopupMenuItem<String>(
value: 'Sign out',
child: Text(
'Sign out',
style: TextStyle(color: Colors.white),
),
),
const PopupMenuItem<String>(
value: 'Controller',
child: Text(
'Set as controller',
style: TextStyle(color: Colors.white),
),
)
];
}
void _handleSignedInMenu(String value) async {
switch (value) {
...
case 'Controller':
Provider.of<ApplicationState>(buildContext, listen: false)
.setLeadDevice();
}
}
}
Write the lead device's state to database
Once you have set a lead device, you can sync the lead device's states to RTDB with the following code. Append the following code to the end of application_state.dart.
This will start storing two attributes: the player state (play or pause) and the slider position.
lib/src/application_state.dart
Future<void> setLeadDeviceState(
int playerState, double sliderPosition) async {
if (_isLeadDevice && _uid != null && _deviceId != null) {
var leadDeviceStateRef =
FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
try {
var playerSnapshot = {
'id': _deviceId,
'state': playerState,
'type': _getDevicePlatform(),
'slider_position': sliderPosition
};
await leadDeviceStateRef.set(playerSnapshot);
} catch (e) {
throw Exception('updated playerState with error');
}
}
}
And finally, you need to call setActiveDeviceState
whenever the controller's player state updates. Make the following changes to the existing player_widget.dart
file:
lib/player_widget.dart
import 'package:provider/provider.dart';
import 'application_state.dart';
void _onSliderChangeHandler(v) {
...
// update player state in RTDB if device is active
Provider.of<ApplicationState>(context, listen: false)
.setLeadDeviceState(_playerState.index, _sliderPosition);
}
Future<int> _pause() async {
...
// update DB if device is active
Provider.of<ApplicationState>(context, listen: false)
.setLeadDeviceState(_playerState.index, _sliderPosition);
return result;
}
Future<int> _play() async {
var result = 0;
// update DB if device is active
Provider.of<ApplicationState>(context, listen: false)
.setLeadDeviceState(PlayerState.PLAYING.index, _sliderPosition);
if (_playerState == PlayerState.PAUSED) {
result = await _audioPlayer.resume();
return result;
}
...
}
Future<int> _updatePositionAndSlider(Duration tempPosition) async {
...
// update DB if device is active
Provider.of<ApplicationState>(context, listen: false)
.setLeadDeviceState(_playerState.index, _sliderPosition);
return result;
}
Read the lead device's state from database
There are two parts to read and use the lead device's state. First, you want to set up a database listener of the lead player state in application_state
. This listener will tell the follower devices when to update the screen via a callback. Notice you have defined an interface OnLeadDeviceChangeCallback
in this step. It's not implemented yet; you will implement this interface in player_widget.dart
in the next step.
lib/src/application_state.dart
// Interface to be implemented by PlayerWidget
typedef OnLeadDeviceChangeCallback = void Function(
Map<dynamic, dynamic> snapshot);
class ApplicationState extends ChangeNotifier {
...
OnLeadDeviceChangeCallback? onLeadDeviceChangeCallback;
Future<void> init() async {
FirebaseAuth.instance.userChanges().listen((user) {
if (user != null) {
_loginState = ApplicationLoginState.loggedIn;
_uid = user.uid;
_addUserDevice().then((_) => listenToLeadDeviceChange());
}
...
});
}
Future<void> listenToLeadDeviceChange() async {
if (_uid != null) {
var activeDeviceRef =
FirebaseDatabase.instance.ref().child('/users/$_uid/active_device');
activeDeviceRef.onValue.listen((event) {
final activeDeviceState = event.snapshot.value as Map<dynamic, dynamic>;
String activeDeviceKey = activeDeviceState['id'] as String;
_isLeadDevice = _deviceId == activeDeviceKey;
leadDeviceType = activeDeviceState['type'] as String;
if (!_isLeadDevice) {
onLeadDeviceChangeCallback?.call(activeDeviceState);
}
notifyListeners();
});
}
}
Second, start the database listener during player initialization in player_widget.dart
. Pass the _updatePlayer
function so that the follower player state can be updated whenever the database value changes.
lib/player_widget.dart
class _PlayerWidgetState extends State<PlayerWidget> {
@override
void initState() {
...
Provider.of<ApplicationState>(context, listen: false)
.onLeadDeviceChangeCallback = updatePlayer;
}
void updatePlayer(Map<dynamic, dynamic> snapshot) {
_updatePlayer(snapshot['state'], snapshot['slider_position']);
}
void _updatePlayer(dynamic state, dynamic sliderPosition) {
if (state is int && sliderPosition is double) {
try {
_updateSlider(sliderPosition);
final PlayerState newState = PlayerState.values[state];
if (newState != _playerState) {
switch (newState) {
case PlayerState.PLAYING:
_play();
break;
case PlayerState.PAUSED:
_pause();
break;
case PlayerState.STOPPED:
case PlayerState.COMPLETED:
_stop();
break;
}
_playerState = newState;
}
} catch (e) {
if (kDebugMode) {
print('sync player failed');
}
}
}
}
Now you are ready to test the app:
- On the command line, run the app on emulators and/or in a browser with:
flutter run -d <device-name>
- Open the apps in a browser, on an iOS simulator, or an Android emulator. Go to the context menu, choose one app to be the leader device. You should be able to see the follower devices' players change as the leader device updates.
- Now change the leader device, play or pause music, and observe the follower devices updating accordingly.
If the follower devices update properly, you have succeeded in making a cross device controller. There's just one crucial step left.
7. Update Security Rules
Unless we write better security rules, someone could write a state to a device that they don't own! So before you finish, update the Realtime Database Security Rules to make sure the only users who can read or write to a device is the user who is signed into that device. In the Firebase Console, navigate to the Realtime Database, and then to the Rules tab. Paste the following rules allowing only signed in user to read and write their own device states:
{
"rules": {
"users": {
"$uid": {
".read": "$uid === auth.uid",
".write": "$uid === auth.uid"
}
},
}
}
8. Congratulations!
Congratulations, you've successfully built a cross device remote controller using Flutter!
Credits
Better Together, a Firebase Song
- Music by Ryan Vernon
- Lyrics and album cover by Marissa Christy
- Voice by JP Gomez
9. Bonus
As an added challenge, consider using Flutter FutureBuilder
to add the current lead device type to the UI asynchronously. If you need an assist, it's implemented in the folder containing the codelab's finished state.