☎️

Flutter × Firebase × Agoraで通話アプリを作ってみる

2023/03/30に公開

はじめに

注意事項

「とりあえず動く」というだけで、セキュリティの懸念があります。
参考にする際には十分にご注意ください。
なにか不都合が生じた際も、私は一切の責任を負いかねます。
ご了承ください。

初記事となります。
不明な点がありましたら指摘していただけたら幸いです。

オープニング的な

FlutterとFirebaseは個人開発やスピーディな開発をするにあたってよくみる組み合わせだと思います。
Flutterは様々なデバイスに対応し(しかもちゃんと動く)、Firebaseを使うことでバックエンドのコスト削減を達成していて、「予算がそんなにない」や「自分のスマホアプリを作ってみたい」といった方々の要望を叶えています。
一方で、誰でも簡単に通話機能を実装できるSDKとしてAgoraというものがあります。
おそらくは古代ギリシャのアゴラから来ていると思います(ソクラテスがよくいた場所です)。気軽に談話したいという思いを込めてのネーミングが伝わりますね。
そのネーミングに合うかのように、通話機能をとても簡単に実装できます。

FlutterとFirebase, Agoraって、もしかして相性が良い?
という思いから記事にしました。

方針

Flutterにはすでにagoraを簡単に実装できる外部パッケージがあります。
が、その中に「agoraトークン」というものが必要になってきます。
どうやらこのトークンはFlutterが自動的に生成できるものではなく、どこかサーバーを立ててトークンを発行する必要があるようです。
今回はこのサーバーをFirebase(厳密にはFirebaseはサーバーではない)に置き換えて実装していきます。
したがって

  • まずAgoraトークンを自動発行するようにする
  • そのトークンとFlutterのパッケージを組み合わせて通話機能を実装する

という方針でいきたいと思います。

コーディング

nodeJS & FirebaseFunctionsでトークン取得

nodeJSでトークンを生成する

まず最初にagoraトークンを生成する必要があります。
トークン自体はagoraの公式から一時的に生成することはできますが、あくまで一時的というところと、いちいちトークンを生成しなければならない(友人同士で使うアプリならともかく、何百人何千人のユーザーに使わせると考えると、自動化させるべきですよね?)ので、まずはここから手をつけていきましょう💪

必要なパッケージをインストールする

コードを書くにしても、まずパッケージを導入しないと話になりません。
npm installコマンドで必要なパッケージをインストールしましょう。
必要なパッケージは三つです。

// firebaseのSDKを初期化する時に使います
npm install firebase-admin
// agoraのトークンを生成する時に使います
npm install agora-access-token
// firebaseのcloud functionsを使うのに必要なものです
npm install firebase-functions

また、別途でFirebase CLIをインストールする必要があります。
詳しくはこちら

nodeJSで書いていく

必要なパッケージをインストールしたら、いよいよnodeJSを書いていきます🔥

全体のコードはこちら
const admin = require("firebase-admin");
const agora = require("agora-access-token");
const serviceAccount = require("Firebaseの秘密鍵")
const functions = require("firebase-functions");

// Firebase Admin SDKの初期化
admin.initializeApp({
  credential: admin.credential.cert(serviceAccount),
  databaseURL: "データベースの名前",
});

// Firebase Cloud Functionのエントリポイント
exports.generateAgoraToken = functions.https.onCall(async (data, context) => {
  // ユーザーの認証
  if (!context.auth) {
    throw new functions.https.HttpsError(
      "unauthenticated",
      "Authentication is required."
    );
  }

  // パラメータの取得
  const channelName = data.channelName;
  const uid = data.uid;
  
  // Agora App ID
    const agoraAppId = "アゴラのアプリID";
  
    // Agora App Certificate
    const agoraAppCertificate = "証明書";
  
    // トークンの有効期限 (秒単位)
    const expirationTimeInSeconds = 3600;
  
    // Agoraトークンの生成
    const token = agora.RtcTokenBuilder.buildTokenWithUid(
      agoraAppId,
      agoraAppCertificate,
      channelName,
      uid,
      agora.RtcRole.PUBLISHER,
      Math.floor(Date.now() / 1000) + expirationTimeInSeconds
    );

  return {token};
});

firebase.https.onCallとはFirebase Functionsのトリガーで、HTTPSリクエストをトリガーにしてCloud Functionを起動します。onCallはFirebaseFunctionsのタイプの一つで他にもrepuestとか色々あります。

