RokidDemo入門:初心者でも実践できるスマホ×メガネ連携アプリ開発

Rokidメガネのエコシステムに初めて触れるなら、RokidDemoはまさに「スマホとメガネを繋ぐ架け橋」です。単なるサンプルコードではなく、スマホとメガネを実際に連携させ、インタラクションや協調動作を実現するための基盤プロジェクトです。このプロジェクトでは以下の機能を確認できます。

  • Rokidメガネのスキャン発見、Bluetooth接続と認証
  • メガネの状態(バッテリー残量、音量、輝度、充電状態)を取得し、スマホ側で表示・調整
  • メガネカメラを起動して撮影、画像をスマホ側に転送して保存・管理
  • カスタムUIをメガネ側に表示(例:テキストメッセージのポップアップ)
  • グローバルメッセージ/TTSフィードバックの送信
  • リモコンボタンのシミュレーション(方向、戻る、音量)をHIDパケット経由でメガネ制御
  • これらのインタラクション履歴をデータベースに記録し、ページング表示

一言で言えば、これが「スマホ+メガネ」連携アプリを開発するためのスタートラインです。プロジェクトコードはGitHubで公開されています(作者に感謝):https://github.com/StudiousXiaoYu/RokidDemo

環境構築(Windows)

この工程は重要です。「ビルド失敗」「デバイス未認識」のほとんどは環境に起因します。

Kotlinのインストール

プロジェクトコードはKotlin環境なので、ローカルIDEに対応プラグインをインストールします。

Kotlinプラグインインストール画面

Android Studio と OpenJDK 11 のインストール

導入状況の確認

winget search --source winget Android | Select-String -Pattern "Android Studio|SDK|Platform Tools|OpenJDK"

winget検索結果

OpenJDK 11 のインストール

winget install -e --id Microsoft.OpenJDK.11 --accept-source-agreements --accept-package-agreements

OpenJDKインストール画面

Android Studio のインストール

winget install -e --id Google.AndroidStudio --accept-source-agreements --accept-package-agreements

AndroidStudioインストール画面

Platform Tools のインストール

winget install -e --id Google.PlatformTools --accept-source-agreements --accept-package-agreements

PlatformToolsインストール画面

以上で基本環境のインストールは完了です。

Android SDK コンポーネントのインストール

以下のコンポーネントを必ずインストールしてください。

  • Android SDK Platform 36(プロジェクトの compileSdk 36 に対応)
  • Android SDK Build-Tools 35(または互換バージョン)
  • Android SDK Platform-Tools(adb を含む)
  • Android SDK Command-line Tools (latest)(コマンドライン管理に便利)

Android Studio を起動し、設定から「SDK Tools」を開き、該当ツールをダウンロードします。

SDK Tools設定画面

実機デバッグ準備

スマホで「開発者オプション」と「USBデバッグ」を有効にします。

開発者オプション画面

初回接続時にUSBデバッグ許可を求められたら、「常に許可」にチェックを入れて許可します。

Windowsでドライバ不足の場合は Google USB Driver をインストールしてください。ターミナルで adb devices と入力し、デバイスが表示されることを確認します。

プロジェクト技術詳細

このセクションではプロジェクトを起動し、ビルドが通るまで進めます。3ステップ:local.properties 設定 → Rokid リポジトリ追加 → client-m 依存関係導入。

プロジェクトルートの local.propertiessdk.dir を Android SDK パス(通常 C:\Users\ユーザー名\AppData\Local\Android\Sdk)に設定します。

local.properties編集画面

sdk.dir=C:\Users\yu\AppData\Local\Android\Sdk

リポジトリと依存関係(Gradle)
トップレベルにリポジトリ、モジュールに依存関係を追加してGradle Syncを実行します。

// settings.gradle
pluginManagement {
  repositories {
    gradlePluginPortal()
    google()
    mavenCentral()
    maven { url 'https://maven.rokid.com/repository/maven-public' }
  }
}
dependencyResolutionManagement {
  repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
  repositories {
    google()
    mavenCentral()
    maven { url 'https://maven.rokid.com/repository/maven-public' }
  }
}
// app/build.gradle
android {
    defaultConfig {
        minSdk = 28
    }
}
dependencies {
    implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")
}

確認ポイント:sdk.dir のパスが有効、依存解決可能、Sync がエラーなし。

Manifest パーミッションと機能(Android 12+)

以下のパーミッションを AndroidManifest.xml に追加します。Android 12 以降ではランタイムでの許可申請も必要です。

<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />

<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />

<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" android:maxSdkVersion="30" />

<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Bluetooth関連パーミッションは接続前に先に申請します。ForegroundService によりプロセスがシステムに強制終了されるのを防ぎます。

ビルドとインストール

