Kotlinの拡張関数を使って、Cursor#use すると ClassCastException で落ちる

現象

Fatal Exception: java.lang.ClassCastException: android.content.ContentResolver$CursorWrapperInner cannot be cast to java.io.Closeable

原因

Cursor が Closeable を継承するのは API 16 から。ContentResolver の返す Cursor が custom cursor にできない問題に気を取られすぎて、Closeable を継承してないのを忘れていた・・・

明示的に Cursor を Closeable にすると、ちゃんと Android Lint が怒ってくれる。use を使う場合は出てこない。悲しみが深い。

f:id:jmatsu:20181115134434j:plain

解決策

以下の拡張関数をcompatで書いて対応。関数名をuseにしていないのは書いてるときにimportする関数を間違える可能性が高いから。

import android.database.Cursor
import android.os.Build
import kotlin.io.use as ioUse

inline fun <T : Cursor?, R> T.useCompat(block: (T) -> R): R {
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
        ioUse(block)
    } else {
        var exception: Throwable? = null
        try {
            return block(this)
        } catch (expected: Throwable) {
            exception = expected
            throw expected
        } finally {
            when {
                this == null -> {
                }
                exception == null -> close()
                else ->
                    try {
                        close()
                    } catch (ignored: Throwable) {
                    }
            }
        }
    }
}

理想的な解決策

minimum support API を28にする

Android app の versioning と naming

弊社ァでは今までリリースごとに1つずつバージョンを上げていたけれど、以下の問題点が存在した。

  • meaninglessなバージョンなので「それはアプリのx.y.zバージョンだとどれなのか」が分かりづらい。 e.g. SDK とのやり取り
  • Internal Trackテスト版ではログが有効であるなど、そのアプリをそのままリリースはできない。したがって最終的なリリース版は内部テストの回数だけ番号が上がってしまう。どのapkがproduction apkが分かりづらく、↑の問題をさらに加速させる。

そこで versioning と naming をうまくやることで上記の問題点を解決することにした。

  • CI でしかリリース署名apkは作らない
  • release ブランチの成果物を production artifacts としている
  • internal/xxx ブランチの成果物は Internal Track で使う artifacts

として、以下の記述にしている。

ext {
    isForInternalTrack = System.getenv("CI") == "true" && System.getenv("CIRCLE_BRANCH")?.startsWith("internal/")
    isReleaseCandidate = !isForInternalTrack && System.getenv("CI") == "true" && System.getenv("CIRCLE_BRANCH") == "release"
}

android {
    defaultConfig {
        applicationId ...

        def versions = [
                "major"   : 1,
                "minor"   : 0,
                "patch"   : 0,
                "internal": 0
        ]

        def offset = isForInternalTrack ? versions["internal"] + 50 : 0

        assert 0 <= versions["major"] && versions["major"] <= 99
        assert 0 <= versions["minor"] && versions["minor"] <= 99
        assert 0 <= versions["patch"] && versions["patch"] <= 49
        assert 0 <= versions["internal"] && offset <= 99 - versions["patch"]

        def name = versions["major"] + "." + versions["minor"] + "." + versions["patch"]

        if (!isReleaseCandidate) {
            // リリース名として出てくるため、プロダクション版かどうかがひと目で分かる
            name += "-${hashOrCINum}-非プロダクション版"
        }

        versionName name
        ...
    }
}

リリース自体を自動化してもっとシュッとしたい(小並感)

エミュレーターでプリインアプリを再現する

  1. Google APIの入っていないAVDを作成しておく

今回は API 27 でお試し。

f:id:jmatsu:20181112162241j:plain

  1. AVD name を確認しておく

ls ~/.android/avd/

cd $ANDROID_SDK_HOME/tools

export PATH=$PWD:$PATH

# tools 以下にいないと以下のコマンドは失敗する
# -writable-system で起動しないと/systemに書き込み権限がない
emulator -avd $avd_name -writable-system
# note: avd_name に拡張子はいらない

