Kotlinのcoroutinesを使ってUsbManagerのrequestPermissionをきれいに処理する

AndroidでUSBデバイスを直接制御したいと考える奇特な人はそう多くはないと思いますが、
いざ制御するぞとなったときにぶち当たるのがUSBデバイスの制御権限の問題です。

Androidは最近のバージョンに於いてはリソースのアクセス制限が厳しく、
ユーザーの許可なしに物理デバイスにアクセスするのは不可能な構造になっています。
そこで、開発者はユーザーに明示的に利用許可を求める必要があるわけです。

デバイスの列挙

ウォーミングアップとして、USBデバイスの検出方法について見ていきましょう。
ここでは、MainActivityというアクティビティがデフォルトで起動されるという設定で行きます。
さらに、outputLabelという名前のTextViewと、buttonという名前のButtonが既に追加されているものとします。

まずは以下のコードを見てください。

package com.example.example

import android.app.Activity
import android.content.Context
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Bundle
import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
            
        // ボタンのクリックイベント
        button.setOnClickListener { view ->
            // UsbManagerを取得
            val manager = getSystemService(Context.USB_SERVICE) as UsbManager
            // UsbManagerからデバイス一覧を列挙
            var outStr = ""
            for(item in manager.deviceList) {
                // デバイスファイル名
                outStr += item.key
                // UsbDeviceオブジェクト
                val device: UsbDevice = item.value
                // VID
                outStr += ",VID=" + device.vendorId
                // PID
                outStr += ",PID=" + device.productId + "\n"
            }
            // 一覧をoutputLabelに表示
            outputLabel.setText(outStr)
        }
    }
}

これを実行してボタンを押すと、以下のような出力になるかと思います。

このように、UsbManager.deviceListからUsbDeviceオブジェクトを取得できるので、
目的のデバイスを検索して使います。
しかしながら、この時点で取得できるUsbDeviceオブジェクトは何の権限も持っていません。
そこで、ユーザーに明示的に権限を要求します。

デバイス使用の権限を要求

権限を要求するには、UsbManager.requestPermission(UsbDevice)メソッドを呼び出します。

このメソッドを呼び出すと、権限要求の表示タスクがキューされ、UIスレッドの制御を返すと実際に確認ダイアログ的なものが表示されます。
そして、要求の結果はBroadcastIntentで返ってきます。よって、BroadcastReceiverをActivity内で登録してやらなければいけません。

実際にやってみると以下のようになります。

package com.example.example


import android.app.Activity
import android.app.PendingIntent
import android.content.Context
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Bundle
import android.util.Log
import kotlinx.android.synthetic.main.activity_main.*

// 権限要求インテントを識別するための名前
private const val ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION"

// USB_PERMISSION用のBroadcastReceiver
private val usbReceiver = object : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (ACTION_USB_PERMISSION == intent.action) {
            synchronized(this) {
                // 権限付きのUsbDeviceオブジェクトを取得
                val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)

                if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
                    // 許可されたときの処理
                    Log.i("USB_PERMISSION", "Granted")
                } else {
                    // 拒否されたときの処理
                    Log.i("USB_PERMISSION", "Rejected")
                }
            }
        }
    }
}

class MainActivity : Activity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 権限要求用のBroadcastReceiverを登録
        val manager = getSystemService(Context.USB_SERVICE) as UsbManager
        val permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), 0)
        val filter = IntentFilter(ACTION_USB_PERMISSION)
        registerReceiver(usbReceiver, filter)


        // ボタンのクリックイベント
        button.setOnClickListener { view ->
            // UsbManagerからデバイス一覧を列挙
            for(item in manager.deviceList) {
                // 目的のデバイスだったら権限を要求する
                val device = item.value
                if(device.vendorId == 3599 && device.productId == 3) {
                    manager.requestPermission(device, permissionIntent)
                }
            }
        }
    }
}

ご覧の通り、権限要求部分と要求の結果を受け取る部分が完全に分かれてしまいます。これはなんともやりにくい。
やはり、やる側としては

val grantedDevice: UsbDevice? = manager.requestPermission(device)
if(grantedDevice != null) {
    //許可されたときの処理
}

みたいな感じで書きたいわけです。

再開可能な関数

上記のような書き方を実現する方法として、Kotlinにはコルーチンというものがあります。

