全員集合！必ず定刻に始まるオンラインミーティングサービス by TencentCloudビデオ通話SDK

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/35212bcf-130f-dd6b-f8b6-861fe9f10f84.png)
TencentCloudビデオ通話SDKを使ってオンライン会議サービスをつくってみました。

# 解決したいこと
オンラインミーティングやオンライン飲み会って、時間どおりに人が集まらなくてなかなかスタートできない時ってありますよね？
定刻に始まらない理由は色々とあると思いますが、ミーティングのURLを探している間に時間が過ぎちゃった、とか、いつものことだらかどうせまだ始まらないだろう...とか、あっ・ミーティング忘れてた！というのもありがちだと思います。

## 解決方法
これを解決するために、TencentCloudを使用した「必ず定刻に始まるオンラインミーティングサービス」を作ってみました。
- ミーティングの開始時間になったら、端末にPushコール＆ウェークアップ着信
強制的に呼び出しがかかるので、もう電話に出るしかありません。着信に応答するとその場でビデオミーティングが始まります。あれこれ面倒な手間はいりません。
- 携帯電話やLINE通話みたいにネイティブUIで着信
端末がスリープ状態だったり、アプリを終了していても大丈夫です。圏外やコールドOFFでない限り着信するので見逃しません。もし、その時に応答できなかったとしても、アプリ上のミーティングルームから未参加のメンバーに対して追っかけコールもできます。

## つくったもの
今回、端末機能はTencentCloudビデオ通話SDKとCallKitを組み合わせて実装しました。iPadでもCallKitが使えるためiPhoneと同様に電話UIで着信します。

### 着信から応答までの流れ

1. スリープから復帰
    <img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/0d40f01b-1a6e-7879-8867-4ab1c218a431.png">

1. スライドで応答
    <img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/7c551e4d-a14f-7ba2-95e0-5251af2ea334.png">

1. 応答をタップ
    <img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/c2360829-36f3-9215-6a38-b5f63e7c12c6.png">

1. オンラインミーティングが始まります
    <img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/d202059c-607c-ea90-7823-6fc71db5000d.png">

    iPadだとこんな感じです。↓
    <img width="550" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/6e4d4f64-d823-9a09-34e5-1dafaffc3001.png">

### 今回の実装機能
- 通話呼び出し
    - Pushコール＆ウェークアップ着信
    - 特定メンバーへの追っかけ発信
- ミーティング管理
    - スケジュール作成
    - スケジュール一覧表示
- コミュニケーション機能
    - 参加メンバーの一覧
    - 音声会話
    - ビデオ会話
    - テキストチャット


# 実装のポイント
クラウド側と端末側の主な実装内容について説明します。
- 音声・ビデオ通話　- TencentCloud
- Pushコール　- AWS + Apple VoIP Services
- 端末アプリ　- SwiftUI + TencentCloudビデオ通話SDK

今回のソースコード一式はGitHubに上げておきました。

https://github.com/yuppejp/trtcsdk-pushcall

TRTC SDKの基本的な使用方法は前回の記事を参考にしてください。

https://qiita.com/yuppejp/items/dd415aefc0f81df4c602

## システム構成
オンラインミーティング機能はTencent Real-Time Communication（TRTC）で音声通話、ビデオ通話、テキストチャット機能が一体で提供されています。TRTC SDKを使うことでTRTCの機能を自分のアプリケーションに組み込むことができます。
PushコールはAWSのSNS(Simple Notification Service)でApple VoIP Serviceが使えたのでAWSとTencentCloudを併用したシステム構成にしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/f090ec28-40e3-a4c6-a457-6bcafd57948d.png)

## Pushコール(VoIP Push)の実装
端末側とクラウド側でそれぞれ実装しています。

### 端末側の実装
- ApplicationDelegate
スリープからのウェークアップに対応するために、CallKitのdelegateをApplicationDelegateに実装します。今回の端末アプリはSwiftUIで実装したため、SwiftUIの`@UIApplicationDelegateAdaptor`を使用してプロトコルに適合させました。delegateはViewModel内に実装しSwiftUIの環境変数としてインスタンスを保持し、イベントの発生とともにUIを更新します。

