CLIでSVGをVectorDrawableに一括変換する

Androidアプリではベクター形式の画像を扱うためにVectorDrawableというSVGと(おおよそ)互換のあるフォーマットをサポートしています。しかし残念なことにVectorDrawableの出力をサポートしているデザインツールはほぼ無いためベクターデータをアプリ内で扱いたい場合には「デザインツールからSVGに書き出してもらう-> Vector Asset Studioを利用してSVGをVectorDrawableに変換する」という手順を踏みます。
Vector Asset StudioはAndroid Studioに組み込まれたツールでGUIインターフェースしかサポートされていません。これは悩みの種で例えば複数のプラットフォーム間で共通利用しているアイコンセットがあった場合にはSVGが追加/更新されたらVectorDrawableも追従するCI/CDを用意したいものですがCLIからアクセスする手段が無いので自動化出来ないんですよね。そんなことをTwitterに書いたらこにふぁーさんから素晴らしい情報を教えてもらったので今文字を書いています。

教えて頂いたIssueTrackerの要点は

  • Vector Asset Studio自体はAOSPの一部として開発されているからソースコードにアクセスすることが出来るよ
  • 実はvd-toolというCLI用のコマンドが存在していて、自分でビルドすれば使えるよ
  • ただ今の所公式に提供するつもりはないよ

という話でした。2016年に立てられたissueの情報のため今のレポジトリでは多少手順が変わっているのかなと思ってコードをチェックアウトして試してみたのですが、全く同じ手順でビルド出来ました。:tada:

vd-toolをビルドする

まずはAOSPからAndroidStudioが開発されているブランチをチェックアウトします。repoコマンドの導入からまとめると下記のようなになります。(パスはお好みで)

# repoコマンドを使えるようにする
curl https://storage.googleapis.com/git-repo-downloads/repo > ~/bin/repo
chmod a+x ~/bin/repo
# studio-master-dev(Android Studioの開発ブランチ)をチェックアウトする
mkdir studio-master-dev
cd studio-master-dev
repo init -u https://android.googlesource.com/platform/manifest -b studio-master-dev
repo sync -c -j4 -q #4は並列数なのでお好みで

Android Developer Tools - Checkout and build the source code

チェックアウトしたらvd-toolをビルドします

cd tools/
./gradlew :base:vector-drawable-tool:distZip

SVGを一括で変換する

ビルドが成功すると $PROJECT_ROOT/out/build/base/vector-drawable-tool/build/distributionsvd-tool.zipが生成されているので解凍して利用します。
例えば下記のように実行すると/your/icon/svg/以下のsvgをすべてVectorDrawableに変換して/your/icon/vdに配置してくれます。

bin/vd-tool -c -in /your/icon/svg -out /your/icon/vd

引数でwidthやheightの指定も可能です

Usage: [-c] [-d] [-in <file or directory>] [-out <directory>] [-widthDp <size>] [-heightDp <size>] [-addHeader]
Options:
  -in <file or directory>:  If -c is specified, Converts the given .svg file
                            to VectorDrawable XML, or if a directory is specified,
                            all .svg files in the given directory. Otherwise, if -d
                            is specified, displays the given VectorDrawable XML file
                            or all VectorDrawables in the given directory.
  -out <directory>          If specified, write converted files out to the given
                            directory, which must exist. If not specified the
                            converted files will be written to the directory
                            containing the input files.
  -c                        If present, SVG files are converted to VectorDrawable XML
                            and written out.
  -d                        Displays the given VectorDrawable(s), or if -c is
                            specified the results of the conversion.
  -widthDp <size>           Force the width to be <size> dp, <size> must be integer
  -heightDp <size>          Force the height to be <size> dp, <size> must be integer
  -addHeader                Add AOSP header to the top of the generated XML file

Read It Laterをもう一度

黎明期に人気を集めた"後で読む"アプリ

スマートフォンが一般にも普及してきた2010年頃、後で読む(save-for-later)系のサービスが注目を集めていました。当時二大サービスとして「Read It Later」と「Instapaper」がありRead It Laterは2012年にPocketという名前にブランドを変えました。Instapaperは作者の意向でAndroidアプリが当時無かったため、Androidユーザーであった私はRead It Laterを愛用していましたが、定期的な端末内のアプリリストの淘汰によってアプリはアンインストールされ自然と使わなくなっていました。

