くらしのマーケット開発ブログ

「くらしのマーケット」を運営する、みんなのマーケット株式会社のテックブログです

Twilio AudioSwitchを使ってAndroid Bluetooth通話を爆速で開発する方法

こんにちは、モバイルアプリエンジニアのリュウです。

最近、ワイヤレスイヤホンなどのBluetoothデバイスを使って通話する対応を行いました。 そこで、Twilio AudioSwitchというライブラリを知って利用しました。

本記事では、AudioSwitchの使い方とその開発で得た知見をご紹介します。

開発の背景

我々「くらしのマーケット」が提供しているユーザー向けアプリと店舗向けアプリには、 インターネットを介したIPネットワークを通じて音声通話をする機能(VoIP)があります。

ユーザーがアプリ内での通話をより便利に利用できるために、以下のようなさらなる進化を遂げたいと考えました。 - Bluetoothデバイスとのシームレスな連携 - Bluetoothデバイスに接続したら、Bluetoothで通話を続ける - Bluetoothデバイスに接続中に、端末のスピーカーなど他のデバイスに切り替えられる

AudioSwitchとは

AudioSwitchは、オーディオデバイスの管理を簡素化するAndroid向けのツールです。

AudioSwitchの特徴

  • オーディオデバイスの管理

    スピーカーフォン、イヤホン、またはヘッドセット間でオーディオ入出力を簡単に切り替えあれる。

  • オーディオデバイスの可用性の変更の検出

    オーディオデバイスの入出力の可用性が変更された際に、この変更を検出し、適切に対応できる。

  • エラーやタイムアウトの処理

    オーディオデバイスの選択中にエラーが発生した場合や、タイムアウトが発生した場合にも、適切に処理できる。

  • Bluetoothデバイスの選択

    Bluetoothデバイスを安全的にハンドリングできる。

AudioSwitchを採用した理由

  • 開発コストの削減

    Android標準のフレームワーク(例えばAudioManager)を使用すると、数百行もの複雑なコードが必要になるが、AudioSwitchはこれらの機能をカプセル化したため、数十行で完結できる。

  • ライブラリの一貫性

    「くらしのマーケットのアプリ」ではTwilio Voice SDKを使用して音声通話機能を開発したため、同じくTwilioが提供しているライブラリを導入することでライブラリの一貫性を保つことができる。

実際に取り組んだこと

さて、サンプルコードとともに解説していきます。

導入

まずは、GradleにAudioSwitchの設定を追加します。

dependencies {
  implementation 'com.twilio:audioswitch:1.1.8'
}

インスタンス化

AudioSwitchを使い始める前に、アプリケーション コンテキスト参照を使用してインスタンス化します。

    // 必要に応じてログの有効化・無効化を行う
    val audioSwitch = AudioSwitch(context, loggingEnabled = true)

アプリのライフサイクル全体で必要なのは、1つのAudioSwitchインスタンスだけです。
依存性の注入(DI)フレームワークを使用してコンポーネントに依存関係を注入するのも良いでしょう。

デバイスの変化をリッスンする

オーディオデバイスの可用性の変更をリアルタイムで検出できるよう、リッスンを開始します。

また、不要なタイミングに停止させます。

fun start() {
    // リッスンの開始
    audioSwitch.start { devices, selectedDevice ->
        // 有効なデバイスや選択したデバイスが変わるたびに実行される
        handleAudioDeviceChanges(devices)
    }
}

fun stop() {
    // リッスンの停止
    audioSwitch.stop()
}

デバイスの変化により選択したデバイスを自動的に切り替える

Bluetooth機器の接続状態・使用状態をもとに、接続や切断などの処理を行います。

// 有効なデバイスのキャッシュ
val cachedAvailableDevices = mutableListOf<AudioDevice>()

// 指定したデバイスを使用する
fun useAudioDevice(device: AudioDevice?) {
    if (audioSwitch.selectedAudioDevice == device) {
        return
    }
    audioSwitch.selectDevice(device)
    // デバイスを選択した後は、そのデバイスのアクティブ化が必須
    audioSwitch.activate()
}

fun findBluetooth(devices: List<AudioDevice>) =
    devices.find { it is AudioDevice.BluetoothHeadset }