パッケージを実機にインストールします。前提:adb devices でデバイスが device と表示されていること。
Windows用コマンドスクリプトはプロジェクト内にあります。

  1. Debug パッケージのビルド:

.\gradlew.bat assembleDebug -x lint

  1. デバイスへのインストール:

.\gradlew.bat installDebug

  1. エントリActivity起動:

adb shell am start -n com.blue.armobile/com.blue.glassesapp.feature.init.InitActivity

初回起動時にパーミッションの許可を求められます。ストレージと通知の許可を与えないと初期化がブロックされるので注意してください。

パーミッション要求画面

必要な許可を付与後、スマホ側アプリの画面が正常に表示されます。

メイン画面

デバイス接続

始める前に「SDK導入」を完了しておいてください。ここではBluetoothスキャンによるデバイス追加を行います。

Bluetoothスキャン画面

1 Bluetoothデバイスの検索

package com.rokid.cxrandroiddocsample.helpers

import android.Manifest
import android.app.Activity
import android.bluetooth.*
import android.bluetooth.le.BluetoothLeScanner
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.os.Build
import android.os.ParcelUuid
import android.util.Log
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.MutableLiveData
import java.util.concurrent.ConcurrentHashMap

class BluetoothHelper(
    private val context: AppCompatActivity,
    private val initStatus: (INIT_STATUS) -> Unit,
    private val deviceFound: () -> Unit
) {
    companion object {
        const val TAG = "Rokid Glasses CXR-M"
        const val REQUEST_CODE_PERMISSIONS = 100
        private val REQUIRED_PERMISSIONS = mutableListOf(
            Manifest.permission.ACCESS_FINE_LOCATION,
            Manifest.permission.BLUETOOTH,
            Manifest.permission.BLUETOOTH_ADMIN,
        ).apply {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
                add(Manifest.permission.BLUETOOTH_SCAN)
                add(Manifest.permission.BLUETOOTH_CONNECT)
            }
        }.toTypedArray()

        enum class INIT_STATUS { NotStart, INITING, INIT_END }
    }

    val scanResultMap: ConcurrentHashMap = ConcurrentHashMap()
    val bondedDeviceMap: ConcurrentHashMap = ConcurrentHashMap()
    private var adapter: BluetoothAdapter? = null
    private var manager: BluetoothManager? = null

    private val scanner: BluetoothLeScanner by lazy {
        adapter?.bluetoothLeScanner ?: run {
            showRequestPermissionDialog()
            throw Exception("Bluetooth is not supported!!")
        }
    }

    private val bluetoothEnabled: MutableLiveData<Boolean> = MutableLiveData<Boolean>().apply {
        this.observe(context) {
            if (this.value == true) {
                initStatus.invoke(INIT_STATUS.INIT_END)
                startScan()
            } else {
                showRequestBluetoothEnableDialog()
            }
        }
    }

    private val requestBluetoothEnable = context.registerForActivityResult(
        ActivityResultContracts.StartActivityForResult()
    ) { result ->
        if (result.resultCode == Activity.RESULT_OK) {
            adapter = manager?.adapter
        } else {
            showRequestBluetoothEnableDialog()
        }
    }

    private var adapterHolder: BluetoothAdapter? = null
        set(value) {
            field = value
            value?.let {
                if (!it.isEnabled) {
                    requestBluetoothEnable.launch(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
                } else {
                    bluetoothEnabled.postValue(true)
                }
            }
        }

    private var managerHolder: BluetoothManager? = null
        set(value) {
            field = value
            initStatus.invoke(INIT_STATUS.INITING)
            value?.let { adapterHolder = it.adapter } ?: run { showRequestPermissionDialog() }
        }

    val permissionResult: MutableLiveData<Boolean> = MutableLiveData<Boolean>().apply {
        this.observe(context) {
            if (it == true) {
                managerHolder = context.getSystemService(AppCompatActivity.BLUETOOTH_SERVICE) as BluetoothManager
            } else {
                showRequestPermissionDialog()
            }
        }
    }

    val scanListener = object : ScanCallback() {
        @SuppressLint("MissingPermission")
        override fun onScanResult(callbackType: Int, result: ScanResult?) {
            super.onScanResult(callbackType, result)
            result?.let { r ->
                r.device.name?.let {
                    scanResultMap[it] = r.device
                    deviceFound.invoke()
                }
            }
        }

        override fun onScanFailed(errorCode: Int) {
        }
    }

    fun checkPermissions() {
        initStatus.invoke(INIT_STATUS.NotStart)
        context.requestPermissions(REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
        context.registerReceiver(
            bluetoothStateListener,
            IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED)
        )
    }

    @SuppressLint("MissingPermission")
    fun release() {
        context.unregisterReceiver(bluetoothStateListener)
        stopScan()
        permissionResult.postValue(false)
        bluetoothEnabled.postValue(false)
    }

    private fun showRequestPermissionDialog() {}
    private fun showRequestBluetoothEnableDialog() {}

    @SuppressLint("MissingPermission")
    @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
    fun startScan() {
        scanResultMap.clear()
        try {
            scanner.startScan(
                listOf(
                    ScanFilter.Builder()
                        .setServiceUuid(ParcelUuid.fromString("00009100-0000-1000-8000-00805f9b34fb"))
                        .build()
                ),
                ScanSettings.Builder().build(),
                scanListener
            )
        } catch (_: Exception) {}
    }

    @RequiresPermission(Manifest.permission.BLUETOOTH_SCAN)
    fun stopScan() {
        scanner.stopScan(scanListener)
    }

    private val bluetoothStateListener = object : BroadcastReceiver() {
        override fun onReceive(context: Context?, intent: Intent?) {
            val action = intent?.action
            if (action == BluetoothAdapter.ACTION_STATE_CHANGED) {
                val state = intent.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)
                if (state == BluetoothAdapter.STATE_OFF) {
                    initStatus.invoke(INIT_STATUS.NotStart)
                    bluetoothEnabled.postValue(false)
                }
            }
        }
    }
}