```swift:MyApp.swift
@main
struct TrtcVideoRoomApp: App {
    @UIApplicationDelegateAdaptor(TrtcViewModel.self) var viewModel

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(viewModel)
        }
    }
}

```

- CallKitのPKPushRegistryDelegateの実装例
PKPushCredentialsで端末のトークンが通知されるので、AWSのSNSエンドポイントに登録します。PushコールはdidReceiveIncomingPushWithで通知されます。SNSエンドポイントに登録したユーザデータはpayloadに入っているので、メッセージやルームIDを取り出し、TencentCloudにログインします。

```swift:ViewModel.swift
extension TrtcViewModel: PKPushRegistryDelegate {
    func pushRegistry(_ registry: PKPushRegistry, didUpdate pushCredentials: PKPushCredentials, for type: PKPushType) {
        let deviceToken = pushCredentials.token.map { String(format: "%02.2hhx", $0) }.joined()
        print("*** device token: \(deviceToken)")
        
        if defaults.awsEnabled {
            // register device token on AWS
            registerDeviceToken(deviceToken: deviceToken)
        }
    }
    
    func pushRegistry(_ registry: PKPushRegistry, didInvalidatePushTokenFor type: PKPushType) {
        print("*** didInvalidatePushTokenFor")
    }
    
    func pushRegistry(_ registry: PKPushRegistry, didReceiveIncomingPushWith payload: PKPushPayload, for type: PKPushType) {
        print("*** didReceiveIncomingPushWith")
        let dictionary = payload.dictionaryPayload as NSDictionary
        let aps = dictionary["aps"] as! NSDictionary
        let alert = aps["alert"]
        if let message = alert as? String {
            callModel.IncomingCall(true, displayText: "\(message)@room\(defaults.roomId)")
        } else {
            callModel.IncomingCall(true, displayText: "room\(defaults.roomId)")
        }
        enterRoom(userId: defaults.userId, roomId: defaults.roomId)
    }
}

```

### ルームメンバーとデバイストークンとの紐付け
CallKitで払い出されるデバイストークンとユーザーの紐付けはDBを用意するのが良いと思いますが、今回は手軽な方法としてAWSのSNSエンドポイント内のユーザーデータに保存しました。ユーザーデータは文字列しか格納できませんが、JSON文字列にしておくことで階層的にデータを保存することができます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/99542815-0cfb-f2bb-8e17-cb17b819eb12.png)

- Lambda側の実装コード例
TRTC　SDKのユーザーIDから該当するエンドポイントを検索(力技でループ検索w)して、AWS SNS経由でVoIP Pushを送信します。
```javascript:index.mjs(AWS Lambda: Node.js 18.x)
import { SNSClient, PublishCommand, ListEndpointsByPlatformApplicationCommand } from "@aws-sdk/client-sns";
const client = new SNSClient();
const snsArn = 'arn:aws:sns:XXX/APNS_VOIP_SANDBOX/TrtcVideoRoom';

export const handler = async(event) => {
    try {
        const result = await pushCall(event.userIds, event.message);
        const response = {
            statusCode: 200,
            body: JSON.stringify(result),
        };
        return response;
    } catch (error) {
        const response = {
            statusCode: 400,
            body: JSON.stringify(error),
        };
        return response;
    }
};

async function pushCall(userIds, message) {
    const response = await listEndpoints();
    for (const endpoint of response.Endpoints) {
        const userData = JSON.parse(endpoint.Attributes.CustomUserData);
        for (const userId of userIds) {
            if (userData.userId == userId) {
                await publish(endpoint.EndpointArn, message);
            }
        }
    }
    return 'OK';
}

async function listEndpoints() {
    const input = {
        'PlatformApplicationArn': snsArn
    };
    const command = new ListEndpointsByPlatformApplicationCommand(input);
    const response = await client.send(command);
    return response;
}

async function publish(arn, message) {
    const messageAttributes = {
        'AWS.SNS.MOBILE.APNS.PUSH_TYPE': {
            'DataType': 'String',
            'StringValue': 'voip'
        }
    };
    const input = {
        'TargetArn': arn,
        'Message': message,
        'Subject': 'subject1',
        'MessageAttributes': messageAttributes
    };
    const command = new PublishCommand(input);
    const response = await client.send(command);
    return response;
}
```