fun handleAudioDeviceChanges(devices: List<AudioDevice>) {
    val oldBluetooth = findBluetooth(cachedAvailableDevices)
    val newBluetooth = findBluetooth(devices)

    // 変化したデバイスはBluetoothかどうかを確認
    if (oldBluetooth != newBluetooth) {
        when (newBluetooth) {
            // 接続 -> 切断
            null -> when {
                // 選択中のデバイスをリセットする(端末に戻る)
                audioSwitch.selectedAudioDevice is AudioDevice.BluetoothHeadset -> useAudioDevice(null)
            }

            // 切断 -> 接続
            else -> useAudioDevice(newBluetooth)
        }
    }

    // Bluetoothは変化なし
    cachedAvailableDevices.clear()
    cachedAvailableDevices.addAll(devices)
}

次回の課題

現在、Bluetooth機器のボタンで電話に応答・切電する機能に対応していません。
このため、TwilioとAndroid ConnectionServiceを統合して、この機能を実現したいと考えています。

ConnectionServiceは、Androidの通話管理システムを拡張し、通話の発信、着信、通話中の動作制御などを簡単に実装できるフレームワークです。
Twilioの通話機能と組み合わせることで、より安定した通話体験や追加機能の実装が可能となります。

まとめ

この記事では、Bluetooth通話機能の開発においてTwilio AudioSwitchがどのように活用されるかを解説しました。
今後のプロジェクトでBluetooth通話機能を実装する際は、ぜひ、検討してみてくださいね。


最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています!
詳しくは、こちらをご覧ください。

Node.js18を20にアップデートして、jestの実行速度を3倍にした

こんにちは!バックエンドエンジニアのハラノです。
くらしのマーケットのシステムの中には、Node.js(NestJS)を使用したマイクロサービスが存在しており、今回 Node.js のバージョンアップを行いました。
バージョンアップの方針及び、実際にアップデートを行う際に出てきた問題とその対策をご紹介します。

バージョンアップの方針

まずは、Active LTS になった Node.js を 20 に追従し、負債化しないようにすることが目的でした。
また、TypeScript もバージョンが少し古くなっていたため、ミニマムに進めるためにも当初は Node.js と TypeScript のみをバージョンアップしようと考えていました。
しかし、Node.js と TypeScript のバージョンアップを行ったところ、NestJS のバージョンが古く互換性がないようで、コンパイルが通らなくなってしまい NestJS のバージョンアップも必要になってしまいました。
この時点で、当初考えていた規模より大きくなったため、チームメンバーと話し合い、痛みを伴ってでも主に使用しているライブラリのバージョンアップを行うことで負債を残さないようにする、ということを決めました。
そのため、今回は Node.js 自体のバージョンアップに加え、TypeScript、NestJS、TypeORM などの主に使用しているライブラリのバージョンアップも行うことにしました。

バージョンアップの結果

最終的には主に以下をバージョンアップすることにしました。
また、この他にも devDependencies なライブラリ(Prettier, ESLint) も現時点の最新バージョンに更新しています。

ライブラリ名 バージョンアップ前 バージョンアップ後
Node.js v18 v20
NestJS v6 v10
TypeScript v3.9 v5.3
TypeORM v0.2 v0.3
Jest, ts-jest v26 v29, ts-jest は@swc/jest に置き換えました

以下では、バージョンアップなどの対応を行うにあたり発生した問題などを共有いたします。

各種対応において、発生した問題と対応

TypeScript のバージョンアップ

TypeScript のバージョンアップを行ったところ以下のような部分で、コンパイルエラーが発生するようになりました。

try {
    throw new Error("test");
} catch (e) {
    // ここでエラーが発生する
    console.log(e.message);
}

原因は、TypeScript4.4 で追加されたオプションであるuseUnknownInCatchVariablesです。ドキュメント
このオプションは、catch 節での変数の型を unknown にするかどうかを指定するもので、strict を true にしている場合デフォルトで true になります。
e は本来 unknown 型になるのが適切ですが、e が any に推論されてる前提で実装されており、e.messageを logging するなどの処理が大量にあり、今回は全てを変更する必要はないと判断したため、以下のように tsconfig.json で false に設定することで回避しました。

