LoginSignup
188
182

More than 5 years have passed since last update.

Android BLE API及びAndroid Beacon Libraryの設計の酷さを技術的に説明する

Posted at

はじめに

Android 4.3 から、BLE (Bluetooth Low Energy) 用の API が提供されるようになりました。その後、Android 5.0 で android.bluetooth.le というパッケージが追加され、BLE 用の API が再定義されました。しかし、輪をかけて酷くなりました。

しばらく BLE の仕事を離れることになりそうなので (そして恐らく戻ってこないので)、Android BLE API に対して言いたいことを忘れないうちに書いておこうと思います。なお、主に BLE アドバータイズメントパケットのペイロードの話になります。BLE API 全体については扱いません。

1. ペイロードのフォーマット

BLE アドバータイズメントパケットのペイロード部のフォーマットが BLE の仕様でどのように定義されているかを知れば、あるべき API の姿が想像できます。そこで、まずはその説明をします。具体的には、LeScanCallback インターフェースで定義されている onLeScan メソッドの三番目の引数である byte[] scanRecord の中身がどのようなフォーマットになっているかの説明をします。

1.1. ペイロードの構造

BLE アドバータイズメントパケットのペイロード部は、AD ストラクチャーというもののリストとなっています。個々の AD ストラクチャーは可変長です。下図は三つの AD ストラクチャーを含むペイロードを表しています。

payload.png

1.2. AD ストラクチャーの構造

AD ストラクチャーは、1 バイト目に「AD ストラクチャー全体の長さ - 1」、2 バイト目に AD ストラクチャーのタイプ (AD タイプ)、3 バイト目以降はデータ (AD データ) となっています。データの長さとフォーマットは、2 バイト目の値が示す AD タイプによって変化します。次の表は AD ストラクチャーの構造を示しています。

位置 大分類
1 バイト目 長さ
2 バイト目 AD タイプ
3 バイト目以降 AD データ

例えば、AD タイプが 0x01 (Flags) である AD ストラクチャーは、データ部の長さは 1 バイトになるので (ただし仕様拡張により今後変化する可能性はあります)、全体としては 3 バイトになります。次の表は AD タイプが 0x01 のときの AD ストラクチャーの構造を示しています。

位置 大分類 中分類
1 バイト目 長さ 0x02
2 バイト目 AD タイプ Flags 0x01
3 バイト目 AD データ フラグ群 0x??

なお、AD ストラクチャーの構造については、「Bluetooth Core Specification 4.2」の「11 ADVERTISING AND SCAN RESPONSE DATA FORMAT」で次のように説明されています。

Each AD structure shall have a Length field of one octet, which contains the Length value, and a Data field of Length octets. The first octet of the Data field contains the AD type field. The content of the remaining Length - 1 octet in the Data field depends on the value of the AD type field and is called the AD data.

1.3. AD タイプの種類

定義済みの AD タイプの種類は、「Generic Access Profile」ページにリストアップされています。下表は、当該ページ内の表から「Reference for Definition」欄を取り除いて見やすくしたものです。

データタイプ名
0x01 Flags
0x02 Incomplete List of 16-bit Service Class UUIDs
0x03 Complete List of 16-bit Service Class UUIDs
0x04 Incomplete List of 32-bit Service Class UUIDs
0x05 Complete List of 32-bit Service Class UUIDs
0x06 Incomplete List of 128-bit Service Class UUIDs
0x07 Complete List of 128-bit Service Class UUIDs
0x08 Shortened Local Name
0x09 Complete Local Name
0x0A Tx Power Level
0x0D Class of Device
0x0E Simple Pairing Hash C
0x0E Simple Pairing Hash C-192
0x0F Simple Pairing Randomizer R
0x0F Simple Pairing Randomizer R-192
0x10 Device ID
0x10 Security Manager TK Value
0x11 Security Manager Out of Band Flags
0x12 Slave Connection Interval Range
0x14 List of 16-bit Service Solicitation UUIDs
0x1F List of 32-bit Service Solicitation UUIDs
0x15 List of 128-bit Service Solicitation UUIDs
0x16 Service Data
0x16 Service Data - 16-bit UUID
0x20 Service Data - 32-bit UUID
0x21 Service Data - 128-bit UUID
0x22 ​LE Secure Connections Confirmation Value
0x23 LE Secure Connections Random Value
0x24 URI
0x25 Indoor Positioning
0x26 Transport Discovery Data
0x17 Public Target Address
0x18 Random Target Address
0x19 Appearance
0x1A Advertising Interval
0x1B ​LE Bluetooth Device Address
0x1C ​LE Role
0x1D Simple Pairing Hash C-256
0x1E Simple Pairing Randomizer R-256
0x3D 3D Information Data
0xFF Manufacturer Specific Data