- 端末側の実装コードです
AWS API Gateway経由でVoIP Pushを送信します。

```swift:AwsModel.swift
import Foundation

let apiGateway = MY_apiGateway

struct AwsResponseHeader: Codable {
    let statusCode: Int
    let body: String
}

struct PushCallRequest: Codable {
    var command: String = "PushCall"
    let userIds: [String]
    let message: String
}
struct PushCallResponse: Codable {
}

class AwsModel {
    func pushCall(userIds: [String], message: String) async throws {
        // request parameter
        let input = PushCallRequest(userIds: userIds, message: message)
        let encoder = JSONEncoder()
        let json = try encoder.encode(input)
        
        // post request
        let data = try await HttpModel.shared.post(urlString: apiGateway, body: json)
    }
}
```

### スケジュールの日時が到来したらPushコールを発火
端末アプリで設定したミーティングスケジュールの時刻にメンバーにPushコールを発生させます。
<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/66e3d9f3-1e17-17eb-b36f-bc95a83e0c5f.png">

- クラウド側の実装例
発火のタイミングはAWSのEventBridgeのタイマーイベントでDynamoDBに保存されたスケジュール情報を参照し、日時が一致していれば(タイマー誤差があるのでマージンをもたせておく)、ルームIDに紐づいているメンバーの端末へVoIP Pushを送ります。通知後はスケジュールのステータスを「done」に変更してスケジュールを完了状態にします。
```javascript:index.mjs(AWS Lambda: Node.js 18.x)
import { SNSClient, PublishCommand, ListEndpointsByPlatformApplicationCommand } from "@aws-sdk/client-sns";
const client = new SNSClient();
const snsArn = 'arn:aws:sns:XXX/APNS_VOIP_SANDBOX/TrtcVideoRoom';

import { DynamoDBClient, ScanCommand } from "@aws-sdk/client-dynamodb";
import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
const dynamoClient = new DynamoDBClient();

export const handler = async(event) => {
    try {
        const result = await OnTimer();
        const response = {
            statusCode: 200,
            body: JSON.stringify(result),
        };
        return response;
    } catch (error) {
        const response = {
            statusCode: 400,
            body: JSON.stringify(error),
        };
        return response;
    }
};

async function OnTimer() {
    const response = await scanUpcomingMeetings();
    
    var count = 0;
    for (const item of response.Items) {
        const idString = item.id.N;
        const roomId = item.roomId.S;
        const subject = item.subject.S;
        const startDateString = item.startDate.S;
        const startDate = parseInt(startDateString, 10);
        
        const date = new Date();
        const now = date.getTime();
        const elapsed = now - startDate;
        const timeMargin1 = 10 * 60 * 1000; // 10 minutes ago
        const timeMargin2 = 1 * 60 * 1000; // 1 minutes later
        if (elapsed <= timeMargin1 && -elapsed <= timeMargin2) {
            // VOIP Push
            await pushCallByRoomId(roomId, subject);
            
            // mark done
            const id = parseInt(idString, 10);
            await updateMeetingStatus(id, 'done');
            count++;
        }
    }
    return { count: count };
}

async function pushCallByRoomId(roomId, message) {
    const response = await listEndpoints();
    for (const endpoint of response.Endpoints) {
        const userData = JSON.parse(endpoint.Attributes.CustomUserData);
        if (userData.roomId == roomId) {
            await publish(endpoint.EndpointArn, message);
        }
    }
    return 'OK';
}
    
async function scanUpcomingMeetings() {
    const params = {
        FilterExpression: "meetingStatus = :topic",
        ExpressionAttributeValues: {
            ":topic": { S: "upcoming" },
        },
        TableName: "TrtcVideoMeeting",
    };

    const command = new ScanCommand(params);
    const response = await dynamoClient.send(command);
    return response;
}

async function updateMeetingStatus(id, meetingStatus) {
    const params = {
        Key: { id: id },
        UpdateExpression: 'set #a = :x',
        ExpressionAttributeNames: {
            '#a': 'meetingStatus'
        },
        ExpressionAttributeValues: {
            ':x': meetingStatus
        },
        TableName: "TrtcVideoMeeting",
    };

    const command = new UpdateCommand(params);
    const response = await dynamoClient.send(command);
    return response;
}
```


