CORDEA blog

Android applications engineer

Ktor Client で Twitter の Filtered stream を取得する

小ネタ

developer.twitter.com

HttpClient

このへんはあまり関係ないのでよしなに

val client = HttpClient(CIO) {
    defaultRequest {
        url {
            protocol = URLProtocol.HTTPS
            host = "api.twitter.com"
        }
        header("Authorization", "Bearer $token")
    }

    Json {
        serializer = KotlinxSerializer(json)
    }
}

Add rules

取得する Tweets の rules を POST する

返り値は String でも HttpResponse でも任意に定義した Response でも何でもいいですが、
kotlinx.serialization でそのまま decode する場合は、errors など key がない場合があるので ignoreUnknownKeys = true を指定しておくと良いです

val response = client.post<String> {
    url {
        encodedPath = "2/tweets/search/stream/rules"
    }
    body = StreamRulesRequest(rules)
}

Get tweets

Tweets を real-time に取得する
Ktor Client の doc に書いてある通り、HttpStatement を使用します。

Streaming—Ktor

client.get<HttpStatement> {
    url { encodedPath = "2/tweets/search/stream" }
}.execute { response ->
    val channel = response.receive<ByteReadChannel>()
    do {
        val tweet = channel.readUTF8Line() ?: break
    } while (tweet.isNotBlank())
}

Flow とかで emit すれば扱いやすくなると思います。

もう少し詳しく見たければこちらを

info-provision-bot/TwitterClient.kt at main · CORDEA/info-provision-bot · GitHub

Android app の Owner 変更と Google Play Developer account の削除

をしたので手順のメモ

Owner 変更はだいたいここに書いてあるとおり。
チェックすべきことも書いてあるので一度目を通しておくと良さそうです。

Transfer apps to a different developer account - Play Console Help

1. 移行先の Google Play Developer account を登録する

これはまぁ普通に。
Registration fee を支払う必要がありますが、先の Help に書いてあるとおり、元の Account を削除する際に返金してもらうことができるようです。
何も言わなければ返金されないので、あとの手順で Support にメールする時ちゃんと「返金して」って書いておくと良いと思います。

2. 移行先の Transaction ID を取得する

これは console にあるものではなく、支払ったときに付与される ID です。
なので、Registration fee を支払ったときに来る Google Payments からのメールに記載されています。
日本語だと「Google の注文番号」ってやつです。

https://pay.google.com からも参照できます。この場合は「取引 ID」です。

3. App の移行申請

ここから申請します。全部移行する場合は全部選択します。

https://play.google.com/console/developers/app-transfer

App が多い場合など、最初から Support にメールして進めたほうが早いかもしれません。
2-3 営業日でメールが来ます。

4. Developer account の削除依頼

以下に書いてあるとおり、3 で来たメールに返信する形で削除を依頼します。

Manage your developer account information - Play Console Help

まぁいい感じに私の Account 削除してください的なこと書いておけばいいと思います。 (返金を求める場合はそれも)
その際、削除対象の Account の Transaction ID や Developer name を記載しておくとスムーズです。
Developer name は Google Play Console の設定 -> デベロッパーアカウント -> デベロッパーページ で参照できます。

その後、移行先の Account の情報とか聞かれたりして、答えていくと削除してくれます。


なんだかんだ Owner 変更と削除で 2 週間くらいかかりました。

Reports API で G Suite 監査ログの更新通知を受け取る

G Suite の Admin console には監査ログというものがあり、そこで色々見たり Alert を上げたりすることが出来るのですが、
G Suite Admin SDKReports API を使用すると、この監査ログを取得することが出来ます
また、Callback URL を登録することで更新通知を受け取ることも出来ます。

ということでこの記事では Meet の更新通知を受け取ってみます。
Meet は非対応ですが、主要なイベントだと Alert Center でメールに飛ばせるのでメールならそちらのほうが良いでしょう。

注意

