外部デバイスを操作する

Android 11 以降では、クイック アクセス デバイス コントロール機能により、デフォルトのランチャーから 3 回以内の操作で、照明、サーモスタット、カメラなどの外部デバイスをすばやく表示し、制御できます。デバイスの OEM が、使用するランチャーを選択します。デバイス アグリゲータ(Google Home など)やサードパーティ ベンダーのアプリは、このスペースに表示されるデバイスを指定できます。このページでは、このスペースにデバイス コントロールを表示し、コントロール アプリにリンクする方法について説明します。

図 1. Android UI のデバイス コントロール スペース。

このサポートを追加するには、ControlsProviderService を作成して宣言します。事前定義されたコントロール タイプに基づいて、アプリがサポートするコントロールを作成し、これらのコントロールのパブリッシャーを作成します。

ユーザー インターフェース

デバイスは、[デバイス コントロール] の下にテンプレート化されたウィジェットとして表示されます。次の図に示すように、5 つのデバイス コントロール ウィジェットを使用できます。

デバイス コントロールのウィジェットを切り替える
切り替え
スライダー ウィジェットで切り替える
スライダーで切り替え
デバイス コントロール用の範囲スライダー ウィジェット
範囲(オン / オフを切り替えることはできません)
ステートレスな切り替えコントロール ウィジェット
ステートレスな切り替え
温度パネル ウィジェット(閉じた状態)
温度パネル(閉じた状態)
図 2. テンプレート化されたウィジェットのコレクション。

ウィジェットを長押しすると、アプリに移動してより詳細な操作を行うことができます。各ウィジェットのアイコンと色はカスタマイズできますが、ユーザー エクスペリエンスを最適化するには、デフォルトのセットがデバイスと一致する場合はデフォルトのアイコンと色を使用します。

開いた状態の温度パネル ウィジェット
図 3. 温度パネル ウィジェットを開きます。

サービスを作成する

このセクションでは、ControlsProviderService の作成方法について説明します。このサービスは、Android UI のデバイス コントロール領域に表示する必要があるデバイス コントロールがアプリに含まれていることを Android システム UI に通知します。

ControlsProviderService API は、Reactive Streams GitHub プロジェクトで定義され、Java 9 Flow インターフェースで実装されているリアクティブ ストリームに精通していることを前提としています。この API は、次のコンセプトに基づいています。

  • パブリッシャー: アプリケーションがパブリッシャーです。
  • サブスクライバー: システム UI はサブスクライバーであり、パブリッシャーに多数のコントロールをリクエストできます。
  • サブスクリプション: パブリッシャーがシステム UI にアップデートを送信できる期間。パブリッシャーまたはサブスクライバーのいずれかがこのウィンドウを閉じることができます。

サービスを宣言する

アプリは、アプリ マニフェストでサービス(MyCustomControlService など)を宣言する必要があります。

サービスには ControlsProviderService のインテント フィルタが含まれている必要があります。このフィルタを使用すると、アプリケーションはシステム UI にコントロールを提供できます。

また、システム UI のコントロールに表示される label も必要です。

次の例は、サービスを宣言する方法を示しています。

<service
    android:name="MyCustomControlService"
    android:label="My Custom Controls"
    android:permission="android.permission.BIND_CONTROLS"
    android:exported="true"
    >
    <intent-filter>
      <action android:name="android.service.controls.ControlsProviderService" />
    </intent-filter>
</service>

次に、MyCustomControlService.kt という名前の新しい Kotlin ファイルを作成し、ControlsProviderService を拡張します。

class MyCustomControlService : ControlsProviderService() {
    // ...
}

正しいコントロール タイプを選択する

API には、コントロールを作成するためのビルダー メソッドが用意されています。ビルダーにデータを入力するには、制御するデバイスと、ユーザーがデバイスを操作する方法を決定します。次の手順を行います。

  1. コントロールが表すデバイスのタイプを選択します。DeviceTypes クラスは、サポートされているすべてのデバイスの列挙です。このタイプは、UI でデバイスのアイコンと色を決定するために使用されます。
  2. ユーザー向けの名称、デバイスの場所(キッチンなど)、コントロールに関連するその他の UI テキスト要素を決定します。
  3. ユーザー インタラクションをサポートする最適なテンプレートを選択します。コントロールには、アプリケーションから ControlTemplate が割り当てられます。このテンプレートは、コントロールの状態と、使用可能な入力方法(ControlAction)をユーザーに直接表示します。次の表に、使用可能なテンプレートの一部と、それらがサポートするアクションを示します。
