Help us understand the problem. What is going on with this article?

政府の接触確認アプリ(COCOA)の機能を補完するアプリを作ってみた(3密チェッカー)

https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/33a241c4-5ff6-b793-6e7c-c4c05d52debf.png

はじめに

私は、ラビットプログラムの名前でAndroidスマートフォン向けアプリを開発・公開している19歳の専門学校生(愛知工業大学情報電子専門学校)です。
去年スマホアプリ開発コンテスト アプリ甲子園 2019に「ピックアップ通知音」アプリを応募して、特別企業賞 マイナビ賞を受賞しました。
https://www.applikoshien.jp/report/2019.html
あれから半年ほど経って最近これといったものを作っていなかったのですが、今COVID-19が流行っているので何かこれを題材にした新しいアプリが作れないかと考えていました。

その間に、5月あたりから報道されていた「接触確認アプリ」(COCOA)が公開されて実際に使ってみたのですが、何か味気ないというか、物足りないと感じたので、足りないと感じた部分を補完するアプリを自分で作ってみました。

そしてQiitaの接触確認アプリ関連の記事が話題になっていたので、私も負けずに書いてみました:grin:
https://qiita.com/Anharu/items/c00e882d678538be0ab0

なぜ1から作ったのか

COCOAはオープンソースなので、誰でも開発に参加することができます。
ですが、COCOAはできるだけ多くの国民の方に使ってもらえるようにと考えられて作られたものなので、できること・取得する情報が制限されています。
実際には個人情報を取得しないアプリなのに問題視されることが多いので、下手に機能を増やすだけだとかえって利用者数が減ってしまう恐れがあります。
そこで、COCOAとは別にアプリを作ることで、もっと多くの情報を得たいと思う人だけが使えるものになったらいいと思い、この考えに至りました。

また、COCOAはみんながインストールしていないと機能しませんが、このアプリは自分だけインストールすればすべての機能が使えるところにとてもこだわりました!
相手の判別はビーコンではなくMACアドレスで行うので、相手はBluetoothがオンの状態であるだけでOKなんです:ok_hand:

開発環境について

私はいつもと同じくAndroid Studioを使ってJavaで作りました。
COCOAはXamarinで作られているようですが、1からアプリを作れるほどの技術がなかったです...
本当はiOS版も作りたかったのですが、同じ理由で現在はAndroidのみです。
また、とにかく早く作り上げたかったので、特別難しいことはしていません。
COCOAではAppleとGoogleが共同で開発したAPI「Exposure Notification」を使用してメイン機能を実装していますが、このアプリはこのAPIは一切使用していません。
というか、個人がこのAPIを自由に使用することはできないようになっています。


ここからは、私が作ったアプリの機能と実装にあたって工夫した点を、少しコードを交えながらご紹介します。
できるだけ分かりやすくするために画像も付けたので、一緒にご覧ください!
:point_right: このアプリはGoogle Playに公開しているものです。もしよろしければダウンロードして使ってみてください!
:iphone: https://play.google.com/store/apps/details?id=rabbitp.check.threec

ホームタブ

アプリを起動してまず表示される画面です。
この画面では、今日と昨日の合計すれちがい・密接人数と、バックグラウンドの動作状態を確認することができます。
バックグラウンド状態のところには、次の測定時間と最後に測定したときの3密状態が表示されます。
また、「今すぐ3密状態をチェック」ボタンをタップすると、次回測定時間まで待たなくてもすぐに調べることもできます。
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/f7b8c336-9833-42a5-6484-065380b496bb.png
今すぐ3密状態をチェックするを押して測定した様子はこちらです。
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/7747ee84-6a48-3651-e290-7bb435a57f07.png

今すぐ3密状態をチェックするときのプログラムと、バックグラウンドで測定するときのプログラムは、時間を指定して自動実行するかどうかの違いだけであとはほとんど同じです。

測定する項目について

15分おきの自動測定や、「今すぐ3密チェック」ボタンを押したときは次の項目を測定しています。
※より正確に測定できるよう、基準値は随時アップデートで変更しています。

:convenience_store: 密閉 → マイクで騒音レベルを調べる

騒音レベルが55dBを超えた場合、密閉と判定します。
※Ver.2.4のアップデートで、騒音レベル測定のプログラムをこちらに変更して現実的な音の大きさを測定することができるようになったので、40dBから55dBに変更しました。

:family: 密集 → Bluetoothデバイス数を調べる

デバイス数が40台を超えた場合、密集と判定します。

:two_men_holding_hands: 密接 → 近接Bluetoothデバイス