# Google APIが入ってるとproduction扱いなので、adbdがrootで立ち上がらない
adb root
adb remount

adb shell
# このディレクトリにプリインが入る。古いバージョンだとディレクトリが違う。
cd /system/priv-app
mkdir $package_name

exit

adb push $apk_file /system/priv-app/$package_name/$apk_file

# 再起動するまではインストールされない
adb reboot

昔とsystem以下への書き込み権限の取得方法が違ったのでちょっとだけハマった

qiita.com

CircleCI の特定ブランチの特定jobのアーティファクトをダウンロードする

はい

#!/usr/bin/env bash

set -o pipefail
set -eu

export PRODUCTION_BRANCH="release"
export JOB_NAME="build"

latest_artifacts() {
    local -r build_num=$(curl -u "$CIRCLECI_TOKEN:" "https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/tree/$PRODUCTION_BRANCH?filter=completed" | \
      ruby -rjson -e 'puts JSON.parse(STDIN.read).find { |j| j["build_parameters"]["CIRCLE_JOB"] == ENV.fetch("JOB_NAME") }["build_num"]')
    
    curl -u "$CIRCLECI_TOKEN:" "https://circleci.com/api/v1.1/project/github/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/$build_num/artifacts"
}

get_asset_url() {
    cat - | ruby -rjson -e 'puts JSON.parse(STDIN.read).select { |j| <a filter like `j["url"].include?(".apk")`> }.first["url"]'
}

download_asset() {
    local -r asset_url=$(latest_artifacts | get_asset_url)
    curl -o "$1" "$asset_url?circle-token=$CIRCLECI_TOKEN"
}

download_asset "$1"

GETでbranchを絞る方法はAPI Docになかったけれど、POSTの方法を参考にしたらちゃんとfilterされた。今後動かなくなるかもしれない。

https://circleci.com/docs/api/v1-reference/

GithubのLatest Releaseからassetをダウンロードするスクリプト

はい

#!/usr/bin/env bash

set -o pipefail
set -eu

GITHUB_USERNAME="..."
GITHUB_REPONAME="..."
GITHUB_API_TOKEN_USERNAME="...
: "${GITHUB_API_TOKEN:=$DANGER_GITHUB_API_TOKEN}"

latest_gh_release() {
    curl -#L -H "Authorization: token $GITHUB_API_TOKEN" "https://api.github.com/repos/$GITHUB_USERNAME/$GITHUB_REPONAME/releases/latest"
}

get_asset_url() {
    cat - | ruby -rjson -e 'puts JSON.parse(STDIN.read)["assets"].select { |j| <<the filter like `j["name"].end_with?(".apk")` %>> }.first["url"]'
}

download_asset() {
    local -r asset_url=$(latest_gh_release | get_asset_url)

    curl -#Lo "$1" -u "$GITHUB_API_TOKEN_USERNAME:$GITHUB_API_TOKEN" -H 'Accept: application/octet-stream' "$asset_url"
}

download_asset "$1"

Let's Improve Your and Co-workers' DX ~ CI Improvement Tips ~

そもそもの背景として、「自分や同僚のDX (Developer Experience) を上げる」というテーマで LT をしようとしていました。

そこで CI を設定、保守・運用するにあたって便利な Git、GitHub 周りの Tips と 具体的な CI として CircleCI の Tips を取り上げたんですが・・・

書いていたらスライドが30枚くらいになってしまい、これは15分でも終わるか分からないしそんな勉強会ないしなーということでブログ記事に起こします。Marpで書いていたスライドをそのまま貼っているので、見やすいかどうかは分からないです。例えばURLの生成はスライド幅の都合上、無理やり配列を使っていますが、通常であれば文字列宣言で終わります。

現職、前職でも参照できるように英語で資料を使っていましたが、まだ推敲前だった 1 のでかなり適当です。

スライド版は以下。

Let's improve your and co-workers' DX ~ CI improvement tips ~ - Speaker Deck