2 初期化:Bluetooth経由でデバイス情報を取得

fun initDevice(context: Context, device: BluetoothDevice) {
    // CXR SDKを用いてBluetoothモジュールを初期化し、接続情報と状態を監視
    CxrApi.getInstance().initBluetooth(context, device, object : BluetoothStatusCallback {
        override fun onConnectionInfo(
            socketUuid: String?,
            macAddress: String?,
            rokidAccount: String?,
            glassesType: Int
        ) {
            // 成功時にsocketUuidとmacAddressが返る。後続のconnectBluetoothで使用
            socketUuid?.let { uuid ->
                macAddress?.let { address ->
                    connect(context, uuid, address)
                }
            }
        }

        override fun onConnected() { }               // データチャネル確立
        override fun onDisconnected() { }            // データチャネル切断
        override fun onFailed(p0: ValueUtil.CxrBluetoothErrorCode?) { } // 初期化失敗
    })
}

3 Bluetoothモジュールへの接続

fun connect(context: Context, socketUuid: String, macAddress: String) {
    CxrApi.getInstance().connectBluetooth(context, socketUuid, macAddress, object : BluetoothStatusCallback {
        override fun onConnectionInfo(
            socketUuid: String?,
            macAddress: String?,
            rokidAccount: String?,
            glassesType: Int
        ) { }

        override fun onConnected() { }               // 接続成功
        override fun onDisconnected() { }            // 接続断
        override fun onFailed(p0: ValueUtil.CxrBluetoothErrorCode?) { } // 接続失敗
    })
}

4 接続状態の取得

fun isDeviceConnected(): Boolean {
    // true: 接続中, false: 未接続
    return CxrApi.getInstance().isBluetoothConnected
}

5 Bluetoothの解放

fun deInitBluetooth() {
    CxrApi.getInstance().deinitBluetooth()
}

6 Bluetooth再接続

fun reconnect(context: Context, socketUuid: String, macAddress: String) {
    // 切断後、同じsocketUuidとmacAddressで再接続
    CxrApi.getInstance().connectBluetooth(context, socketUuid, macAddress, object : BluetoothStatusCallback { })
}

Wi‑Fi接続

fun initWiFi(): ValueUtil.CxrStatus? {
    // Wi‑Fi P2P通信モジュールの初期化(Bluetoothリンク確立後に使用)
    return CxrApi.getInstance().initWifiP2P(object : WifiP2PStatusCallback {
        override fun onConnected() { /* Wi‑Fi P2P確立成功 */ }
        override fun onDisconnected() { /* Wi‑Fi P2P切断 */ }
        override fun onFailed(errorCode: ValueUtil.CxrWifiErrorCode?) { /* エラーコード処理 */ }
    })
}

fun isWiFiConnected(): Boolean {
    return CxrApi.getInstance().isWifiP2PConnected
}

private fun deinitWiFi() {
    CxrApi.getInstance().deinitWifiP2P()
}
  • スキャンフェーズでは発見とフィルタリングのみ行い、デバイス識別情報(名前、MACなど)を取得することが重要。
  • initBluetooth は socketUuid と macAddress を返す。これらが以降の通信のエントリパラメータとなる。
  • 実際のデータリンクは connectBluetooth で確立され、成功後に利用可能となる。
  • 接続状態は isBluetoothConnected で判定し、切断時はUI更新とリトライ処理を行う。
  • Wi‑Fi P2Pは大容量データ転送用チャネルとして、Bluetooth安定後に有効化すると省電力かつ失敗を減らせる。

写真撮影と録音

