開発者ドキュメントユーザーガイドAndroid SDKクイックスタート(JetPack Compose)

🚀 クイックスタート(JetPack Compose)

このセクションでは、SkyWay Android SDK を利用した最小限の JetPack Compose をベースとするアプリケーションを開発する方法について掲載しています。 はじめて Android SDK を利用する方はこちらを参考に導入してください。

完成品は 公式リポジトリ にて公開しています。

尚、この記事は以下を前提に構成しています。

  • JetPack Compose を用いたアプリ開発経験があること
  • Kotlin の文法(関数、変数定義、呼び出し、コルーチンなど)がわかること

上記については Android Developers などを参考にしてください。

アプリの概要

このアプリには以下のシンプルな通話機能をP2Pによって実装します。

  1. ユーザーはルーム名を指定して入室できる
  2. ユーザーがパーミッションの要求を許可すれば、入室と同時に同じルームのメンバーに対してカメラ映像とマイク音声を配信する
  3. ユーザーは入室時から同じルームに入室しているユーザーのマイク音声とカメラ映像を視聴できる

Android プロジェクトの作成

Android Developers の公式ウェブサイトを参考に、Android プロジェクトを作成します。

https://developer.android.com/codelabs/basic-android-kotlin-compose-first-app

「Minimum SDK」は Android 6.0(API Level: 23) 以降を選択してください。また、「Build configuration language」は kotlin DSL を選択してください。

Android SDK のインストール

gradle/libs.versions.toml および app/build.gradle.kts に以下の依存関係を追加し、Sync Project with Gradle Files ボタンを押すだけでSDKのインストールは完了です。

libs.version.toml:

[versions] agp = ... # 追加 skyway = "x.x.x" # 最新の SDK バージョンに差し替えてください # 最新の SDK バージョン情報は以下のリンクより、Maven Central Repository にて確認できます # https://central.sonatype.com/artifact/com.ntt.skyway/room [libraries] androidx-core-ktx = ... # 追加 skyway-room = { group = "com.ntt.skyway", name = "room", version.ref = "skyway"}

build.gradle.kts:

