20191011のAndroidに関する記事は4件です。

BLEデバイスのスキャンをkotlin coroutineのcallbackFlowでやる

はじめに

勉強のためAndroidでBLE(BluetoothLowEnergy)デバイスのスキャンを行う際のコールバック処理に、kotlin coroutineのcallbackFlowを使ったサンプルアプリを作ってみました。

サンプルアプリのgithubレポジトリ
https://github.com/cnaos/BleDeviceScanExample

通常のBLEデバイスのスキャン方法

BLEデバイスのスキャン処理部分だけ取り出すとこんな感じ。

// スキャン時間
private val SCAN_PERIOD: Long =  
    TimeUnit.MILLISECONDS.convert(20, TimeUnit.SECONDS)

// スキャンのタイムアウト処理用
private val handler: Handler = Handler()

// デバイススキャンコールバック  
private val mLeScanCallback = object : ScanCallback() {  
    override fun onScanResult(callbackType: Int, result: ScanResult) {  
        // スキャンしたBLEデバイスの処理  
        addDevice(result)  
    }  
}

fun startDeviceScan() { 
    // タイムアウト処理の仕込み
    handler.postDelayed({
        scanner.stopScan(mLeScanCallback)
    }, SCAN_PERIOD)

    // BLEデバイスのスキャン開始
    scanner.startScan(mLeScanCallback)
}

スキャンのコールバック

// デバイススキャンコールバック  
private val mLeScanCallback = object : ScanCallback() {  
    override fun onScanResult(callbackType: Int, result: ScanResult) {  
        // スキャンしたBLEデバイスの処理  
        addDevice(result)  
    }  
}

BLEデバイスを検出した際にBluetoothLeScannerから呼ばれるcallback用のオブジェクト(mLeScanCallback)を用意します。

スキャン処理

fun startDeviceScan() { 
    /// 省略 ///

    // BLEデバイスのスキャン開始
    scanner.startScan(mLeScanCallback)
}

BluetoothLeScannerのstartScanメソッドの引数にmLeScanCallbackを渡してstartScanメソッドを呼び出すとBLEデバイスのスキャンが実行されます。

BluetoothLeScannerがBLEデバイスを検出されると、mLeScanCallbackのonScanResultメソッドが呼ばれるので、
onScanResultメソッドで検出したBLEデバイスを処理します。
例ではaddDeviceメソッドで処理しています。

タイムアウト処理

fun startDeviceScan() { 
    // タイムアウト処理の仕込み
    handler.postDelayed({
        scanner.stopScan(mLeScanCallback)
    }, SCAN_PERIOD)

    // BLEデバイスのスキャン開始
}

また、一定時間経過したあとにBLEデバイスのスキャンを停止するために
HandlerをつかってBluetoothLeScannerのstopScanメソッドを呼び出します。

callbackFlowを使った方法

callbackFlowのリファレンス

Flowを提供する側の実装

// 定数
companion object {
    // スキャン時間
    private val SCAN_PERIOD: Long =
        TimeUnit.MILLISECONDS.convert(20, TimeUnit.SECONDS)
}

/**
 * ログ出力用のメソッド
 */
fun log(functionName: String, msg: String = "") =
    Log.i(TAG, "[${Thread.currentThread().name}] $functionName $msg")


/**
 * BLEデバイスのスキャン結果をFlowで取得する
 */
fun deviceScanFlow(
    scanner: BluetoothLeScanner
): Flow<ScanResult> = callbackFlow {
    val functionName = "deviceScanFlow()"
    val mLeScanCallback = object : ScanCallback() {
        // BLEデバイスがスキャンされると呼ばれる。
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            if (channel.isClosedForSend) {
                return
            }
            // callbackFlowのchannelにScanResultを送る
            offer(result)
        }
    }

    // BLEデバイスのスキャン処理開始
    scanner.startScan(mLeScanCallback)

    // 一定時間経過したらchannelをcloseするタイマーを仕掛ける
    launch {
        delay(SCAN_PERIOD)
        log(functionName, "channel close delay")
        channel.close()
    }

    // callbackFlowのchannelが閉じるか、利用側でjobがキャンセルされた場合の処理
    awaitClose {
        log(functionName, "channel closed")
        scanner.stopScan(mLeScanCallback)
        stopDeviceScan()
    }
}

