🎧

AndroidアプリでBluetoothミュージックコントローラーを作る

に公開

はじめに

車載向けにミュージックプレイヤー機能のあるAndroidアプリを開発する場合など、手持ちのiOSデバイスなどからAndroidデバイスにBluetooth接続し、Androidデバイス側をミュージックコントローラーとして機能させたいときがあります。このとき、iOSデバイスがオーディオをストリーミングし、Androidデバイスはそのオーディオスピーカーでありコントローラーです。

この記事では、Bluetooth接続している外部デバイスからストリーミングされる音楽の操作機能を、AndroidアプリでJetpack Media3を用いて実装する方法を紹介します。

https://developer.android.com/media/media3?hl=ja

前提知識

Bluetooth通信によるオーディオ再生と操作


GRAPEVINE | ROADSIDE PROPHET | スピードスターレコーズ

Bluetoothには、接続されたデバイス間でどのようなインタフェース仕様で通信するかを定めたプロファイルがあります。接続したデバイス同士が同じBluetoothプロファイルに対応していることで、その方式で通信が可能になります。

A2DP - Advanced Audio Distribution Profile

Bluetoothで接続したデバイス間でオーディオデータをストリーミングするためのプロファイルです。A2DPにはA2DP Source(配信側)とA2DP Sink(受信側)というロールが存在します。

  • A2DP Source: エンコードしたオーディオデータを送る側
    • 例: スマホ、PC
  • A2DP Sink: オーディオデータを受け取り再生する側
    • 例: ヘッドホン、スピーカー

本記事では、iOSデバイスがA2DP Source、AndroidデバイスがA2DP Sinkという構成を想定しています。

AVRCP - Audio/Video Remote Control Profile

Bluetoothで接続したデバイス間でオーディオ機器を操作するためのプロファイルです。こちらもTarget(受信側)とController(配信側)というロールが存在します。

  • AVRCP Target: 操作を受けてストリーミングに反映する側
    • 例: スマホ、PC
  • AVRCP Controller: 再生や一時停止などの操作を送る側
    • 例: ヘッドホン、スピーカー

本記事では、iOSデバイスがAVRCP Target、AndroidデバイスがAVRCP Controllerという構成を想定しています。

Androidが提供するメディアAPI


オーディオ アプリの概要 | Legacy media APIs | Android Developers
AndroidのメディアAPIを利用することで、MediaSessionというオブジェクトを通じて自端末で再生されているオーディオの再生状態やメタデータの取得、操作の実行ができます。MediaSession自体はMediaBrowserServiceを実装する他のアプリ(主にミュージックアプリ)やシステムプロセスから公開され、自アプリからはMediaControllerというオブジェクトを通して接続しにいくことができます。なお、MediaControllerとMediaSessionの接続はMediaSessionServiceが媒介しており、これはAndroidのシステムプロセスであるsystem_serverでホストされています。

最終的に、Bluetoothを通じて自端末上で再生している音楽(MediaSession)にMediaControllerを接続し、MediaControllerから取得できる再生状態やメタデータをUiStateに変換したり、ユーザーアクションをMediaControllerに対する操作に変換したりすることでUIを構築することが可能になっています。

そして、Jetpack Media3はAndroidが提供するメディアAPIをより扱いやすくしたライブラリです。

実装の概観


GRAPEVINE | ROADSIDE PROPHET | スピードスターレコーズ
実装の全体像は上図になります。AVRCPでBluetooth接続した外部デバイスと連携するMediaSessionは、com.android.bluetoothというシステムプロセスがホストするBluetoothMediaBrowserServiceで公開されています。これらの実装の詳細は、下記のAOSPリポジトリで確認することができます。
https://android.googlesource.com/platform/packages/apps/Bluetooth/+/refs/heads/main/src/com/android/bluetooth/avrcpcontroller/

BluetoothMediaBrowserServiceが公開するMediaSessionに対して、今回はJetpack Media3のMediaControllerから接続しミュージックコントローラーのUIを構築します。

MediaController自体もレガシーメディアAPIで提供されていますが、Media3で提供されているMediaControllerと比較すると標準で対応しているオーディオ操作が少なく、特にシャッフル再生やリピート再生といった操作は自前で実装する必要があったりします。Media3ではそれらの操作も含め豊富なインタフェースが提供されています。

実装の詳細

1. NotificationListenerServiceの登録

まずはBluetoothMediaBrowserServiceが公開するMediaSessionを取得する必要がありますが、Androidアプリから自端末でアクティブなMediaSessionを取得するには下記のいずれかを満たしている必要があります。

  • MEDIA_CONTENT_CONTROLというシステム特権を取得している
  • NotificationListenerServiceを実装しデバイス上の全通知の読み取り許可をユーザーから取得している