// Firebase Cloud Functionのエントリポイント
exports.generateAgoraToken = functions.https.onCall(async (data, context) => {});

この場合で言うと、

  1. nodeJSがリクエストを受け取り、cloud functionsを起動させ、トークンを発行する
  2. 発行したトークンをクライアント側に返す。

といった処理が行われています。

インストールしたagora-access-tokenパッケージ内ですでにagoraトークンを作る関数自体はあるのですが、

const token = agora.RtcTokenBuilder.buildTokenWithUid(
    agoraAppId,
    agoraAppCertificate,
    channelName,
    uid,
    agora.RtcRole.PUBLISHER,
    Math.floor(Date.now() / 1000) + expirationTimeInSeconds
  );

その引数を設定するために色々やってるって感じです。
agoraAppId,agoraAppCertificateとは、agoraプロジェクトにアクセスするために必要なもので、ダッシュボードのconfigに載っています。
なので別途で確認して、そのID上で定義しています。

// Agora App ID
  const agoraAppId = "アゴラのアプリID";

  // Agora App Certificate
  const agoraAppCertificate = "証明書";

channelName, uidは自明なので説明は省きます。

5番目の引数にはユーザーの役割を指定します。具体的な役割は以下。

PUBLISHER: パブリッシャーは、チャンネル内で音声やビデオをブロードキャストすることができます。つまり、パブリッシャーは、カメラやマイクなどの入力デバイスを制御し、他の参加者に音声やビデオを送信できます。
SUBSCRIBER: サブスクライバーは、チャンネル内でパブリッシャーが送信した音声やビデオを受信できます。つまり、サブスクライバーは、他の参加者の音声やビデオを再生できます。
ADMIN: アドミンは、チャンネルの設定や操作を行うことができます。たとえば、アドミンは、他の参加者のミュートやキックなどのアクションを実行できます。

本来であればあらかじめenum型を定義しておいてそれを引数に取る、みたいなやり方になると思いますが、今回は便宜上PUBLISHERオンリーでいきます。

最後の引数にはトークンの有効期限を設定します。ここでは、現在のタイムスタンプに expirationTimeInSeconds を加算して有効期限を設定しています。expirationTimeInSeconds は秒単位で指定されます。

最後に、色々設定して取得したtokenをクライアント側に返しています。
以上より、nodeJSとFirebaseFunctionsを使用してagoraトークンを取得することができました。

Flutterで書いていく。

クライアントサイドを実装していきます。
やりたいこととしては

  • uidとcannelNameを取得したい
  • nodeJSのgenerateAgoraTokenを呼び出してagoraTokenを取得したい
  • uid,channelName,tokenを使用して通話したい

となります。順番に見ていきましょう👀

uidとchannelNameを取得する

まずは、uidとchannelNameを取得するページを作りましょう。
ページに関しては各々の好みになりますので、自分の好みがあればそちらを採用していただければと思います👍

import 'package:agora_flutter_quickstart/src/pages/call.dart';
import 'package:flutter/material.dart';
import 'package:agora_rtc_engine/rtc_engine.dart';
import '../../agotaState.dart';

class AgoraCallPage extends StatefulWidget {
  
  _AgoraCallPageState createState() => _AgoraCallPageState();
}

class _AgoraCallPageState extends State<AgoraCallPage> {
  final _channelNameController = TextEditingController(text: 'お好みのchannelName');
  final TextEditingController _uidController =
      TextEditingController(text: 'ユーザーのuid');
  ClientRole? _role = ClientRole.Broadcaster;
  AgoraState? agoraState;

  Future<void> _startCall() async {
    final _engine = await RtcEngine.create('generateAgoraTokenで定義したagoraのappIDと同じID');
    await _engine.enableVideo();
    await _engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
    await _engine.setClientRole(_role!);
    try {
      AgoraState _agoraState = AgoraState(
        uid: _uidController.text,
        channelName: _channelNameController.text,
        role: _role!,
        engine: _engine,
      );
      Navigator.push(
          context,
          MaterialPageRoute(
              builder: (_) => MeetingPage(agoraState: _agoraState)));
    } catch (e) {
      print(e);
    }
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Agora Call'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            TextField(
              controller: _channelNameController,
              decoration: InputDecoration(
                labelText: 'Channel Name',
              ),
            ),
            TextField(
              controller: _uidController,
              keyboardType: TextInputType.number,
              decoration: InputDecoration(
                labelText: 'UID',
              ),
            ),
            Column(
              children: [
                ListTile(
                  title: Text(ClientRole.Broadcaster.toString()),
                  leading: Radio(
                    value: ClientRole.Broadcaster,
                    groupValue: _role,
                    onChanged: (ClientRole? value) {
                      setState(() {
                        _role = value;
                      });
                    },
                  ),
                ),
                ListTile(
                  title: Text(ClientRole.Audience.toString()),
                  leading: Radio(
                    value: ClientRole.Audience,
                    groupValue: _role,
                    onChanged: (ClientRole? value) {
                      setState(() {
                        _role = value;
                      });
                    },
                  ),
                )
              ],
            ),
            SizedBox(height: 16.0),
            ElevatedButton(
              onPressed: _startCall,
              child: Text('Start Call'),
            ),
          ],
        ),
      ),
    );
  }
}