テンプレート アクション 説明
ControlTemplate.getNoTemplateObject() None アプリはこれを使用してコントロールに関する情報を伝えることができますが、ユーザーは操作できません。
ToggleTemplate BooleanAction 有効状態と無効状態を切り替えられるコントロールを表します。BooleanAction オブジェクトには、ユーザーがコントロールをタップしたときに、リクエストされた新しい状態を表すように変化するフィールドが含まれています。
RangeTemplate FloatAction 指定された最小値、最大値、ステップ値を持つスライダー ウィジェットを表します。ユーザーがスライダーを操作すると、更新された値を含む新しい FloatAction オブジェクトがアプリケーションに返されます。
ToggleRangeTemplate BooleanAction, FloatAction このテンプレートは ToggleTemplate と RangeTemplate を組み合わせたものです。タッチイベントとスライダー(調光可能なライトの制御など)をサポートしています。
TemperatureControlTemplate ModeAction, BooleanAction, FloatAction このテンプレートでは、前述のアクションをカプセル化するだけでなく、ユーザーが暖房、冷房、暖房/冷房、エコ、オフなどのモードを設定することもできます。
StatelessTemplate CommandAction タップ機能を提供するが、状態を特定できないコントロール(IR テレビのリモコンなど)を示すために使用されます。このテンプレートを使用して、コントロールと状態の変化を集約したルーティンやマクロを定義できます。

この情報を使用して、コントロールを作成できます。

  • コントロールの状態が不明な場合は、Control.StatelessBuilder ビルダー クラスを使用します。
  • コントロールの状態がわかっている場合は、Control.StatefulBuilder ビルダー クラスを使用します。

たとえば、スマートライトとサーモスタットを操作するには、次の定数を MyCustomControlService に追加します。

private const val LIGHT_ID = 1234
private const val LIGHT_TITLE = "My fancy light"
private const val LIGHT_TYPE = DeviceTypes.TYPE_LIGHT
private const val THERMOSTAT_ID = 5678
private const val THERMOSTAT_TITLE = "My fancy thermostat"
private const val THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT

class MyCustomControlService : ControlsProviderService() {
    // ...
}

コントロール用のパブリッシャーを作成する

コントロールを作成したら、パブリッシャーが必要です。パブリッシャーは、システム UI にコントロールの存在を通知します。ControlsProviderService クラスには、アプリケーション コードでオーバーライドする必要がある 2 つのパブリッシャー メソッドがあります。

  • createPublisherForAllAvailable: アプリで使用可能なすべてのコントロールの Publisher を作成します。Control.StatelessBuilder を使用して、このパブリッシャーの Control オブジェクトをビルドします。
  • createPublisherFor: 文字列識別子で識別される、指定されたコントロールのリストの Publisher を作成します。パブリッシャーは各コントロールに状態を割り当てる必要があるため、Control.StatefulBuilder を使用してこれらの Control オブジェクトをビルドします。

パブリッシャーを作成する

アプリが初めてシステム UI にコントロールを公開するとき、アプリは各コントロールの状態を認識していません。状態の取得は、デバイス プロバイダのネットワークで多くのホップを伴う、時間のかかるオペレーションになる可能性があります。createPublisherForAllAvailable メソッドを使用して、利用可能なコントロールをシステムにアドバタイズします。このメソッドでは、各コントロールの状態が不明なため、Control.StatelessBuilder ビルダー クラスを使用します。

Android UI にコントロールが表示されたら、ユーザーはお気に入りのコントロールを選択できます。

Kotlin コルーチンを使用して ControlsProviderService を作成するには、build.gradle に新しい依存関係を追加します。

Groovy

dependencies {
    implementation "org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.6.4"
}

Kotlin

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:1.6.4")
}

Gradle ファイルを同期したら、Service に次のスニペットを追加して createPublisherForAllAvailable を実装します。

class MyCustomControlService : ControlsProviderService() {

    override fun createPublisherForAllAvailable(): Flow.Publisher<Control> =
        flowPublish {
            send(createStatelessControl(LIGHT_ID, LIGHT_TITLE, LIGHT_TYPE))
            send(createStatelessControl(THERMOSTAT_ID, THERMOSTAT_TITLE, THERMOSTAT_TYPE))
        }

    private fun createStatelessControl(id: Int, title: String, type: Int): Control {
        val intent = Intent(this, MainActivity::class.java)
            .putExtra(EXTRA_MESSAGE, title)
            .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        val action = PendingIntent.getActivity(
            this,
            id,
            intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
        )

        return Control.StatelessBuilder(id.toString(), action)
            .setTitle(title)
            .setDeviceType(type)
            .build()
    }

    override fun createPublisherFor(controlIds: List<String>): Flow.Publisher<Control> {
        TODO()
    }