Flowを利用する側の実装

// BLEデバイスのスキャンを行うJob
private var deviceScanJob: Job? = null

fun startDeviceScan() {
    log("startDeviceScan")

    // RuntimePermissionとかの処理

    // BLEデバイスのcallbackFlowを作ってそこからデータを受け取る
    deviceScanJob = viewModelScope.launch(Dispatchers.IO) {
        val scanFlow = deviceScanFlow(scanner)
        scanFlow.buffer().collect {
            addDevice(it.device)
        }
    }
}

/**
 * BLEデバイスのスキャンJobをキャンセルする
 */
fun cancelDeviceScanJob() {
    log("cancel deviceScanJob")
    deviceScanJob?.cancel()
}

解説

私のイメージだと、コールバック処理をcallbackFlowで作ったFlow内に閉じ込めて、使う側ではFlowで流れてきたコールバックの結果を処理するという感じです。

callbackFlow.png

スキャン処理

    val mLeScanCallback = object : ScanCallback() {
        // BLEデバイスがスキャンされると呼ばれる。
        override fun onScanResult(callbackType: Int, result: ScanResult) {
            if (channel.isClosedForSend) {
                return
            }
            // callbackFlowのchannelにScanResultを送る
            offer(result)
        }
    }

    // BLEデバイスのスキャン処理開始
    scanner.startScan(mLeScanCallback)

通常の方法だと、onScanResult内で直接スキャン結果を処理していましたが、callbackFlowを使った方法では、検出したBLEデバイスの情報(ScanResult)をofferメソッドを使ってかcallbackFlowが持っているchannelに送ります。

スキャン結果の受信処理

    // BLEデバイスのcallbackFlowを作ってそこからデータを受け取る
    deviceScanJob = viewModelScope.launch(Dispatchers.IO) {
        val scanFlow = deviceScanFlow(scanner)
        scanFlow.buffer().collect {
            addDevice(it.device)
        }
    }

送ったスキャン結果はFlowを使う側でcollectで受信して処理します。

タイムアウト処理

    // 一定時間経過したらchannelをcloseするタイマーを仕掛ける
    launch {
        delay(SCAN_PERIOD)
        log(functionName, "channel close delay")
        channel.close()
    }

通常のBLEデバイスのスキャンの例ではHandlerを使って実装していたスキャンのタイムアウト処理をcoroutineのdelayを使って実装しています。
タイマーでBluetoothLEScannerのstopScanを呼び出す代わりに、タイマーでcallbackFlowのchannelをcloseします。

コールバックの解除処理

    // callbackFlowのchannelが閉じるか、利用側でjobがキャンセルされた場合の処理
    awaitClose {
        log(functionName, "channel closed")
        scanner.stopScan(mLeScanCallback)
        stopDeviceScan()
    }

最後のawaitCloseのブロックは、
callbackFlowのchannelがcloseされた場合、または、callbackFlowを含むJobがキャンセルされた場合に、ブロック内の処理が実行されます。

参考にさせていただいたもの

GoogleのBLEデバイスのスキャン処理の説明

kotlinのcallbackFlowの公式ドキュメント

BLEデバイスを検出する処理の作成

Callbackから定期的に受け取る位置情報をKotlin Coroutines Flowを使って監視する方法

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

初めてのAndroidアプリで躓いたところ【1:アプリ編】(プログラミング初学者)

アプリの紹介・経緯とか

Androidアプリの初めての成果物として、テキストを読み上げるアラームを作りました。
既存のテキスト読み上げアラームは「アラームが起動中」に読み上げるので、
全文を聴き終える前に停止するのを防ぐ意味で「アラームを停止後」に読み上げるように作りました。
このアラームを作る過程でつまづいた、分かり辛かったことについて書きます。
作成したアプリはこちら