:::

nodeJSのgenerateAgoraTokenを呼び出してagoraTokenを取得する

全体のコード
import 'package:agora_rtc_engine/rtc_engine.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:flutter/cupertino.dart';


class AgoraState {
  final String channelName;
  final ClientRole role;
  final RtcEngine engine;
  final String uid;
  AgoraState({
    required this.channelName,
    required this.role,
    required this.engine,
    required this.uid,
  }) {
    if (channelName == '') {
      throw 'チャンネル名を入力してください';
    }
    if (uid == '') {
      throw 'uidを入力してください';
    }
  }

  Future<void> joinChannel() async {
    final HttpsCallable generateTokenCallable =
        FirebaseFunctions.instance.httpsCallable('generateAgoraToken');

    try {
      final result = await generateTokenCallable.call({
        'channelName': channelName,
        'uid': int.tryParse(uid) ?? 0,
      });

      final _token = result.data['token'];
      await engine.enableVideo();
      await engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
      await engine.setClientRole(role);
      VideoEncoderConfiguration configuration = VideoEncoderConfiguration();
      configuration.dimensions = VideoDimensions(width: 1920, height: 1080);
      await engine.setVideoEncoderConfiguration(configuration);
      await engine.joinChannel(_token, channelName, null, 123);
      print('成功しました');
    } on FirebaseFunctionsException catch (e) {
      print('Error generating Agora token with Firebase : ${e.message}');
    } catch (e) {
      print('Error generating Agora token: $e');
    }
  }
}

nodeJSを呼び出すためには、FirebaseFunctionを初期化する必要があります。

final HttpsCallable generateTokenCallable =
        FirebaseFunctions.instance.httpsCallable('generateAgoraToken');

初期化したら、関数をcallして取得したデータを格納しましょう。

final result = await generateTokenCallable.call({
        'channelName': channelName,
        'uid': int.tryParse(uid) ?? 0,
      });

こちらのresultがagoraトークンになります。
が、このままではFuture型で直接使えないので.dataプロパティを使ってデータを取り出しましょう。

final _token = result.data['token'];

これでagoraトークンが使えるようになりました🎉

uid,channelName,tokenを使用して通話する

いよいよ通話する機能を実装していきます、、、!!!!
まずは通話の設定を各種設定します。

await engine.enableVideo();
await engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
await engine.setClientRole(role);
VideoEncoderConfiguration configuration = VideoEncoderConfiguration();
configuration.dimensions = VideoDimensions(width: 1920, height: 1080);
await engine.setVideoEncoderConfiguration(configuration);

そして通話に参加するにはengineの中にあるjoinChannel関数を使う必要があります。

await engine.joinChannel(_token, channelName, null, 123);

以上のコードをまとめたのがagoraStateクラスになります。

import 'package:agora_rtc_engine/rtc_engine.dart';
import 'package:cloud_functions/cloud_functions.dart';
import 'package:flutter/cupertino.dart';


class AgoraState {
  final String channelName;
  final ClientRole role;
  final RtcEngine engine;
  final String uid;
  AgoraState({
    required this.channelName,
    required this.role,
    required this.engine,
    required this.uid,
  }) {
    if (channelName == '') {
      throw 'チャンネル名を入力してください';
    }
    if (uid == '') {
      throw 'uidを入力してください';
    }
  }