{
+   "useUnknownInCatchVariables": true
}

NestJS のバージョンアップ

@nestjs/common から HttpService, HttpModule が削除された

@nestjs/commonからHttpService,HttpModuleが削除されました。
@nestjs/axiosに移動しており、interface の変更はなかったため 以下のように import を変更することで解決できました。

-import { HttpModule } from '@nestjs/common';
-import { HttpService } from '@nestjs/common';
+import { HttpModule } from '@nestjs/axios';
+import { HttpService } from '@nestjs/axios';

Inject にInject(TestRepository.name)のように、クラス名を渡している部分について、依存関係の解決が行えなくなっていた

依存の解決について、厳密に行うようになっていたようで、@Inject(TestRepository.name)のような記述をしている部分で、依存関係の解決が行えなくなっていました。
以下のように、クラスを渡すように変更することで解決しました。

@Injectable()
export class TestLogic {
-   constructor(@Inject(TestRepository.name) private testRepository: ITestRepository) {}
+   constructor(@Inject(TestRepository) private testRepository: ITestRepository) {}
}

RxJS のtoPromiseが Deprecated になった

NestJS は非同期処理に RxJS を利用していますが、RxJS のtoPromiseが Deprecated になっていました。
現在の使い方であれば、lastValueFromを使って解決できることが、ドキュメントに記載されていたため、以下のようにlastValueFromを使うように変更することで解決しました。

As a replacement to the deprecated toPromise() method, you should use one of the two built in static conversion functions firstValueFrom or lastValueFrom. ※ドキュメントより引用

class TestApiClass {
    @Inject() private httpService!: HttpService;

    async post(payload: any) {
        const url = ''; // 省略
-       return this.httpService.post(url, payload).toPromise();
+       return lastValueFrom(this.httpService.post(url, payload));
    }
}

TypeORM のバージョンアップ

Connection が Deprecated になった

接続方法がConnectionを使う方法から、DataSourceを使う方法に標準が変更されました。
Connectionで設定している内容をDataSourceで設定するように変更することで、対応しました。
また、作成した Connection を使用して Repostiory に Inject するようにしていたため、Repository 全体で DataSource を使うように変更したうえで、型も変更しました。

@Injectable()
export class TestRepository {
-   constructor(@Inject(ProdDBConnection) private connection: Connection) {}
+   constructor(@Inject(ProdDataSource) private dataSource: DataSource) {}
}

ormconfig のサポートが無くなった

TypeORM 0.3 から ormconfig のサポートが無くなりました。

現在は非推奨になっただけなため、影響はありませんが 0.4.0 で削除されるため、対応を行わない場合 cli から呼び出しが行えなくなります。

そのため、前述した DataSource を使用するように変更したうえで、cli から呼び出す際は ormconfig で設定していた情報をオプションとしてつけることで対応しました。

-npm run typeorm migration:create -- -n
+npm run typeorm migration:generate -- -d ${マイグレーションを乗せるdirectory} -n

グレイスフルシャットダウンの処理が正常に完了しない

npm のバージョンアップを行った際に、グレイスフルシャットダウンの処理が正常に完了しない問題が発生しました。

今回対応したマイクロサービスは Http Request の他 Rabbit MQ を subscribe して、非同期処理を行う Worker も動作しており ECS にデプロイされていました。
Worker について、デプロイ時古いタスクを停止するために、 SIGTERM が発行される -> Rabbit MQ から新しい Event を取得しないようにする -> 現在実行している処理がすべて完了したら Rabbit MQ の Connection を閉じてシャットダウンする、という処理を行っていました。

しかし、npm のバージョンアップによって、SIGTERM が発行されても、Node.js 側でそれを受け取ることができず、一定時間後に SIGKILL が発行されて、強制的に終了するようになってしまいました。

Issue を探してみると 10.3.0 で解決したとの記述がありましたが実際に試しても治りませんでした。

npm 経由で呼び出しをしていることで発生していたため、一時的にnodeコマンドで直接呼び出すように変更し、SIGTERM を受け取ることができるようになりました。

Deprecated なコードの利用を削除