RSSI(電波強度)が-60以上のデバイス数が3台を超えた場合、密接と判定します。

:satellite_orbital: 位置情報 → Wi-Fiや基地局の情報から緯度と経度を調べる

※Ver.2.4から、省電力対策のためGPSを使用しない方式(PRIORITY_BALANCED_POWER_ACCURACY)に変更しました。
自動測定時のみ、測定場所を記録しておきます。
「危険度分布マップ」機能は、この情報をもとに地図上にピンを立てます。

:abc: BluetoothデバイスのMACアドレス

MACアドレスを調べることで既に検出済みデバイスかどうかの判定ができるので、同じデバイスをカウントしないようにするために記録しておきます。
この記録は1日ごとにリセットされます。

Exposure Notification APIは使えない

本日より、Exposure Notification の提供を開始します。
同技術は公衆衛生機関が利用でき、Android 及び iOS 搭載端末上で動作します。

引用元:https://japan.googleblog.com/2020/05/apple-google-exposure-notification-api-launches.html

COCOAアプリは、接触者検知や通知といった処理をExposure Notification APIに任せています。
COVID-19に感染した人の濃厚接触者を追跡するために開発されたこのAPIは、利用範囲が公的機関に限定されているため個人利用ができません。
そのため、3密の測定をするには他の方法で行わなければいけません...
IMAGE ALT TEXT HERE
APIについての参考動画(日本語訳):https://www.youtube.com/watch?v=jRIOwmywuQE

Exposure Notificationを使用しない測定方法について

https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/be8171d6-213b-3a6b-9b67-bcd4ebcb4dd8.png
※画像は「接触確認アプリ及び関連システム仕様書」の文章の一部
やりたいことは、周りのデバイス数とCOCOAをインストールしている人数の測定です。
仕様書を見る限りこのAPIを使わないとバックグラウンドではBluetooth(BLE)検索ができないと思われがちですが......

問題1:BLEとバックグラウンドの相性

実際試してみると、普通にBluetoothもBLEもバックグラウンドで検索できることが分かりました!(あっさり解決!!!)
なので、APIを使わなくてもBluetooth検索をしてデバイス数をカウントすれば密集人数を調べられます。
また、RSSI(電波強度)を調べれば近くにあるかどうかもわかるので、密接人数も調べることができます。
Serviceを継承したクラスに検索処理を書き、ActivityからstartForegroundService(APIレベル26未満の場合はstartService)を呼べば実行できます。
※startForegroundServiceの場合は、開始から5秒以内にstartForegroundをServiceクラス内で呼ぶ必要があります。→詳しくはこちら

問題2:UUID取得できない、startScan勝手に終わらない!

先に結論を言うと、私はgetBluetoothLeScannerとstartScanを組み合わせて測定する方法で実装しました。
なぜ両方を使う必要があるのか... まずはこの表を見てください。

①BroadcastReceiverクラスを作ってstartDiscovery ②ScanCallbackクラスを作ってstartScan
バックグラウンド動作 ○ できる ○ できる
画面オフ時の動作 ○ できる △ 検索対象のUUIDを制限する必要がある
デバイス検索終了判断 ○ できる × 手動で止めないといけない
COCOA判別(UUID取得) × できない ○できる

①:https://www.hiramine.com/programming/bluetoothcommunicator/02_scan_device.html
②:https://www.hiramine.com/programming/blecommunicator/02_scan_device.html
それぞれ試してみて分かったことを表にまとめました。

