📑

App Bundle のアップロードを自分で Gradle タスクとして実装してみる

2023/08/23に公開

App Bundle のアップロードを自分で実装してみます。
便利な Gradle プラグインやツールは公開されていますが、 Google Play Developer Publishing API がどう使われるかを把握したいのが目的なので使いません。

前提条件は以下の通りです。

  • Android Studio Giraffe | 2022.3.1
  • Gradle 8.0.1
    • Android Studio 同梱の Java 17 で実行
    • Configuration cache は有効
  • New Project ウィザードの Empty Activity で作成したのと同じディレクトリ構造のプロジェクト
  • Google Play Developer API へ Service Account で認証

方針

あくまで実験で使いまわさないので Gradle の custom task として ビルドスクリプトに記述 していきます。
またライブラリは本筋と関係ないところで詰まるので極力使わないようにします。
実装が多少汚いことも見なかったことにします。

タスクの定義

まずはタスクを定義します。
アップロードするためには App Bundle が生成される必要があるので app project のタスクであるべきということで build.gradle.kts に追加します。
info レベルだと不要な出力が多く、ここでは動作が確認できれば良いので logger.error を使っています。

// 書く場所はどこでも良いです
// この例では末尾の dependencies {} の下に書き足しているとします

tasks.register("uploadAppBundle") {
  logger.error("Upload App Bundle task")
}

Run configuration で Gradle のテンプレートを選択し、 Tasks and arguments に uploadAppBundle と入力します。
これで追加したタスクが実行できるようになるので実行ボタンを押してみます。
以下のように出力されれば OK です。

22:00:00: Executing 'uploadAppBundle'...

Executing tasks: [uploadAppBundle] in project /path/to/MyApplication

Configuration cache is an incubating feature.
Calculating task graph as no configuration cache is available for tasks: uploadAppBundle
Upload App Bundle task
> Task :app:uploadAppBundle UP-TO-DATE

0 problems were found storing the configuration cache.

See the complete report at file:///path/to/MyApplication/build/reports/configuration-cache/some-cache/hash-key/configuration-cache-report.html

BUILD SUCCESSFUL in 528ms
Configuration cache entry stored.

Build Analyzer results available
22:00:00: Execution finished 'uploadAppBundle'.

タスクのライフサイクル

ここでは実装に必要最小限の内容に触れます。
詳細は ドキュメント を読んでください。

Gradle のビルドは Initialization -> Configuration -> Execution の 3 つのフェーズがあります。
タスクを定義する時には Configuration と Execution の 2 フェーズを気にすることになります。
今回の場合は主に Execution に実装することになりますが、 doLast もしくは doFirst のコールバックに書くことになります。

// この register の invoke は Initialization phase です
tasks.register("uploadAppBundle") {
  // ここは Configuration phase です

  doLast {
    // ここは Execution phase です
  }
}

アクセストークンの取得

タスクの追加が終わったのでいよいよ実装していきます。
Play Console にアップロードするためにはまず認証する必要があります。
ドキュメント にしたがってクライアントライブラリを使えばいいはずですが、試したところ必要な依存ライブラリがわからず、数時間溶かしたので諦めました。
なので REST で実装していきます。

import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpClient.Redirect
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.http.HttpResponse.BodyHandlers
import java.security.KeyFactory
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import org.json.JSONObject

// android {} などがありますが省略

// task の依存はこのように書きます
buildscript {
  dependencies {
    classpath("org.json:json:20230618")
  }
}