監査ログはリアルタイムに通知されるわけではなく、利用できるようになるまでに一定の時間がかかります。また、この時間はサービスによって異なります。
Meet はほぼリアルタイムに更新されるようですが、私が見ていたところでは監査ログに表示されるまでに概ね 3~5 分程度かかっていました。

Data retention and lag times - G Suite Admin Help

また、更新通知の受け取りに関してですが、最初は届いていたのですが何度も試していたせいか途中から届かなくなってしまいました。(同期用の Message は届くが監査ログの更新は通知されない)
Stackoverflow でもいくつか同様の投稿が見受けられましたので、使用する際には注意したほうが良いかもしれません。

個人で契約している G Suite Basic なので、Business とか Enterprise だと違ったりするのかもしれません。

実装

実装したやつ置いときます

github.com

通知を受け取る Web API の用意

とりあえず通知が POST されるので、その先を予め用意しておく必要があります。

Flask だと以下のようになります。

@app.route('/events', methods=['POST'])
def events():
  headers = request.headers

if __name__ == '__main__':
  app.run(debug=True)

まず、ここにはいくつかの Message が POST されますが、そのうちの一つに同期用の Sync Message があります。

これは登録後に POST される確認用の Message です。
この Message には登録解除用の ID などが含まれていますが、監査ログとしての情報はないため、ここでは無視します。

@app.route('/events', methods=['POST'])
def events():
  headers = request.headers
  if headers['X-Goog-Resource-State'] == 'sync':
    return {}

Header に含まれる情報はここに書いてあります。

あとは通知の Parse です。
POST される json には種別や ID などが含まれますが、おそらく大体の人が必要としている情報は events にあります。
配下は配列となっており、その中身には Event type, name と Event の parameter が含まれています。
Event の parameter についてはそれぞれの Reference に記載があります。
Meet の場合は以下

Hangouts Meet Audit Activity Events  |  Reports API  |  Google Developers

あとは欲しい情報を取得してなんかいい感じにするだけです。

@app.route('/events', methods=['POST'])
def events():
  headers = request.headers
  if headers['X-Goog-Resource-State'] == 'sync':
    return {}
  for event in request.json['events']:
    print(event['name'])

Deploy する

さきほど用意した Web API をどこか見えるところに Deploy します。
Callback URL に登録するためにはいくつか条件があります。

  • https であること
    • 自己署名などは不可です
  • Search console で domain の所有権が確認できること

私の場合は Google App Engine に deploy しました。
Repository に app.yaml を commit しておいたので同じように試すときは gcloud app deploy で試すことが出来ます。

Domain を登録する

Google API Console にアクセスし、必要なら任意の Project を選択します (無いなら作る)。
Domain verification から Add domain を選択し
Callback URL の domain を入力します。
この時、所有権が確認できていない場合は Seach Console での domain 登録を求められますので、その通りにします。
私は meta tag で行ったので Repository に index.html.example として commit してあります。

Reports API を有効化する

おそらく Admin SDK という名前になっているのですが、これを有効化して credentials.json を持ってくる必要があります。

Python Quickstart  |  Reports API  |  Google Developers

Callback URL を登録する

Activities: watch  |  Reports API  |  Google Developers

あとは Callback URL を登録するだけです。
Python だったらこのへんを参考にします。

Python Quickstart  |  Reports API  |  Google Developers

def __get_google_service():
    creds = None
    if os.path.exists(TOKEN_PICKLE):
        with open(TOKEN_PICKLE, 'rb') as token:
            creds = pickle.load(token)
    if not creds or not creds.valid:
        if creds and creds.expired and creds.refresh_token:
            creds.refresh(Request())
        else:
            flow = InstalledAppFlow.from_client_secrets_file(
                'credentials.json',
                SCOPES
            )
            creds = flow.run_local_server(port=0)
        with open(TOKEN_PICKLE, 'wb') as token:
            pickle.dump(creds, token)
    return build('admin', 'reports_v1', credentials=creds)


def __register():
    response = __get_google_service().activities().watch(
        userKey='all',
        applicationName='meet',
        body={
            'address': settings.CALLBACK_URL,
            'type': 'web_hook',
            'id': ID
        }
    ).execute()