コルーチンとは、簡単に言うと「指定した箇所で処理の中断・再開が可能な関数」です。
雰囲気的には以下のような感じです。

package com.example.example

import android.app.Activity
import android.os.Bundle
import android.util.Log
import kotlinx.android.synthetic.main.activity_main.*
import kotlin.coroutines.*
import kotlinx.coroutines.*

class MainActivity : Activity() {
    // コルーチンの継続
    private var currentContinuation: Continuation<Unit>? = null

    private suspend fun SampleCoroutine() {
        Log.i("COROUTINE", "Coroutine Started")
        // コルーチンを中断する
        suspendCoroutine<Unit> { continuation ->
            // continuation(継続)は関数の「つづき」(suspendCoroutineのブロックより下の部分)
            currentContinuation = continuation
        }
        // 継続をクリアする(重複してresumeすると例外が発生するため)
        currentContinuation = null
        Log.i("COROUTINE", "Coroutine Waited")
    }

    private fun ExecuteCoroutine() {
        // メインスレッドのコルーチン実行コンテキストを準備
        val context = CoroutineScope(Dispatchers.Main);
        // コルーチンを実行
        context.launch {
            SampleCoroutine()
            Log.i("COROUTINE", "Coroutine Finished.")
        }
        Log.i("COROUTINE", "Launch Completed")
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // コルーチンの実行を開始
        ExecuteCoroutine()

        button.setOnClickListener {
            // ボタンが押されたらコルーチンの再開を試みる
            currentContinuation?.resume(Unit)
        }
    }
}

上記のプログラムを実行してボタンを押すと以下のような出力になります。

I/COROUTINE: Launch Completed
I/COROUTINE: Coroutine Started
<ボタンクリック>
I/COROUTINE: Coroutine Waited
I/COROUTINE: Coroutine Finished

実際にどういう挙動になっているのか、まずはExecuteCoroutine関数から見ていきましょう。

// メインスレッドのコルーチン実行コンテキストを準備
val context = CoroutineScope(Dispatchers.Main);

まず最初に、CoroutineScopeというものを準備しています。
これはコルーチンの実行に関する面倒を見てくれるオブジェクトで、ここではメインスレッド(UIスレッド)から作成しています。

// コルーチンを実行
context.launch {
    SampleCoroutine()
    Log.i("COROUTINE", "Coroutine Finished.")
}

CoroutineScope.launchからコルーチンを実行します。(実際にはMainスレッドのディスパッチャにコルーチンの実行をキューします)
ここでラムダ式を与えていますが、これ自体が中断関数と呼ばれる特殊な関数になっています。
中断関数からはsuspend修飾子のついた関数を呼び出すことができます。(それの何がいいかは後述。)

さて、launchメソッドは実行をキューするだけなので、launchが完了するとすぐに次の行の

Log.i("COROUTINE", "Launch Completed")

が実行されます。


ExecuteCoroutine関数を抜けると、onCreate関数内の残りの処理が実行された後に、AndroidシステムへとUIスレッドの制御が返されます。
すると、先ほどキューした中断関数(ラムダ式)がAndroidシステムによって実行されます。

まずはSampleCoroutine関数(これもsuspend修飾子がついている中断関数です)が呼び出されます。

開始のログを出力したら、コルーチンを実際に中断します。

// コルーチンを中断する
suspendCoroutine<Unit> { continuation ->
    // continuation(継続)は関数の「つづき」(suspendCoroutineのブロックより下の部分)
    currentContinuation = continuation
}

suspendCoroutine<T>関数は、コルーチンを中断する関数です。
これは、suspend修飾子のついた中断関数からのみ呼び出すことができます。

suspendCoroutineは、与えられたラムダ式の中身を実行すると制御をAndroidシステムに返してしまいます。
(即ち、後続の処理はすぐには実行されない)

さて、重要なのは、ラムダ式の引数として与えられているcontinuation: Continuation<Unit>です。
これは、suspendCoroutineによって実行が先送りにされてしまった「後続の処理」そのものを表すオブジェクトです。
このオブジェクトを操作すると、後続の処理を任意のタイミングで実行することができます。

実際に実行しているのはbuttonのonClickListner内です。

button.setOnClickListener {
    // ボタンが押されたらコルーチンの再開を試みる
    currentContinuation?.resume(Unit)
}