OEM向けの組み込みアプリであれば前者の方法で問題ないですが、今回は通常のアプリでも実装可能な後者の方法を用います。

実装自体はかなりシンプルで、NotificationListenerServiceを継承したクラスを実装し、AndroidManifestにサービスとして宣言するのみになります。ただし、アプリを起動してからサービスがBindされるまでにMediaSessionを取得しようとすると例外が発生するため、サービスがBindされたことを検知できるように実装しておきます。

AppNotificationListenerService.kt
class AppNotificationListenerService : NotificationListenerService() {
    companion object {
        private val _isConnected = MutableStateFlow(false)
        val isConnected: StateFlow<Boolean> = _isConnected
    }

    override fun onListenerConnected() {
        _isConnected.update { true }
        super.onListenerConnected()
    }

    override fun onListenerDisconnected() {
        _isConnected.update { false }
        super.onListenerDisconnected()
    }

    override fun onDestroy() {
        _isConnected.update { false }
        super.onDestroy()
    }
}
AndroidManifest.xml
<service
    android:name=".base.notification.AppNotificationListenerService"
    android:label="@string/app_name"
    android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE"
    android:exported="true">
    <intent-filter>
        <action android:name="android.service.notification.NotificationListenerService" />
    </intent-filter>
</service>

また、必要に応じてユーザー許諾が得られていない場合に設定画面に遷移させるような処理を実装しておくと良さそうです。

HomeScreen.kt
LaunchedEffect(Unit) {
    val permissionGranted = NotificationManagerCompat.getEnabledListenerPackages(context)
        .contains(context.packageName)

    if (!permissionGranted) {
        try {
            val intent = Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS")
            context.startActivity(intent)
        } catch (e: Exception) {
            /* no-op */
        }
    }
}

2. AvrcpControllerが公開しているMediaSessionの取得

NotificationListenerServiceがBindされたことを確認して、MediaSessionManagerのgetActiveSessions()を実行し、自端末上のアクティブなすべてのMediaSessionを取得します。

HomeScreenViewModel.kt
private val mediaSessionManager by lazy {
    context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
}

init {
    viewModelScope.launch {
        AppNotificationListenerService.isConnected.first { isConnected ->
            if (!isConnected) return@first false

            val componentName = ComponentName(context, AppNotificationListenerService::class.java)
            val sessions = mediaSessionManager.getActiveSessions(componentName)
            attachMediaController(sessions)

            // do something

            return@first true
        }
    }
}

基本的にデバイス上で音楽を再生中のMediaSessionはリストの先頭にありますが、厳密にAvrcpControllerが公開するMediaSessionを取得したい場合は、下記のようにパッケージ名で絞り込むことで可能です。

HomeScreenViewModel.kt
val sessions = mediaSessionManager.getActiveSessions(componentName).filter { 
    it.packageName == "com.android.bluetooth"
}

3. 取得したMediaSessionに紐づくMediaControllerを生成

Media3のMediaControllerを生成する際に、特定のMediaSessionと接続させるためにはSessionTokenを用いて指定する必要があります。このSessionTokenは、レガシーメディアAPIとMedia3それぞれで同名オブジェクトが提供されており、Media3のMediaControllerを生成するためには、Media3のSessionTokenが必要です。レガシーメディアAPIのSessionTokenからMedia3のSessionTokenに変換するヘルパーが提供されているので、これを用いて下記のような処理を実装します。

  1. レガシーメディアAPIのMediaSessionからSessionTokenを取得
  2. ヘルパーを用いてMedia3のSessionTokenに変換
  3. そのSessionTokenを用いてMedia3のMediaControllerを生成する
HomeScreenViewModel.kt
private var mediaController: MediaController? = null

private suspend fun attachMediaController(sessions: List<android.media.session.MediaController>?) {
    releaseMediaController()
    if (sessions.isNullOrEmpty()) return

    val legacySessionToken = sessions.first().sessionToken
    val sessionToken = SessionToken.createSessionToken(context, legacySessionToken).await()
    mediaController = MediaController.Builder(context, sessionToken)
        .setApplicationLooper(Looper.getMainLooper())
        .buildAsync()
        .await()

    updateMusicPlayerState()
}

4. MediaControllerを用いてUiStateの更新とオーディオ操作

最後にMediaContollerが持つメタデータやオーディオ操作のためのAPIを利用してUIを構築します。今回はJetpack ComposeでUIを構築したいので、ViewModelが持つUiStateに対して更新をかけます。