applicationName は今回は Meet の Event を取得するため 'meet' にします。Calendar の場合は 'calendar' です。
userKey は特定の User に絞りたい場合は使用します。今回は全て見たいので 'all' とします。

Response で得られる Resource ID と ID は保持するなり覚えておくなりすると登録解除が出来ます。
登録状態を見る API は無いため、Resource ID と ID がどこかへ行ってしまうと期限が切れる (たぶん 6 時間) のを待つ以外にありません。

登録するとしばらくして Sync Message が届くはずです。

登録を解除する

Channels: stop  |  Reports API  |  Google Developers

登録の解除は以下のように行います。

__get_google_service().channels().stop(
    body={
        'id': id,
        'resourceId': resource_id
    }
).execute()

が、どうやら Python の Client library だとこの stop API の URL が間違っているようなので Issue を参考に書き換えます。

channels._baseUrl = channels._baseUrl.replace('/admin/reports/v1', '')

なお Issue では AuthorizedSession を使う方法も提案されていて、そちらのほうが良さげです。


おわり

EMV Contactless 対応のクレジットカードから情報を読み取る

クレジットカードやデビットカードには EMV Contactless という非接触決済に対応しているものがあります。

EMV Contactless は NFC を使用しているため、手持ちの Android 端末でカードの情報を読み取る事ができます。
ということでここではカードからカード番号及び有効期限を読み取ってみます。

なお、触れると長くなるのであまり具体的な実装には触れません。Android での実装は下部に Repository の URL を貼っておくので興味があればそちらを見てください。

はじめに

情報を読み取るために、ISO/IEC 14443-4 に定義されている通信プロトコルを介して通信を行います。データへのアクセスには ISO/IEC 7816-4 で定義されている Application Protocol Data Unit (APDU) という通信フォーマットを使用します。

雑に Android での実装の話をすると、IsoDep を使って connect で操作を開始し、transceive でやり取りし、close で終了します。

APDU Commands

Command は以下のように構成されます。

| CLA | INS | P1 | P2 | Lc | Data | Le |

Lc には Data length、Le には Response length を入れます。Response length は Response をすべて取りたい場合は 0 にします。
例えばこのあと出てくる SELECT FILE command の場合

| 00 | A4 | 04 | 00 | Lc | Data | Le |

となります。
P1/P2 は Parameter です。P1 の 04 (00000100) はこの場合 Dedicated File (DF) をその名前によって選択するということになります。詳しくは以下の Table 58 を見てください。

また、Response は以下のように構成されます。

| Data | SW1 | SW2 |

SW1-SW2 で Status を表します。90 00 が成功ステータスです。詳しくは以下の Table 17,18 を見てください。

読み取り

本編です

PSE を選択する

SELECT FILE Command を送信し、Payment System Environment (PSE) を選択します。
PSE の DF Name は "1PAY.SYS.DDF01"、PPSE の場合は "2PAY.SYS.DDF01" です。
サポートしていれば PSE の File Control Information (FCI) が返却されます。これに Application Elementary Files (AEF) の Short File Identifier (SFI) が含まれています。

AEF のレコードを読む

PSE をサポートしているカードのみの手順です。

SFI を使用して READ RECORD Command を送信し、Application Identifier (AID) を取得します。

AID でファイルを選択する

PSE をサポートしていた場合は先に取得した AID を、していなかった場合は直接 AID を指定し選択します。

例えば、Visa のクレジットカード・デビットカードは A0 00 00 00 03 10 10 です。

以下のように SELECT FILE Command を送信します。

| 00 | A4 | 04 | 00 | 07 | A0 00 00 00 03 10 10 | 00 |

Response として Processing Options Data Object List (PDOL) が返却されます。

PDOL を Parse する

返却された PDOL を Parse します。
PDOL は概ね以下のようになります。

 ... 01 01 9F 38 18 9F 66 04 9F 02 06 9F 03 06 9F 1A 02 95 05 5F 2A 02 9A 03 9C 01 9F 37 04 ...

