外部デバイスを操作する

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

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

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

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

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

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

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

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

サービスを作成する

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

ControlsProviderService API は、リアクティブ ストリーム GitHub プロジェクトで定義され、Java 9 フロー インターフェースに実装されているリアクティブ ストリームに精通していることを前提としています。この 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() を拡張します。

Kotlin

    class MyCustomControlService : ControlsProviderService() {
        ...
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
        ...
    }
    

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

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

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

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

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

たとえば、スマート電球とサーモスタットを制御するには、次の定数を MyCustomControlService に追加します。

Kotlin

    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() {
      ...
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
 
    private final int LIGHT_ID = 1337;
    private final String LIGHT_TITLE = "My fancy light";
    private final int LIGHT_TYPE = DeviceTypes.TYPE_LIGHT;
    private final int THERMOSTAT_ID = 1338;
    private final String THERMOSTAT_TITLE = "My fancy thermostat";
    private final int THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT;
 
    ...
    }
    

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

コントロールを作成したら、パブリッシャーが必要になります。パブリッシャーは、システム 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() を実装します。

Kotlin

    class MyCustomControlService : ControlsProviderService() {
 
      override fun createPublisherForAllAvailable(): Flow.Publisher =
          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): Flow.Publisher {
           TODO()
        }
 
        override fun performControlAction(
            controlId: String,
            action: ControlAction,
            consumer: Consumer
        ) {
            TODO()
        }
    }
    

Java

    public class MyCustomJavaControlService extends ControlsProviderService {
 
        private final int LIGHT_ID = 1337;
        private final String LIGHT_TITLE = "My fancy light";
        private final int LIGHT_TYPE = DeviceTypes.TYPE_LIGHT;
        private final int THERMOSTAT_ID = 1338;
        private final String THERMOSTAT_TITLE = "My fancy thermostat";
        private final int THERMOSTAT_TYPE = DeviceTypes.TYPE_THERMOSTAT;
 
        private boolean toggleState = false;
        private float rangeState = 18f;
        private final Map> controlFlows = new HashMap<>();
 
        @NonNull
        @Override
        public Flow.Publisher createPublisherForAllAvailable() {
            List controls = new ArrayList<>();
            controls.add(createStatelessControl(LIGHT_ID, LIGHT_TITLE, LIGHT_TYPE));
            controls.add(createStatelessControl(THERMOSTAT_ID, THERMOSTAT_TITLE, THERMOSTAT_TYPE));
            return FlowAdapters.toFlowPublisher(Flowable.fromIterable(controls));
        }
 
        @NonNull
        @Override
        public Flow.Publisher createPublisherFor(@NonNull List controlIds) {
            ReplayProcessor updatePublisher = ReplayProcessor.create();
 
            controlIds.forEach(control -> {
                controlFlows.put(control, updatePublisher);
                updatePublisher.onNext(createLight());
                updatePublisher.onNext(createThermostat());
            });
 
            return FlowAdapters.toFlowPublisher(updatePublisher);
        }
    }
    

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

デバイス コントロールのシステム UI を示す画像
図 4. システム メニューのデバイス コントロール

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

ライトとサーモスタットのコントロールを含むシステム メニューを示す画像
図 5.追加する照明とサーモスタットのコントロール。

次に、createPublisherFor() メソッドを実装し、次のコードを Service に追加します。

Kotlin

    private val job = SupervisorJob()
    private val scope = CoroutineScope(Dispatchers.IO + job)
    private val controlFlows = mutableMapOf>()
 
    private var toggleState = false
    private var rangeState = 18f
 
    override fun createPublisherFor(controlIds: List): Flow.Publisher {
        val flow = MutableSharedFlow(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  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()
    }
 
    

Java

    @NonNull
    @Override
    public Flow.Publisher createPublisherFor(@NonNull List controlIds) {
        ReplayProcessor updatePublisher = ReplayProcessor.create();
 
        controlIds.forEach(control -> {
            controlFlows.put(control, updatePublisher);
            updatePublisher.onNext(createLight());
            updatePublisher.onNext(createThermostat());
        });
 
        return FlowAdapters.toFlowPublisher(updatePublisher);
    }
 
    private Control createStatelessControl(int id, String title, int type) {
        Intent intent = new Intent(this, MainActivity.class)
                .putExtra(EXTRA_MESSAGE, title)
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        PendingIntent action = PendingIntent.getActivity(
                this,
                id,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        );
 
        return new Control.StatelessBuilder(id + "", action)
                .setTitle(title)
                .setDeviceType(type)
                .build();
    }
 
    private Control createLight() {
        return createStatefulControl(
                LIGHT_ID,
                LIGHT_TITLE,
                LIGHT_TYPE,
                toggleState,
                new ToggleTemplate(
                        LIGHT_ID + "",
                        new ControlButton(
                                toggleState,
                                String.valueOf(toggleState).toUpperCase(Locale.getDefault())
                        )
                )
        );
    }
 
    private Control createThermostat() {
        return createStatefulControl(
                THERMOSTAT_ID,
                THERMOSTAT_TITLE,
                THERMOSTAT_TYPE,
                rangeState,
                new RangeTemplate(
                        THERMOSTAT_ID + "",
                        15f,
                        25f,
                        rangeState,
                        0.1f,
                        "%1.1f"
                )
        );
    }
 
    private  Control createStatefulControl(int id, String title, int type, T state, ControlTemplate template) {
        Intent intent = new Intent(this, MainActivity.class)
                .putExtra(EXTRA_MESSAGE, "$title $state")
                .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        PendingIntent action = PendingIntent.getActivity(
                this,
                id,
                intent,
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
        );
 
        return new Control.StatefulBuilder(id + "", action)
                .setTitle(title)
                .setDeviceType(type)
                .setStatus(Control.STATUS_OK)
                .setControlTemplate(template)
                .build();
    }
    

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

createPublisherFor() メソッドは、Kotlin のコルーチンと Flow を使用して、必要な Reactive Streams API の要件を満たします。手順は次のとおりです。

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

アクションを処理する

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

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

Kotlin

    override fun performControlAction(
        controlId: String,
        action: ControlAction,
        consumer: Consumer
    ) {
        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)
    }
    

Java

    @Override
    public void performControlAction(@NonNull String controlId, @NonNull ControlAction action, @NonNull Consumer consumer) {
        ReplayProcessor processor = controlFlows.get(controlId);
        if (processor == null) return;
 
        if (controlId.equals(LIGHT_ID + "")) {
            consumer.accept(ControlAction.RESPONSE_OK);
            if (action instanceof BooleanAction) toggleState = ((BooleanAction) action).getNewState();
            processor.onNext(createLight());
        }
        if (controlId.equals(THERMOSTAT_ID + "")) {
            consumer.accept(ControlAction.RESPONSE_OK);
            if (action instanceof FloatAction) rangeState = ((FloatAction) action).getNewValue()
            processor.onNext(createThermostat());
        }
    }
    

アプリを実行し、[Device controls] メニューにアクセスし、照明とサーモスタットのコントロールを表示します。

照明とエアコンのコントロールの画像
図 6.照明とサーモスタットの操作