テキスト読み上げ機能

上記のツイートでPocketの音声読み上げ機能の存在を知り再度インストールしてました。スクリーンショットはiOS版ですがAndroid版でも音声読み上げ機能は利用できます。読み上げ能力はAmazon Pollyくらいの品質です。聞いてて心地よいかは別として十分に聞き取ることが可能です。タイトルと本文などコンテキストによって声色が切り替わったり、間合いのとり方に工夫を感じられ個人的にはとても好印象です。無料プランで日本語のテキスト読み上げをサポートしているサービスは2020年現在ではとても貴重だと思います。

有料プラン

Pocketにはもちろん有料プランがあるんですが、なんとも商売下手な感じがして可愛らしくもありちょっと不安になります。個人的には無料プランではテキスト読み上げに制限をかけても良さそうに思えるんですが、有料プランの設計の難しさは計り知るところでもあるので外野からは応援することしか出来ないですね。有料プランに乗り換える価値があるかは別として好きなアプリの一つなのでスポンサーしたくなるそんなアプリです。

f:id:kazy1991:20200516040905p:plain
getpocket.com/premium

Pocket

Pocket

  • Read It Later, Inc
  • ニュース
  • 無料
apps.apple.com

play.google.com

はじめての管理画面

会社勤めのソフトウェアエンジニアをしてると管理画面を作る機会が何度かあります。幸いなことに私は作った経験が無いですが欲しい時にシュッと作れるとかっこいいのでrailsとAdminLTEを組み合わせて管理画面のベースを組み立てる手順を記録します。

f:id:kazy1991:20200502232117p:plain
最終的な完成イメージ

とりあえずのrails new

rails以外のWAF(Web Application Framework)を知らないのでrailsを使います。知らない言語で書いてみたい気持ちはグッと抑えました。包丁を研いでいると人生は終わってしまうのです。
rails new -hを見ながら直感と好みで色々とskipしていきます。 ところでRails is omakase1 というDHHがうなぎについて熱く語っているエッセイがあります。

rails new admin-console --database mysql --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-active-storage --skip-action-cable --skip-sprockets --skip-turbolinks --skip-spring --skip-javascript

Simpackerを使う

webフロントエンド開発環境を最高にしようとは思ってないですが、素朴で気の利く感じにしたかったのでSimpackerを導入しました。

techlife.cookpad.com

Simpackerは初めての利用でしたがGithubレポジトリのInstallationセクションに従って問題なく導入できました。

# Add simpacker to Gemfile
rails simpacker:install
# app/views/layouts/application.html.erb
<html>
  <head>
    <title>MobileConsole</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>
+    <%= javascript_pack_tag 'application' %>
    <%= stylesheet_link_tag    'application', media: 'all' %>
  </head>

  <body>
    <%= yield %>
  </body>
</html>

開発のためrailsのプロセスとwebpackのプロセスを同時に起動させる必要があるようなのでForemanを使います。

# Procfile
rails: bin/rails s
webpack: ./node_modules/.bin/webpack --watch

はじめてのAdminLTE

管理画面はそれっぽさが何よりも大事なのでCSSフレームワークを使って見た目を演出します。せっかくならモダンな見た目がやる気が出るので"2020 admin css framework"などの検索キーワードで検索しましたが気に入るものが見つからなかったのでAdminLTEにしました。AdminLTEは長い間人気のある管理画面向けのCSSフレームワークです。

github.com

Installationのセクションには各パッケージマネージャーのコマンドが書かれているのみでなかなか困りものです。途方にくれながら様々なAdminLTEの導入記事をよんでみるとstarter.html なるものがあるらしいのでAdminLTEのレポジトリを探すとプロジェクトルートに見つけました。そこで今回の目標をstarter.html をsimpacker環境で動かすことにしました。ひとまず app/views/layouts/application.html.erb の中身をstarter.htmlに置き換え、rootのアクセスを適当なControllerに割り当てて見た目が破滅していることを確認しました。

