Packages
mob_dev
0.5.14
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.4
0.6.3
0.6.2
0.6.1
0.6.0
0.5.17
0.5.16
0.5.15
0.5.14
0.5.13
0.5.12
0.5.11
0.5.10
0.5.9
0.5.8
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.37
0.3.35
0.3.34
0.3.33
0.3.28
0.3.26
0.3.23
0.3.21
0.3.19
0.3.18
0.3.17
0.3.16
0.3.15
0.3.14
0.3.13
0.3.12
0.3.11
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.18
0.2.17
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Development tooling for the Mob mobile framework
Current section
Files
Jump to
Current section
Files
lib/mob_dev/google_play.ex
defmodule MobDev.GooglePlay do
@moduledoc """
Google Play Developer API client for uploading Android App Bundles.
Authenticates using a service account JSON key (RSA JWT → OAuth2 access token),
then drives the Play edit workflow:
create edit → upload .aab → assign track → commit
## Service account setup (one-time)
1. Go to Play Console → Setup → API access → link to a Google Cloud project.
2. In Google Cloud Console → IAM → Service Accounts → Create a service account.
3. Download the JSON key for that service account.
4. Back in Play Console → Setup → API access → grant the service account
"Release manager" (or "Admin") permission.
## mob.exs config
config :mob_dev,
google_play: [
package_name: "com.example.myapp",
service_account_json: "~/.google_play/my-service-account.json",
track: "internal" # internal | alpha | beta | production
]
"""
alias MobDev.GooglePlay.HTTP
@play "https://androidpublisher.googleapis.com/androidpublisher/v3/applications"
@upload "https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications"
@token_url "https://oauth2.googleapis.com/token"
@scope "https://www.googleapis.com/auth/androidpublisher"
@doc """
Uploads `aab_path` to Google Play and assigns it to `track`.
Options (all required unless noted):
- `:service_account_json` — path to the service account JSON key file
- `:package_name` — Android applicationId (e.g. "com.beyondagronomy.aircartmax")
- `:track` — "internal" | "alpha" | "beta" | "production" (default: "internal")
Returns `{:ok, version_code}` or `{:error, reason}`.
"""
@spec upload(Path.t(), keyword()) :: {:ok, integer()} | {:error, String.t()}
def upload(aab_path, opts) do
sa_path = Keyword.fetch!(opts, :service_account_json) |> Path.expand()
package = Keyword.fetch!(opts, :package_name)
track = Keyword.get(opts, :track, "internal")
ensure_started!()
with {:ok, sa} <- load_service_account(sa_path),
log("Authenticating with Google..."),
{:ok, token} <- fetch_access_token(sa),
log("Creating edit..."),
{:ok, edit_id} <- create_edit(token, package),
log("Uploading #{file_size(aab_path)}..."),
{:ok, version_code} <- upload_bundle(token, package, edit_id, aab_path),
log("Assigning versionCode #{version_code} to #{track} track..."),
:ok <- assign_track(token, package, edit_id, track, version_code),
log("Committing edit..."),
:ok <- commit_edit(token, package, edit_id) do
{:ok, version_code}
end
end
# ── Service account ──────────────────────────────────────────────────────────
defp load_service_account(path) do
case File.read(path) do
{:ok, json} ->
Jason.decode(json)
{:error, reason} ->
{:error, "Cannot read service account file #{path}: #{:file.format_error(reason)}"}
end
end
# ── JWT / OAuth2 ─────────────────────────────────────────────────────────────
defp fetch_access_token(sa) do
jwt = sign_jwt(sa)
body =
URI.encode_query(%{
"grant_type" => "urn:ietf:params:oauth:grant-type:jwt-bearer",
"assertion" => jwt
})
case post(@token_url, [{"content-type", "application/x-www-form-urlencoded"}], body) do
{:ok, %{"access_token" => token}} -> {:ok, token}
{:ok, resp} -> {:error, "Token exchange failed: #{inspect(resp)}"}
{:error, _} = err -> err
end
end
defp sign_jwt(sa) do
now = System.os_time(:second)
header =
%{"alg" => "RS256", "typ" => "JWT"}
|> Jason.encode!()
|> Base.url_encode64(padding: false)
payload =
%{
"iss" => sa["client_email"],
"scope" => @scope,
"aud" => @token_url,
"iat" => now,
"exp" => now + 3600
}
|> Jason.encode!()
|> Base.url_encode64(padding: false)
input = "#{header}.#{payload}"
key = decode_private_key(sa["private_key"])
sig = :public_key.sign(input, :sha256, key) |> Base.url_encode64(padding: false)
"#{input}.#{sig}"
end
# Google service account keys are PKCS#8 ("BEGIN PRIVATE KEY").
# pem_entry_decode/1 handles unwrapping to the inner RSAPrivateKey.
defp decode_private_key(pem) do
[entry] = :public_key.pem_decode(pem)
:public_key.pem_entry_decode(entry)
end
# ── Play API edit workflow ───────────────────────────────────────────────────
defp create_edit(token, package) do
case post("#{@play}/#{package}/edits", json_headers(token), "{}") do
{:ok, %{"id" => id}} -> {:ok, id}
{:ok, resp} -> {:error, "Create edit: #{inspect(resp)}"}
err -> err
end
end
defp upload_bundle(token, package, edit_id, aab_path) do
url = "#{@upload}/#{package}/edits/#{edit_id}/bundles?uploadType=media"
aab = File.read!(aab_path)
headers = [
{"authorization", "Bearer #{token}"},
{"content-type", "application/octet-stream"}
]
case post(url, headers, aab) do
{:ok, %{"versionCode" => vc}} -> {:ok, vc}
{:ok, resp} -> {:error, "Upload bundle: #{inspect(resp)}"}
err -> err
end
end
defp assign_track(token, package, edit_id, track, version_code) do
url = "#{@play}/#{package}/edits/#{edit_id}/tracks/#{track}"
body =
Jason.encode!(%{
"releases" => [
%{
"versionCodes" => [Integer.to_string(version_code)],
"status" => "completed"
}
]
})
case put(url, json_headers(token), body) do
{:ok, _} -> :ok
err -> err
end
end
defp commit_edit(token, package, edit_id) do
case post("#{@play}/#{package}/edits/#{edit_id}:commit", json_headers(token), "{}") do
{:ok, _} -> :ok
err -> err
end
end
# ── HTTP ─────────────────────────────────────────────────────────────────────
defp post(url, headers, body), do: HTTP.post(url, headers, body)
defp put(url, headers, body), do: HTTP.put(url, headers, body)
defp json_headers(token), do: HTTP.json_headers(token)
defp ensure_started!, do: HTTP.ensure_started!()
defp file_size(path) do
case File.stat(path) do
{:ok, %{size: b}} when b >= 1_048_576 ->
:io_lib.format("~.1fMB", [b / 1_048_576]) |> List.flatten() |> to_string()
{:ok, %{size: b}} ->
:io_lib.format("~.1fKB", [b / 1024]) |> List.flatten() |> to_string()
_ ->
"?"
end
end
defp log(msg), do: Mix.shell().info(" #{msg}")
end