音声編

TextToSpeecnの初期化・リソースの解放

onCreate中でTextToSpeech(this, this)で初期化する必要があります。
また、onDestroyでリソースを解放しないとエラーが出ます。(ServiceConnectionLeaked)

AlarmBootActivity.kt
override fun onCreate(savedInstanceState: Bundle?) {
        ....
        // TextToSpeechの初期化、初期設定
        tts = TextToSpeech(this, this)
        ....
}

override fun onDestroy() {
    super.onDestroy()
    tts.shutdown() // ttsのリソースを解放
    ....
}

Androidの音量設定と分ける

Androidの端末の音量設定を操作するので、
onCreateonResumeで端末の音量を変数に保持します。
onStoponPauseonDestroyで元の音量に戻し、onResumeでアプリの設定値に戻します。
しかし、onCreateから起動した場合は、onRsumeでは端末の音量を取得しないようにします。
(アプリで設定した音量を取得してしまうので)

AlarmBootActivity.kt
private var preMusicVol: Int? = null // 端末の元の音量設定(アラーム音: メディアの音量)
private var preVoiceVol: Int? = null // 端末の元の音量設定(テキスト読み上げ: 着信音の音量)
private var musicVol: Int? = null // アラームの音量
private var voiceVol: Int? = null // 声の音量
private var onCreateMark: Boolean? = null // onCreateからの起動かを判定
override fun onCreate(savedInstanceState: Bundle?) {
        ....
        // 現在の端末の音量設定を格納(onDestroy()・onPause()・onStop()で元に戻す)
        getPreVolumeConfig()
        // onCreateから起動したことを確認する
        onCreateMark = true
        ....
}

override fun onPause() {
    super.onPause()
    when (onCreateMark) {
        true -> onCreateMark = false
        false -> {
            // 現在の端末の音量設定を取得
            getPreVolumeConfig()
            // シークバーの音量設定に戻す(アラーム音)
            musicVol = musicVolSeekbar.progress
            // シークバーの音量設定に戻す(テキスト読み上げ音)
            voiceVol = voiceVolSeekbar.progress
        }
    }
}

override fun onStop() {
    super.onStop()
    preVolumeSet() // 元の音量設定に戻す
}

override fun onDestroy() {
    super.onDestroy()
    preVolumeSet() // 元の音量設定に戻す
}

// 現在の端末の音量設定を格納(onDestroy()・onPause()・onStop()で元に戻す)
private fun getPreVolumeConfig() {
    preMusicVol = am.getStreamVolume(STREAM_NOTIFICATION) // アラーム音
    preVoiceVol = am.getStreamVolume(STREAM_MUSIC) // テキスト読み上げ音
}

// アラーム音・テキスト読み上げ音の音量設定を戻す
private fun preVolumeSet() {
    am.setStreamVolume(STREAM_NOTIFICATION, preMusicVol!!, 0)
    am.setStreamVolume(STREAM_MUSIC, preVoiceVol!!, 0)
}

カレンダー編

現在時刻を取得する場合、
毎回Calendar.getInstance()でカレンダーを取得する必要があります。
timeInMillies()はカレンダーを取得した時点での現在時刻を取得するので、
数秒前にCalendar.getInstance()で取得した場合、数秒前が現在時刻となるので注意。

アラーム編

再起動時にアラームを再設定する(ダイレクトブート)

Android起動時のダイレクトブートモードで、アラームを再設定するReceiverを呼び出します。
呼び出すReceiverにはAndroidManifestで以下のように記述します。

AndroidManifest.xml
<receiver
        android:name=".DirectBootReceiver"
        android:directBootAware="true"
        android:enabled="true">
    <intent-filter>
        <action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
        <action android:name="android.intent.action.BOOT_COMPLETED" />
    </intent-filter>
</receiver>

指定したReceiverでDBから「アラーム」がONになっているデータだけ取得してアラームを再設定します。