写真撮影(3種類)

  • 単体ボタン撮影:解像度設定後、未同期メディアファイルとして保存
  • AIシーン撮影:カメラ起動→撮影→Bluetooth経由でWebPバイト列を返却
  • カメラ直接起動撮影:ファイルパスを取得し、ローカル保存または同期処理
// 単体ボタン撮影用解像度設定
CxrApi.getInstance().setPhotoParams(width, height)

// AIシーン:カメラ起動 + 撮影(quality: 0~100)
CxrApi.getInstance().openGlassCamera(width, height, quality)
CxrApi.getInstance().takeGlassPhoto(width, height, quality, photoResultCallback)

// 直接カメラ起動:ファイルパスを取得
CxrApi.getInstance().takeGlassPhoto(width, height, quality, photoPathCallback)

photoResultCallback は状態とWebPバイト列、photoPathCallback は状態と保存パスを返す。Bluetooth転送時は低解像度・適切な品質にすることでタイムアウトや失敗を防げる。

データ操作

データ操作前にBluetooth接続が確立していることを確認してください。メディアファイル同期にはWi‑Fi通信モジュールの初期化が必要です。

メガネ側へのデータ送信

val streamCallback = object : SendStatusCallback {
    override fun onSendSucceed() { }
    override fun onSendFailed(errorCode: ValueUtil.CxrSendErrorCode?) { }
}
CxrApi.getInstance().sendStream(ValueUtil.CxrStreamType.WORD_TIPS, bytes, fileName, streamCallback)

sendStream でメガネ側にストリームデータ(テロップなど)を送信。成功/失敗はコールバックで通知され、エラーコードで原因特定可能。

未同期メディアファイル数の取得

val unSyncCallback = object : UnsyncNumResultCallback {
    override fun onUnsyncNumResult(status: ValueUtil.CxrStatus?, audioNum: Int, pictureNum: Int, videoNum: Int) { }
}
CxrApi.getInstance().getUnsyncNum(unSyncCallback)

音声、画像、動画それぞれの未同期数を取得。バッチ同期の事前判断や通知に利用。

メガネ側メディアファイル更新の監視

val mediaFileUpdateListener = object : MediaFilesUpdateListener {
    override fun onMediaFilesUpdated() { }
}
CxrApi.getInstance().setMediaFilesUpdateListener(mediaFileUpdateListener)

メガネ側のメディアライブラリが更新されたときにトリガー。不要になったらリスナーを解除してリソースを節約。

メディアファイルの同期(Wi‑Fi必須)

val syncCallback = object : SyncStatusCallback {
    override fun onSyncStart() { }
    override fun onSingleFileSynced(fileName: String?) { }
    override fun onSyncFailed() { }
    override fun onSyncFinished() { }
}
CxrApi.getInstance().startSync(savePath, arrayOf(ValueUtil.CxrMediaType.PICTURE, ValueUtil.CxrMediaType.VIDEO), syncCallback)

複数タイプのファイルを同時同期。保存パスにはファイル管理権限が必要。開始・完了・単一ファイル成功・失敗の各タイミングでコールバックが呼ばれる。

CxrApi.getInstance().syncSingleFile(savePath, ValueUtil.CxrMediaType.PICTURE, fileName, syncCallback)
CxrApi.getInstance().stopSync()

指定した単一ファイルの同期と停止。選択・同期のユースケース向け。

よくある問題とトラブルシューティング

  • 依存関係解決失敗 → Rokidリポジトリ未設定 → settings.gradle に maven リポジトリを追加
  • Bluetoothスキャン失敗 → ランタイムパーミッション未申請 → BLUETOOTH_SCAN/CONNECT を申請
  • 認証失敗 → .lc ファイル未パッケージ化、またはリソースID誤り → res/raw に配置して読み込み・検証
  • ビルドエラー → compileSdk と SDK の不整合 → compileSdk 36 に統一
  • 初期化で止まる → ストレージ/通知パーミッション未許可 → 初回起動時に許可を付与

まとめ

ここまでで環境構築、依存関係、パーミッション設定が完了し、メガネに接続して音量・輝度調整、撮影、メッセージ送信、データ記録が動作するようになりました。本記事では、Rokidメガネとスマホ間の連携アプリの構築・最適化方法を詳しく紹介しました。提供されたサンプルコードと手順に従うことで、スマホとメガネの接続、音量調整・撮影・情報プッシュといった基本機能を素早く実装できます。環境設定、Android Studioの構成、デバイス接続やパーミッション管理といった技術的なポイントを押さえました。

また、Bluetooth接続、TTSフィードバック、リモート制御などの機能実装例を通じて、CXR SDKを活用したメガネ・スマホ間のインタラクションをさらに探求しました。これらの機能は、開発者がメガネ端末とスマホ間の通信メカニズムを深く理解する助けとなり、将来のアプリ拡張や最適化の基盤となります。

タグ: Rokid CXR SDK Android Bluetooth Kotlin

6月28日 23:38 投稿