1.4. 製造者固有データ

AD タイプ一覧表の末尾に、値が 0xFF、データタイプ名が「Manufacturer Specific Data」(製造者固有データ) というエントリーがあります。これは、AD データのフォーマットを自由に拡張できるようにするためのものです。

AD タイプが 0xFF の場合、AD データ部の先頭の 2 バイトには、企業を識別するための番号がリトルエンディアンで格納されます。AD データ部の 3 バイト目以降の内容は、その企業が自由に定義することができます。次の表は、AD タイプが 0xFF のときの AD ストラクチャーの構造を示しています。

位置 大分類 中分類
1 バイト目 長さ
2 バイト目 AD タイプ 製造者固有
3 ~ 4 バイト目 AD データ 企業 ID
5 バイト目以降 AD データ 企業固有データ

例えば、Apple 社が独自にデータ構造を定義する場合、Apple 社の企業 ID は 0x004C ですので、AD ストラクチャーは次のようになります。企業 ID はリトルエンディアンで格納されるので注意してください。

位置 大分類 中分類 小分類
1 バイト目 長さ ?
2 バイト目 AD タイプ 製造者固有 0xFF
3 バイト目 AD データ 企業 ID Apple 社 0x4C
4 バイト目 AD データ 企業 ID Apple 社 0x00
5 バイト目以降 AD データ 企業固有データ Apple 社固有データ ?

1.5. 企業 ID

登録済み企業 ID は「Company Identifiers」ページにリストされています。Apple 社の企業 ID が 0x004C であることも、このページを見ると分かります。

1.6. iBeacon

製造者固有データの一例として、iBeacon を紹介します。

iBeacon は、Apple 社が定めた独自フォーマットです。AD データ部は、先頭 2 バイトに Apple 社の企業 ID、次の 2 バイトが 0x020x015 で固定、以降、16 バイトの Proximity UUID、2 バイトのメジャー番号、2 バイトのマイナー番号、1 バイトの送信出力が続きます。Proximity UUID、メジャー番号、マイナー番号はビッグエンディアンで格納されます。

iBeacon フォーマットを図にすると次のようになります。

ibeacon-format.png

表形式で表現すると次のようになります。

位置 大分類 中分類 小分類
1 バイト目 長さ 26
2 バイト目 AD タイプ 製造者固有 0xFF
3 バイト目 AD データ 企業 ID Apple 社 0x4C
4 バイト目 AD データ 企業 ID Apple 社 0x00
5 バイト目 AD データ 企業固有データ 固定値 0x02
6 バイト目 AD データ 企業固有データ 固定値 0x15
7 ~ 22 バイト目 AD データ 企業固有データ Proximity UUID ?
23 ~ 24 バイト目 AD データ 企業固有データ メジャー番号 ?
25 ~ 26 バイト目 AD データ 企業固有データ マイナー番号 ?
27 バイト目 AD データ 企業固有データ 送信出力 ?

「iBeacon パケット」という言葉を耳にすることがあると思いますが、技術的に正確に表現すると、「iBeacon フォーマットの AD ストラクチャーを含むペイロードを持つ BLE アドバータイズメントパケット」となります。

1.7. Eddystone

iBeacon を紹介したので、Eddystone も紹介します。

Eddystone は Google 社が旗振り役なので、Google 社の企業 ID を持つ製造者固有データとして定義されていると思われるかもしれませんが、実はそうではなく、サービスデータの一種として定義されています。

サービスデータを表す AD タイプは複数あるのですが、Eddystone は 0x16 を使用します。この AD タイプの場合、AD データ部の先頭に置くサービス識別子が 2 バイトで表現されます。ちなみに、AD タイプ 0x200x21 もサービスデータを意味しますが、サービス識別子をそれぞれ 4 バイト、16 バイトで表現する点が異なります。