  Future<void> joinChannel() async {
    final HttpsCallable generateTokenCallable =
        FirebaseFunctions.instance.httpsCallable('generateAgoraToken');

    try {
      final result = await generateTokenCallable.call({
        'channelName': channelName,
        'uid': int.tryParse(uid) ?? 0,
      });

      final _token = result.data['token'];
      await engine.enableVideo();
      await engine.setChannelProfile(ChannelProfile.LiveBroadcasting);
      await engine.setClientRole(role);
      VideoEncoderConfiguration configuration = VideoEncoderConfiguration();
      configuration.dimensions = VideoDimensions(width: 1920, height: 1080);
      await engine.setVideoEncoderConfiguration(configuration);
      await engine.joinChannel(_token, channelName, null, 123);
    } on FirebaseFunctionsException catch (e) {
      print('Error generating Agora token with Firebase : ${e.message}');
    } catch (e) {
      print('Error generating Agora token: $e');
    }
  }
}

Flutter側でagora周りの実装をする時はこのクラスを使っていきます。

続いてUI部分を作っていきましょう。

import 'package:agora_rtc_engine/rtc_engine.dart';
import 'package:flutter/material.dart';
import 'package:agora_rtc_engine/rtc_local_view.dart' as RtcLocalView;
import 'package:agora_rtc_engine/rtc_remote_view.dart' as RtcRemoteView;
import '../../agotaState.dart';

class MeetingPage extends StatefulWidget {
  final AgoraState agoraState;

  MeetingPage({required this.agoraState});

  
  _MeetingPageState createState() => _MeetingPageState();
}

class _MeetingPageState extends State<MeetingPage> {
  final _users = <int>[];
  final _infoStrings = <String>[];
  bool muted = false;
  late RtcEngine _engine;
  
  
  void initState() {
    super.initState();
    // initialize agora sdk
    widget.agoraState.joinChannel();
    _addAgoraEventHandlers();
  }

  /// ログ出力
  void _addAgoraEventHandlers() {
    widget.agoraState.engine
        .setEventHandler(RtcEngineEventHandler(error: (code) {
      setState(() {
        final info = 'onError: $code';
        _infoStrings.add(info);
      });
    }, joinChannelSuccess: (channel, uid, elapsed) {
      setState(() {
        final info = 'onJoinChannel: $channel, uid: $uid';
        _infoStrings.add(info);
      });
    }, leaveChannel: (stats) {
      setState(() {
        _infoStrings.add('onLeaveChannel');
        _users.clear();
      });
    }, userJoined: (uid, elapsed) {
      setState(() {
        final info = 'userJoined: $uid';
        _infoStrings.add(info);
        _users.add(uid);
      });
    }, userOffline: (uid, elapsed) {
      setState(() {
        final info = 'userOffline: $uid';
        _infoStrings.add(info);
        _users.remove(uid);
      });
    }, firstRemoteVideoFrame: (uid, width, height, elapsed) {
      setState(() {
        final info = 'firstRemoteVideo: $uid ${width}x $height';
        _infoStrings.add(info);
      });
    }));
  }

  // 参加ユーザーを格納する
  // 役割ごとでLocalViewかRemoteViewか分ける
  List<Widget> _getRenderViews() {
    final List<StatefulWidget> list = [];
    if (widget.agoraState.role == ClientRole.Broadcaster) {
      list.add(RtcLocalView.SurfaceView());
    }
    _users.forEach((int uid) => list.add(RtcRemoteView.SurfaceView(
        channelId: widget.agoraState.channelName, uid: uid)));
    return list;
  }
  
  // 参加ユーザーが一人の時の画面表示
  Widget _videoView(view) {
    return Expanded(child: Container(child: view));
  }
  
  // 参加ユーザーが複数の時の画面表示
  Widget _expandedVideoRow(List<Widget> views) {
    final wrappedViews = views.map<Widget>(_videoView).toList();
    return Expanded(
      child: Row(
        children: wrappedViews,
      ),
    );
  }

  // 最終的に出力する画面
  Widget _viewRows() {
    final views = _getRenderViews();
    switch (views.length) {
      case 1:
        return Container(
            child: Column(
          children: <Widget>[_videoView(views[0])],
        ));
      case 2:
        return Container(
            child: Column(
          children: <Widget>[
            _expandedVideoRow([views[0]]),
            _expandedVideoRow([views[1]])
          ],
        ));
      case 3:
        return Container(
            child: Column(
          children: <Widget>[
            _expandedVideoRow(views.sublist(0, 2)),
            _expandedVideoRow(views.sublist(2, 3))
          ],
        ));
      case 4:
        return Container(
            child: Column(
          children: <Widget>[
            _expandedVideoRow(views.sublist(0, 2)),
            _expandedVideoRow(views.sublist(2, 4))
          ],
        ));
      default:
    }
    return Container();
  }
  
