Packages
mob_dev
0.3.35
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/cloud_setup.ex
defmodule MobDev.GooglePlay.CloudSetup do
@moduledoc """
Google Cloud REST API operations for the Play Store setup wizard.
Covers the three Google Cloud steps that `mix mob.setup.google_play` automates:
- Enabling the Android Publisher API in a Cloud project
- Creating a `play-publisher` service account
- Generating and saving a JSON key for that service account
All functions take an OAuth2 `access_token` obtained from `MobDev.GooglePlay.OAuth`.
## API surface used
| Step | API |
|------|-----|
| List projects | Cloud Resource Manager v3 |
| Enable Android Publisher API | Service Usage v1 |
| Create service account | IAM v1 |
| Create JSON key | IAM v1 |
"""
alias MobDev.GooglePlay.HTTP
@crm "https://cloudresourcemanager.googleapis.com/v3"
@iam "https://iam.googleapis.com/v1"
@service_usage "https://serviceusage.googleapis.com/v1"
@publisher_api "androidpublisher.googleapis.com"
@service_account_id "play-publisher"
# ── Projects ─────────────────────────────────────────────────────────────────
@doc """
Lists Google Cloud projects accessible to the authenticated user.
Returns `{:ok, [%{"projectId" => id, "displayName" => name, ...}]}` or
`{:error, reason}`.
"""
@spec list_projects(String.t()) :: {:ok, [map()]} | {:error, String.t()}
def list_projects(token) do
HTTP.ensure_started!()
url = "#{@crm}/projects?pageSize=100"
case HTTP.get(url, HTTP.json_headers(token)) do
{:ok, %{"projects" => projects}} -> {:ok, projects}
{:ok, resp} -> {:ok, Map.get(resp, "projects", [])}
{:error, _} = err -> err
end
end
@doc """
Parses the `projects` list from a Cloud Resource Manager response body.
Pure function — used for testing without HTTP calls.
"""
@spec parse_projects_response(map()) :: [map()]
def parse_projects_response(%{"projects" => projects}), do: projects
def parse_projects_response(_), do: []
# ── Enable API ───────────────────────────────────────────────────────────────
@doc """
Enables the Android Publisher API in the given Cloud project.
The operation may take up to 60 seconds; this function polls until complete.
Returns `:ok` or `{:error, reason}`.
"""
@spec enable_publisher_api(String.t(), String.t()) :: :ok | {:error, String.t()}
def enable_publisher_api(token, project_id) do
HTTP.ensure_started!()
url = build_enable_api_url(project_id, @publisher_api)
case HTTP.post(url, HTTP.json_headers(token), "{}") do
{:ok, %{"name" => op_name}} ->
poll_operation(token, op_name)
{:ok, %{"done" => true}} ->
:ok
{:ok, resp} ->
{:error, "Unexpected enable API response: #{inspect(resp)}"}
{:error, _} = err ->
err
end
end
@doc """
Builds the Service Usage API URL for enabling a specific service.
Pure function — useful for testing and debugging.
"""
@spec build_enable_api_url(String.t(), String.t()) :: String.t()
def build_enable_api_url(project_id, service_name) do
"#{@service_usage}/projects/#{project_id}/services/#{service_name}:enable"
end
# ── Service account ──────────────────────────────────────────────────────────
@doc """
Creates the `play-publisher` service account in the given Cloud project.
Returns `{:ok, email}` where `email` is the service account email, or
`{:error, reason}`.
If the service account already exists (HTTP 409), returns its email
without error.
"""
@spec create_service_account(String.t(), String.t(), String.t()) ::
{:ok, String.t()} | {:error, String.t()}
def create_service_account(token, project_id, display_name \\ "Mob Play Publisher") do
HTTP.ensure_started!()
url = "#{@iam}/projects/#{project_id}/serviceAccounts"
body =
Jason.encode!(%{
"accountId" => @service_account_id,
"serviceAccount" => %{"displayName" => display_name}
})
case HTTP.post(url, HTTP.json_headers(token), body) do
{:ok, %{"email" => email}} ->
{:ok, email}
{:error, "HTTP 409: " <> _} ->
# Already exists — derive the email from the project ID.
{:ok, "#{@service_account_id}@#{project_id}.iam.gserviceaccount.com"}
{:error, _} = err ->
err
end
end
# ── JSON key ─────────────────────────────────────────────────────────────────
@doc """
Creates a JSON key for the given service account and saves it to disk.
The key is written to `~/.google_play/{filename}.json` (mode 600).
Returns `{:ok, path}` where `path` is the absolute path to the saved file,
or `{:error, reason}`.
"""
@spec create_and_save_key(String.t(), String.t(), String.t(), String.t()) ::
{:ok, Path.t()} | {:error, String.t()}
def create_and_save_key(token, project_id, service_account_email, filename) do
HTTP.ensure_started!()
url = "#{@iam}/projects/#{project_id}/serviceAccounts/#{service_account_email}/keys"
case HTTP.post(url, HTTP.json_headers(token), "{}") do
{:ok, %{"privateKeyData" => b64_json}} ->
save_key_file(b64_json, filename)
{:ok, resp} ->
{:error, "Unexpected key creation response: #{inspect(resp)}"}
{:error, _} = err ->
err
end
end
@doc """
Decodes a base64url-encoded service account JSON key and saves it to disk.
The `b64_json` parameter is the `privateKeyData` field from the IAM keys API
response (standard base64, not URL-safe). Saved to `~/.google_play/{filename}.json`
with mode 600.
Pure IO side-effect (no HTTP) — useful for testing.
"""
@spec save_key_file(String.t(), String.t()) :: {:ok, Path.t()} | {:error, String.t()}
def save_key_file(b64_json, filename) do
dir = Path.expand("~/.google_play")
File.mkdir_p!(dir)
path = Path.join(dir, "#{filename}.json")
with {:ok, json_bytes} <- Base.decode64(b64_json, padding: true),
:ok <- File.write(path, json_bytes),
:ok <- File.chmod(path, 0o600) do
{:ok, path}
else
:error -> {:error, "Could not base64-decode the key data returned by Google"}
{:error, reason} -> {:error, "Could not write key file: #{inspect(reason)}"}
end
end
# ── Long-running operation polling ──────────────────────────────────────────
defp poll_operation(token, op_name, attempts \\ 0)
defp poll_operation(_token, op_name, 20) do
{:error, "Timed out waiting for operation #{op_name} to complete"}
end
defp poll_operation(token, op_name, attempts) do
url = "#{@service_usage}/#{op_name}"
case HTTP.get(url, HTTP.json_headers(token)) do
{:ok, %{"done" => true, "error" => err}} ->
{:error, "Operation failed: #{inspect(err)}"}
{:ok, %{"done" => true}} ->
:ok
{:ok, _} ->
Process.sleep(3_000 + attempts * 1_000)
poll_operation(token, op_name, attempts + 1)
{:error, _} = err ->
err
end
end
end