次の表は、AD タイプが 0x16 で、サービス識別番号に Eddystone を表す 0xFEAA がセットされている AD ストラクチャーの構造を示しています。サービス識別番号はリトルエンディアンで格納されます。

位置 大分類 中分類
1 バイト目 長さ ?
2 バイト目 AD タイプ サービスデータ 0x16
3 バイト目 AD データ サービス識別番号 0xAA
4 バイト目 AD データ サービス識別番号 0xFE
5 バイト目以降 AD データ サービス固有データ ?

5 バイト目以降のサービス固有データ部は、Eddystone 独自のフォーマットとなります。

まず、サービス固有データ部の 1 バイト目の上位 4 ビットで Eddystone のフレーム種別を指定します。Eddystone には次の三つのフレーム種別があり、その種別をこの 4 ビットで指定します。

  1. Eddystone UID
  2. Eddystone URL
  3. Eddystone TLM

サービス固有データ部の 2 バイト目以降のデータフォーマットは、フレーム種別毎に異なります。Eddystone の仕様は GitHub の google/eddystone で公開されているので、ここで詳細に解説はしませんが、図にまとめておきます。

eddystone-format.png

1.8. ucode

iBeacon と Eddystone を知っておけば十分ですが、せっかく日本の組込業界の T-Engine フォーラムが定義した「Bluetooth LE ucode マーカーパケット仕様」というものがあるので、紹介しておきます。

この仕様は、T-Engine フォーラムが定義したグローバル ID である「ucode」の値を BLE アドバータイズメントパケットに埋め込むフォーマットを、製造者固有データの一種として定義しています。ITU-T H.642 としても採択されています。詳細は仕様書を参照してください。

2. 理想と現実

2.1. 理想

ここまで、BLE アドバータイズメントパケットのペイロード部のフォーマットについて見てきました。得られた知識をもとに、あるべき API の姿を考えてみます。

ペイロード部の値がバイト配列として与えられたとき、それをパースするメソッドは、結果として何を返せばよいでしょうか? ペイロード部は AD ストラクチャーのリストなので、それをそのまま表現する結果を返せばよいでしょう。例えば、Java で表現するとしたら、次のような形です。

// BLE アドバータイズメントパケットのペイロード部
byte[] payload = ...;

// ペイロードをパースして、AD ストラクチャーのリストとして受け取る。
List<ADStructure> list = parse(payload);

個々の AD ストラクチャーは、実際には、Flags を表しているかもしれませんし、iBeacon かもしれませんし、Eddystone URL かもしれません。この関係は、継承ツリーとして表現するのが自然でしょう。例えば、ここまでに紹介した、AD ストラクチャー、Flags、iBeacon、Eddystone、ucode の関係は、次のような継承ツリーを構成するのが望ましいと思われます。

inheritance-tree.png

このような継承ツリーが API でも表現されていれば、例えば Eddystone URL の情報を表示するプログラムは次のように書けるでしょう (階層構造を示すために、わざと if を深くネストさせて書いています)。

// AD ストラクチャー
ADStructure ads = ...;

// どの AD ストラクチャーにも、長さとタイプがある。
System.out.println("長さ = " + ads.getLength());
System.out.println("タイプ = " + ads.getType());

// もしもサービスデータであれば、
if (ads instanceof ServiceData)
{
    // サービスデータとして扱うことができる。
    ServiceData sd = (ServiceData)ads;

    // どのサービスデータにも、サービスを一意に特定するサービス UUID がある。
    System.out.println("サービス UUID = " + sd.getServiceUUID());

    // サービスデータの中でも、特に Eddystone であれば、
    if (sd instanceof Eddystone)
    {
        // Eddystone データとして扱うことができる。
        Eddystone es = (Eddystone)sd;

        // どの Eddystone データにも、フレーム種別がある。
        System.out.println("フレーム種別 = " + es.getFrameType());

        // Eddystone URL であれば、
        if (es instanceof EddystoneURL)
        {
            // Eddystone URL として扱うことができる。
            EddystoneURL esurl = (EddystoneURL)es;

            // Eddystone URL には、送信出力と URL の情報が含まれている。
            System.out.println("送信出力 = " + esurl.getTxPower());
            System.out.println("URL = " + esurl.getURL());
        }
    }
}