tasks.register("uploadAppBundle") {
  // Service Account の JSON 鍵ファイルから必要な要素を環境変数で渡していきます

  /**
   * private_key_id の値
   */
  val kid = System.getenv("SERVICE_ACCOUNT_PRIVATE_KEY_ID")
  /**
   * private_key の値から不要な要素を取り除いたもの
   *
   * 手抜きのために `--BEGIN PRIVATE KEY--` と `--END PRIVATE KEY--` の部分をあらかじめ切り取った
   * 文字列が設定されることを前提にしています
   */
  val privateKey = (System.getenv("SERVICE_ACCOUNT_PRIVATE_KEY") ?: "")
      .replace("\\n", "")
  /**
   * client_email の値
   */
  val iss = System.getenv("SERVICE_ACCOUNT_CLIENT_EMAIL")
  if (kid.isNullOrEmpty() || privateKey.isEmpty() || iss.isNullOrEmpty()) {
      throw RuntimeException("環境変数が設定されていません")
  }

  // Android Studio は Gradle を Java 17 で実行しますが
  // エディタ上でのバージョンが Java 11 未満になっているようで
  // HttpClient など Java 11 以上の API で Error とされるので雑に握りつぶします
  @Suppress("Since15")
  doLast {
    // まずは JWT を作ります
    // これはスコープは切りたかったものの手抜きはしたかったのでこんな位置で関数を宣言しています
    fun makeJWT(): String {
      // JWT で使用される Base64 url encode は padding が不要なので使わない設定にします
      val encoder = Base64.getUrlEncoder().withoutPadding()

      // 簡単なデータなので手書きで JSON 文字列を作ってエンコードしていきます
      // まずは header です
      val header = encoder.encodeToString(
          "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"kid\":\"${kid}\"}".toByteArray()
      )

      // 次に claim です
      /**
       * 認可のスコープ
       *
       * 指定すべき内容はドキュメントに書いてあるので必要な API 全てで確認する
       * ex) https://developers.google.com/android-publisher/api-ref/rest/v3/edits/insert
       */
      val scope = "https://www.googleapis.com/auth/androidpublisher"
      val aud = "https://oauth2.googleapis.com/token"
      val iat = System.currentTimeMillis() / 1000
      val exp = iat + 600
      val claim = encoder.encodeToString(
          "{\"iss\":\"${iss}\",\"scope\":\"${scope}\",\"aud\":\"${aud}\",\"exp\":${exp},\"iat\":${iat}}"
              .toByteArray()
      )

      // 次に header と claim を連結したものに署名します
      // Service Acount の JSON 鍵ファイルに記載の秘密鍵を使っていきます
      val signature = Signature.getInstance("SHA256withRSA")
      signature.initSign(
          KeyFactory.getInstance("RSA").generatePrivate(
              PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey))
          )
      )
      signature.update("${header}.${claim}".toByteArray())
      val signResult = signature.sign()

      // 署名できたので 3 つを `.` 繋ぎでくっつけます
      return "${header}.${claim}.${encoder.encodeToString(signResult)}"
    }
    val jwt = makeJWT()

    // 通信は Java 11 から追加された HttpClient を使っていきます
    // 念の為デフォルト設定だとオフになっているリダイレクトだけ有効にしておきます
    val client = HttpClient.newBuilder()
        .followRedirects(Redirect.NORMAL)
        .build()

    // 認証用のアクセストークンを取得します
    // ここでは Authorization ヘッダーに設定できる文字列を返り値にしてしまいます
    fun retrieveAuthorizationValue(): String {
      val grantType = URLEncoder.encode(
          "urn:ietf:params:oauth:grant-type:jwt-bearer",
          "UTF-8"
      )

      // ここはドキュメントを見るとフォーム形式で良いようなのでサクッと投げます
      val response = client.send(
          HttpRequest.newBuilder(URI.create("https://oauth2.googleapis.com/token"))
              .header("Content-Type", "application/x-www-form-urlencoded")
              .POST(BodyPublishers.ofString("grant_type=${grantType}&assertion=${jwt}"))
              .build(),
          BodyHandlers.ofString()
      )
      if (response.statusCode() < 200 || response.statusCode() >= 400) {
        logger.error("トークン取得失敗: ${response.body()}")
        throw RuntimeException("トークン取得失敗")
      }

      // root にある object の値が取得できれば十分なので Moshi などではなく
      // org.json.JSONObject を使います
      val obj = JSONObject(response.body())

      // "Bearer access_token_value" のような形で返します
      return "${obj.getString("token_type")} ${obj.getString("access_token")}"
    }
    val authorization = retrieveAuthorizationValue()
  }
}

処理の流れ

ここからは Publishing API を使っていきます。
編集は Edit という単位になっていて、 insert -> 複数の編集作業 -> commit という流れになっています。
今回はシンプルにアップロードだけが実現できれば良いので insert -> upload -> commit となります。

Publishing API にリクエストする

上記の流れを実装していきます。

// 略
import java.net.http.HttpResponse.BodyHandlers
import java.nio.file.Path  // この行を追加
import java.security.KeyFactory
// 略

// android {} から buildscript まで省略