PDOL の開始 Tag は 9F 38 なので、それを探します。

また、開始 Tag の直後は length です。9F 38 18 であれば length は 24 となります。
このことから PDOL は以下のようになります。

9F 66 04 9F 02 06 9F 03 06 9F 1A 02 95 05 5F 2A 02 9A 03 9C 01 9F 37 04

この PDOL は次に送る Command に request として何をどの length で送ればよいかを示しています。

  • 9F 66
    • Terminal Transaction Qualifiers (TTQ)
  • 9F 02
    • Amount, Authorised
  • 9F 03
    • Amount, Other
  • 9F 1A
    • Terminal Country Code
  • 95
    • Terminal Verification Results (TVR)
  • 5F 2A
    • Transaction Currency Code
  • 9A
    • Transaction Date
  • 9C
    • Transaction Type
  • 9F 37
    • Unpredictable Number

以上の Data を指定された length で構築します。
以下はそれぞれの例です。実際はそれぞれの要件等に合わせて構築します。

できたらこれらをくっつけます。

GPO Command を送信する

GET PROCESSING OPTIONS (GPO) Command を送信します。
先程の Data の length を先頭に付けます。

21 28 00 00 ...

Tag (83) をつけます。

83 21 28 00 00 ...

以下のように送信します。

| 80 | A8 | 00 | 00 | 23 | 83 21 28 00 ... | 00 |
Track2 Equivalent Data を Parse する

カード情報が含まれている Data を Parse します。Response として Track2 Equivalent Data が返却されている場合、開始 Tag は 57 です。
返却されていない場合はこちらではなく次を見てください。
57 の直後は length で、delimiter は 'd' です。

以下のようにデータが含まれています。

xx xx ... xx dy yy ...
  • x = カード番号
  • y = 有効期限
AFL を Parse する

ここから最後までは Track2 Equivalent Data ではなく Application File Locator (AFL) が返却された場合の手順です。

AFL の開始 Tag は 94 です。
Data から SFI と start, end の 3 bytes を取得します。

レコードを読む

さきほどの SFI をもとにすべてのレコードを読みます。
P1 として index (start から end まで), P2 として下位 3 bits を 1 0 0 とした SFI をセットします。これは P1 のレコードを読むという意味です。以下の Table 36 を見てください。

Track2 Data を Parse する

返却された Track2 Data を Parse します。開始 Tag は 9F 6B です。
あとは Track2 Equivalent Data と同じです。

終わりに

以上で読み取りまでの流れは終わりです。
結構端折った部分や私自身の知識が甘い部分もあり、調べながらでないとこの記事を読んで実装することは難しいと思いますが、私が実装するときに情報が散らばっていたりしてかなり苦労したので書いておきました。
以下に参考にしたリンクを貼っておきます。すべて非常に参考になるので読んでみてください。

あと実装した Repository も置いておきます。EMV Contactless 対応の Visa と Mastercard 3 枚で動作を確認していますので興味があれば。

github.com

Android で Liquid-like な animation を Path#cubicTo で実現する

Dribbble にありがちなスライムのような何かぽよぽよした Animation をどう実現するか

ちなみに特に何かイベントに反応する必要がなければ Lottie で良いと思います

https://raw.githubusercontent.com/CORDEA/Poyo/master/images/slime.gif
https://raw.githubusercontent.com/CORDEA/Poyo/master/images/circle.gif

Path#cubicTo

実現する方法として、結論としてはタイトルにある通り Path#cubicTo を使います
これで Bezier curve を描くことができるので、柔らかい曲線や遷移を実現できます。

以下のような感じで引いていきます。

path.moveTo(0f, 0f)
path.cubicTo(0f, 0f, 0f, 0f, 0f, 0f)
path.cubicTo(0f, 0f, 0f, 0f, 0f, 0f)
path.cubicTo(0f, 0f, 0f, 0f, 0f, 0f)
path.close()