HomeScreenViewModel.kt
private val _state = MutableStateFlow(
    HomeScreenState(screenState = _screenState.value)
)

private fun updateMusicPlayerState() {
    val controller = mediaController
    if (controller == null) {
        _state.update { it.copy(musicPlayerState = MusicPlayerState()) }
        return
    }
    
    _state.update {
        it.copy(
            musicPlayerState = it.musicPlayerState.copy(
                currentProgress = controller.currentProgress,
                currentTime = controller.currentTime,
                contentTime = controller.contentTime,
                albumTitle = controller.mediaMetadata.albumTitle?.toString().orEmpty(),
                title = controller.mediaMetadata.title?.toString().orEmpty(),
                artist = controller.mediaMetadata.artist?.toString().orEmpty(),
                artworkImageUri = controller.mediaMetadata.artworkUri,
                isPlaying = controller.isPlaying,
                shuffleMode = controller.shuffleModeEnabled,
                repeatMode = controller.repeatMode,
                audioVolume = controller.deviceVolume,
                maxAudioVolume = controller.deviceInfo.maxVolume,
                isPlayPauseEnabled = controller.isCommandAvailable(Player.COMMAND_PLAY_PAUSE),
                isPreviousEnabled = controller.isCommandAvailable(Player.COMMAND_SEEK_TO_PREVIOUS),
                isNextEnabled = controller.isCommandAvailable(Player.COMMAND_SEEK_TO_NEXT),
                isShuffleEnabled = controller.isCommandAvailable(Player.COMMAND_SET_SHUFFLE_MODE),
                isRepeatEnabled = controller.isCommandAvailable(Player.COMMAND_SET_REPEAT_MODE)
            )
        )
    }
}

また、MediaControllerから便利に値を参照できるようにするために、下記のような拡張関数を実装しておくと良さそうです。

HomeScreenViewModel.kt
private val MediaController.currentProgress: Float
    get() = this.currentPosition / this.contentDuration.toFloat()

private val MediaController.currentTime: String
    get() {
        val currentPosition = this.currentPosition.let { if (it < 0) 0 else it }
        val currentMinutes = (currentPosition / 1000 / 60).toInt()
        val currentSeconds = (currentPosition / 1000 % 60).toInt()
        return String.format(US, "%d:%02d", currentMinutes, currentSeconds)
    }

private val MediaController.contentTime: String
    get() {
        val currentPosition = this.currentPosition.let { if (it < 0) 0 else it }
        val currentMinutes = (currentPosition / 1000 / 60).toInt()
        val currentSeconds = (currentPosition / 1000 % 60).toInt()
        val contentDuration = this.contentDuration.let { if (it < 0) 0 else it }
        val contentMinutes = (contentDuration / 1000 / 60).toInt() - currentMinutes
        val contentSeconds = if(contentMinutes == 0) {
            (contentDuration / 1000 % 60).toInt() - currentSeconds
        } else 60 - currentSeconds
        return String.format(US, "-%d:%02d", contentMinutes, contentSeconds)
    }

musicPlayerStateからUIを構築しているComposableは下記を参照してください。