※ダイレクトブートで呼び出したReceiverからアクティビティを呼び出すことはできますが、Serviceを呼び出すことはできません。
(「サービスの開始を許可しないIntentです」と怒られます。)

予約した自動スヌーズの遅延処理をキャンセルする

サービスを破棄するときに自動でスヌーズを実行する遅延処理をキャンセルします。
これを忘れるとサービスを破棄された後でも遅延処理は実行されてしまいます。

ForegroundService.kt
private var runSnooze: Runnable? = null // 遅延処理でアラームを自動スヌーズ

val snoozeHandler = Handler()
runSnooze = Runnable {
    // スヌーズ実行
    val snoozeIntent = Intent(this, AlarmSnoozeBroadcastReceiver::class.java)
    sendBroadcast(snoozeIntent)
}
snoozeHandler.postDelayed(runSnooze!!, 60000)

override fun onDestroy() {
    super.onDestroy()
    // タップしてアラームを停止させた場合、1分後のスヌーズ処理をキャンセル
    snoozeHandler.removeCallbacks(runSnooze!!)
}

setRepeatが使えない。

setRepeatはAPI19以降から不正確なので推奨されていません。
なのでアラームを止めた後に、
同じrequestCodeを使ってset()setExact()setAlarmClock()でアラームを再設定しました。

Android10ではActivityをバックグランドから呼べない

Android9.0以下は、アラーム起動時にActivityを呼ぶようにしていますが、
Android10はバックグランドからActivityを呼べないのでServiceを呼ぶようにしています。
サービスもstartForegroundService()で呼ぶ必要があります。
ForegroundServiceについてはこちらを参考にしました。
Foreground Serviceの基本

通知の削除

setAutoCancel(true)Notification.FLAG_AUTO_CANCELでは通知をタップしても何故か消えなかったので、チャンネルIDを指定して通知を削除するようにしました。

ForegroundService.kt
override fun onDestroy() {
    super.onDestroy()
    // アラームを停止させたら、サービス終了時に通知を消す。
    val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    manager.deleteNotificationChannel("alarm_notification") // アラームの通知を削除
}

Realm・RecyclerView編

requestCodeをどうやって分けるか

それぞれ月〜日曜日のrequestCodeを保存するカラムを用意しました。
配列で保存しようとも考えましたが、
RDBは基本的に配列を記録させられないので単一のデータごとに保存しました。
※1桁の数字だけを配列にするなら、文字列にして保存した後にcontains('2')で検索すれば取得できなくもありません。
Screenshot 2019-10-10_21-17-50-502.png

削除の演出

RecyclerViewに表示されている登録したアラームを削除するときに、
notifyItemRemoved()notifyDataSetChanged()で削除&更新ができます。
(最初なぜか上手く実行できませんでした)

CustomRecyclerViewAdapter.kt
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    ....
    notifyItemRemoved(position) // アラームの位置を取得して消す
    notifyDataSetChanged() // Realmのデータが変更されたら自動的に更新する
    ....
}

RealmのDB内容の確認

Realm Studio(Mac)での確認方法はいくつか解説しているサイトがありましたが、
自分はあまりうまくいきませんでした。
最終的にこれで確認できました。↓
Realm StudioでDBの内容を確認する方法【Android・Mac】

まとめ

初めてのAndroidアプリ開発ということで、
Androidの特有の機能やバージョン間の違いを理解するのに苦労しました。

次はアプリのリリース編を書きます。
初めてのAndroidアプリで躓いたところ【2:リリース編】(プログラミング初学者)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Realm StudioでDBの内容を確認する方法【Android・Mac】

Realm Studioでデータベースを確認する方法について紹介します。
他のサイトで解説している通りでは上手くいかなかったため、
僕の場合は最終的にこのような方法になりました。
参考になれば幸いですm(_ _)m

※環境はAndroid Studio3.5

adbコマンドの有効化

adbコマンドを実行して、

bash: adb: command not found