## 端末側のUI実装
前回練習用に作成した[SwiftUIアプリ](![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/fa251ea4-1c0b-42c2-d37b-87677e7eacec.png)
)をベースに機能を追加しました。ルームの参加者の一覧、ビデオ映像一覧、チャットビューを配置しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/ef93bea6-c26e-cb02-2c60-a3e7d313774d.png)

### 追っかけコールUI
Pushコールがかかったのにもかかわらず、まだルームに参加していないメンバーへ追っかけコールができます。メンバーの参加状況はTRTC SDKのルームイベントを拾ってUIに反映します。特定のメンバーをコールするか、まとめて複数のメンバーを呼び出せます。
<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/040187ac-5ff2-0afa-b846-6824d8ad5cb3.png">

### チャットビュー
以前作成した[吹き出しテキスト](https://qiita.com/yuppejp/items/92429a0fc8440f9da487)をScrollViewに入れてチャットビューを作成。テキストメッセージの送受信処理はTRTC SDKのCustomCmdMsgを使うと数行で実装できます。

- テキストメッセージの送信
```swift
    func sendMessage(text: String) {
        do {
            let userId = defaults.userId
            let msg = CustomCmdMsg(userId: userId, text: text)
            let encoder = JSONEncoder()
            let json = try encoder.encode(msg)
            trtcCloud.sendCustomCmdMsg(1, data: json, reliable: false, ordered: false)
        } catch {
            errMessage = error.localizedDescription
        }
    }
```

- テキストメッセージ受信
```swift
    func onRecvCustomCmdMsgUserId(_ userId: String, cmdID: Int, seq: UInt32, message: Data) {
        print("*** onRecvCustomCmdMsgUserId: userId: \(userId), cmdID: \(cmdID), seq: \(seq)")
        do {
            let decoder = JSONDecoder()
            let msg = try decoder.decode(CustomCmdMsg.self, from: message)
            room.messages.append(RoomMessage(userName: msg.userId, text: msg.text))
        } catch {
            errMessage = error.localizedDescription
        }
    }
```

これで完成です。

## おまけ
また、複数人の通話アプリのデバッグする際は端末をたくさん用意する必要がありますが、それも限界があるので、TRTC SDKに入っている`Web/base-js/index.html`サンプルがめっちゃ役立ちました。今回は参加人数に応じてビデオViewの列数を変えるUIを実装をしたのでその確認がやりやすかったです。

- TRTC SDKに入っているWeb版サンプルの動作イメージ
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77444/0397f175-4c1f-9506-20ce-f41d383f3663.png)

# まとめ
TRTC SDKは他のサービスやライブラリーとの依存性がなく、要件にあった個々のアプリケーションやサービスに組み込みやすいと思います。今回はAWSやApple Push Servicesなどのクラウドサービスと、端末側はSwift/SwiftUIで実装しましたがスムーズに音声ビデオ通話機能を組み込むことができました。 

開発用に無料枠が用意されているのも嬉しい点です。デバッグ中にルームセッションを開放せずに終了することが何度かありましたが、自動的にセッションがパージされるので無料枠を消費することなく安心してデバッグすることができました。アプリの開発段階でもTRTCクラウド、TRTC SDKともに安定動作していたのが印象的です。

まだまだTencentCloudの豊富な機能を使い切れていないので、これからも色々な機能を実装していきたと思います！

## 作成物一式

https://github.com/yuppejp/trtcsdk-pushcall

## 参考情報
- [Tencent Cloud developer](https://console.tencentcloud.com/developer)
    - [TRTC SDK ダウンロード](https://www.tencentcloud.com/jp/document/product/647/34615?lang=jp)
    - [TRTC サンプルアプリ](https://github.com/tencentyun/TUICallKit)


- [AWS](https://aws.amazon.com/jp)
    - [Creating the AWS Lambda function](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/scheduled-events-invoking-lambda-browser-script.html)
    - [Creating scheduled events to execute AWS Lambda functions](https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/scheduled-events-invoking-lambda-example.html)
    - [AWS SDK for JavaScript v3](https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/modules/_aws_sdk_lib_dynamodb.html)

- [Apple Developer](https://developer.apple.com)
    - [CallKit](https://developer.apple.com/documentation/callkit)