さらに、独自のデータ構造を定義し、それをペイロードをパースした結果のリストに含ませることを可能とするため、何かしら独自パーサーを定義して登録できるとよいでしょう。例えば、AD ストラクチャーの構成要素である「(1) 長さ、(2) AD タイプ、(3) AD データ」から AD ストラクチャーのサブクラスのインスタンスを生成して返すメソッドを持つインターフェースを次のように定義し、

public interface ADStructureBuilder
{
    /**
     * AD ストラクチャーを構築する。
     *
     * @param length
     *         AD ストラクチャーの長さ。 AD ストラクチャーの
     *         バイト配列の一番目のバイトの値。
     *
     * @param type
     *         AD タイプ。 AD ストラクチャーのバイト配列の
     *         二番目のバイトの値。
     *
     * @param data
     *         AD データ。 AD ストラクチャーのバイト配列の
     *         三番目以降のバイト群。
     *
     * @return
     *         ADStructure もしくはそのサブクラスのインスタンス。
     *         インスタンスを生成できないときは null を返す。
     */
    ADStructure build(int length, int type, byte[] data);
}

このインターフェースを実装したクラスのインスタンスを何らかの方法で登録できるようなっていればよいでしょう。

2.2. 現実

さて、現実の世界ではどのような API となっているのでしょうか?

API リファレンスには、「BluetoothAdapter クラスの startLeScan(LeScanCallback) メソッドは API level 21 (Android 5.0) で deprecated 扱いとなったので、かわりに startScan(List, ScanSettings, ScanCallback) メソッドを使うように」と書いてあります。この新しいメソッドの三番目の引数は ScanCallback クラスのインスタンスです。クラスの名称から、アドバータイズメントパケットを受信したときに呼び出されるコールバックだということが分かります。Android 4.X から Android 5.X にバージョンアップしたときに、コールバックが LeScanCallback から ScanCallback に変更になったようです。

この ScanCallback クラスには次のような三つのメソッドが定義されています。

void onBatchScanResults(List<ScanResult> results) { ... }
void onScanFailed(int errorCode) { ... }
void onScanResult(int callbackType, ScanResult result) { ... }

メソッド名が on で始まることから、何らかのイベントを受け取るためにはこれらのメソッドをオーバーライドすればいいだろうということが推測できます。ただ、これら以外のメソッドが定義されていないことから、「なぜインターフェースではなくクラスとして定義したのか」という疑問は当然湧いてきて、早くも怪しげですが、次に進みます。

ScanCallback クラスに定義されている onBatchScanResults メソッドも onScanResult メソッドも、データ構造として ScanResult クラスのインスタンスを受け取ることが分かります。そこで、この ScanResult クラスの定義を見ると、幾つかメソッドがありますが、アドバータイズメントパケットをパースした結果は、getScanRecord() メソッドにより ScanRecord クラスのインスタンスとして取り出すことができることが分かります。

ScanRecord getScanRecord() { ... }

ではここで、ScanRecord クラスのメソッド一覧を紹介します。

戻り値 メソッド
int getAdvertiseFlags()
byte[] getBytes()
String getDeviceName()
SparseArray<byte[]> getManufacturerSpecificData()
byte[] getManufacturerSpecificData(int)
byte[] getServiceData(ParcelUuid)
Map<ParcelUuid,byte[]> getServiceData()
List<ParcelUuid> getServiceUuids()
int getTxPowerLevel()
String toString()

アドバータイズメントパケットのペイロードのフォーマットを知っていれば、これらのメソッドがそれぞれ何の情報を返そうというしているのかが分かります。getBytes() メソッドと toString() メソッドを除く他のメソッドは、ある特定の AD タイプを持つ AD ストラクチャーのデータを返そうとしているのです。表にまとめると次のようになります。

メソッド AD タイプ
getAdvertiseFlags 0x01
getDeviceName 0x08, 0x09
getManufacturerSpecificData 0xFF
getServiceData 0x16, 0x20, 0x21
getServiceUuids 0x020x07
getTxPowerLevel 0x0A

ペイロードのフォーマットをちゃんと理解していれば、ここで ScanRecord クラスの設計の酷さに気付くはずです。