アジェンダ

  • Git Tips

    • Commit-aware
      • Know if HEAD is a merge-commit
      • Get the latest merge commit
      • Get a branch ref of a merge-commit
      • How can we know the last merged branch?
    • Change-aware
      • Know if a directory/file has been changed
      • What changes happned between merges?
  • Github Tips for CI

    • Get the base branch of your PR
    • Know if directories/files have been changed in the PR
    • Create a PR from CI
  • CircleCI Tips

    • General tips
      • md5 hash of multiple files
      • Download the latest artifact from the specific branch
    • Go with better caches!
      • Use version for cache keys
      • Use environment variables for cache key versions
    • For your good development
      • Don't need to source your utilities multiple time
      • Use {{ .Revision }} or {{ .BuildNum }} when spinning up CI environment

Let's Improve Your and Co-workers' DX

~ CI Improvement Tips ~


Who?


DX - Developer Experience

  • General speaking, there are several definitions:

    • User Experience for Developers
    • Quality of Your Developer Life etc...
  • What this presentation uses

    • Quality of Your Developer Life {on CI, when modifying CI conf}

Why should we improve DX?

OBVIOUS, right?

Why don't you improve your DX?

Enough? Your company is awesome!

Please recruit me to join


Git Tips for CI

It's better to know two approaches!

  • Commit-aware
    • Know if HEAD is a merge-commit
    • Get the latest merge commit
    • Get a branch ref of a merge-commit
    • How can we know the last merged branch?
  • Change-aware
    • Know if a directory/file has been changed
    • What changes happned between merges?

Commit-aware Git tips


Know if HEAD is a merge-commit

[[ -n "$(git show --merges HEAD -q)" ]]
  • --merges only merge commits will be shown
  • -q suppress diff part

So you can infer the job trigger! e.g. merge or direct push

allow_deploy() {
  # Never allow deployment if pushed directly!
  [[ ! -n "(git show --merges HEAD -q)" ]]
}

Know sha1 of the latest merge-commit

git show --merges -1 -q --format='%h'
  • --format='%h' only sha1 hash will be shown

Not useful if you will be using only this tip :)


Get a branch ref of a merge-commit

# origin/foo/bar
remote_slash_branch=$(
    git show $sha1 --merges --format='%s' -q | \
    awk '$0=$NF'
)

# origin/foo/bar => foo/bar, origin/foo => foo
echo $remote_slash_branch | sed 's/^[^\/]*//'
  • --format='%s' only subject will be shown
  • The message format must be like .... $REMOTE/$BRANCH_NAME
  • Omit the sed command if you wanan use this for local merge

How can we know the last merged branch?

  • Combine these tips!
    • Know sha1 of the latest merge-commit
    • Get a branch ref of a merge-commit
hash=$(git show --merges -1 -q --format='%h')

remote_slash_branch=$(
    git show $hash --merges --format='%s' -q | \
    awk '$0=$NF'
)

Change-aware Git tips


Was the file/directory changed?

# all workspace
[[ -n $(git diff ${ref}) ]]

# 'foo' file/directory
[[ -n $(git diff ${ref} -- 'foo') ]]

# .kt files under 'bar' directory
git diff ${ref} --name-only -- 'bar' | \
    grep "*.kt" >/dev/null 2>&1
    
# submodule change
[[ -n $(git diff ${ref} --diff-filter=T) ]]

What changes happned between merges?

l=$(git show --merges -q -1 --format=%h)
s=$(git show --merges -q -2 --format=%h)

# Show all files which have been changed between them
git diff $s...$l --name-only

# For example,
# If app's version has been changed,
if [[ -n "$(git diff $s...$l -- "app/version")" ]]; then

  # This should fail if REAMDE is not updated
  [[ -n "$(git diff $s...$l -- "README.md")" ]] || exit 1
fi

Not yet enough...

Let's see Github Tips and CircleCI Tips!


Github Tips for CI

  • Get the base branch of your PR
  • Know if directories/files have been changed in the PR
  • Create a PR from CI

Get the base branch of your PR

