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
ディスカッション
コメント一覧
まだ、コメントがありません