f:id:kazy1991:20200502233834p:plain
破滅したstarter.html

simpackerの設定

見た目が破滅している原因はCSS及びJSが読み込まれていないためなので、simpackerを通して参照できるように色々と設定していきます。

# admin-lte本体を追加
npm install --save admin-lte@^3.0
# admin-lte及びbootstrapの動作に必要なもの
npm install --save jquery popper.js 
npm install --save @fortawesome/fontawesome-free
# webpackのcssのbundle環境に必要な依存
npm install --save-dev css-loader mini-css-extract-plugin
# jsよりtsが書きなれているのでts環境に必要な依存
npm install --sav-dev typescript ts-loader

webpack.config.js はsimpackerのexmaple集を参考にして最終的に下記のようになりました。

const path = require("path");
const WebpackAssetsManifest = require("webpack-assets-manifest");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const webpack = require('webpack');

const { NODE_ENV } = process.env;
const isProd = NODE_ENV === "production";

module.exports = {
  mode: isProd ? "production" : "development",
  devtool: "source-map",
  entry: {
    application: path.resolve(__dirname, "app/javascript/application.ts")
  },
  output: {
    path: path.resolve(__dirname, "public/packs"),
    publicPath: "/packs/",
    filename: isProd ? "[name]-[hash].js" : "[name].js"
  },
  resolve: {
    extensions: [".js", ".ts"]
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: "ts-loader",
        options: {
          transpileOnly: true
        }
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, "css-loader"]
      }
    ]
  },
  plugins: [
    new WebpackAssetsManifest({ publicPath: true }),
    new webpack.ProvidePlugin({$: 'jquery', jQuery: 'jquery',}), //グローバル領域にjqueryが必要とされるため
    new MiniCssExtractPlugin({
        filename: isProd ? "[name]-[hash].css" : "[name].css"
      })
  ]
};

application.ts は参考にできるものが全く無かったので試行錯誤しましたが、node_modules以下のディレクトリ構造を確認して相対パスを繋ぐといい感じに認識されるようです。この辺りの仕組みは理解していません。

import 'bootstrap/dist/css/bootstrap.min.css';
import 'admin-lte/dist/css/adminlte.min.css';
import '@fortawesome/fontawesome-free/js/all.min.js';
import 'jquery/dist/jquery.js'
import 'bootstrap/dist/js/bootstrap.js';
import 'admin-lte/dist/js/adminlte.min.js';

完成

これまでの変更でそれっぽいViewが表示されるので後は適当に画像パスを書き換えると一番最初の画像になります。結局包丁を研いで疲れてしまい昼寝してたらGW初日は終わったのでこうして日記を書きました。

f:id:kazy1991:20200502232117p:plain
完成したstarter.html

Kotlinで書いたGradle Pluginからrepositoriesを変更する

こんな感じに書くとプロジェクトのrepositoriesを変更するが出来ます。
行儀が良さは一旦置いておきましょう。

class SamplePlugin : Plugin<Project> {
    override fun apply(project: Project) {
        project.repositories(closureOf<RepositoryHandler> {
            add(maven { repository ->
                repository.url = URI("https://maven.google.com")
            })
        })
    }
}

背景

このネタを考えるきっかけはWantedlyさんのテックブログです。カッコいいですね。

us.wantedly.com

記事を読んでもらうとわかるのですが、上記のエントリではkotlinの拡張関数を使ってwantedly()を実現されています。つまりbuild.gradleをkotlinで書く制約があります。
残念なことに私が関わっているプロジェクの多くはgroovyで設定ファイルを書いています。そこでgroovy版のbuild.gradleではこういったおしゃれコードが書けないのかと調べてみるとそれっぽいレポジトリを見つけました。

github.com

このレポジトリのコードを読んでみるとプラグインのコードからmetaclass というものを使っています。これはgroovyがメタプログラミング用に用意している仕組みです。動的にRepositoryHandlerにメソッドを生やすことで任意のレポジトリのエイリアスを作っているようでした。つまりbuild.gradleをkotlinで書いてしまうとみえないと思います。惜しいですが残念ですね。

RepositoryHandlerというのGradleの内部実装のクラスです。