paths=(
  "https://api.github.com/repos"
  "/$CIRCLE_PROJECT_USERNAME"
  "/$CIRCLE_PROJECT_REPONAME/pulls"
  "/$(basename $CIRCLE_PULL_REQUEST)"
)
URL="$(echo ${paths[*]} | tr -d " ")"

curl -H "Authorization: token $GH_TOKEN" \
    "$URL" | jq '.base.ref'
# ruby -rjson -ne 'puts JSON.parse($_)["base"]["ref"]'
  • Don't fetch all remote refs because the cost is high.

Was the file/directory changed in the PR?

  • Combine these tips!
    • Was the file/directory changed? from Git Tips

    • Get the base branch of your PR

base_branch_name=$(...)

# make sure fetch the base branch. 
# Usually CI doesn't have it on local
git fetch origin $base_branch_name

# Use Git Tips! e.g.
git diff $base_branch_name...HEAD -- ...

Create a PR from CI

paths=(
  "https://api.github.com/repos"
  "/$CIRCLE_PROJECT_USERNAME"
  "/$CIRCLE_PROJECT_REPONAME/pulls"
)

URL="$(echo ${paths[*]} | tr -d " ")"
  
CURRENT_BRANCH="$(git rev-parse --abbrev-ref HEAD)"
fields=("\"head\": \"$CURRENT_BRANCH\"", \
      "\"base\": \"<base branch>\"", \
      "\"title\": \"<PR title>\"")
JSON_BODY="{${fields[*]}}"

curl -s -H "Authorization: token $GH_TOKEN" \
  -H "Content-Type: application/json" \
  -d "$JSON_BODY" "$URL"
  • Bash Script can create a PR! Don't need to use other languages!

CircleCI Tips

  • General tips
    • md5 hash of multiple files
    • Download the latest artifact from the specific branch
  • Go with better caches!
    • Use version for cache keys
    • Use environment variables for cache key versions
  • For your good development
    • Don't need to source your utilities multiple time
    • Use {{ .Revision }} or {{ .BuildNum }} when spinning up CI environment

md5 hash of multiple files

  • {{ checksum "a" }}-{{ checksum "b" }}-{{ checksum "c" }}-.... 😫

  • Get md5 hash of a file which contains the output of the following command

# For examaple, multi-module project
while read target_file; do
    md5sum $target_file
done < <(find . -name "build.gradle" | sort) 
# ↑ sort is important
# `find` might be more complicated in some use-cases

Download the latest artifact from the specific branch

EDITED cuz the previous implementation was wrong

latest_artifacts() {
    local -r build_num=$(curl -u "$CIRCLECI_TOKEN:" "https://circleci.com/api/v1.1/project/github/DeployGate/deploygate-android?filter=completed" | \
      ruby -rjson -e 'puts JSON.parse(STDIN.read).find { |j| j["branch"] == ENV.fetch['PRODUCTION_BRANCH'] && j["build_parameters"]["CIRCLE_JOB"] == "build" }["build_num"]')

    curl -u "$CIRCLECI_TOKEN:" "https://circleci.com/api/v1.1/project/github/DeployGate/deploygate-android/$build_num/artifacts"
}

get_url() {
    cat - | jq -r ".[].url" | grep "<use regexp>" | head -1
}

URL=$(latest_artifacts | get_url)

curl -o "<output>" "$URL?circle-token=$CIRCLECI_TOKEN"

Use version for cache keys

It would be your help when you want to refresh caches.

  • e.g. when configuring CI, when introducing new key parts
- v1-danger-{{ checksum "~/Gemfile.lock" }}
- v2-gradle-...

Cache key versions matter

  • You need to upgrade all cache versions of save/restore sections
  • Sometimes we forget to modify keys... 😩
  • If we can use environment variables for cache keys, this can be solved...
  • However, {{ .Environment.variableName }} doesn't mean we can use any arbitrary variables... So let's use workaround!

Use environment variables for cache key versions

cache_version_keys: &cache_version_keys
  CACHE_VERSION_OF_DANGER: v1
  
environment:
  <<: *cache_version_keys
 