一つ注意点として、cubicTo の x1, y1 は point の左側の control point ではないので、
以下のような曲線を引く時

f:id:CORDEA:20200426121908p:plain:w500


x1, y1 は前の point の右側の control point を指します。

f:id:CORDEA:20200426121946p:plain:w500

作ってみる

これだけだと cubicTo の説明にしかなっていないので、作るときに楽だった方法だけ書いておきます。
まず Sketch や Figma などまぁそのへんの色々を使用して Animation のあたりをつけておきます。
先にあった gif の一番目だとこのような感じで作っていました。

f:id:CORDEA:20200426122611p:plain:w500

f:id:CORDEA:20200426122630p:plain:w500

こうするとだいたい progress 0.0 ~ 1.0 までどのように control point を動かしていけば想定どおりの動きとなるかが見えてきます。
まず progress 0.0 と 1.0 の状態 (もっと状態が複雑ならそれら) を作って、大体出来たらそれを補完するように point を動かします。
やることはこれだけです。

なお、作るときには control point も同じく描画しておくと良いでしょう。今は Android Studio 上で View の preview が見られるので調整作業がかなり楽になります。

f:id:CORDEA:20200426123234p:plain:w500

終わりに

完全な円は書けないので、円から何かするというのは難しいかもしれません。
あと、Animation の見え方が device によってかなり変わると思うので、お仕事でやるなら相当苦しむことになると思います。パフォーマンス的にもとてもおすすめできない。
作るだけならまぁまぁ楽しいので、一回やってみると良いかもしれません。

上の Animation の実装は以下の Repository にあります。とても参考にできない実装ですが興味があれば。

github.com

Jetpack Compose の @Model で生成されるコード

メモ

こういうの書いたときに生成されるコードが気になっていた

@Model
class AnimalState(
    var isAnimal: Boolean,
    var cats: List<Cat>,
    var dog: Dog
)

@Model
class Cat(
    var isRunning: Boolean,
    var isWalking: Boolean
)

@Model
class Dog(
    var count: Int,
    var type: Type
) {
    enum class Type {
        BULLDOG,
        DACHSHUND,
        SHIBA
    }
}

build 配下を見て Kotlin で書き直すと概ねこんな感じになりそう

class AnimalState(
    isAnimal: Boolean,
    cats: List<Cat>,
    dog: Dog
) : Framed {
    private var record: Record = Record()

    private val readable
        get() = _readable(
            record,
            this
        ) as Record

    private val writable
        get() = _writable(
            record,
            this
        ) as Record

    var isAnimal: Boolean
        get() = readable.isAnimal
        set(value) {
            writable.isAnimal = value
        }

    var cats: List<Cat>
        get() = readable.cats
        set(value) {
            writable.cats = value
        }

    var dog: Dog
        get() = readable.dog!!
        set(value) {
            writable.dog = value
        }

    init {
        record.isAnimal = isAnimal
        record.cats = cats
        record.dog = dog
        _created(this)
    }

    override val firstFrameRecord: Record
        get() = record

    override fun prependFrameRecord(value: androidx.compose.frames.Record) {
        value.next = record
        record = value as Record
    }

    class Record : AbstractRecord() {
        var isAnimal: Boolean = false
        var cats: List<Cat> = emptyList()
        var dog: Dog? = null

        override fun assign(value: androidx.compose.frames.Record) {
            (value as Record).let {
                isAnimal = it.isAnimal
                cats = it.cats
                dog = it.dog
            }
        }

        override fun create(): androidx.compose.frames.Record = Record()
    }
}

class Cat(
    isRunning: Boolean,
    isWalking: Boolean
) : Framed {
    private var record = Record()

    private val readable
        get() = _readable(
            record,
            this
        ) as Record

    private val writable
        get() = _writable(
            record,
            this
        ) as Record

    var isRunning: Boolean
        get() = readable.isRunning
        set(value) {
            writable.isRunning = value
        }

    var isWalking: Boolean
        get() = readable.isWalking
        set(value) {
            writable.isWalking = value
        }

    init {
        record.isRunning = isRunning
        record.isWalking = isWalking
        _created(this)
    }

    override val firstFrameRecord: Record get() = record

    override fun prependFrameRecord(value: androidx.compose.frames.Record) {
        value.next = record
        record = value as Record
    }

    class Record : AbstractRecord() {
        var isRunning: Boolean = false
        var isWalking: Boolean = false

        override fun assign(value: androidx.compose.frames.Record) {
            (value as Record).let {
                isRunning = it.isRunning
                isWalking = it.isWalking
            }
        }

        override fun create(): androidx.compose.frames.Record = Record()
    }
}

class Dog(
    count: Int,
    type: Type
) : Framed {
    private var record = Record()

    private val readable
        get() = _readable(
            record,
            this
        ) as Record

    private val writable
        get() = _writable(
            record,
            this
        ) as Record

    var count: Int
        get() = readable.count
        set(value) {
            writable.count = value
        }

    var type: Type
        get() = readable.type
        set(value) {
            writable.type = value
        }

    init {
        record.count = count
        record.type = type
        _created(this)
    }

    override val firstFrameRecord: Record get() = record

    override fun prependFrameRecord(value: androidx.compose.frames.Record) {
        value.next = record
        record = value as Record
    }

    enum class Type {
        BULLDOG,
        DACHSHUND,
        SHIBA
    }

    class Record : AbstractRecord() {
        var count: Int = 0
        var type: Type = Type.BULLDOG

        override fun assign(value: androidx.compose.frames.Record) {
            (value as Record).let {
                count = it.count
                type = it.type
            }
        }

        override fun create(): androidx.compose.frames.Record = Record()
    }
}

なるほどー

第 1 水準以外の漢字を検出する

JIS 漢字コードの第 1 水準漢字以外を検出します。
Perl が一番楽そうだったので Perl 使いました。

何がしたいか

$ echo "perl" | hoge
false
$ echo "あいうえお。" | hoge
false
$ echo "漢字 | hoge
false
$ echo "弌腕。" | hoge # 第 2 水準漢字が入っている
true

第 1 水準漢字

第 1 水準漢字は亜から腕までの範囲を指します。
ただ、亜-腕のような正規表現でチェックするだけでは不十分です。

y0m0r.hateblo.jp

この記事に詳細は書いてあり、非常に参考になりました。
ということで範囲を調べる必要があります。

コード表を参照して、Shift JIS における文字コードの範囲を調べます。
亜だったら 889F, 腕は 9872 です。

一通り調べると、

  • 889F - 88FC
  • 8940 - 97FC
  • 9840 - 9872

の範囲であることが分かります。
これを正規表現に起こすとこんな感じ

\x88[\x9F-\xFC]|[\x89-\x97][\x40-\xFC]|\x98[\x40-\x72]

第 1 水準漢字以外の漢字

第 1 水準漢字の検出はできましたが、実際に欲しいものは違います。

漢字の検出は \p{Han} で可能です。
なので、\p{Han} で取得した漢字を先程の正規表現に当てて、引っかからないものが第 1 水準漢字以外としました。

ただ、\p{Han} だと句読点なども含まれます。\p{Script=Han} にすると句読点は除くことができますが、々などは入ってきます。
なので、先程の正規表現で記号も見ることにします。コード表によると記号は

  • 8140-81AC

あたりです (実際はもうちょっとあります、サボりました)

これを付け加えて

\x81[\x40-\xAC]|\x88[\x9F-\xFC]|[\x89-\x97][\x40-\xFC]|\x98[\x40-\x72]

これでふんわり記号 + 第 1 水準漢字です。ちゃんと見るなら 1 区から 8 区までの文字コードを追加すれば良いと思います。
この正規表現で引っかからない場合は第 2 水準漢字とか、そのへんです。

あと、第 1 水準漢字のところでは Shift JIS になっている必要があるので

encode("sjis", $hans);

こういう処理が必要です。

Repository はここ

github.com

おわり。