メモ2ブログ

メモtoウェブログ。旧ブログはこちら。 http://sakebook.blogspot.jp/

Gradleタスクでadbを実行してアプリを起動する

ちょいちょいターミナルからビルドしてアプリを立ち上げたりとかしたくなるときがあり、都度一発でできてなくて面倒なところがありました。

そうすればいいのかーって思ったのでメモと、ちょっと汎用的にしたものを共有。

こちら見かけたやつです

  • app/build.gradle
task startDemo(type: Exec) {
    dependsOn 'installDebug'
    def adb = new File("${System.env.ANDROID_HOME}", "platform-tools${File.separator}adb")
    commandLine adb, "shell", "am", "start", "-n", "com.google.maps.android.utils.demo/.MainActivity"
}

Execタスクで実行するコマンドは、PATHを通してないとGradleタスクとして実行できないです。

PATHを通すにはコマンドをファイルで渡してあげることで存在確認しつつ実行可能になります。

これを、Kotlinでかつ汎用的に書き直してみました。

環境は

  • Android Gradle Plugin v4.0.0
  • Gradle v6.1.1

です。

  • app/build/gradle.kts
tasks {
    val sdkDir = project.android.sdkDirectory
    val adb = file("${sdkDir.path}${File.separator}platform-tools${File.separator}adb")
    project.android.buildTypes.forEach { buildType ->
        val typeName = buildType.name.capitalize()
        create("run$typeName", Exec::class) {
            dependsOn("install$typeName")
            setCommandLine("bash", "-e", "-c", """
                $adb shell am start -n ${'$'}(adb shell pm dump ${project.android.defaultConfig.applicationId}${buildType.applicationIdSuffix} | grep -A 2 android.intent.action.MAIN | head -2 | tail -1 | awk '{print ${'$'}2}')
            """.trimIndent())
        }
    }
}

SDKのPATHは、環境変数やlocal.propertiesを使わなくてもAGPで取得できます。なので任せてしまうのがいいです。

project.android.sdkDirectory

実際の環境だと、debugとreleaseなど複数のBuildTypeがある場合がほとんどだと思います。なのでBuildTypeごとにタスクを作成しています。今回の例だと runXXX という名前で作成してます。

create("run$typeName", Exec::class)

installXXXタスクに依存させることで、アプリのビルドとインストールはできている状態にしています。

dependsOn("install$typeName")

アプリを起動させる部分ですが、起動させるにはパッケージ名プレフィックス付きのコンポーネント名を指定します。

コンポーネント名を取得するのは、AndroidManifest.xmlに定義してあるのでそこからXmlSlurperで取り出す例を見かけますが、今回はアプリをビルドしてインストールも済んでいるので、adbコマンドを使って取り出します。

adb shell pm dump ${project.android.defaultConfig.applicationId}${buildType.applicationIdSuffix} | grep -A 2 android.intent.action.MAIN | head -2 | tail -1 | awk '{print ${'$'}2}'

debugを開発版としている場合、パッケージ名を被らせないようにしていると思うのでsuffixをつけて対応させます。

取得したコンポーネント名を、再度adbに渡してアプリを起動させています。

参考

GitHub - googlemaps/android-maps-utils: Maps SDK for Android Utility Library

Multiple bash commands in single Gradle Exec task vs. multiple Gradle Exec Tasks each with a single command - Stack Overflow

AndroidのGradleでbuildTypes毎にtaskを作成する方法 - 混沌とした備忘録

Android Debug Bridge(adb)  |  Android デベロッパー  |  Android Developers

Launch application from command line. · GitHub

Kotlin/JSでLINE Botを動かす

Kotlin/JSを扱う題材としてCloud FunctionsでLINE Botを動かしてみたのですが、前回の記事がモリモリになってしまうので導入と実践を分けました。今回が実践編です。

前回の記事はこちら

sakebook.hatenablog.com

環境は次のとおりです

  • Kotlin v1.3.72
  • dukat v0.0.28
  • Node v13.3.0
  • Google Cloud SDK v291.0.0

題材

LINE Botを動かしてみることにしました。Slackもそうですが、inputとoutputが同じ場所で見えるチャット系サービスはCloud Functionsと相性が良いです。

f:id:sakebook:20200520222139p:plain

Messaging API overview | LINE Developers

  1. ユーザーが、LINE公式アカウントにメッセージを送信します。
  2. LINEプラットフォームからボットサーバーのWebhook URLに、Webhookイベントが送信されます。
  3. Webhookイベントに応じて、ボットサーバーからユーザーにLINEプラットフォームを介して応答します。

今回は応答メッセージを使って、ユーザがLINE Botにメッセージを送ったときにオウム返しをするBotを作成します。

LINE Bot作成で必要なこと

LINE Botでは次のことが必要です

  • リクエストからデータを受け取り
  • 応答メッセージの作成
  • LINEサーバにメッセージの送信

Cloud Functionsで動かす部分は同じです。

開発を便利にするためにいくつかの準備をします。

型の用意

せっかくKotlinで書くので型の恩恵を得られるようにします。

デプロイする関数の引数であるRequestとResponseの型を定義してあげます。今回利用する最低限度のものしか用意してないです。

  • Wrapper.kt
external interface Request {
    val body: Any
}

external interface Response {
    fun status(code: Int): Response
    fun send(message: String): Response
}

デプロイする関数を用意して、関数型として渡します。

  • main.kt
import wrapper.Request
import wrapper.Response

external val exports: dynamic

fun main() {
    exports.message = ::message
}

fun message(req: Request, res: Response) {
    TODO("実装を書く")
}

これでエントリーポイントと実装が分けられてスッキリします。

kotlin-extentionsの導入

Kotlin/JSを扱うに当たり、ヘルパーが用意されてるので導入します。

@jetbrains/kotlin-extensions という名前のnpmパッケージです。

www.npmjs.com

  • build.gradle.kts
dependencies {
        ...
    implementation(npm("@jetbrains/kotlin-extensions", "^1.0.1-pre.91"))
        ...
}

npmパッケージが用意されてるので利用はできるのですが、デプロイを考えたときにちょっと問題があります。

生成されるJSファイルでは、require('kotlin-extensions')となってしまうので利用時にエラーになります。

なので、JSファイルを生成する際に一部書き換えて対応します。

  • build.gradle.kts
compileKotlinJs {
        doLast {
            // workaround for use kotlin-extensions
            val jsFile = File("build/js/packages/${project.name}/kotlin/${project.name}.js")
            val text = jsFile.readText()
            val rep = text.replace("require('kotlin-extensions')", "require('@jetbrains/kotlin-extensions')")
            jsFile.writeText(rep)
        }
    }

正直ちょっと手間ではあるので、利用したい部分のコードをローカルにコピーしてくる運用でも良いかもしれません。

環境変数の利用

トークンなどはソースコード管理に含めたくないので環境変数を経由して利用します。

js関数でJSのコードを実行して利用します。

例えば

$ export CHANNEL_ACCESS_TOKEN=hogehoge

としていたら、Kotlinからは次のように利用できます。

js("process.env.CHANNEL_ACCESS_TOKEN") // hogehoge

型はdynamicです。

kotlinx.serializationの導入

通信の際にJSONデータを扱うので、手動でSerialize/deserializeしなくていいようにライブラリを利用します。

公式の通りにやれば追加できます。

github.com

注意として、バージョンは 0.14.00.20.0 しか対応していません。

package.jsonには kotlinx-serialization-kotlinx-serialization-runtime というnpmパッケージで探しに行くので、次のnpmパッケージが一致して解決されます。

www.npmjs.com

ちょっと気になるのは、このnpmパッケージが公式のものなのかどうかわからない点です。

npmパッケージの中身はbuild/package_importedに生成されるJSファイルと同じなので気のせいかもしれませんが。

issueが閉じられてないのも気になります。

できればextentionと同じようにscopeを切って公式とわかるようにして、苦なく利用できるような形になっててほしいです。

通信ライブラリの導入

Axiosがdukatでいい感じに利用できたので、Axiosを採用します。

dukatについてはこちら

sakebook.hatenablog.com

こんな感じでPOSTリクエストを送れます。HeaderにAuthorizationを追加してます。

  • main.kt
private fun postReply(reply: Reply): Promise<JsonObject> {
    val axios = Axios.create(object : AxiosRequestConfig {
        override var method: String = "POST"
        override var responseType: String = "json"
        override var headers: Any? = kotlinext.js.js {
            this.Authorization =
                "Bearer ${js("process.env.CHANNEL_ACCESS_TOKEN")}"
        } as? Any
    })
    return axios.post<Unit, JsonObject>(url = "https://api.line.me/v2/bot/message/reply", data = reply)
}

kotlinext.js.js は前述のkotlin-extentionsです。

JSON.stringifyでの循環参照回避

JSON.stringify(o: Any?) を利用することで、JSのオブジェクトをJSON文字列に変換できます。

JVMの世界では起きなかったが、JSの世界では起きる問題があります。

stringifyしようとしたJSオブジェクトが、循環参照している場合例外が発生します。

それを解決するために第2引数に replacer: ((key: String, value: Any?) -> Any?) という関数が用意されています。

こちらで循環参照してしまうものを除外して解決します。

次の例では、 request というkeyが循環参照していたので除外しています。

  • main.kt
fun message(req: Request, res: Response) {
    val webhookEvent = parseRequest(req)
    val reply = createReply(webhookEvent)
    postReply(reply)
        .then {
            console.log("then ${JSON.stringify(it) { key: String, value: Any? ->
                when (key) {
                    "request" -> ""
                    else -> value
                }
            }}")
            res.status(200).send("Success!!")
        }.catch {
            console.log("catch ${JSON.stringify(it)}")
            res.status(400).send("Error!!")
        }
}

普段JSを書いていないので、慣れている人からしたら当たり前かもしれません。自分は困りました。

Cloud Functionsへのデプロイ

デプロイ時に環境変数を渡すのを忘れないようにします。

$ ./gradlew clean cKJ // compileKotlinJsの略
$ cd functions
functions$ gcloud functions deploy message --region asia-northeast1 --trigger-http --runtime nodejs8 --update-env-vars CHANNEL_ACCESS_TOKEN="YOUR_ACCESS_TOKEN"

まとめ

JSならではの部分や、エコシステム周りで足りない部分も感じますが、準備さえしてしまえばわりと解決できる問題が多いです。

Kotlin/JSでHello World以上のサンプルがまだ少ないので、いくらか参考になると思います。

github.com

READMEにLINE Botの友だち追加のQRを置いてあるので、気になった方は試すことはできます。

参考

Messaging API overview | LINE Developers

Functional programming - Kotlin Programming Language

JSON.stringifyを改めて調べる。 - Qiita

Kotlin/JSでCloud Functionsを動かす

最近Kotlin/JSを触ってます。

実はCloud FunctionsがまだJavaをサポートしていないのでKotlinでCloud Functionsを動かそうと思ったらJSに変換する必要があります。

DartでもJSに変換してCloud Functionsを動かしたことがあったので、同じ用に動かせるのではないかと思いトライしました。

sakebook.hatenablog.com

現在はEAPが稼働しているので、時期に対応されますが待ちきれない!という方には参考になると思います。

環境は次のとおりです

  • Kotlin v1.3.72
  • dukat v0.0.28
  • Node.js v13.3.0
  • Google Cloud SDK v290.0.0

Cloud Functions

まずイメージを掴むために、JSのCloud Functionsの公式サンプル実装を見てみます。

const escapeHtml = require('escape-html');

    /**
     * HTTP Cloud Function.
     *
     * @param {Object} req Cloud Function request context.
     *                     More info: https://expressjs.com/en/api.html#req
     * @param {Object} res Cloud Function response context.
     *                     More info: https://expressjs.com/en/api.html#res
     */
    exports.helloHttp = (req, res) => {
      res.send(`Hello ${escapeHtml(req.query.name || req.body.name || 'World')}!`);
    };

これはhelloHttpという関数のサンプルです。helloHttpに、requestresponseを引数に持つ関数を代入して、レスポンスを返しているコードになります。

同じようなコードをKotlinで書いてみます。

ライブラリの追加

Kotlin 1.3.70からdependenciesに直接依存ライブラリを定義できるようになりました。

  • build.gradle.kts
dependencies {
    implementation(kotlin("stdlib-js"))
    implementation(npm("escape-html", "1.0.3"))
    implementation(npm("@types/escape-html", "0.0.20"))
}

escape-html はdukatが使えたので利用します。

  • build.gradle.kts
kotlin {
    sourceSets["main"].kotlin.srcDir("src/main/external")
    target {
        nodejs {}
        useCommonJs()
    }
}
$ dukat -d src/main/external/ build/js/node_modules/@types/escape-html/index.d.ts

helloHttp関数を作成

helloHttpラムダ式を代入しています。引数の型はJSから渡されるのでdynamicにしています。

req.query などは存在しなくてもエラーになりません。変数として扱う限りundefinedなだけです。

  • Main.kt
external val exports: dynamic

fun main() {
    exports.helloHttp = { req: dynamic, res: dynamic ->
        res.send(createMessage(req))
    }
}

private fun createMessage(req: dynamic): String {
    val message = when {
        req.query.name !== undefined -> escapeHTML(req.query.name)
        req.body.name !== undefined -> escapeHTML(req.body.name)
        else -> "World"
    }
    return "Hello $message"
}

ローカルで実行

次のコマンドでNode.jsで実行できます。

$ ./gradlew nodeRun

ただし先程の例だと何も出力されません。helloHttp関数が呼ばれていないからです。Cloud Functionsをローカルで確認するには @google-cloud/functions-frameworkを使うと確認できます。

現状、build.gradle.ktsのdependenciesからはdevDependenciesに追加できないので、npxを使ってコマンドを実行することで回避します。

$ cd build/js/packages/${PROJECT_NAME}
$ npx @google-cloud/functions-framework --target=helloHttp
npx: installed 52 in 3.789s
Serving function...
Function: helloHttp
URL: http://localhost:8080/

ローカルに立ち上がるので、アクセスするとhelloHttp関数が呼ばれます。

$ curl -X POST -H "Content-Type: application/json" "http://localhost:8080/"                                                         
Hello World

nameクエリをつけてアクセスしてみます。

$ curl -X POST -H "Content-Type: application/json" "http://localhost:8080/?name=\"Kotlin/JS\""
Hello &quot;Kotlin/JS&quot;

しっかりescape-htmlも動いています。

Cloud Functionsデプロイのためのプロジェクト構成

Cloud Functionsのデプロイに必要なものはJSのコードと、ライブラリを利用していたらpackage.jsonが必要です。

2つのファイルをまとめるタスクを定義します。

タスク定義

  • build.gradle.kts
tasks {
    val packaging by creating(Copy::class) {
        from("build/js/packages/${project.name}/kotlin/${project.name}.js", "build/js/packages/${project.name}/package.json")
        into("functions")
        rename { it.replace("${project.name}.js", "index.js") }
        
        doLast {
            val jsonFile = file("functions/package.json")
            val texts = jsonFile.readLines()
                .map { it.replace("kotlin/${project.name}.js", "index.js") }
            jsonFile.writeText(texts.joinToString("\n"))
        }
    }
}

functionsフォルダを生成して配置してます。jsファイルは、Cloud Functionsの制約があるのでindex.jsとなるようにリネームしています。合わせて、package.jsonのエントリーポイントをindex.jsとなるように書き換えてます。置き換え後のpackage.jsonは次のようになってます。

デプロイ先で依存の解決がされるので、npm install する必要はないです。

  • functions/package.json
{
  "main": "index.js",
  "devDependencies": {
    "source-map-support": "0.5.16"
  },
  "dependencies": {
    "kotlin": "1.3.72",
    "escape-html": "1.0.3",
    "@types/escape-html": "0.0.20"
  },
  "peerDependencies": {},
  "optionalDependencies": {},
  "bundledDependencies": [],
  "name": "kotlin-js-cloud-functions",
  "version": "1.0.0-SNAPSHOT"
}

次のように実行することでfunctionsフォルダに必要なファイルを 設置できます。

$ ./gradlew clean compileKotlinJs 
$ ./gradlew packaging

JSファイルを生成するだけならnodeRunではなくてcompileKotlinJsで生成できます。

生成してから置き換えタスクを実行します。

実行後は次のような配置になります。

$ tree -L 2
.
├── build
│   ├── js
│   ├── kotlin
│   └── tmp
├── build.gradle.kts
├── functions
│   ├── index.js
│   └── package.json
├── gradle
│   └── wrapper
├── gradle.properties
├── gradlew
├── gradlew.bat
├── settings.gradle.kts
└── src
    ├── main
    └── test

デプロイ

gcloudでデプロイします。

取り掛かる前に、GCPのConfigurationが意図しているものかどうか確認しておいてください。

$ gcloud config list 

functionsフォルダに移動してからコマンドを実行します。

$ cd functions
$ gcloud functions deploy helloHttp --region=asia-northeast1 --trigger-http --runtime=nodejs8 --allow-unauthenticated 

サクッと確認したいため、--allow-unauthenticatedフラグを付けてpublicにしています。

次のようなURLが排出されると思います。

https://{REGION_NAME}-{PROJECT_NAME}.cloudfunctions.net/{FUNCTION_NAME}

まとめ

コードとしてはシンプルに導入できます。

デバッグも組み込まれてはいないですが、npxを利用することで回避できます。 デプロイに関しても、必要な処理は今回用意したタスクでまかなえるのでそこまで手間にならないと思います。

リポジトリはこちらです。

github.com

参考

Google Cloud Blog - News, Features and Announcements

Functions Framework  |  Cloud Functions Documentation  |  Google Cloud

gcloud functions deploy  |  Cloud SDK Documentation  |  Google Cloud