github.com

Gradle実装のこの辺り読むとわかるんですが、RepositoryHandler interfaceに定義されてないものは基本的にrepositoriesDSLの中には書けないんですよね。そういうことならプラグインの中で追加してしまえばbuild.gradleがgroovyだろうがkotlinだろうが解決できるなと思って試してみたら出来たという話でした。

大切な補足

実は冒頭のコードあれだけでは動かなくてclosureOfという拡張関数を生やす必要があります。closureOfの出典元はgradle/gradleです。

gradle/GroovyInteroperability.kt at e9461fe3e2641398c6bdb82461ffbdadb21d0b1f · gradle/gradle · GitHub

fun <T> Any.closureOf(action: T.() -> Unit): Closure<Any?> =
    KotlinClosure1(action, this, this)

class KotlinClosure1<in T : Any?, V : Any>(
    val function: T.() -> V?,
    owner: Any? = null,
    thisObject: Any? = null
) : Closure<V?>(owner, thisObject) {
    @Suppress("unused") // to be called dynamically by Groovy
    fun doCall(it: T): V? = it.function()
}

Web -> Android App誘導の知見

この記事は去年社内ブログから投稿した記事です。一部削ってありますが参考になれば幸いです。
エンジニア以外の方にも読んでもらう意図があったため、わかりやすさを重視して一部適切とは言い難い表現もあります。

DeferredDeepLinkについて

Webサービスからモバイルアプリへのインストール誘導をする場合に、DeferredDeepLinkを利用する機会が増えてきています。 DeferredDeepLinkとはwebで開いてるページの情報をアプリに渡せる機能を持つDeepLinkです。例えばDeferredDeepLinkを使うとwebで開いていたレシピをアプリインストール後に表示させる事ができます。

DeepLinkとは

DeepLinkは一般名称でiOSではUnivresal Links Androidでは Android AppLinksという名前で呼ばれています。DeepLinkとはweb上のURLとアプリの特定の画面に紐付る技術のことで対応することでアプリの特定の画面ヘ直接遷移するリンクを作ることが出来ます。ややこしいですがDeferredDeepLinkを施策で利用する際にUnivresal LinksAndroid AppLinksの対応は必ずしも必須ではないです。遷移させたい画面へのDeferredDeepLinkが作成可能かどうかは担当のモバイルアプリエンジニアに相談してください。

DeepLinkの発展

DeepLinkの欠点はアプリを未インストールユーザーには対応できない点でした。そこで未インストールユーザーであってもアプリの特定のコンテンツヘ直接遷移するリンクを作る技術が開発されました。それがDeferredDeepLinkと呼ばれるものです。

DeferredDeepLinkを提供するサービス

DeferredDeepLinkはiOSやAndroid OSレベルで実装されているものではなく、2nd or 3rd partyのライブラリを使って実現されています。 いくつかのサービスがDeferredDeepLinkを提供しますがメリット・デメリットを考慮するとFirebase Dynamic Linksがオススメです。

Firebase Dynamic Links

  • 遷移先
    • https://から始まるURIに制限される
    • cookpad:// みたいなカスタムスキームは使えない
  • 作成方法
    • Firebase Consoleで"短いURL"を手動生成する
    • Rest APIで"短いURL"生成する
    • 動的に"長いURL"を生成する
  • 長いURL:
  • 短いURL:
  • 計測:
    • Firebase Consoleで生成した短いURL 下記のイベントが計測可能
      • click
      • redirect
      • app_install(Android限定)
      • first_open
    • 長いURLとREST APIで生成したURL: Firebase上の計測は不可能。
    • 共通事項:
      • utm_paramsを設定可能でFirebaseConversionを利用して初回起動数のみ計測可能
      • iOSはctパラメータを付与してApp Store Connectからイベント別インストール数を計測が可能
      • AndroidはFirebaseで設定したutm_paramsがPlayConsoleに渡らない仕様のためイベント別インストール数の計測は不可能
  • クライアントSDK
    • 通常の画面遷移処理に透過的に組み込める(Android)
  • 画面遷移の遅延
    • 多くのケースでPlayStoreから起動する際のIntentにDeepLinkが含まれているので、画面遷移は遅延はほぼない(Android)
  • 費用
    • 追加費用なし