tasks.register("uploadAppBundle") {
  // 環境変数のあたりを略

  // android.namespace にはアクセスできなかったので変数を定義しています
  val packageName = "com.example.myapplication"

  // 出力ディレクトリの絶対パスを変数に入れておきます
  //
  // これは Gradle の Configuration cache が有効になっていると
  // Execution phase で書き換えてキャッシュの不整合が発生しないように
  // project 変数にアクセスできないようになるからです
  val buildDirPath = project.buildDir.absolutePath

  @Suppress("Since15")
  doLast {
    //
    // 色々と略
    //
    val authorization = retrieveAuthorizationValue()

    val origin = "https://androidpublisher.googleapis.com"

    // まずは Edit を新規作成して ID を取得します
    fun insertEdit(): String {
      val uri = URI.create("${origin}/androidpublisher/v3/applications/${packageName}/edits")

      // ドキュメントには body に AppEdit を指定するとありますが
      // field は output only のみなので空 object を設定します
      //
      // Content-Type については何を指定するか見当たらなかったので
      // 試してみて動いた application/json にしました
      //
      // Authorization には前のステップで取得したアクセストークンを設定します
      val response = client.send(
          HttpRequest.newBuilder(uri)
              .header("Content-Type", "application/json")
              .header("Authorization", authorization)
              .POST(BodyPublishers.ofString("{}"))
              .build(),
          BodyHandlers.ofString()
      )
      if (response.statusCode() < 200 || response.statusCode() >= 400) {
        logger.error("Edit の insert 失敗: ${response.body()}")
        throw RuntimeException("Edit の insert 失敗")
      }

      // editID が後続のリクエストで必要なので返します
      return JSONObject(response.body()).getString("id")
    }
    val editID = insertEdit()

    // App Bundle をアップロードします
    fun uploadBundle() {
      val uri = URI.create(
          "${origin}/upload/androidpublisher/v3/applications/${packageName}/edits/${editID}/bundles"
      )

      // ビルドで生成されている aab ファイルへのパスを組み立てます
      val path = Path.of(
          buildDirPath,
          "outputs",
          "bundle",
          "Release",
          "app-release.aab"
      )

      // ファイルをアップロードします
      //
      // ここでも Content-Type は書いてないので勘で書いて試したら
      // 受け付けられたので application/octet-stream にしました
      //
      // レスポンスの中身は使いませんがエラーには表示したいので
      // 文字列が来ることを期待しておきます
      val response = client.send(
          HttpRequest.newBuilder(uri)
              .header("Content-Type", "application/octet-stream")
              .header("Authorization", authorization)
              .POST(BodyPublishers.ofFile(path))
              .build(),
          BodyHandlers.ofString()
      )
      if (response.statusCode() < 200 || response.statusCode() >= 400) {
        logger.error("App Bundle のアップロード失敗: ${response.body()}")
        throw RuntimeException("App Bundle のアップロード失敗")
      }
    }
    uploadBundle()

    // 最後に編集をコミットします
    fun commitEdit() {
      val uri = URI.create(
          "${origin}/androidpublisher/v3/applications/${packageName}/edits/${editID}:commit"
      )

      // body は必ず空にするとあるのでその通りにします
      //
      // ここでもレスポンスの中身は使いませんがエラーには表示したいので
      // 文字列が来ることを期待しておきます
      val response = client.send(
          HttpRequest.newBuilder(uri)
              .header("Authorization", authorization)
              .POST(BodyPublishers.noBody())
              .build(),
          BodyHandlers.ofString()
      )
      if (response.statusCode() < 200 || response.statusCode() >= 400) {
        logger.error("コミット失敗: ${response.body()}")
        throw RuntimeException("コミット失敗")
      }
    }
    commitEdit()
  }
}

他タスクへの依存

App Bundle の作成はタスクが存在するので、今回作成したタスクはそれに依存することで生成されたファイルが必ず存在することを担保できます。
これは dependsOn によって設定できるので Configuration phase で呼び出します。

bundleRelease というタスクを使用しますが、プロジェクトを指定しないと他プロジェクトまで実行してしまうので app プロジェクトを明記します。

// import から buildscript まで省略

tasks.register("uploadAppBundle") {
  // 環境変数やらなにやらを略

  dependsOn(":app:bundleRelease")

  @Suppress("Since15")
  doLast {
    // 色々と略
  }
}

まとめ

これで uploadAppBundle を実行すると Play Developer Console にアップロードできるようになりました。

単純なアップロードであれば比較的簡単な実装でできることが確認できて満足です。
既存のプラグイン等ではできない作業で自動化が難しくても、 Edit に変更を加えることで解決できる選択肢を持てるとわかったのが今回の実装で良かったかなと思います。

最終的な app/build.gradle.kts
import java.net.URI
import java.net.URLEncoder
import java.net.http.HttpClient
import java.net.http.HttpClient.Redirect
import java.net.http.HttpRequest
import java.net.http.HttpRequest.BodyPublishers
import java.net.http.HttpResponse.BodyHandlers
import java.nio.file.Path
import java.security.KeyFactory
import java.security.Signature
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import org.json.JSONObject

// android {} などがありますが省略

// task の依存はこのように書きます
buildscript {
  dependencies {
    classpath("org.json:json:20230618")
  }
}