Composable
MusicPlayer.kt
@Composable
internal fun MusicPlayer(
    modifier: Modifier = Modifier,
    state: MusicPlayerState,
    dispatch: (event: ScreenEvent) -> Unit = {}
) {
    Column(
        modifier = modifier
            .fillMaxSize()
            .background(
                Brush.verticalGradient(
                    listOf(
                        Color(0xFF817D7D),
                        Color(0xFF2A2927),
                    )
                )
            )
            .padding(horizontal = 32.dp, vertical = 16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Spacer(modifier = Modifier.height(8.dp))
        Text(
            style = SystemTheme.typography.titleMedium,
            color = SystemTheme.colors.surface,
            text = state.albumTitle.orEmpty()
        )
        Spacer(modifier = Modifier.height(48.dp))
        AsyncImage(
            modifier = Modifier
                .fillMaxWidth()
                .aspectRatio(1f)
                .clip(RoundedCornerShape(16.dp)),
            model = state.artworkImageUri,
            contentDescription = null,
            placeholder = ColorPainter(Color.Gray),
            error = ColorPainter(Color.Gray)
        )
        Spacer(modifier = Modifier.height(48.dp))
        Text(
            modifier = Modifier.fillMaxWidth(),
            style = SystemTheme.typography.headlineMedium,
            color = SystemTheme.colors.surface,
            text = state.title.orEmpty(),
            textAlign = TextAlign.Start
        )
        Spacer(modifier = Modifier.height(4.dp))
        Text(
            modifier = Modifier.fillMaxWidth(),
            style = SystemTheme.typography.bodyMedium,
            color = SystemTheme.colors.surface.copy(alpha = 0.5f),
            text = state.artist.orEmpty(),
            textAlign = TextAlign.Start
        )
        Spacer(modifier = Modifier.height(16.dp))
        MusicIndicator(state)
        Spacer(modifier = Modifier.height(16.dp))
        MusicController(
            state = state,
            onClickPlayPause = { dispatch(HomeScreenEvent.OnClickPlayPause) },
            onClickShuffle = { dispatch(HomeScreenEvent.OnClickShuffle) },
            onClickRepeat = { dispatch(HomeScreenEvent.OnClickRepeat) },
            onClickPrevious = { dispatch(HomeScreenEvent.OnClickSeekToPrevious) },
            onClickNext = { dispatch(HomeScreenEvent.OnClickSeekToNext) }
        )
        Spacer(modifier = Modifier.height(16.dp))
        MusicVolumeSlider(
            state = state,
            onValueChange = { dispatch(HomeScreenEvent.OnChangeVolume((it * state.maxAudioVolume).toInt())) }
        )
    }
}

@Composable
private fun MusicIndicator(
    state: MusicPlayerState
) {
    Column {
        LinearProgressIndicator(
            modifier = Modifier.fillMaxWidth(),
            progress = { state.currentProgress },
            color = SystemTheme.colors.surface,
            trackColor = SystemTheme.colors.scrim.copy(alpha = 0.2f),
            drawStopIndicator = { /* no-op */ }
        )
        Spacer(modifier = Modifier.height(4.dp))
        Row(
            modifier = Modifier.fillMaxWidth(),
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Text(
                style = SystemTheme.typography.bodySmall,
                color = SystemTheme.colors.surface.copy(alpha = 0.5f),
                text = state.currentTime
            )
            Text(
                style = SystemTheme.typography.bodySmall,
                color = SystemTheme.colors.surface.copy(alpha = 0.5f),
                text = state.contentTime
            )
        }
    }
}

@Composable
private fun MusicController(
    state: MusicPlayerState,
    onClickPlayPause: () -> Unit = {},
    onClickShuffle: () -> Unit = {},
    onClickRepeat: () -> Unit = {},
    onClickPrevious: () -> Unit = {},
    onClickNext: () -> Unit = {}
) {
    Row(
        modifier = Modifier.fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.SpaceBetween
    ) {
        Icon(
            modifier = Modifier
                .size(36.dp)
                .clickable(onClick = onClickShuffle),
            painter = painterResource(R.drawable.icon_shuffle),
            contentDescription = null,
            tint = if (state.isShuffleEnabled) {
                if (state.shuffleMode) {
                    Color(0xFFDEBE3F)
                } else {
                    SystemTheme.colors.surface
                }
            } else {
                Color.Gray
            }
        )
        Icon(
            modifier = Modifier
                .size(36.dp)
                .clickable(onClick = onClickPrevious),
            painter = painterResource(R.drawable.icon_skip_previous),
            contentDescription = null,
            tint = if (state.isPreviousEnabled) {
                SystemTheme.colors.surface
            } else {
                Color.Gray
            }
        )
        Icon(
            modifier = Modifier
                .size(56.dp)
                .clickable(onClick = onClickPlayPause),
            painter = if (state.isPlaying) {
                painterResource(R.drawable.icon_play_circle)
            } else {
                painterResource(R.drawable.icon_pause_circle)
            },
            contentDescription = null,
            tint = if (state.isPlayPauseEnabled) {
                SystemTheme.colors.surface
            } else {
                Color.Gray
            }
        )
        Icon(
            modifier = Modifier
                .size(36.dp)
                .clickable(onClick = onClickNext),
            painter = painterResource(R.drawable.icon_skip_next),
            contentDescription = null,
            tint = if (state.isNextEnabled) {
                SystemTheme.colors.surface
            } else {
                Color.Gray
            }
        )
        Icon(
            modifier = Modifier
                .size(36.dp)
                .clickable(onClick = onClickRepeat),
            painter = when (state.repeatMode) {
                REPEAT_MODE_ONE -> painterResource(R.drawable.icon_repeat_one)
                else -> painterResource(R.drawable.icon_repeat)
            },
            contentDescription = null,
            tint = if (state.isRepeatEnabled) {
                if (state.repeatMode != REPEAT_MODE_OFF) {
                    Color(0xFFDEBE3F)
                } else {
                    SystemTheme.colors.surface
                }
            } else {
                Color.Gray
            }
        )
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MusicVolumeSlider(
    state: MusicPlayerState,
    onValueChange: (Float) -> Unit = {}
) {
    val interactionSource = remember { MutableInteractionSource() }
    Row(
        modifier = Modifier.fillMaxWidth(),
        verticalAlignment = Alignment.CenterVertically,
        horizontalArrangement = Arrangement.spacedBy(4.dp)
    ) {
        Icon(
            modifier = Modifier.size(20.dp),
            painter = painterResource(R.drawable.icon_volume_mute),
            contentDescription = null,
            tint = SystemTheme.colors.surface
        )
        Slider(
            interactionSource = interactionSource,
            modifier = Modifier.weight(1f),
            value = state.audioVolume / state.maxAudioVolume.toFloat(),
            onValueChange = onValueChange,
            thumb = {
                SliderDefaults.Thumb(
                    interactionSource = interactionSource,
                    modifier = Modifier
                        .size(16.dp)
                        .shadow(1.dp, CircleShape, clip = false)
                        .indication(
                            interactionSource = interactionSource,
                            indication = ripple(bounded = false, radius = 16.dp)
                        ),
                    colors = SliderDefaults.colors(
                        thumbColor = SystemTheme.colors.surface
                    )
                )
            },
            track = {
                SliderDefaults.Track(
                    sliderState = it,
                    modifier = Modifier.height(4.dp),
                    thumbTrackGapSize = 0.dp,
                    trackInsideCornerSize = 0.dp,
                    drawStopIndicator = null,
                    colors = SliderDefaults.colors(
                        activeTrackColor = SystemTheme.colors.surface,
                        inactiveTrackColor = SystemTheme.colors.scrim.copy(alpha = 0.2f)
                    )
                )
            }
        )
        Icon(
            modifier = Modifier.size(20.dp),
            painter = painterResource(R.drawable.icon_volume_up),
            contentDescription = null,
            tint = SystemTheme.colors.surface
        )
    }
}

@Preview(showBackground = true, device = "id:pixel_9")
@Composable
private fun MusicPlayerPreview() {
    SystemTheme {
        MusicPlayer(
            state = MusicPlayerState(
                currentProgress = 0.66f,
                currentTime = "3:28",
                contentTime = "4:28",
                albumTitle = "Album Title",
                title = "Song Title",
                artist = "Song Artist",
                artworkImageUri = "".toUri(),
                isPlaying = true,
                shuffleMode = false,
                repeatMode = REPEAT_MODE_OFF,
                audioVolume = 66,
                maxAudioVolume = 100,
                isPlayPauseEnabled = true,
                isPreviousEnabled = true,
                isNextEnabled = true,
                isShuffleEnabled = true,
                isRepeatEnabled = true,
            ),
        )
    }
}
Preview

ユーザーアクションをもとにオーディオ操作を行う際は下記のようになります。

HomeScreenViewModel.kt
fun playPause() {
    mediaController?.let { controller ->
        if (controller.isPlaying) {
            controller.pause()
        } else {
            controller.play()
        }
    }
}

fun toggleShuffleMode() {
    mediaController?.let { controller ->
        controller.shuffleModeEnabled = !controller.shuffleModeEnabled
    }
}

fun rotateRepeatMode() {
    mediaController?.let { controller ->
        controller.repeatMode = when (controller.repeatMode) {
            Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ALL
            Player.REPEAT_MODE_ALL -> Player.REPEAT_MODE_ONE
            Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_OFF
            else -> Player.REPEAT_MODE_OFF
        }
    }
}

fun seekToPrevious() {
    mediaController?.seekToPrevious()
}

fun seekToNext() {
    mediaController?.seekToNext()
}

fun changeVolume(volume: Int) {
    mediaController?.setDeviceVolume(volume, VOLUME_FLAG_PLAY_SOUND)
}

おわり

初めてAndroidアプリでBluetoothに関連する処理やメディアAPIを用いた機能を開発しましたが、普段あまり触れない領域であることに加え、技術記事もそこまで豊富にあるわけでなかったり、あっても古い記事だったりでかなり理解を深めるのに苦戦しました。

今回の記事で扱った範囲もメディアAPIが提供する表層的な部分が主にはなりますが、特にBluetooth通信している外部デバイスへどのようにメディアAPIで接続できているのか、AOSPリポジトリのソースコードを参照してAndroidのシステムプロセスも絡んだ構造を整理できたことは、今後Bluetoothオーディオを開発する人たちの参考になれば嬉しいです。

Discussion