プレビューページについて

Dynamic Linksを経由してiOSでアプリをインストールするとこのような中間ページを挟みます。 動作はやや不安定になりますがこの画面は設定でスキップすることも可能です。
参考までに私の関わっていたプロジェクトではスキップする判断をしました。 調査した所この画面ではクリップボードに情報を埋め込んでいてアプリから読み込めるようにしているようでした。スキップした場合はなにか別の方法でアプリに情報を伝えるようです。

ナビゲーション パラメータ elf:「1」に設定されている場合は、ダイナミック リンクが開いているときにアプリのプレビュー ページをスキップし、代わりにアプリまたはストアにリダイレクトします。アプリのプレビュー ページ(デフォルトで有効になっています)は、ユーザーがアプリでダイナミック リンクを開いたときにユーザーを最も適切な宛先に確実に転送できる手段ですが、ダイナミック リンクがアプリのみで開かれ、かつアプリがプレビュー ページなしでもダイナミック リンクを確実に開くことができることを前提とする場合は、このパラメータを使用してアプリのプレビュー ページを無効にすることができます。注: 現在アプリのプレビュー ページが表示されるのは iOS のみですが、将来的には Android でも表示される可能性があります。このパラメータは、両方のプラットフォームでダイナミック リンクの動作に影響します。

https://firebase.google.com/docs/dynamic-links/create-manually


引用元: 「アプリで開く」を実現する、Firebase Dynamic Linksの実装と運用Tips - ログミーTech

[重要] *.app.goo.glドメイン

古いFirebaseプロジェクトでは、*.app.goo.glというドメインが割り振られている事がありますが、これは現在のDynamicLinksと異なる振る舞いをするので非推奨です。xxx.page.linkを利用してください

Adjust