    override fun performControlAction(
        controlId: String,
        action: ControlAction,
        consumer: Consumer<Int>,
    ) {
        TODO()
    }
}

システム メニューを下にスワイプして、[デバイス コントロール] ボタンを見つけます(図 4 を参照)。

デバイス コントロールのシステム ユーザー インターフェース
図 4. システム メニューのデバイス コントロール。

[デバイス コントロール] をタップすると、アプリを選択できる 2 番目の画面に移動します。アプリを選択すると、図 5 に示すように、前のスニペットで新しいコントロールを表示するカスタム システム メニューが作成されます。

照明とサーモスタットのコントロールが表示されたシステム メニュー
図 5. 照明とエアコンの操作を追加。

次に、createPublisherFor メソッドを実装し、Service に以下を追加します。

private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.IO + job)
private val controlFlows = mutableMapOf<String, MutableSharedFlow<Control>>()

private var toggleState = false
private var rangeState = 18f

override fun createPublisherFor(controlIds: List<String>): Flow.Publisher<Control> {
    val flow = MutableSharedFlow<Control>(replay = 2, extraBufferCapacity = 2)

    controlIds.forEach { controlFlows[it] = flow }

    scope.launch {
        delay(1000) // Retrieving the toggle state.
        flow.tryEmit(createLight())

        delay(1000) // Retrieving the range state.
        flow.tryEmit(createThermostat())
    }
    return flow.asPublisher()
}

private fun createLight() = createStatefulControl(
    LIGHT_ID,
    LIGHT_TITLE,
    LIGHT_TYPE,
    toggleState,
    ToggleTemplate(
        LIGHT_ID.toString(),
        ControlButton(
            toggleState,
            toggleState.toString().uppercase(Locale.getDefault()),
        ),
    ),
)

private fun createThermostat() = createStatefulControl(
    THERMOSTAT_ID,
    THERMOSTAT_TITLE,
    THERMOSTAT_TYPE,
    rangeState,
    RangeTemplate(
        THERMOSTAT_ID.toString(),
        15f,
        25f,
        rangeState,
        0.1f,
        "%1.1f",
    ),
)

private fun <T> createStatefulControl(
    id: Int,
    title: String,
    type: Int,
    state: T,
    template: ControlTemplate,
): Control {
    val intent = Intent(this, MainActivity::class.java)
        .putExtra(EXTRA_MESSAGE, "$title $state")
        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
    val action = PendingIntent.getActivity(
        this,
        id,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
    )

    return Control.StatefulBuilder(id.toString(), action)
        .setTitle(title)
        .setDeviceType(type)
        .setStatus(Control.STATUS_OK)
        .setControlTemplate(template)
        .build()
}

override fun onDestroy() {
    super.onDestroy()
    job.cancel()
}

この例では、createPublisherFor メソッドに、アプリが行う必要がある処理(デバイスと通信してステータスを取得し、そのステータスをシステムに送信する)のフェイク実装が含まれています。

createPublisherFor メソッドは、Kotlin のコルーチンとフローを使用して、次の処理を行うことで、必要な Reactive Streams API を満たします。

  1. Flow を作成します。
  2. 1 秒待機します。
  3. スマートライトの状態を作成して出力します。
  4. さらに 1 秒待ちます。
  5. サーモスタットの状態を作成して出力します。

アクションを処理する

performControlAction メソッドは、ユーザーが公開されたコントロールを操作したときにシグナルを送信します。送信される ControlAction のタイプによってアクションが決まります。指定されたコントロールに対して適切なアクションを実行し、Android UI でデバイスの状態を更新します。

この例を完成させるには、Service に次のコードを追加します。

override fun performControlAction(
    controlId: String,
    action: ControlAction,
    consumer: Consumer<Int>,
) {
    controlFlows[controlId]?.let { flow ->
        when (controlId) {
            LIGHT_ID.toString() -> {
                consumer.accept(ControlAction.RESPONSE_OK)
                if (action is BooleanAction) toggleState = action.newState
                flow.tryEmit(createLight())
            }
            THERMOSTAT_ID.toString() -> {
                consumer.accept(ControlAction.RESPONSE_OK)
                if (action is FloatAction) rangeState = action.newValue
                flow.tryEmit(createThermostat())
            }
            else -> consumer.accept(ControlAction.RESPONSE_FAIL)
        }
    } ?: consumer.accept(ControlAction.RESPONSE_FAIL)
}

アプリを実行し、[デバイス コントロール] メニューにアクセスして、照明とサーモスタットのコントロールを確認します。

照明とサーモスタットを表示するコントロール
図 6. 照明とエアコンの操作。