Firebase Cross Device Codelab

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.

7f0279938e1d3ab5.gif

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/.

  1. Sign in to Firebase.
  2. In the Firebase console, click Add Project (or Create a project), and name your Firebase project Firebase-Cross-Device-Codelab.
  3. 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

  1. If you don't have the Firebase CLI installed, you can install it by running curl -sL https://firebase.tools | bash.
  2. Log in by running firebase login and following the prompts.
  3. Install the FlutterFire CLI by running dart pub global activate flutterfire_cli.
  4. Configure the FlutterFire CLI by running flutterfire configure.
  5. At the prompt, choose the project you've just created for this codelab, something like Firebase-Cross-Device-Codelab.
  6. Select iOS, Android and Web when you are prompted to choose configuration support.
  7. 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:

  1. In the Firebase console, expand the Build menu in the left panel.
  2. Click Authentication, and then click the Get Started button, then the Sign-in method tab.
  3. Click Email/Password in the Sign-in providers list, set the Enable switch to the on position, and then click Save. 58e3e3e23c2f16a4.png

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:

  1. A logged out user will initiate the sign-in flow by clicking on the context menu 71fcc1030a336423.pngon the right hand side of the app bar.
  2. The sign-in flow will be displayed in a dialog.
  3. If the user has never signed in before, they will be prompted to create an account using a valid email address and a password.
  4. If the user has signed in before, they will be prompted to enter their password.
  5. Once the user is signed in, clicking on the context menu will show a Sign out option.

c295f6fa2e1d40f3.png

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 71fcc1030a336423.png 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.

888506c86a28a72c.png

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,

  1. Navigate to the Realtime Database section of the Firebase console. Click Create Database.
  2. 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.

1b6076f60a36263b.png

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.

5bef49cea3564248.png

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:

  1. On the command line, run the app on emulators and/or in a browser with: flutter run -d <device-name>
  2. 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.
  3. 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!

bcd986f7106d892b.gif

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.

Reference docs and next steps