Adjustという広告系のサービスも検討したのでざっとだけ紹介します。

  • 遷移先
    • 任意のスキーマを指定可能(cookpad:// など)
  • 作成方法
    • Webコンソールからの手動生成のみ
    • 動的なURL生成には未対応
  • 計測:
    • web consoleからclick, app_installが計測可能
  • クライアントSDK
    • ログを仕込むと動かなくなったり不安定な印象(Android)
  • 画面遷移の遅延
    • 起動後にGETリクエストなげるので1~2s程かかる UXはFDLのほうが良い(Android)
  • 費用(プランによります)
    • プロジェクト全体で月1500アトリビューション(インストール)まで無料
    • それ以降は1アトリビューション/10円

動作確認

Adjustで作成したリンクを動作確認する場合都度テストコンソールで検証する端末の情報を削除する必要があります。

手順は下記の通りです(iOS Android共通)

  • Adjustのアカウントを入手する
  • Adjust Insightというアプリを入れてGoogle ad IDを確認する
  • testing-console から自分のad IDを検索して見つかった場合には削除する

Android App Links

App Links登場以前からAndroidはアプリの特定の画面ヘ直接遷移するリンクが作成可能でした。しかし特定のURLに紐づくアプリが複数ある場合にはどのアプリで開くかユーザーに判断が委ねられます。 例えばcookpadの場合はレシピのURLはブラウザとクックパッドアプリの2つで開くことが出来るのでアプリを選択する画面が表示されます。 App Linksはアプリの署名とドメイン情報をDigital Aseets Linkで証明することでアプリ同士に優先度を付けクックパッドアプリを優先的に表示させる機能です。

利用条件

  • Digital Aseets Linkでの認証がintent-filterに登録している全てのドメインで有効である
    • ドキュメントには曖昧な記載しかないが検証した所全てのドメインに対して認証が通らないと有効にならない
  • intent-filterにautoVerify=trueが記述されている

autoVerify属性はintent-filterに記述しますが一つでもautoVerifyが有効なintent-filterがあるとアプリケーション全体がAppLinksが有効になってしまい、ドメイン単位やパス単位で有効化するのは不可能です。

その他のAndroidアプリへの誘導

Firebase App Index

https://firebase.google.com/docs/app-indexing/?hl=ja

Firebase App IndexingはGoogleの検索結果にアプリの情報を載せる機能群の総称です。過去に何度も名前が変わったり、機能が増えたりしてわかりにくいのですがcookpadアプリでは有効になっているので注意が必要です。

f:id:kazy1991:20200124232123p:plain

2019年10月の地点ではCookpadアプリがインストールされている場合のみこのような導線が現れてAppLinksを有効にしていなくても検索結果からアプリに直接遷移します。 AppLinksを有効にした場合の動作検証際には注意が必要です。

Github Actionsを使ってGradle Plugin Portalにリリースする

試しにやってみたら便利な気がしたので紹介します。

最初にタイトルの内容を実現するactinosを貼っておきます。以降は時間があったら読んでください。

name: Publish
on:
  push:
    tags:
    - '*'
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v1
    - name: set up JDK 1.8
      uses: actions/setup-java@v1
      with:
        java-version: 1.8
    - name: Add .gradle dir
      run: mkdir ~/.gradle
    - name: Add gradle portal token
      run: |
        cat << EOS > ~/.gradle/gradle.properties
        gradle.publish.key=$GRADLE_PUBLISH_KEY
        gradle.publish.secret=$GRADLE_PUBLISH_SECRET
        EOS
      env:
        GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
        GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }}
    - name: Set tag version
      run: echo ::set-env name=TAG_VERSION::${GITHUB_REF#refs/tags/}
    - name: Publish this plugin to gradle portal
      run: ./gradlew clean publishPlugins

あらすじ

私はAndroid開発者をやっています。最近Gradle Pluginを書く機会があり、そのプラグインは会社のOrganazationでメンテする事になったため自分以外の人間も手軽にリリースできる仕組みを作りました。この記事ではプラグイン公開までざっくりした過程とGithub Actionsを利用したリリース方法について書きます。

プラグインの公開先について

少し前はプラグインもBintrayで公開する方法が一般的だった認識ですが、最近はGradle公式のプラグイン用のMaven Repositoryが用意されたのでGradle Plugin Portalにホストするのが主流なようです。詳しい公開手順は省きますが、下記のリンクを辿ると迷いなく公開まで出来るかと思います。
(もしGradleプラグインの作り方そのものについて詳しくなかった場合は、2月に開催されるDroidkaigi2020でその辺りを話すので足を運んでください :D )

Publishing Plugins to the Gradle Plugin Portal

リリースの自動化

社内のよく知らないライブラリの更新作業を突然頼まれた場合に一番困るパターンが、"リリースするために必要な秘匿値を頑張ってを探し出し、手元でリリースコマンドを叩くケース"かと思います。これは過去の経験から避けたいと思っていました。 そこでRundeckなどのジョブスケジューラを使ったリリースを考えたんですが、二点困り事があります。

  • 秘匿値を何で管理するか
  • Rundeckにリリースタスクがあることをどのように伝えるか

前者はどうにでもなリそうですが、後者は微妙な問題でPublishなレポジトリのREADMEに社内のジョブスケジューラのパーマリンクを貼るのは避けたいし、かといって社内ブログに書くと情報が分散してしまい見つけにくいデメリットがあります。 そこで最近興味があったGithub ActionsとレポジトリのSecretを使って解決できないか試したという経緯です。

タグを打ったら自動でリリース

Github Actionsは基本的にはイベントドリブンで動作します。一般的なジョブスケジューラみたいにGUIにRunボタンはないのでどうやってリリース処理を実行させるか考える必要があります。今回は任意tagがpushされたら反応するトリガーを設定し、そのtagのバージョンでMaven repositoryにリリースするスクリプトを書きました。それがこの部分に当たります。

on:
  push:
    tags:
    - '*'

環境変数やタスクの引数で秘匿値が渡せない

先程リンクしたプラグインの公開記事を読んでもらうと com.gradle.plugin-publish というライブラリを利用して公開することがわかると思います。記事の中では秘匿値として扱うべきtokenとsecretが ~/.gradle/gradle.properties から読み込まれていることがわかります。これはCI環境では絶妙に厄介です。出来ればタスクの引数や環境変数を通して設定したいのですがそのような方法は公式のドキュメントには存在しません。その上このプラグインはコードが公開されていないので正攻法で調べることも不可能でした(厳密には過去のバージョンは見れます) 。
一応ゴニョゴニョしてみたものの、結果的にタスクの引数や環境変数で秘匿値の受け渡しには対応してない事を確認したので、CI上で.gradle/gradle.propertiesを作って上げることにしました。その処理が下記の部分に該当します。Github Actionsファイルを作成するため3rd party製ツールのサポートもあったんですが、shell scriptでも書いても大差ないので利用しませんでした。

- name: Add .gradle dir
  run: mkdir ~/.gradle
- name: Add gradle portal token
  run: |
    cat << EOS > ~/.gradle/gradle.properties
    gradle.publish.key=$GRADLE_PUBLISH_KEY
    gradle.publish.secret=$GRADLE_PUBLISH_SECRET
    EOS
  env:
     GRADLE_PUBLISH_KEY: ${{ secrets.GRADLE_PUBLISH_KEY }}
     GRADLE_PUBLISH_SECRET: ${{ secrets.GRADLE_PUBLISH_SECRET }}

タグの情報を環境変数にする

リリースバージョンにtagの値を使いたかったので環境変数に設定したかったのですが、意外にもこの作業が面倒だったので下記のURLを参考に強引に環境変数に設定しています。

Solved: Re: How to get just the tag name? - GitHub Community Forum

- name: Set tag version
  run: echo ::set-env name=TAG_VERSION::${GITHUB_REF#refs/tags/}

終わり

こんな感じにリリースを自動化すると秘匿値の管理もリリースもGithubのレポジトリで完結し、push権限のある人はリリースが可能になるので便利です。READMEにも詳しく手順を書くことが可能なのでぜひ試してみてください。

コードレビューで気付きにくい言語仕様の話

この文章は日記として書きました。 うちの会社のアプリが急に使えなくなったという話が弊社slackの#generalチャンネルで話されていた。気になって会話を遡り状況を整理してみると、iOS版及びWebは問題ないがAndroid版だけが週末からずっと通信できなくなっているとのことだった。この不具合が報告されているのは一人で自分を含め他の人は問題なく動いていた。僕の所属部署はそのアプリを保守する立場にあり、自分はそのアプリのコードベースに詳しかったので原因を調査してみることにした。
まず調査のきっかけとして、一番手軽でかつ情報がありそうなサーバーログをKibanaを使って探した。しかし直近のログから該当ユーザーが送信したと思われるものは1件も見当たらなかった。そこでネットワークリクエストを投げる前にクライアント側でエラーが発生していると考え、モバイルアプリ側のエラー収集サービスであるCrashlyticsで該当のuser_idに関連するエラーを探した。こちらは多数見つかった。日時の新しいセッションを一つ選びスタックトレースを追ってみるとAPIリクエストをする際に呼ばれる認証系のコード内で「String から longへの変換」に失敗してクラッシュしていることがわかった。初歩的で単純な例外に思えたが該当の行を読んでもなぜNumberFormatException: For input string "null"となるのかまるで理解できなかった。重要なのは null.toLong() ではなく "null".toLong()であるところだった。周辺のコードを読む限りにおいて文字列の"null"なんて入る余地が無いように思えた。僕の頭では解決できそうになかったので、認証周りに詳しい同僚に調査の続きをお願いした。
結果から言うと問題は同僚が解決してくれた。原因はKolinのAny?.toString()の振る舞いで、このメソッドは直感に反してString?ではなくStringを返り型とする。ではレシーバーがnullだった時にどうするか、文字列の"null"を返すのだ。原因がわかり問題は既に修正された。

toString - Kotlin Programming Language

Returns a string representation of the object. 
Can be called with a null receiver, in which case it returns the string "null".

KotlinとAndroidStudioの優れた静的解析のおかげでNullableの扱いによるミスは減ったように思うけど、今回のケースでは逆にIDEの検査もPR時のlintも警告しなかったことで安心して不具合を入れてしまったのではないかとふと思った。
nullがレシーバーの場合に"null"文字列を返す仕様はどのようにして入ってしまったんだろうかと思いを馳せた出来事だった。