まず、1 つのペイロードに、上記表内に挙げられている全ての AD ストラクチャーが同時に含まれていることは、まずありません。また逆に、上記のいずれの AD ストラクチャーも含まないペイロードというのも当然ありえます。ですので、ScanRecord インスタンスを受け取っても、上記のメソッド群が全て null もしくは空のコレクションを返す場合があります。

次に、選択された AD タイプの恣意性です。なぜ、0x010x0A, 0x16, 0x20, 0x21, 0xFF は選ばれ、他は選ばれなかったのか。この非対称性はなんなのでしょうか。

ScanRecord の設計を、動物を例に例えます。今、動物に関するデータが並んでいて、それをパースするとします。パースの結果を受け取るとき、受け手は普通、List<動物> というデータ構造を期待します。しかし、ScanRecord と同じ構造とするならば、受け手が受け取るのは、次のようなデータ構造です。

public class 動物レコード
{
    getロシアンブルー() { ... }
    getポメラニアン() { ... }
    get鳥類タカ目() { ... }
    get両生類() { ... }
    get元データ() { ... }
}

 
     why.png
 

この ScanRecord クラスの設計は、Android API の中でも最悪レベルで、罵倒されてもやむをえません。この他、Android の BLE 関連の API は全般的に設計ミスの宝庫です。Android 4.X から 5.X にバージョンアップする際に BLE API を再設計したのにこの低品質で、かつそれが公式 API に採用されてしまったということは、Android の BLE 界隈はかなりの人材不足なのだと推測せざるをえません。

3. Android Beacon Library

3.1. Android Beacon Library の紹介

「アドバータイズメントパケットのペイロードをパースしたい」と、みんな思います。iBeacon や Eddystone のデータをアドバータイズメントパケットから取り出したいのです。独自フォーマットのアドバータイズメントパケットを投げるビーコンを作りたい企業もあるでしょう。

Android BLE API は前述の通り酷いので、ペイロードをパースしたい人は解を求めて Stack Overflow に行きます。するとすぐに Android Beacon Library というライブラリに辿りつきます。このライブラリはオープンソースで (AltBeacon/android-beacon-library)、Radius Networks という会社がサポートしています。Radius Networkds 社の関係者が積極的に回答していることもあり、Stack Overflow は Android Beacon Library の回答で溢れ返っています。

このライブラリにアドバータイズメントパケットをパースさせるには、対象となる AD ストラクチャーのレイアウトをパーサーに登録する必要があります。例えば iBeacon データを取り出すには、パーサーに次のようにレイアウトを登録します。

// iBeacon のレイアウトを登録する。
setBeaconLayout("m:2-3=0215,i:4-19,i:20-21,i:22-23,p:24-24");

レイアウトの書式の説明は、BeaconParser クラスの setBeaconLayout メソッドの JavaDoc に書かれています。だいたい想像がつく通り、フォーマットとバイト位置の組を列挙しています。

Android Beacon Library を使って Eddystone データを扱う方法は「How to detect Eddystone-Compatible Beacons」に記述されています。例えば Eddystone URL を検出するには、まず、パーサーに Eddystone URL のレイアウトを登録し、

mBeaconManager.getBeaconParsers().add(new BeaconParser().
    setBeaconLayout("s:0-1=feaa,m:2-2=10,p:3-3:-41,i:4-20v"));

アドバータイズメントパケットのペイロードのパース結果を得た時に、次のようにして Eddystone URL だということを判定して、情報を取り出します。

for (Beacon beacon: beacons) {
    if (beacon.getServiceUuid() == 0xfeaa && beacon.getBeaconTypeCode() == 0x10) {
        // This is a Eddystone-URL frame
        String url = UrlBeaconUrlCompressor.uncompress(beacon.getId1().toByteArray());
        Log.d(TAG, "I see a beacon transmitting a url: " + url +
                " approximately " + beacon.getDistance() + " meters away.");
    }
}

このコードは前出のドキュメントから転載したものですが、(1) Eddystone のサービス番号が 0xFEAA ということ、及び (2) Eddystone URL のフレーム種別を示す値が 0x1? であるということを知らないと、このコードは書けません。なお、正確には

beacon.getBeaconType() == 0x10

となっている箇所は

(beacon.getBeaconType() & 0xF0) == 0x10

と書くべきです。

3.2. Android Beacon Library の問題点