  // 退室ボタンとかもろもろのボタン
  Widget _toolbar() {
    if (widget.agoraState.role == ClientRole.Audience) return Container();
    return Container(
      alignment: Alignment.bottomCenter,
      padding: const EdgeInsets.symmetric(vertical: 48),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          RawMaterialButton(
            onPressed: _onToggleMute,
            child: Icon(
              muted ? Icons.mic_off : Icons.mic,
              color: muted ? Colors.white : Colors.blueAccent,
              size: 20.0,
            ),
            shape: CircleBorder(),
            elevation: 2.0,
            fillColor: muted ? Colors.blueAccent : Colors.white,
            padding: const EdgeInsets.all(12.0),
          ),
          RawMaterialButton(
            onPressed: () => _onCallEnd(context),
            child: Icon(
              Icons.call_end,
              color: Colors.white,
              size: 35.0,
            ),
            shape: CircleBorder(),
            elevation: 2.0,
            fillColor: Colors.redAccent,
            padding: const EdgeInsets.all(15.0),
          ),
          RawMaterialButton(
            onPressed: _onSwitchCamera,
            child: Icon(
              Icons.switch_camera,
              color: Colors.blueAccent,
              size: 20.0,
            ),
            shape: CircleBorder(),
            elevation: 2.0,
            fillColor: Colors.white,
            padding: const EdgeInsets.all(12.0),
          )
        ],
      ),
    );
  }
  
  // ミーティングのログを画面に表示させる
  Widget _panel() {
    return Container(
      padding: const EdgeInsets.symmetric(vertical: 48),
      alignment: Alignment.bottomCenter,
      child: FractionallySizedBox(
        heightFactor: 0.5,
        child: Container(
          padding: const EdgeInsets.symmetric(vertical: 48),
          child: ListView.builder(
            reverse: true,
            itemCount: _infoStrings.length,
            itemBuilder: (BuildContext context, int index) {
              if (_infoStrings.isEmpty) {
                return Text(
                    "null"); // return type can't be null, a widget was required
              }
              return Padding(
                padding: const EdgeInsets.symmetric(
                  vertical: 3,
                  horizontal: 10,
                ),
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Flexible(
                      child: Container(
                        padding: const EdgeInsets.symmetric(
                          vertical: 2,
                          horizontal: 5,
                        ),
                        decoration: BoxDecoration(
                          color: Colors.yellowAccent,
                          borderRadius: BorderRadius.circular(5),
                        ),
                        child: Text(
                          _infoStrings[index],
                          style: TextStyle(color: Colors.blueGrey),
                        ),
                      ),
                    )
                  ],
                ),
              );
            },
          ),
        ),
      ),
    );
  }

  void _onCallEnd(BuildContext context) {
    Navigator.pop(context);
  }

  void _onToggleMute() {
    setState(() {
      muted = !muted;
    });
    _engine.muteLocalAudioStream(muted);
  }

  void _onSwitchCamera() {
    _engine.switchCamera();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Agora Flutter QuickStart'),
      ),
      backgroundColor: Colors.black,
      body: Center(
        child: Stack(
          children: <Widget>[
            _viewRows(),
            _panel(),
            _toolbar(),
          ],
        ),
      ),
    );
  }
}

こちらのコードをちょこっと変えました。
https://github.com/AgoraIO-Community/Agora-Flutter-Quickstart/blob/master/lib/src/pages/call.dart

やるべきことは3点あって

  • ログを画面に出力する
  • ユーザーの役割ごとで画面表示を変える
  • 退室ボタン等を表示させる

があると思います。
実はMeetingPageのbuild関数の末端にある三つのwidgetはそれぞれ上記の3点の役割を担っています。

Stack(
    children: <Widget>[
	// ユーザーの画面を出力する
        _viewRows(),
	// ログを出力する
        _panel(),
	// ボタンを表示する
	_toolbar(),
    ],
),

以上になります。

まとめ

長々と書きましたが、重要なところはagoraトークンを自動生成するというところで、それさえクリアできればそこまで難しいものではないです。
幸いにもFlutterにはagoraSDKがあるのでagoraとの接続もスムーズに行うことができます。
上記において不明なところや矛盾しているところがあれば指摘していただけると幸いです。

参考文献

本記事作成にあたって参考にした記事たちです。
貴重な情報を提供してくださりありがとうございました。
https://zenn.dev/karamage/articles/33fd407c6ad018
https://zenn.dev/ginpei/articles/agora-voice-chat
https://zenn.dev/arahabica/articles/0f54f2cdb1a29d

Discussion