android { ... } dependencies { ... // 追加 implementation(libs.skyway.room) }

SkyWay Auth Token の作成

SkyWay を利用するためには、初めに JWT(JSON Web Token) を用いて SkyWayContext を初期化します。 認証認可について、詳しくはこちらをご覧ください。

SkyWay Auth Token は本来サーバーサイドで生成するため、Android SDK にはトークンの生成機能はございません。 ここでは JavaScript SDK で配布しているライブラリを利用し、サーバーから認可された後トークンを取得してきたとしましょう。

skyway_tokenフォルダを作成し、npm がインストールされた環境でライブラリをインストールします。

$ mkdir skyway_token && cd skyway_token $ npm i @skyway-sdk/token

次に token.js を作成し、以下のコードをペーストします。

const { SkyWayAuthToken, uuidV4 } = require("@skyway-sdk/token"); const token = new SkyWayAuthToken({ jti: uuidV4(), iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, scope: { app: { id: "ここにアプリケーションIDをペーストしてください", turn: true, actions: ["read"], channels: [ { id: "*", name: "*", actions: ["write"], members: [ { id: "*", name: "*", actions: ["write"], publication: { actions: ["write"], }, subscription: { actions: ["write"], }, }, ], sfuBots: [ { actions: ["write"], forwardings: [ { actions: ["write"], }, ], }, ], }, ], }, }, }).encode("ここにシークレットキーをペーストしてください"); console.log(token);

SkyWay会員登録した後、Console画面アプリケーションを作成 により生成したアプリケーション ID とシークレットキーをスコープの app.idencode 引数にペーストしてください。

node で token.js を実行すると SkyWay Auth Token が生成されます。このトークンは後程使うのでコピーしてください。

$ node token.js eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIxOTA1MzU5Yi0xOGY3LTRhOWMtYmU4Zi1kMTgxMTQ0OTY1MjMiLCJpYXQiOjE2NzQxOTg3MDQsImV4cCI6MTY3NDI4NTEwNCwic2NvcGUiOnsiYXBwIjp7ImlkIjoi44GT44GT44Gr44Ki44OX44Oq44Kx44O844K344On44OzSUTjgpLjg5rjg7zjgrnjg4jjgZfjgabjgY_jgaDjgZXjgYQiLCJ0dXJuIjp0cnVlLCJhY3Rpb25zIjpbInJlYWQiXSwiY2hhbm5lbHMiOlt7ImlkIjoiKiIsIm5hbWUiOiIqIiwiYWN0aW9ucyI6WyJ3cml0ZSJdLCJtZW1iZXJzIjpbeyJpZCI6IioiLCJuYW1lIjoiKiIsImFjdGlvbnMiOlsid3JpdGUiXSwicHVibGljYXRpb24iOnsiYWN0aW9ucyI6WyJ3cml0ZSJdfSwic3Vic2NyaXB0aW9uIjp7ImFjdGlvbnMiOlsid3JpdGUiXX19XSwic2Z1Qm90cyI6W3siYWN0aW9ucyI6WyJ3cml0ZSJdLCJmb3J3YXJkaW5ncyI6W3siYWN0aW9ucyI6WyJ3cml0ZSJdfV19XX1dfX19.qmLpoOjou0S5JwxAiaBvH0KaGzZqN4-0t1xq708_b3M

なお、このトークンの有効期限は生成から1日です。有効期限が切れた場合は再度 token.js を実行してトークンを生成してください。

機能の実装

SkyWayContext, メンバの初期化

MainActivity と同じpackageにて、MainViewModel というクラスを新規に作成し、ViewModel を継承し、機能を実装します。

MainViewModel クラスから SkyWayContext.Options を設定し、先ほど生成された SkyWay Auth Tokenを記入します。
処理を [1] の部分に記入します。  

また、後ほど必要になるメンバをまとめて宣言します。
処理を [2] の部分に記入します。

import android.content.Context import android.util.Log import android.widget.Toast import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.ntt.skyway.core.SkyWayContext import com.ntt.skyway.core.content.Stream import com.ntt.skyway.core.content.local.LocalAudioStream import com.ntt.skyway.core.content.local.LocalVideoStream import com.ntt.skyway.core.content.local.source.AudioSource import com.ntt.skyway.core.content.local.source.CameraSource import com.ntt.skyway.core.content.remote.RemoteVideoStream import com.ntt.skyway.core.util.Logger import com.ntt.skyway.room.RoomPublication import com.ntt.skyway.room.member.LocalRoomMember import com.ntt.skyway.room.member.RoomMember import com.ntt.skyway.room.p2p.P2PRoom import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.util.UUID class MainViewModel(): ViewModel() { // [1] SkyWayContext.Optionsの設定 private val option = SkyWayContext.Options( // 先ほど生成されたtokenを差し替えてください authToken = "YOUR_TOKEN", logLevel = Logger.LogLevel.VERBOSE ) // [2] メンバの宣言 var applicationContext: Context? = null private var localRoomMember: LocalRoomMember? = null private var room: P2PRoom? = null var localVideoStream by mutableStateOf<LocalVideoStream?>(null) private set var localAudioStream by mutableStateOf<LocalAudioStream?>(null) private set var remoteVideoStream by mutableStateOf<RemoteVideoStream?>(null) private set // [3] Roomへの入室 }

ユーザが入室する関数の作成

MainViewModel にて、メンバー入室操作を表す joinAndPublish() という関数を定義します。
処理を [3] の部分に記入します。
Android SDKの一部の機能はCoroutineでの非同期的な処理が必須となりますので、予め viewModelScope を用いてCoroutineを作成します。

class MainViewModel(): ViewModel() { ... 省略 // [3] Roomへの入室 fun joinAndPublish(roomName: String) { viewModelScope.launch() { // [4] SkyWayContextのセットアップ // [5] カメラからの映像取得と UI への表示 // [6] マイクからの音声取得 // [7] Room の作成 // [8] Room への参加 // [9] Stream の Publish と Subscribe } } }

SkyWayContextのセットアップ

MainViewModel にて、SkyWayContext をセットアップします。
処理を [4] の部分に記入します。

class MainViewModel(): ViewModel() { ... 省略 // [3] Roomへの入室 fun joinAndPublish(roomName: String){ viewModelScope.launch() { // [4] SkyWayContextのセットアップ val result = SkyWayContext.setup(applicationContext!!, option) if (result) { Log.d("App", "Setup succeed") } } } }

カメラからの映像取得と UI への表示

先ほどに続いて、同じCoroutine内で、映像入力を取得しUIへ反映します。
処理を [5] の部分に記入します。

class MainViewModel(): ViewModel() { fun joinAndPublish(roomName: String){ viewModelScope.launch() { // [4] SkyWayContextのセットアップ ... 省略 // [5] カメラからの映像取得と UI への表示 // cameraリソースの取得 val device = CameraSource.getFrontCameras(applicationContext!!).first() // camera映像のキャプチャを開始します val cameraOption = CameraSource.CapturingOptions(800, 800) CameraSource.startCapturing(applicationContext!!, device, cameraOption) // MainThreadで LocalVideoStreamを更新します withContext(Dispatchers.Main) { // 描画やpublishが可能なStreamを作成します // localVideoStream Stateが更新され、MainScreenのSurfaceViewRendererより描画します localVideoStream = CameraSource.createStream() } } } }

マイクからの音声取得

音声を取得します。
処理を [6] の部分に記入します。
AudioSource は静的な object であることに注意してください。

class MainViewModel(): ViewModel() { fun joinAndPublish(roomName: String){ viewModelScope.launch() { // [5] カメラからの映像取得と UI への表示 ... 省略 // [6] マイクからの音声取得 // 音声入力を開始します AudioSource.start() // publishが可能なStreamを作成します localAudioStream = AudioSource.createStream() } } }

Room の作成

Streamの作成に続けて、入退室可能な Room を作成します。
処理を [7] の部分に記入します。
今回はユーザーが編集可能な roomName 要素を部屋の名前として利用します。

class MainViewModel(): ViewModel() { fun joinAndPublish(roomName: String){ viewModelScope.launch() { // [6] マイクからの音声取得 ... 省略 // [7] Room の作成 room = P2PRoom.findOrCreate(name = roomName) } } }

Room への参加

続いて Room に参加し、 publish / subscribe が可能な LocalRoomMember を取得します。
処理を [8] の部分に記入します。
ユーザー名は Room 内でユニークである必要があります。今回はUUIDを用いてランダムに決定しています。 resultMessage には Room への参加の成否を格納し、結果を Toast によるポップアップで通知しています。

class MainViewModel(): ViewModel() { fun joinAndPublish(roomName: String){ viewModelScope.launch() { // [7] Room の作成 ... 省略 // [8] Room への参加 val memberInit = RoomMember.Init(name = "member_" + UUID.randomUUID()) localRoomMember = room?.join(memberInit) val resultMessage = if (localRoomMember == null) "Join failed" else "Joined room" withContext(Dispatchers.Main) { Toast.makeText(applicationContext, resultMessage, Toast.LENGTH_SHORT).show() } } } }

Stream の Publish

Room に入室しているあるメンバーから他のメンバーへ Stream を公開(publish)をします。
処理を [9] の部分に記入します。
これにより、他のメンバーは対象の Stream を購読( subscribe )することが可能になります。 subscribe は次の項で実装します。

また、 Room にハンドラを追加することで publish 時に特定の動作を行わせることが可能です。

class MainViewModel(): ViewModel() { fun joinAndPublish(roomName: String){ viewModelScope.launch() { // [8] Room への参加 ... 省略 // [9] Stream の Publish と Subscribe // ハンドラ room?.onStreamPublishedHandler = { // [9.1] このRoom内で誰かがPublishするたびに実行される } // [9.2] 入室時に他のメンバーのStreamを購読する // 映像、音声のPublish localRoomMember?.publish(localVideoStream!!) localRoomMember?.publish(localAudioStream!!) } } }

ハンドラの登録は publish() との順序が重要です。例えば publication が追加されたことをハンドラで検知する場合は、上記のように、予め Room にハンドラを追加する必要があります。

Stream の Subscribe

Room に入室中のメンバーが公開( publish )している stream を購読( subscribe )します。
処理を [10] の部分に記入します。
また、[9.1][9.2] の部分に、購読関数を呼び出します。  

自身で publish した streamsubscribe できないことに注意してください。

class MainViewModel(): ViewModel() { ... 省略 fun joinAndPublish(roomName: String) { viewModelScope.launch(){ // [8] Room への参加 ... 省略 // [9] Stream の Publish と Subscribe // ハンドラ room?.onStreamPublishedHandler = Any@{ //ここの Any@ の追加も忘れなく // [9.1] このRoom内で誰かがPublishするたびに実行される Log.d("room", "onStreamPublished: ${it.id}") if (it.publisher?.id == localRoomMember?.id) { return@Any } subscribe(it) } // [9.2] 入室時に他のメンバーのStreamを購読する room?.publications?.forEach { if (it.publisher?.id == localRoomMember?.id) return@forEach subscribe(it) } localRoomMember?.publish(localVideoStream!!) localRoomMember?.publish(localAudioStream!!) } } // [10] 室中のメンバーが公開しているstreamを購読する private fun subscribe(publication: RoomPublication) { viewModelScope.launch { // Publicationをsubscribeします val subscription = localRoomMember?.subscribe(publication) subscription?.stream?.let { stream -> if (stream.contentType == Stream.ContentType.VIDEO) { withContext(Dispatchers.Main) { // remoteVideoStream Stateが更新され、MainScreenのSurfaceViewRendererより描画します remoteVideoStream = subscription.stream as RemoteVideoStream } } } } } }

なお、 AudioStream の場合は subscribe を完了したタイミングでスピーカーから音声が流れます。

レイアウトの設定

MainActivity と同じpackageにて、MainScreen.kt という新規ファイルを作成します、表示したいUIコンポーネントを @Composalbe でマークされた関数として記述します、またUI表示に関する変数も定義します。

import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material3.Button import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import com.ntt.skyway.core.content.local.LocalVideoStream import com.ntt.skyway.core.content.remote.RemoteVideoStream import com.ntt.skyway.core.content.sink.SurfaceViewRenderer import java.util.UUID @Composable fun MainScreen( mainViewModel: MainViewModel, modifier: Modifier, ) { // Room名表示state var roomName by remember { mutableStateOf(UUID.randomUUID().toString()) } // 映像表示state val localVideoStream: LocalVideoStream? = mainViewModel.localVideoStream val remoteVideoStream: RemoteVideoStream? = mainViewModel.remoteVideoStream var localRenderView by remember { mutableStateOf<SurfaceViewRenderer?>(null) } var remoteRenderView by remember { mutableStateOf<SurfaceViewRenderer?>(null) } Column(modifier = Modifier.fillMaxSize()) { // [11] Video 表示コンポーネントの配置 // [12] ルーム名の表示枠と参加ボタンの作成 } }

Video 表示コンポーネントの配置

"ローカルのビデオ"には自身の映像、"リモートのビデオ"には通話相手の映像を映します。

処理を [11] の部分に記入します。

@Composable fun MainScreen( mainViewModel: MainViewModel, modifier: Modifier ) { ... 省略 Column(modifier = Modifier.fillMaxSize()) { // [11] Video 表示コンポーネントの配置 Row( horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() .height(200.dp) ) { // ローカルのビデオ if (localVideoStream != null) { AndroidView( modifier = Modifier .width(150.dp) .height(150.dp), factory = { context -> localRenderView = SurfaceViewRenderer(context) localRenderView!!.apply { setup() localVideoStream.addRenderer(this) } }, update = { localVideoStream.removeRenderer(localRenderView!!) localVideoStream.addRenderer(localRenderView!!) } ) } // リモートのビデオ if (remoteVideoStream != null) { AndroidView( modifier = Modifier .width(150.dp) .height(150.dp), factory = { context -> remoteRenderView = SurfaceViewRenderer(context) remoteRenderView!!.apply { setup() remoteVideoStream.addRenderer(this) } }, update = { remoteVideoStream.removeRenderer(remoteRenderView!!) remoteVideoStream.addRenderer(remoteRenderView!!) } ) } } // [12] ルーム名の表示枠と参加ボタンの作成 } }

ルーム名の表示枠と参加ボタンの作成

ルーム名の表示枠、または参加ボタンを追加し、参加ボタンのonClick 関数にて、 mainViewModel.joinAndPublish() 関数を呼び出します。
処理を [12] の部分に記入します。

@Composable fun MainScreen( mainViewModel: MainViewModel, modifier: Modifier ) { ... 省略 Column(modifier = Modifier.fillMaxSize()) { // [11] Video 表示コンポーネントの配置 Row() { ... 省略 } // [12] ルーム名の表示枠と参加ボタンの作成 Row(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text("room:") TextField( value = roomName, onValueChange = { newText -> roomName = newText }, ) } Row( horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() ) { Button( onClick = { // Roomへの参加処理 mainViewModel.joinAndPublish(roomName) } ) { Text("Join") } } } }

UIの初期化

MainActivity に以下を実装します:

  • [13] の部分では、MainViewModel をActivityのメンバー変数として定義し、初期化しています。
  • [14] の部分では、Activityから取得される applicationContextmainViewModel に渡します。
  • [15] の部分では、FullScreen正しく表示させるために設定を行います。
  • [16] の部分では、MainScreen を呼び出して、UI初期化を行なっています。
import androidx.compose.foundation.layout.safeDrawingPadding class MainActivity : ComponentActivity() { // [13] viewModelの定義および初期化 private val mainViewModel = MainViewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() // [14] contextのセットアップ // SkyWayの諸APIを呼び出すにはapplicationContextが必要 mainViewModel.applicationContext = applicationContext setContent { // Android Project作成される際にProject名によって自動的に生成されるTheme名となります // Project名によって異なります SkyWayComposeQuickStartAppTheme { Scaffold(modifier = Modifier .fillMaxSize() // [15] FullScreen対応 .safeDrawingPadding() ) { innerPadding -> // [16] MainScreenの呼び出し // 本来のGreeting関数を削除し、代わりにMainScreen関数を呼び出します MainScreen( mainViewModel = mainViewModel, modifier = Modifier.padding(innerPadding) ) } } } // [17] 権限の要求 } }

パーミッションの設定

Android SDK の動作に必要なパーミッションを app/src/main/AndroidManifest.xml に記述します。

以下に、映像・音声の通信をする際に必要なパーミッションの例を示します。

<?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> <!-- ネットワーク接続に必要なパーミッション --> <uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/> <!-- カメラ映像・マイク音声の取得に必要なパーミッション --> <uses-permission android:name="android.permission.CAMERA"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <!-- CAMERA パーミッションのWarningを消すため --> <uses-feature android:name="android.hardware.camera" android:required="false" /> <application> ... </application> </manifest>

パーミッションの要求

カメラやマイクから入力を受け取る前に、アプリケーションにはデバイスを使う権限が必要です。
処理を [17] の部分に記入します。

import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat import androidx.core.content.PermissionChecker class MainActivity : ComponentActivity() { ... 省略 override fun onCreate(savedInstanceState: Bundle?) { ... 省略 setContent { ... 省略 } // [17] 権限の要求 if (ContextCompat.checkSelfPermission( applicationContext, android.Manifest.permission.CAMERA ) != PermissionChecker.PERMISSION_GRANTED || ContextCompat.checkSelfPermission( applicationContext, android.Manifest.permission.RECORD_AUDIO ) != PermissionChecker.PERMISSION_GRANTED ) { ActivityCompat.requestPermissions( this, arrayOf( android.Manifest.permission.CAMERA, android.Manifest.permission.RECORD_AUDIO, ), 0 ) } } }

動作確認

Android Studio の Run ボタンを押下してアプリケーションを実行してください。

次のステップ

今回はメディアを publish,subscribe するシンプルな例でした。

その他のサンプルアプリケーションはこちらをご参照ください。

サンプルコード

また、開発の前に開発ドキュメントもご一読ください。

Android SDKの開発ドキュメント