run: |
  while read temp; do
    var_name="$(echo $temp | awk -F= '$0=$1')"
    echo "$temp" | sed "s/$var_name=//" > ~/$var_name
  done < <(env | grep "CACHE_VERSION_OF_")

# keys
- {{ checksum "~/CACHE_VERSION_OF_DANGER" }}-danger-...

You need to change only one variable when bumping up the version!


Don't need to source utils multiple times

BASH_ENV is a entry point to be loaded before running each steps.

run: echo 'source <your util file>' >> $BASH_ENV
run: echo 'export XXXX=YYYYY' >> $BASH_ENV
  • If you are using custom images which don't extend CircleCI's image, then this tips might not work for you.

Use {{ .Revision }} or {{ .BuildNum }} when spinning up CI environment

I guess some of you have written wrong caches by the correct key when configuring CI environment, right?

# Use this share caches between multiple jobs
- v1-foo-bar-{{ .Revision }}

# Use one-time cache key for the debug perpose 
- v1-foo-bar-{{ .BuildNum }}
# Use the key when restoring caches if cached 💯

After debugging, please remove {{ .Revision }} and {{ .BuildNum}}, and also bump up version of cache key, v1 to v2.


BTW


DroidKaigi 2019

Please join us and enjoy :)


  1. ただし推敲したからといってしっかりとした英語が書けるわけではないので大した話ではないです。

OSS の公開と保守再開的な何か

最近いくつか OSS を公開したり、保守を再開しました。最近は Ruby 4割弱、BashScript 2割、Kotlin 2割、Golang 1割、YAML 1割、残りJava みたいな生き方をしています。

danger-apkstats

Assertion system である danger/dangerAndroid apk 解析用 plugin です。

github.com

現職のアプリの機能は非常にシンプルで、だからこそむやみに apk サイズを大きくすることは好まれません。

そういうわけで apk サイズの変化を計測したかったこと、また permission や feature についても同様に注意を払うために「2つのapkの差分をレポートする」機能を実装しました。

どうやって前の apk を用意するかについては今後どこかで LT をするような気がします。

dpg

DeployGate API clientのGolang CLIを公開しました。 CLI ですが、API クライアントとしても利用できるようなパッケージ構成になっています。 初 Golang OSS な上に実務 Golang-er と働いたこともないので、パッケージ構成やら何やら非常に Java っぽい。マサカリをください。

github.com

公開した背景

DeployGate ではメンバー管理を行うための API も公開されていますが、アプリ開発者の関心事は「デプロイ(アップロード)」に注視しがちで、あまり他の API について関心を払う人はそう多くないように見受けられます。本来開発版アプリの管理を考えるなら手動による管理や手放しの運用は決して褒められるものではなく、メンバーデータと API を用いた自動管理を前提としたいところです。

公式ドキュメントとして curl サンプルが存在するので API コール自体ができない人はそういないと思いつつも、エラーレスポンスのクオリティは逆立ちしても実用的とは言えず非常に難を強いてしまっているのではないかと思っています。1

そこで、ある程度のバリデーション層を用意し、かつそれらを強い静的型付けの中で行い、CI との親和性や保守性を考えた結果が Golang によるクライアントの実装でした。これの開発中にエラーメッセージが意味不明すぎて、サーバーのコードを読まないとトラブルシューティングできない不便さに本気でブチギレていたことは記憶に新しいです。

公式 dg コマンドとの違い

公式ではすでに dg コマンド、gem名称は deploygate として公開されたものがありますが、これはデプロイ周りのヘルパー CLI であって純粋な API クライアントではありません。2

GitHub - DeployGate/deploygate-cli: A command-line interface for DeployGate

非公式の理由

これは僕個人の感情が多いに影響しています。

公式として提供する場合、会社として精神的な面も含めてメンテナンスの運用体制を整える必要があります。Golang による実装、IssueやPRへの対応といった人的リソースに関してはもちろんのこと、会社として実装するということは「機能の取捨選択を会社目線で行う必要性」が出てきます。