tasks.register("uploadAppBundle") {
  val kid = System.getenv("SERVICE_ACCOUNT_PRIVATE_KEY_ID")
  val privateKey = (System.getenv("SERVICE_ACCOUNT_PRIVATE_KEY") ?: "")
      .replace("\\n", "")
  val iss = System.getenv("SERVICE_ACCOUNT_CLIENT_EMAIL")
  if (kid.isNullOrEmpty() || privateKey.isEmpty() || iss.isNullOrEmpty()) {
      throw RuntimeException("環境変数が設定されていません")
  }

  val packageName = "com.example.myapplication"
  val buildDirPath = project.buildDir.absolutePath

  dependsOn(":app:bundleRelease")

  @Suppress("Since15")
  doLast {
    fun makeJWT(): String {
      val encoder = Base64.getUrlEncoder().withoutPadding()

      val header = encoder.encodeToString(
          "{\"alg\":\"RS256\",\"typ\":\"JWT\",\"kid\":\"${kid}\"}".toByteArray()
      )

      val scope = "https://www.googleapis.com/auth/androidpublisher"
      val aud = "https://oauth2.googleapis.com/token"
      val iat = System.currentTimeMillis() / 1000
      val exp = iat + 600
      val claim = encoder.encodeToString(
          "{\"iss\":\"${iss}\",\"scope\":\"${scope}\",\"aud\":\"${aud}\",\"exp\":${exp},\"iat\":${iat}}"
              .toByteArray()
      )

      val signature = Signature.getInstance("SHA256withRSA")
      signature.initSign(
          KeyFactory.getInstance("RSA").generatePrivate(
              PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKey))
          )
      )
      signature.update("${header}.${claim}".toByteArray())
      val signResult = signature.sign()

      return "${header}.${claim}.${encoder.encodeToString(signResult)}"
    }
    val jwt = makeJWT()

    val client = HttpClient.newBuilder()
        .followRedirects(Redirect.NORMAL)
        .build()

    fun retrieveAuthorizationValue(): String {
      val grantType = URLEncoder.encode(
          "urn:ietf:params:oauth:grant-type:jwt-bearer",
          "UTF-8"
      )

      val response = client.send(
          HttpRequest.newBuilder(URI.create("https://oauth2.googleapis.com/token"))
              .header("Content-Type", "application/x-www-form-urlencoded")
              .POST(BodyPublishers.ofString("grant_type=${grantType}&assertion=${jwt}"))
              .build(),
          BodyHandlers.ofString()
      )
      if (response.statusCode() < 200 || response.statusCode() >= 400) {
        logger.error("トークン取得失敗: ${response.body()}")
        throw RuntimeException("トークン取得失敗")
      }

      val obj = JSONObject(response.body())

      return "${obj.getString("token_type")} ${obj.getString("access_token")}"
    }
    val authorization = retrieveAuthorizationValue()

    val origin = "https://androidpublisher.googleapis.com"

    fun insertEdit(): String {
      val uri = URI.create("${origin}/androidpublisher/v3/applications/${packageName}/edits")

      val response = client.send(
          HttpRequest.newBuilder(uri)
              .header("Content-Type", "application/json")
              .header("Authorization", authorization)
              .POST(BodyPublishers.ofString("{}"))
              .build(),
          BodyHandlers.ofString()
      )
      if (response.statusCode() < 200 || response.statusCode() >= 400) {
        logger.error("Edit の insert 失敗: ${response.body()}")
        throw RuntimeException("Edit の insert 失敗")
      }

      return JSONObject(response.body()).getString("id")
    }
    val editID = insertEdit()

    fun uploadBundle() {
      val uri = URI.create(
          "${origin}/upload/androidpublisher/v3/applications/${packageName}/edits/${editID}/bundles"
      )

      val path = Path.of(
          buildDirPath,
          "outputs",
          "bundle",
          "Release",
          "app-release.aab"
      )
      val response = client.send(
          HttpRequest.newBuilder(uri)
              .header("Content-Type", "application/octet-stream")
              .header("Authorization", authorization)
              .POST(BodyPublishers.ofFile(path))
              .build(),
          BodyHandlers.ofString()
      )
      if (response.statusCode() < 200 || response.statusCode() >= 400) {
        logger.error("App Bundle のアップロード失敗: ${response.body()}")
        throw RuntimeException("App Bundle のアップロード失敗")
      }
    }
    uploadBundle()

    fun commitEdit() {
      val uri = URI.create(
          "${origin}/androidpublisher/v3/applications/${packageName}/edits/${editID}:commit"
      )

      val response = client.send(
          HttpRequest.newBuilder(uri)
              .header("Authorization", authorization)
              .POST(BodyPublishers.noBody())
              .build(),
          BodyHandlers.ofString()
      )
      if (response.statusCode() < 200 || response.statusCode() >= 400) {
        logger.error("コミット失敗: ${response.body()}")
        throw RuntimeException("コミット失敗")
      }
    }
    commitEdit()
  }
}

Discussion