①は画面オフ時でもバックグラウンド同様動作しますが、②はそのままやっても画面オフ時だけ動作しません。
これはAndroid8.1(APIレベル27)から仕様が変更になったことが原因だそうです。(参考サイト1参考サイト2
このページに書かれているように検索するUUIDを制限すれば、問題なく動作します。

①でデバイス検索が終了したとき、1度BroadcastReceiverが呼ばれるので、そこでintent.getAction()がBluetoothAdapter.ACTION_DISCOVERY_FINISHEDになったら終了したと分かりますが、②は見つけるたびにScanCallbackが呼ばれ続けるので終了判断ができません。(終わらせるためには時間指定などで手動で止める必要があります。)

①ではUUIDの取得ができません。(これについては詳しく分かってないです...参考サイトはここ?
②を使えば、UUIDを取得することができます。(詳しくはこちら

こうなるととても厄介です。
①だと対象がExposure Notificationかどうか判別できないし、②だと検索がひと段落した時に停止させることができません。
ならどうすればいいか、両方使えばいいんです!
①で密集・密接を調べるためのデバイス数測定を行い、②でCOCOAインストール人数を測定します。
また、周囲のExposure Notificationの信号の数は大体4秒で取得できることも分かりました。(Beacon Scopeというアプリで事前に調べました。)
つまり、②は検索開始から4秒経ったら止めるようにします。

周囲のデバイス数を測定するためのプログラム

startDiscoveryでBLE検索を開始し、変化があったらBroadcastReceiverが呼ばれます。

    ...省略

    //ブロードキャストレシーバーの登録
    context.registerReceiver(mBroadcastReceiver, new IntentFilter(BluetoothDevice.ACTION_FOUND));
    context.registerReceiver(mBroadcastReceiver, new IntentFilter(BluetoothAdapter.ACTION_DISCOVERY_FINISHED));

    //Bluetoothアダプタの取得
    mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();
    scanner = mBluetoothAdapter.getBluetoothLeScanner();

    //スキャン開始
    mBluetoothAdapter.startDiscovery();

    ...省略

    //ブロードキャストレシーバー
    private BroadcastReceiver mBroadcastReceiver = new BroadcastReceiver()
    {
        @Override
        public void onReceive(Context context, Intent intent )
        {
            String action = intent.getAction();

            if( BluetoothDevice.ACTION_FOUND.equals( action ) )
            {
                //発見!!!
                BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);
                String addmacaddress=device.getAddress();  //デバイスのMACアドレス
                int rssi=intent.getShortExtra(BluetoothDevice.EXTRA_RSSI, Short.MIN_VALUE);  //デバイスの電波強度

                ...省略

                return;
            }

            //Bluetooth端末検索終了
            if( BluetoothAdapter.ACTION_DISCOVERY_FINISHED.equals( action ) )
            {
                try {
                    //スキャンの停止
                    mBluetoothAdapter.cancelDiscovery();

                    //ブロードキャストレシーバーの登録解除
                    context.unregisterReceiver(mBroadcastReceiver);


                    //次にCOCOAのインストール数を調べる
                    startScanBLE(context);

                }catch (Exception e){

                }

                return;
            }
        }
    };

COCOAのインストールデバイス数を測定するためのプログラム

startScanでBLE検索を開始し、変化があったらScanCallbackが呼ばれます。

    //Exposure NotificationのUUID
    String ExpNoti_UUID = "0000fd6f-0000-1000-8000-00805f9b34fb";
    //スキャン時間
    private static final long SCAN_PERIOD = 40000;
    //BLE機器のスキャンを行うクラス
    private BluetoothAdapter mBluetoothAdapter;
    //BLE機器のスキャンを別スレッドで実行するためのHandler
    private Handler mHandler;

    //デバイススキャンコールバック
    private static ScanCallback mLeScanCallback = new ScanCallback()
    {
        //スキャンに成功
        @Override
        public void onScanResult(int callbackType, ScanResult result)
        {
            BluetoothDevice device = result.getDevice();
            String MACaddress=device.getAddress();  //デバイスのMACアドレス
            int rssi=result.getRssi();  //デバイスの電波強度
            List<ParcelUuid> uuids = result.getScanRecord().getServiceUuids();

            if (uuids != null) {
                for (ParcelUuid uuid : uuids) {
                    //発見!!!(指定したUUIDの場合のみ)

                    ...省略

                }
            }


        }

        // スキャンに失敗
        @Override
        public void onScanFailed( int errorCode )
        {
            super.onScanFailed( errorCode );
        }
    };

    ...省略

    //スキャン開始(COCOAのインストール数を調べる)
    private void startScanBLE(Context context)
    {
        //Exposure NotificationのUUIDのみ調べる
        ScanSettings settings = new ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build();
        ScanFilter uuidFilter = new ScanFilter.Builder().setServiceUuid(new ParcelUuid(UUID.fromString(ExpNoti_UUID))).build();
        List<ScanFilter> scanFilters = new ArrayList<>();
        scanFilters.add(uuidFilter);

        //スキャン開始
        scanner.startScan(scanFilters, settings, mLeScanCallback);

        //指定した時間で検索終了
        mHandler = new Handler();
        mHandler.postDelayed(new Runnable() {
            @Override
            public void run() {
                //指定時間が経過したらScanCallbackを止める
                stopScanBLE(context);

                ...省略

            }
        }, SCAN_PERIOD);

    }

    ...省略

    //BLEスキャンの停止
    public static void stopScanBLE(Context context)
    {
        //一定期間後にスキャン停止するためのHandlerのRunnableの削除
        mHandler.removeCallbacksAndMessages( null );

        //BluetoothLeScannerの取得
        android.bluetooth.le.BluetoothLeScanner scanner = mBluetoothAdapter.getBluetoothLeScanner();
        if( null == scanner )
        {
            return;
        }
        scanner.stopScan( mLeScanCallback );
    }

長くなりましたが、これが測定のメインプログラムです。

詳細情報タブ

Spinner項目1:「蓄積データからの種類別グラフ」

測定データをもとに様々なグラフが表示されます。
上の2つは、3密の割合を示す円グラフです。
1つ目が今日の3密割合、2つ目が最大21日分の測定データから計算した3密割合です。
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/97b4729e-5a7c-b369-dbb7-ade88cb6cab7.png

下にスクロールすると、更に2つグラフが表示されます。

  • 「これまでの日別危険度」では、1日ごとに1密を1カウントとして全体の50%(毎時4回測定*24時間*3密*0.5=144カウント)を危険度100%として密割合を日別に表示しています。
  • 「1日の時間別割合」では、1時間ごとに3つ(密閉・密集・密接)の測定結果を足して、どの時間帯が危険だったかをぱっと見て分かるようにグラフ化しています。(そのまま足して一番値が大きいものを基準に表示しているので、単位はありません。)

https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/4e1aa19f-16cb-f74d-4179-ada49f819c96.png

グラフの描画には、「MPAndroidChart」を使用しました。
https://github.com/PhilJay/MPAndroidChart
このライブラリ、とても便利でカスタマイズも容易なので、どんなグラフでも作れます!
データ自体は配列でセットするだけなのでとても分かりやすいです。
これは、「これまでの日別危険度」で表示しているように折れ線グラフを表示する例です。

build.gradle(appレベル)
...省略

dependencies {

    ...省略

    implementation 'com.github.PhilJay:MPAndroidChart:v2.2.5'
}

レイアウト
<com.github.mikephil.charting.charts.LineChart
    android:id="@+id/LineChart_21days"
    android:layout_width="match_parent"
    android:layout_height="200dp" />
プログラム
    LineChart LineChart_21days;

    ...省略

    LineChart_21days=view.findViewById(R.id.LineChart_21days);

    LineChart_21days.setNoDataText("グラフを描画しています...");
    LineChart_21days.setDescription("最大21日分の測定データから生成");
    LineChart_21days.setHighlightPerTapEnabled(false);  //タップしてハイライトを無効化
    LineChart_21days.getLegend().setEnabled(false);  //チャートラベル説明(左下のやつ)を非表示
    LineChart_21days.setScaleEnabled(false);  //ピンチイン・ダブルタップ拡大を無効化
    LineChart_21days.setDrawGridBackground(false);
    Legend legend3 = LineChart_21days.getLegend();
    legend3.setPosition(Legend.LegendPosition.BELOW_CHART_LEFT);

    //グラフに表示するデータの設定
    List<Entry> entries = new ArrayList<>();
    entries.add(new Entry("データ1", 0));
    entries.add(new Entry("データ2", 1));
    entries.add(new Entry("データ3", 2));

    LineDataSet lineDataSet = new LineDataSet(entries,"ラベル");
    lineDataSet.setDrawValues(true);
    lineDataSet.setColor(Color.parseColor("#FF5722"));  //折れ線の色
    lineDataSet.setLineWidth(2);
    lineDataSet.setDrawFilled(true);  //塗りつぶす
    lineDataSet.setFillAlpha(10);  //塗りつぶしの透明度
    lineDataSet.setFillColor(Color.parseColor("#FF5722"));  //塗りつぶしの色
    lineDataSet.setCircleColor(Color.parseColor("#FF5722"));  //外心の円の色
    lineDataSet.setCircleRadius(5);  //中心の円の半径
    lineDataSet.setCircleHoleRadius(3);  //外心の円の半径
    lineDataSet.setMode(LineDataSet.Mode.CUBIC_BEZIER);  //なめらかな折れ線

    LineData lineData1 = new LineData(labels, lineDataSet);
    lineData1.setValueFormatter(new PercentFormatter());
    lineData1.setValueTextSize(17f);
    lineData1.setValueTextColor(Color.TRANSPARENT);

    LineChart_21days.animateXY(1000, 1000);  //表示アニメーション
    LineChart_21days.setData(lineData1);

Spinner項目2:「蓄積データからの危険度分布マップ」

自動測定したデータで1密以上だった場所にはマーカー(ピン)を立ててお知らせします。
マーカーは密状態を判別できるように色を変えています。
000188.png
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/7ef39698-0ab3-53c1-a55f-71a832ab2c37.png

マーカーをタップすると、いつの結果か等その時の測定データの詳細を確認することができます。
infowindow_R.png

この機能はGoogle Maps Android APIを使用して実装しています。
導入方法:https://qiita.com/yuishihara/items/8955582de6fa639eb504
マーカーをタップしたときに表示されるウィンドウを設定する:https://qiita.com/sho-h-ei/items/a40f813a5178e716fb8c

Spinner項目3:「過去20日分の詳細データ」

名前の通り、すべての測定結果を一覧で見ることができます。
2つ目のSpinnerで確認したい日付を選ぶと、上から順に0:00~23:45までの結果が書かれたCardViewが並びます。
ここには、測定時にGPSで取得した位置情報(緯度・経度)をもとに住所を市区町村名まで表示します。
何密かによって背景色を変えることで、見やすいように工夫しました。
card.png
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/78e85799-c5e8-b6d0-1119-ffdd90ef4b54.png

また、CardViewをタップするとその測定場所をGoogleマップで開くようにしました。
GoogleマップのURLに緯度と経度を入れるだけで簡単に表示できます。
https://www.google.com/maps?q={緯度},{経度}
例えば、名古屋駅を開くリンクはこう書きます:https://www.google.com/maps?q=35.171131,136.881499

共有タブ

最新の都道府県別COCOA普及率を確認できます。
3密チェッカーアプリをインストールしてこの機能を有効にしているユーザーのデータから、割合と順位を算出しています。
アンケート調査と異なり、スマホを持ち歩くだけでデータを集計できるので、リアルタイムかつ簡単に集計することができました。
※集計されたデータは都道府県単位で管理しているので、だれがいつ測定したものなのかは分からないようになっています。
なお、この機能で集計されたデータを確認するためにはアプリ内購入が必要(350JPY)ですので、一部ぼかしをいれています。
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/52b25cfd-25cd-4691-e7d9-b15dd777b7b9.png
今はCOCOA普及率分布マップ機能しかありませんが、これからはもっと共有機能を増やしたいです。
具体的には、あらかじめ登録した家族の測定結果をもとに、最近増えている家庭内感染(二次感染)を予測できる機能を付けたいと思っています。

最新情報タブ

一番上には、毎日17時くらいに更新される東京都の最新情報を、公式サイトから取得して表示しています。
取得に使用しているサイト:https://stopcovid19.metro.tokyo.lg.jp/
本当はJSON形式かCSV形式のデータを使いたいんですが、一部の情報はCSVで公開されているのですが、新規感染者数や前日比が載っていないので仕方なくホームページのHTMLを解析して取得するようにしました。
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/edc8ea7a-bb9c-629c-07e5-fb53e6b53abe.png
その下には、JSONで取得した都道府県別の新規感染者数をグラフ化して表示しています。
ただ、こちらは更新が遅いので数日前のものです。
データ取得に使用しているサイト(esriジャパン 新型コロナウイルス対応支援サイト):https://is.gd/Aj5sjK
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/c4fb9870-ad2b-c1fb-b52a-bece5fb1095a.png

設定タブ

自動測定をするかしないか、測定を行わない条件、測定から除外するデバイスを設定できます。
この設定をしておくことで、家にいるときなどに誤判定をしないようにすることができ、更にバッテリー消費を抑えることができます。
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/0ae2ceca-7215-1bbc-507c-b6b66bf659b1.png

ここまでが、アプリに今搭載している機能のすべてです。


Google Playで公開しています!

公開から約2か月が経過し、4万5,000ダウンロードを突破しました!
ぜひダウンロードして使ってみてください!!(紹介したすべての機能が使えます。)
レビューをいただけると幸いです:relaxed:
https://play.google.com/store/apps/details?id=rabbitp.check.threec
https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/409628/2fe97a7c-e5e7-1006-17e3-82b635dc175e.png
Google Play および Google Play ロゴは、Google LLC の商標です。

今後の改善点

アプリのレビューやTwitterなどで寄せられたご意見より、今後のアップデートで改善する箇所をまとめました。
こちらをご覧ください→https://rabbitprogram.com/support/threec/update

参考サイト

:disappointed_relieved: 本文中にもし間違いがあればご教授ください。よろしくお願いします。

Rabbit_Program
趣味でAndroidアプリを開発・公開しています。 2019年にアプリ甲子園に応募し、特別企業賞「マイナビ賞」を受賞して全国4位になりました!
https://rabbitprogram.com/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした