sachaos.md
----------

Kotlin と Android アプリ開発の入門がてら Firebase と連携して Pixela に運動記録をつけるアプリを作ってみた

## モチベーション

Pixela に最近ハマっている。 簡単に記録できて、いい感じに見やすく可視化してくれる。

様々な習慣化したいもののモチベーションに一役買ってくれていて、 ポモドーロ・テクニックの実行数や、運動のセット数などを Pixela で記録し始めた。

Pixela は API 経由で記録することができるため、 Go と Cloud Run で Slack の slash command のウェブフックを処理するサーバーを作り、 以下のように slash command 経由で記録できるようにしていた。

slack

この仕組みはそこそこ楽でさらにチャットに時間が残るのが良かったが、文字を打つのも面倒になってきたので、 もっと気軽に登録できるようにしたくなった。 前からすこし興味があった Kotlin で Android アプリを作ることにした。

## アプリのゴール

  • Android アプリで運動記録をつけることができる。
  • 運動記録はタイムスタンプ(いつ運動をしたか?)のみ。内容は一旦興味ない。
  • アプリの配布は目指さない。ただ自分の端末に入れて使うことを目標にする。
  • UI の綺麗さは求めない。自分が満足できる水準で良い。
  • Pixela で運動記録が可視化される

## どうやるか?

  • Cloud Firestore で運動記録を保存
    • 後にいつ運動したかを一覧にして見れるように。
    • 雑に Exercise/2020-05-16 みたいなドキュメントにフィールドとしてタイムスタンプの array を持てばよさそう。
  • Cloud Firestore のレコードが更新されたら Cloud Functions を発火させて、その中で Pixela の API をコールする。

image

## 雑な UI を作成

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="center_vertical"
    android:orientation="vertical"
    tools:context=".MainActivity" >

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:textSize="20sp"
        android:text="Exercise" />
    <Button
        android:id="@+id/exercise_button"
        android:text="register exercise"
        android:layout_width="wrap_content"
        android:layout_gravity="center_horizontal"
        android:layout_height="wrap_content" />

</LinearLayout>

## Cloud Firestore を使えるようにする

  1. Firebase で新しいプロジェクトを作成
  2. Android アプリに Firebase を追加
  3. Firebase SDK の追加
  4. firebase-android-sdk Kotlin Extensions をインストール
  5. コードを書く

### 4. firebase-android-sdk Kotlin Extensions をインストール

Firestore Kotlin Extensions に従って、インストールする。

maven.google.com を見ると 21.4.3 が最新のようなのでこれを使う。

ビルドエラー Cannot fit requested classes in a single dex file (# methods: 88607 > 65536) が発生した。

解決方法は後述。

### 5. コードを書く

ボタンが押されたら Cloud Firestore に問い合わせ、存在すれば arrayUnion を利用して update。 存在しなければ新たにドキュメントを作成する。

Firestore へ値を渡すときに arrayOf を利用してたらクラッシュしたけど、 落ち着いて adb logcat でログを見れば exception が出ているのが確認できたので問題なく修正できた。

package dev.sachaos.personal

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Button
import com.google.firebase.firestore.FieldValue
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ktx.firestore
import com.google.firebase.ktx.Firebase
import java.lang.Exception
import java.text.SimpleDateFormat
import java.util.*

// NOTE: reference https://stackoverflow.com/questions/53713934/kotlin-convert-utc-to-local-time/53714194
fun Date.formatTo(dateFormat: String, timeZone: TimeZone = TimeZone.getDefault()): String {
    val formatter = SimpleDateFormat(dateFormat, Locale.getDefault())
    formatter.timeZone = timeZone
    return formatter.format(this)
}

val TAG = "PersonalApp"

class MainActivity : AppCompatActivity() {
    lateinit var firestore: FirebaseFirestore

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        Log.d(TAG, "onCreate")

        firestore = Firebase.firestore
        val exerciseButton: Button = findViewById(R.id.exercise_button)
        exerciseButton.setOnClickListener { registerExerciseTime() }
    }

    fun registerExerciseTime() {
        Log.d(TAG, "call registerExerciseTime")
        val now = Date()

        val docRef = firestore.document("Exercise/${now.formatTo("yyyy-MM-dd")}")

        try {
            docRef.get().addOnSuccessListener { document ->
                Log.d(TAG, "success get")
                if (document.exists()) {
                    Log.d(TAG, "document exists")
                    docRef.update("records", FieldValue.arrayUnion(now)).addOnSuccessListener { Log.d(TAG, "DocumentSnapshot successfully updated!") }
                } else {
                    Log.d(TAG, "document is not exists")
                    docRef.set(hashMapOf(
                        "records" to listOf(now)
                    )).addOnSuccessListener { Log.d(TAG, "DocumentSnapshot successfully written!") }
                }
            }
        } catch (e: Exception) {
            Log.d(TAG, "get failed with", e)
        }
    }
}

## Cloud Firestore の更新に応じて Cloud Functions を実行し Pixela に記録する

  1. firebase init を行う。
  2. コードを書く
  3. npm run deploy する。

### 2. コードを書く

Firestore のドキュメントの onWrite をトリガーにして動く Cloud Function を書く。 Pixela はただ HTTP を叩くだけ。

import * as functions from 'firebase-functions';
import {DateTime} from 'luxon'
import fetch from "node-fetch";

const USER_NAME = "hogehoge";
const TOKEN = "secret token";
const EXERCISE_GRAPTH = "hogehoge-record";
const BASE_URL = "https://pixe.la/v1/";

const pixelaDeletePixel = async (userId: string, graphId: string, dateTime: DateTime, token: string) => {
    await fetch(BASE_URL + `users/${userId}/graphs/${graphId}/${dateTime.toFormat("yyyyMMdd")}`, {
        method: "DELETE",
        headers: {
            "X-USER-TOKEN": token,
        }
    })
}

const pixelaSetPixel = async (userId: string, graphId: string, dateTime: DateTime, quantity: number, token: string) => {
    await fetch(BASE_URL + `users/${userId}/graphs/${graphId}/${dateTime.toFormat("yyyyMMdd")}`, {
        method: "PUT",
        headers: {
            "X-USER-TOKEN": token,
        },
        body: JSON.stringify({
            quantity: quantity.toString(),
        })
    })
}

export const onUpdateExercise = functions.firestore.document("Exercise/{date}").onWrite(async (change, context) => {
    const date = context.params.date as string
    const dateTime = DateTime.fromFormat(date, "yyyy-MM-dd");

    if (change.after === undefined) {
        await pixelaDeletePixel(USER_NAME, EXERCISE_GRAPTH, dateTime, TOKEN)
        return
    }

    const data = change.after.data();
    const records = data !== undefined ? data.records : []
    await pixelaSetPixel(USER_NAME, EXERCISE_GRAPTH, dateTime, records.length, TOKEN)

    return
})

この際、 FetchError: request to reason: getaddrinfo EAI_AGAIN が発生した。 解決方法は後述。

## 完成

無事、 Android アプリから Cloud Firestore のドキュメントを更新し、それをトリガーに Cloud Functions が Pixela を更新するという機構を作れた。

### Android App

app

### Cloud Firestore

firestore

### Pixela

pixela

## 発生した問題

### Cannot fit requested classes in a single dex file (# methods: 88607 > 65536)

https://developer.android.com/studio/build/multidex?hl=ja

multidex を有効にすれば良いらしい。 minSdkVersion >= 21 の場合はデフォルトで有効になっているらしいので今回はこちらの方法を利用して解決した。

以下の Issue もあったので、 Cloud Firestore のライブラリのメソッド数が多いのだろう。

[cloud_firestore] Cannot fit requested classes in a single dex file with minSdkVersion 16

### FetchError: request to reason: getaddrinfo EAI_AGAIN

Cloud Functions から 外部 API の呼び出しができない。 これは Firebase の課金設定を Blaze プラン(従量制)にする必要がある。

FetchError: request to https://pixe.la/v1/users/******/graphs/*******/20200516 failed, reason: getaddrinfo EAI_AGAIN pixe.la:443
    at ClientRequest.<anonymous> (/srv/node_modules/node-fetch/lib/index.js:1455:11)
    at emitOne (events.js:116:13)
    at ClientRequest.emit (events.js:211:7)
    at TLSSocket.socketErrorListener (_http_client.js:401:9)
    at emitOne (events.js:116:13)
    at TLSSocket.emit (events.js:211:7)
    at emitErrorNT (internal/streams/destroy.js:66:8)
    at _combinedTickCallback (internal/process/next_tick.js:139:11)
    at process._tickDomainCallback (internal/process/next_tick.js:219:9)

## 所感

## 作りながら見た資料