と出る場合は、adbコマンドを有効化する必要があります。

Android Studioの「ファイル」→「その他の設定」→「デフォルト・プロジェクト構造…」にある、
Android SDK ロケーションのファイルパスをコピーします。
Screenshot 2019-10-11_19-23-05-223.png
Screenshot 2019-10-11_19-25-16-061.png

Android Studioのターミナルに、コピーしたファイルパスを以下のように入力します。

export PATH=$PATH:/Users/ユーザー名/Library/Android/sdk/platform-tools

これでadbコマンドが使用できます。

AndroidのDBをインポートする

続いてAndroid Studioのターミナルに、

adb kill-server

adb start-server

adb shell

を入力します。

次にプロジェクト名をコピーして以下のように実行します。

run-as com.example.hoge

cd files

cat default.realm > /sdcard/export_db.realm

3行目のexport_dbの部分は任意の名前で構いません。

続いて、「control + d」を2回入力してshellを終了し、

adb pull /sdcard/export_db.realm ./

これでDBを取得できました。

あとはRealm Studioで、プロジェクトフォルダに保存されたRealmのファイルを開けばデータの中身が見れます。
Screenshot 2019-10-11_19-56-46-428.png

おわり

adbコマンドを有効化するところから解説している記事がなかったため書いてみました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Android]台風に備えて気圧を測れるアプリを作る

こんにちは。中間テスト終わりました。
今回はやばい台風が来るそうなので気圧が測れるアプリを作りたいと思います。

スマホにはいろんなセンサーがついている

せっかくなので使いましょう。
というのは建前で本当は この記事 でやってることAndroidでも作れるのではと思ったのが本当の理由。

紹介

端末 Pixel 3 XL
Android バージョン Android 10
最低バージョン ろりぽっぷ
言語 Kotlin

実装

レイアウト

真ん中にTextViewを置いただけのものとする。
面倒なのでConstraintLayoutをそのまま使うことにする。

activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="50dp"
        android:id="@+id/barometer_tv"
        android:text="Hello World!"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

コード

Sensor.TYPE_PRESSUREが気圧計になります。
onDestroy()で登録を解除しています。
受け取るセンサーがほかにもあるときはevent?.sensor?.typeを利用して分けましょう。

MainActivity.kt
    lateinit var sensorManager: SensorManager
    lateinit var sensorEventListener: SensorEventListener
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
        //今回は気圧計
        val sensorList = sensorManager.getSensorList(Sensor.TYPE_PRESSURE)
        //受け取る
        sensorEventListener = object : SensorEventListener {
            override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
                //つかわん
            }

            override fun onSensorChanged(event: SensorEvent?) {
                //値はここで受けとる
                //今回は気圧計のみだからいいけどほかにも登録するときは分岐してね
                if(event?.sensor?.type == Sensor.TYPE_PRESSURE){
                    //気圧計の値
                    val barometer = event.values[0]
                    //TextViewに設定
                    barometer_tv.text = "$barometer hPa"
                }
            }
        }
        //登録
        sensorManager.registerListener(
            sensorEventListener,
            sensorList[0],  //配列のいっこめ。気圧計
            SensorManager.SENSOR_DELAY_NORMAL  //更新頻度
         )
    }

    override fun onDestroy() {
        super.onDestroy()
        sensorManager.unregisterListener(sensorEventListener)
    }

これでTextViewに気圧が表示されてると思います。

Screenshot_20191011-112836.png

小数点を消す

roundToInt() できれいになります。

                //値はここで受けとる
                //今回は気圧計のみだからいいけどほかにも登録するときは分岐してね
                if(event?.sensor?.type == Sensor.TYPE_PRESSURE){
                    //気圧計の値
                    val barometer = event.values[0]
                    //整数に
                    val barometerInt = barometer.roundToInt()
                    //TextViewに設定
                    barometer_tv.text = "$barometerInt hPa\n($barometer hPa)"
                }

Screenshot_20191011-113656.png

以上です。
お疲れ様です。88888

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む