Continuation<T>.resume(T)は、Continuationで表現される処理の実行を再開します。
これにより、ボタンがクリックされると、先程中断されていた処理の続き、即ち

// 継続をクリアする(重複してresumeすると例外が発生するため)
currentContinuation = null
Log.i("COROUTINE", "Coroutine Waited")

が実行されます。
ここまでの実行が済むと、SampleCoroutine関数から抜けて、呼び出し元であるラムダ式の2行目の処理

Log.i("COROUTINE", "Coroutine Finished.")

が実行されます。

このように、KotlinのCoroutinesを使うと、関数を中断してContinuationを取り出し、任意のタイミングで再開することができます。
(ちなみに、「継続」というのはれっきとしたプログラミング用語なので、気になる方はぜひ調べてみてください)

プロジェクトの設定

Coroutinesを実際にAndroidStudioプロジェクトで利用するには、いくつかの設定を済ませる必要があります。

build.gradle(Project)

Kotlinのバージョンを1.3以上にする必要があります。
現時点での最新バージョンは1.3.21のようなので、

ext.kotlin_version = "1.3.21"

としておきます。

build.gradle(Module:app)

CoroutinesのAndroid向けサポートを追加します。
(先程の例で見たとおり、コルーチンを正常に動作させるにはAndroidシステムによるサポートが必要不可欠なのです。)

dependencies {
   ...
   implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.1.1"
   implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.1.1"
}

上記を書き換えたら、GradleでSyncを行います。

UsbManager.requestPermissionをcoroutinesで処理する

ざざっと書いてみます。

import android.app.Activity
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbManager
import android.os.Bundle
import kotlinx.android.synthetic.main.activity_main.*
import kotlinx.coroutines.*
import kotlin.coroutines.*

private const val ACTION_USB_PERMISSION = "com.android.example.USB_PERMISSION"

private var permissionContinuation: Continuation<UsbDevice?>? = null

private val usbReceiver = object : BroadcastReceiver() {

    override fun onReceive(context: Context, intent: Intent) {
        if (ACTION_USB_PERMISSION == intent.action) {
            // 権限付きのUsbDeviceオブジェクトを取得
            val device: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)

            if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
                permissionContinuation?.resume(device)
            } else {
                permissionContinuation?.resume(null)
            }
        }
    }
}

private lateinit var permissionIntent: PendingIntent;

public suspend fun RequireDevice(manager: UsbManager, device: UsbDevice): UsbDevice? {
    // 多重要求にならないようにする
    if(permissionContinuation != null)
        return null

    // requestPermissionを実行
    val device = suspendCoroutine<UsbDevice?> {
        manager.requestPermission(device, permissionIntent)
        permissionContinuation = it
    }

    // continuationを消去
    permissionContinuation = null
    return device
}

class MainActivity : Activity() {

    private val uiScope = CoroutineScope(Dispatchers.Main)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        // 権限要求用のBroadcastReceiverを登録
        val manager = getSystemService(Context.USB_SERVICE) as UsbManager
        permissionIntent = PendingIntent.getBroadcast(this, 0, Intent(ACTION_USB_PERMISSION), 0)
        val filter = IntentFilter(ACTION_USB_PERMISSION)
        registerReceiver(usbReceiver, filter)


        button.setOnClickListener {
            uiScope.launch {
                for (item in manager.deviceList) {
                    // 目的のデバイスだったら権限を要求する
                    val device = item.value
                    if (device.vendorId == 3599 && device.productId == 3) {
                        outputLabel.setText("Requesting Permission...")
                        // 権限要求(中断関数の呼び出し)
                        val grantedDevice = RequireDevice(manager, item.value)
                        if (grantedDevice != null)
                            outputLabel.setText("Connected.")
                        else
                            outputLabel.setText("Connection failed.")
                    }
                }
            }
        }
    }
}

やっていることはさっきとだいたい同じなのでソースを良く読んでみてください。

あとは、UIから抜けたとき(onClear)などのときにコルーチンをキャンセルする処理などがあったほうがいいかもしれません。(Jobを使います)

参考文献

Using Kotlin Coroutines in your Android App – Google Codelabs
https://codelabs.developers.google.com/codelabs/kotlin-coroutines

USB host overview | Android Developers
https://developer.android.com/guide/topics/connectivity/usb/host

Posted by grainrigi