ライブラリのバージョンアップをした際に、deprecated なコードの利用が多く残っていることに気づきました。
一つ一つ探して削除するのは大変なため、deprecated なものを使用しているとエラーにしてくれる ESLint 拡張を追加し、呼び出しを探して推奨される対応を行い、進めました。

ユニットテストの実行時間を三分の一にした

ユニットテスト実行の際は ts-jest でトランスパイルしてから実行する形になっています。
しかし、ts-jest はトランスパイルに時間がかかり、最初のテスト実行までに 3~40 秒程度かかっていました。

ライブラリをバージョンアップしてテストを実行してというサイクルを回していたのですが、非常に時間がかかっていたので調べたところ、ユニットテスト高速化のために、@swc/jest などの選択肢があることを知りました。
esbuild-jestを使う選択肢もありましたが、@swc/jestNestJS の公式ドキュメントにも記載されており、@swc/jestを選択しました。
@swc/jestを使用してユニットテストを行ったところ、複数箇所でエラーが発生するようになってしまったので、以下の部分を変更することでテストが正常に動作するようになり、最初のテスト実行までに数秒しかかからなくなり、全体としては三分の一程度で完了するようになりました。

  • TypeORM でリレーションをしているような型についてRelation<Profile>に変更する
    • swc のほうが厳格にチェックしており、Initialize される前に型定義で使用すると、エラーになってしまうためのようです。
  • tsconfig.json の esModuleInterop を true に変更する
    • swc では esModuleInterop を true にしたような動きをしており、tsconfig.json 側を変更しないと行けない状況でした
    • この変更に伴い、CommonJS modules で書かれたものの import 方法が変わりました

移行自体は行えましたが、@swc/jestでは正しくカバレッジを計測できない問題が発生しました。
CI でカバレッジが一定以下だと落ちるようにしており、対応の必要があります。
そのため、カバレッジ計測の場合は現在も ts-jest を使用しています。

まとめ

今回、Node.js のバージョンアップを行い、それに伴うライブラリのバージョンアップを行いました。
コアで使用しているライブラリのバージョンアップを行い、持続的に開発を行うために負債を残さないようにできたと考えています。
また、ユニットテストの実行時間を三分の一にすることができ、開発効率の向上にもつながりました。
一方、当初より規模が大きくなってしまった点は、改善の余地があると考えており、次に活かすためにも学んでいきたいと思いました。

最後にみんなのマーケットでは、くらしのマーケットのサービス開発を一緒に盛り上げてくれるエンジニアを募集しています! 詳しくは、こちらをご覧ください。

セッションストア(ElastiCache for Redis)の分離作業と発生した問題を振り返る

SRE の片山です。

実は12月の SpeakerDeck の担当者が決まっていませんでしたが、4月に投稿したこちらのスライドがありがたいことに一番多くの view を獲得した...ということで選ばれました。

tech.curama.jp

ところで、最近ベトナムで行っている開発に関わることが増えました。そしてある機能の開発を進めるタイミングで負債化してきたセッションストアに利用している ElastiCache クラスターの一部のキーを新しいクラスタへ分離する良い機会が訪れました。

ベトナムの開発が気になる方はこちらを参照ください。

tech.curama.jp

いい機会だ、ということで分離作業を進めたのですが、結果として初回作業時は多くの問題が発生してしまい結局巻き戻す事となりました。 このレベルの大きめのマイグレーション作業は経験がありませんでしたが、振り返ってみると事前準備にいくつか問題がありました。

そこで得た ElastiCache, MemoryDB の知見や作業に関しての教訓を振り返ったものです。興味がありましたらこちらからどうぞ!

speakerdeck.com

プロダクトが日の目を見るまでにPMがやっていること

プロダクトが日の目を見るまでにPMがやっていること

こんにちは、みんなのマーケットでプロダクトマネージャーをしているたざきです。

最近よく聞くプロダクトマネージャー(PM)ですが、実際どんな仕事をしているかはよくわからない方も多いのではないでしょうか? 今回は当社のPMがどのような仕事をしていて、どのような人と関わりながら進めているかをスライドにまとめました!

PMにチャレンジしてみたい!という方にはぜひご覧いただければと思います。。

発表資料

speakerdeck.com