Android Beacon Library の設計は、AD ストラクチャーのレイアウトを登録するという原始的なものなので、パースされた AD ストラクチャーは全て Beacon という汎用のデータ構造で返ってきます。逆に言うと、例えば下記のような便利なクラスのインスタンスが返ってくることはありません。

public class EddystoneURL extends Eddystone
{
    /**
     * Eddystone URL の AD ストラクチャーが保持する URL 情報を
     * java.net.URL クラスのインスタンスとして返す。
     */
    public URL getURL() { ... }
    ...
}

加えて、AD ストラクチャー内の構造が特殊になると、レイアウト書式では対応しきれなくなります。例えば Eddystone URL のエンコーディング方法は特殊なので、次のように色々複雑なことをやらないと欲しい情報が取り出せません。

String url = UrlBeaconUrlCompressor.uncompress(beacon.getId1().toByteArray());

Eddystone TLM が持つ温度情報も固定小数点フォーマット (Cornell University, Electrical Engineering 476, Fixed Point mathematical functions in GCC and assembler 参照) で格納されていて、この形式は Android Beacon Library のレイアウト書式が対応していないものなので、こちらも情報を得るには次のようなコードを書かないといけません。このコードは、Radius Networks 社の関係者による Stack Overflow の回答からの転載です。

long unsignedTemp = (beacon.getExtraDataFields().get(2) >> 8);
double temperature = unsignedTemp > 128 ? 
    unsignedTemp - 256 : 
    unsignedTemp +(beacon.getExtraDataFields().get(2) & 0xff)/256.0;

もしも Eddystone TLM を、次のような便利なクラスのインスタンスとして受け取ることができるのであれば、(1) 温度情報が固定小数点数として格納されていること、及び (2) 固定小数点数を float に直す方法を知らずとも、簡単に温度情報を取り出すことができるでしょう。

public class EddystoneTLM extends Eddystone
{
    /**
     * Eddystone TLM の AD ストラクチャーが保持する、固定小数点
     * フォーマットで表現された温度情報をパースして float として返す。
     */
    public float getBeaconTemperature() { ... }
    ...
}

レイアウト登録というアプローチは一見汎用的で良い解のように思えますが、この設計は意外と安っぽいものであり、結局使い勝手の悪さとして跳ね返ってきます。さらに現実のデータ構造を正しく表現しきれないのです。「それでは」と言って使い勝手を求めると、汎用のはずの Beacon クラスに mServiceUuidmTxPower といった、本来サブクラスで持つべきフィールドが追加されていき、設計がどんどん汚くなっていきます。

いちいちレイアウトを登録しなければならない、パース処理の結果得られた Beacon から取り出したデータをさらに自分でパースしなければならない、という問題点もさることながら、私がそれ以上に Android Beacon Library の設計上の問題として指摘したいのは、Android Beacon Library では AD ストラクチャーの階層構造を絶対に表現できないという点です。 この問題は、レイアウト登録というアプローチをとっている限り解消できません。

4. 繋ぎのソリューション

将来まともな Android BLE API が再々設計・実装されるまでの繋ぎのソリューションとして、手前味噌ではありますが nv-bluetooth ライブラリをお勧めします。 このライブラリを使うと、先に述べた理想の形でのプログラミングが可能となります。使用方法は下記のリンク先をご参照ください。

また、下記の Stack Overflow の回答群もご参照ください。 Android Beacon Library を用いる方法での回答が同時についている質問もあるので、比較してみると違いがはっきり分かると思います。

  • 「Programmatically, How to identify if a beacon belongs to Eddystone or iBeacon?」に対する回答
  • 「How to read UDID, Major, Minor of beacon on android devices?」に対する回答
  • 「How to identify Eddystone URL and uid?」に対する回答
  • 「How to identify a Eddystone via scanRecord」に対する回答
  • 「Android Bluetooth Low Energy - iBeacon」に対する回答
  • 「Finding iBeacon using AltBeacon library」に対する回答

おわりに

Android BLE API の設計があまりにも酷いこと、Android Beacon Library が流行ってしまっていることは、Android を用いた BLE ソリューション発展の阻害要因になっていると考えています。 正しい設計・実装ができるエンジニアを集めて新しいチームを作った上で、ネイティブレイヤーから Android BLE API の再設計・再実装がおこなわれることを希望します。

188
182
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
188
182