現在すでに webサービス以外に dgate、gradle plugin、android sdk を抱えており、人的リソースについてはお世辞にも「人を割ける」とは言えません。

また dpg はただの API クライアントではなく、いくつかの API を組み合わせて実現する半・全自動化された手続き(現状は配布ページの自動作成・削除のみ)のサポートを視野に入れています。こういった応用について、会社からの例の提示ではなく機能として提供するケースでは多くの意思決定を挟む必要があります。特に破壊的変更によるサポート提供の停止について、公式資産がその意思決定に影響を及ぼすこともあるでしょう。

それが嫌だったという表現ではなく、「現状、それはお互いに不幸である」と思ったので非公式にしています。

remocon

remocon は Remote Config as a Code を目標とし、YAMLベースで Remote Config を管理するための Ruby Gem です。これの保守を行いました。

github.com

そもそもの開発モチベーション

前職では Remote Config をフル活用していたのですが、値に JSON を用いるも Web コンソールからはバリデーションが存在しない、PUBLISH CHANGES 押し忘れなどの問題が存在していました。これらの問題点を解決するため の、そして shibuya.apk の発表に間に合わせるため に開発を行いました。

保守

現職では Remote Config を使っていないことからメンテナンスを放置していたという怠惰な背景があります。

つまりgem 開発のモチベーションは前職に置いてきたのですが、 現職でも Remote Config を利用できるようしておこうかなと思ったことと、最近前職の先輩が CI 連携(未完成品だった)を修正しようとしている悲鳴(CI failure 通知や数十にも及ぶチャレンジコミットなど)が聞こえてきたこともあり、放置していたタスクを再開しようと思い直しました。3

APIの変更

ちょっと見ない間に API レスポンスの仕様が変わっていたり、validation のみを行う API コールが増えていたりと、色々と対応した結果、0.1 から現在は 0.4.1 まで上げました。

実装にあたり API ドキュメントに誤りがあったので、フィードバックを送っています。とはいえクリティカルな部分でもなんでもなかったですし、全体的にドキュメントが丁寧で最高でした。

新しいコマンドや機構の実装

Firebase Remote Config は短命の OAuth2 トークンを使って認証を行います。サンプルがあまり見当たらなかったことと発表に間に合わなかったので python で代替していたのですが、今回トークン発行のコマンドを Ruby で実装しておきました。YAML 管理を行わなくとも、remocon を使えばトークン発行、configの取得・更新を利用できるようになったという形です。

また以前は YAML にある定義をヒューリスティックに更新する機構がなかったため、web コンソールから変更を行ってしまったのち、それらの変更を YAML に反映させるためには手動で行う必要がありました。現在はこの問題点に対し、シンプルな差分を計算し、ある程度は YAML を自動更新できるようにしています。

Remocon Starter Kit

gem だけあっても使えないんだけどというクールな DM を貰ったので、一理あるなあと思って CircleCI 上でメンテナンスを開始するためのスターターキットも作成しました。

このリポジトリには CircleCI で remocon を使った Firebase Remote Config の管理を始めるにあたり、必要なジョブ(.circleci/config.yml)やスクリプトを埋め込んであります。

github.com

これのテストをセキュアにやるにはどうやればいいのか思いつかなかったので、今は手動でやっています。とてもつらいです。何かあったら Issue 立ててください。

今後保守するもの

Vector Drawable Previewer

VectorDrawable をプレビューする Chrome Extension です。

が、バグを放置したまま保守をしていませんでした。現職のアイコン類を全て VectorDrawable にする必要があるんですが確認がつらすぎるので、実は今一番必要に迫られています。

github.com

import-android-icons

public にしてるんですが公開の体裁を整えていません。また jar とシェル芸とnode という継ぎ接ぎなので、全部 jar にして提供し直そうと思います。

github.com


  1. 現在エラーメッセージの修正を行っています。

  2. dgate と書いていましたが 正しくは deploygate でした。

  3. 前職には業務委託として別件をお手伝いさせてもらっています