Add Google Play listings: asc play and a play crate (#43)

New smbcloud-ascapi-play crate talks to the Play Developer API (Edits v3) with a service account: RS256 JWT exchanged for a cached access token. It covers listings, listing images and track release notes. asc play listings|images|tracks open one edit per command, validate, and commit only without --dry-run, then read the result back. Text limits, image sizes and release-note length are checked before anything is sent. MCP gets three read-only tools; writes stay CLI-only. The ASC key and issuer flags are no longer required by clap, since play commands don't use them. The existing runtime check still reports them missing for every other command. Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>

Seto Elkahfi committed Oct 25, 2026 at 16:07 UTC 1ab9b4b045e5e685c2a2c96c9907fa9f49302eb7
21 files changed +1883 -8
.agents/AGENTS.md
+33 -1
@@ -102,7 +102,7 @@ cargo fmt --all -- --check
102 cargo clippy --workspace --all-targets --locked -- -D warnings
103 cargo test --workspace --locked
104 RUSTDOCFLAGS="-D warnings" cargo doc --no-deps --workspace --locked
105 -cargo check -p smbcloud-ascapi-core --locked # also -aso, -signing: each lower crate must build alone
105 +cargo check -p smbcloud-ascapi-core --locked # also -aso, -play, -signing: each lower crate must build alone
106 ```
107
108 Single test: `cargo test -p smbcloud-ascapi-aso build::tests::<name>`.
@@ -224,3 +224,35 @@ The API is `ReviewSubmissionsApi` in `crates/aso/src/review_submission.rs`;
224 the plan/submit orchestration is `crates/frontend/src/review.rs`. Submitting is
225 CLI-only on purpose: it sends a build to App Review, which an MCP tool call
226 shouldn't do on a model's say-so.
227 +
228 +## Google Play (CLI)
229 +
230 +`crates/play` is the one non-Apple store backend. It does not depend on
231 +`core`: Play authenticates with a service account (RS256 JWT exchanged at
232 +`oauth2.googleapis.com/token`, scope `androidpublisher`) and has its own error
233 +envelope. The key comes from `--play-service-account`,
234 +`PLAY_SERVICE_ACCOUNT_JSON_PATH`, or the contents in `PLAY_SERVICE_ACCOUNT_JSON`.
235 +`asc play` does not need the App Store Connect key.
236 +
237 +Everything in Play happens inside an *edit*. Each command opens one, applies,
238 +`validate`s, and commits only without `--dry-run`; a dry run prints the body
239 +and deletes the edit. Writes read back after commit and fail on any difference.
240 +
241 +```sh
242 +asc play listings list <PACKAGE>
243 +asc play listings update <PACKAGE> --locale en-US \
244 + --title-file t.txt --short-description-file s.txt --full-description-file d.txt
245 +asc play images list <PACKAGE> --locale en-US --type phone-screenshots
246 +asc play images replace <PACKAGE> --locale en-US --type phone-screenshots --file a.png --file b.png
247 +asc play tracks get <PACKAGE> --track internal
248 +asc play tracks set-notes <PACKAGE> --track internal --version-code 120 --locale en-US --notes-file n.txt
249 +```
250 +
251 +Limits linted before sending: title 30, short description 80, full description
252 +4000, release notes 500 characters per language; feature graphic exactly
253 +1024x500, icon 512x512, screenshots 320 to 3840 px with at most 2:1 aspect.
254 +`images replace` deletes the bucket and uploads the files in one edit, so a
255 +failed upload keeps the old set. MCP exposes only the reads
256 +(`play_listing_list`, `play_images_list`, `play_track_get`); every write stays
257 +CLI-only, like review submission. The operations live in
258 +`crates/frontend/src/play.rs`.
Cargo.lock
+46 -1
@@ -192,6 +192,15 @@ version = "2.13.2"
192 source = "registry+https://github.com/rust-lang/crates.io-index"
193 checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
194
195 +[[package]]
196 +name = "block-buffer"
197 +version = "0.10.4"
198 +source = "registry+https://github.com/rust-lang/crates.io-index"
199 +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
200 +dependencies = [
201 + "generic-array",
202 +]
203 +
204 [[package]]
205 name = "bumpalo"
206 version = "3.20.3"
@@ -235,7 +244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
244 checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
245 dependencies = [
246 "cfg-if",
238 - "cpufeatures",
247 + "cpufeatures 0.3.1",
248 "rand_core 0.10.1",
249 ]
250
@@ -328,6 +337,15 @@ version = "0.8.7"
337 source = "registry+https://github.com/rust-lang/crates.io-index"
338 checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
339
340 +[[package]]
341 +name = "cpufeatures"
342 +version = "0.2.17"
343 +source = "registry+https://github.com/rust-lang/crates.io-index"
344 +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
345 +dependencies = [
346 + "libc",
347 +]
348 +
349 [[package]]
350 name = "cpufeatures"
351 version = "0.3.1"
@@ -454,6 +472,7 @@ version = "0.10.7"
472 source = "registry+https://github.com/rust-lang/crates.io-index"
473 checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
474 dependencies = [
475 + "block-buffer",
476 "const-oid",
477 "crypto-common",
478 ]
@@ -1679,6 +1698,17 @@ dependencies = [
1698 "serde",
1699 ]
1700
1701 +[[package]]
1702 +name = "sha2"
1703 +version = "0.10.9"
1704 +source = "registry+https://github.com/rust-lang/crates.io-index"
1705 +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
1706 +dependencies = [
1707 + "cfg-if",
1708 + "cpufeatures 0.2.17",
1709 + "digest",
1710 +]
1711 +
1712 [[package]]
1713 name = "shlex"
1714 version = "2.0.1"
@@ -1752,6 +1782,7 @@ dependencies = [
1782 "smbcloud-ascapi-core",
1783 "smbcloud-ascapi-frontend",
1784 "smbcloud-ascapi-mcp",
1785 + "smbcloud-ascapi-play",
1786 "smbcloud-ascapi-pricing",
1787 "smbcloud-ascapi-reports",
1788 "smbcloud-ascapi-signing",
@@ -1777,8 +1808,10 @@ dependencies = [
1808 "schemars",
1809 "serde",
1810 "serde_json",
1811 + "sha2",
1812 "smbcloud-ascapi-aso",
1813 "smbcloud-ascapi-core",
1814 + "smbcloud-ascapi-play",
1815 "smbcloud-ascapi-signing",
1816 "tokio",
1817 ]
@@ -1799,6 +1832,18 @@ dependencies = [
1832 "tokio",
1833 ]
1834
1835 +[[package]]
1836 +name = "smbcloud-ascapi-play"
1837 +version = "0.3.0"
1838 +dependencies = [
1839 + "jsonwebtoken",
1840 + "reqwest",
1841 + "serde",
1842 + "serde_json",
1843 + "thiserror",
1844 + "tokio",
1845 +]
1846 +
1847 [[package]]
1848 name = "smbcloud-ascapi-pricing"
1849 version = "0.3.0"
Cargo.toml
+2
@@ -3,6 +3,7 @@ resolver = "2"
3 members = [
4 "crates/core",
5 "crates/aso",
6 + "crates/play",
7 "crates/signing",
8 "crates/pricing",
9 "crates/reports",
@@ -21,6 +22,7 @@ repository = "https://github.com/smbcloudXYZ/smbcloud-ascapi"
22 [workspace.dependencies]
23 smbcloud-ascapi-core = { version = "0.3.0", path = "crates/core" }
24 smbcloud-ascapi-aso = { version = "0.3.0", path = "crates/aso" }
25 +smbcloud-ascapi-play = { version = "0.3.0", path = "crates/play" }
26 smbcloud-ascapi-signing = { version = "0.3.0", path = "crates/signing" }
27 smbcloud-ascapi-pricing = { version = "0.3.0", path = "crates/pricing" }
28 smbcloud-ascapi-reports = { version = "0.3.0", path = "crates/reports" }
README.md
+2 -1
@@ -8,6 +8,7 @@ from the other:
8 | Crate | What it is |
9 | --- | --- |
10 | `smbcloud-ascapi-core` | Shared transport: JWT auth, the HTTP client, JSON:API envelopes, error types |
11 +| `smbcloud-ascapi-play` | Google Play (Edits v3): store listings, listing images, track release notes; service-account auth, independent of `core` |
12 | `smbcloud-ascapi-aso` | App Metadata: apps, app infos, versions, builds, pre-release versions, review submissions, bundle IDs, localizations, screenshots |
13 | `smbcloud-ascapi-pricing` | Pricing: app price schedules, and the per-territory prices joined with their price points and currencies |
14 | `smbcloud-ascapi-signing` | Code signing: certificates and provisioning profiles, plus local RSA key pair and CSR generation |
@@ -16,7 +17,7 @@ from the other:
17 | `smbcloud-ascapi-mcp` | The MCP contract and stdio server |
18 | `smbcloud-ascapi-cli` | The `asc` binary: clap command tree, plus `--mcp` |
19
19 -`aso`, `pricing`, `signing` and `reports` know nothing about each other, and neither knows
20 +`aso`, `play`, `pricing`, `signing` and `reports` know nothing about each other, and neither knows
21 anything about the front ends. Each adds its calls to
22 `smbcloud_ascapi_core::Client` as **extension traits**, since Rust only
23 allows inherent impls in the crate that defines a type:
crates/cli/Cargo.toml
+1
@@ -22,6 +22,7 @@ serde = { workspace = true }
22 serde_json = { workspace = true }
23 smbcloud-ascapi-aso = { workspace = true }
24 smbcloud-ascapi-core = { workspace = true }
25 +smbcloud-ascapi-play = { workspace = true }
26 smbcloud-ascapi-pricing = { workspace = true }
27 smbcloud-ascapi-signing = { workspace = true }
28 smbcloud-ascapi-reports = { workspace = true }
crates/cli/src/main.rs
+262 -2
@@ -23,6 +23,7 @@ use smbcloud_ascapi_frontend::attach_build::attach_build;
23 use smbcloud_ascapi_frontend::await_processing::{await_processing, AwaitConfig};
24 use smbcloud_ascapi_frontend::review;
25 use smbcloud_ascapi_frontend::upload::{self, upload_package, validate_app, AltoolAuth, AltoolOp};
26 +use smbcloud_ascapi_play::{ImageType as PlayImageType, ListingFields as PlayListingFields};
27 use smbcloud_ascapi_pricing::app_price::PriceKind;
28 use smbcloud_ascapi_pricing::prelude::*;
29 use smbcloud_ascapi_reports::prelude::*;
@@ -46,11 +47,11 @@ struct Cli {
47 /// Store Connect API). Not required with `--mcp`, which resolves
48 /// credentials per tool call so an unconfigured server can still list
49 /// its tools.
49 - #[arg(long, env = "ASC_API_KEY", required_unless_present = "mcp")]
50 + #[arg(long, env = "ASC_API_KEY")]
51 key_id: Option<String>,
52
53 /// App Store Connect API issuer ID (same page as the key).
53 - #[arg(long, env = "ASC_ISSUER_ID", required_unless_present = "mcp")]
54 + #[arg(long, env = "ASC_ISSUER_ID")]
55 issuer_id: Option<String>,
56
57 /// Run as an MCP server over stdio instead of executing a subcommand.
@@ -75,8 +76,145 @@ struct Cli {
76 command: Option<Command>,
77 }
78
79 +#[derive(Subcommand)]
80 +enum PlayCommand {
81 + /// Localized store listing text (title, short and full description).
82 + Listings {
83 + #[command(subcommand)]
84 + command: PlayListingsCommand,
85 + },
86 + /// Listing images: screenshots, feature graphic, icon.
87 + Images {
88 + #[command(subcommand)]
89 + command: PlayImagesCommand,
90 + },
91 + /// Release notes on a track release.
92 + Tracks {
93 + #[command(subcommand)]
94 + command: PlayTracksCommand,
95 + },
96 +}
97 +
98 +#[derive(Subcommand)]
99 +enum PlayListingsCommand {
100 + /// Every localized listing of an app.
101 + List { package: String },
102 + /// Patch one locale's listing, then read it back. With `--dry-run` the
103 + /// edit is opened, the body printed, and the edit deleted.
104 + Update {
105 + package: String,
106 + #[arg(long)]
107 + locale: String,
108 + /// Title, up to 30 characters.
109 + #[arg(long, conflicts_with = "title_file")]
110 + title: Option<String>,
111 + /// Read the title from a UTF-8 file; one trailing newline is dropped.
112 + #[arg(long)]
113 + title_file: Option<PathBuf>,
114 + /// Short description, up to 80 characters.
115 + #[arg(long, conflicts_with = "short_description_file")]
116 + short_description: Option<String>,
117 + /// Read the short description from a UTF-8 file.
118 + #[arg(long)]
119 + short_description_file: Option<PathBuf>,
120 + /// Full description, up to 4000 characters.
121 + #[arg(long, conflicts_with = "full_description_file")]
122 + full_description: Option<String>,
123 + /// Read the full description from a UTF-8 file.
124 + #[arg(long)]
125 + full_description_file: Option<PathBuf>,
126 + },
127 +}
128 +
129 +#[derive(Clone, Copy, ValueEnum)]
130 +enum CliPlayImageType {
131 + PhoneScreenshots,
132 + SevenInchScreenshots,
133 + TenInchScreenshots,
134 + FeatureGraphic,
135 + Icon,
136 +}
137 +
138 +impl From<CliPlayImageType> for PlayImageType {
139 + fn from(value: CliPlayImageType) -> Self {
140 + match value {
141 + CliPlayImageType::PhoneScreenshots => Self::PhoneScreenshots,
142 + CliPlayImageType::SevenInchScreenshots => Self::SevenInchScreenshots,
143 + CliPlayImageType::TenInchScreenshots => Self::TenInchScreenshots,
144 + CliPlayImageType::FeatureGraphic => Self::FeatureGraphic,
145 + CliPlayImageType::Icon => Self::Icon,
146 + }
147 + }
148 +}
149 +
150 +#[derive(Subcommand)]
151 +enum PlayImagesCommand {
152 + /// Images in one bucket of one locale.
153 + List {
154 + package: String,
155 + #[arg(long)]
156 + locale: String,
157 + #[arg(long = "type")]
158 + image_type: CliPlayImageType,
159 + },
160 + /// Replace a bucket with the given files, in order, in one edit: the old
161 + /// set is deleted and the new one uploaded, so a failed upload keeps the
162 + /// old set. Sizes are checked before anything is sent.
163 + Replace {
164 + package: String,
165 + #[arg(long)]
166 + locale: String,
167 + #[arg(long = "type")]
168 + image_type: CliPlayImageType,
169 + /// PNG or JPEG file; repeat for each image, in display order.
170 + #[arg(long = "file", required = true)]
171 + files: Vec<PathBuf>,
172 + },
173 +}
174 +
175 +#[derive(Subcommand)]
176 +enum PlayTracksCommand {
177 + /// A track and its releases, with their release notes.
178 + Get {
179 + package: String,
180 + #[arg(long)]
181 + track: String,
182 + },
183 + /// Set one locale's release notes (up to 500 characters) on the release
184 + /// carrying a version code, then read the track back.
185 + SetNotes {
186 + package: String,
187 + #[arg(long)]
188 + track: String,
189 + #[arg(long)]
190 + version_code: String,
191 + #[arg(long)]
192 + locale: String,
193 + #[arg(
194 + long,
195 + conflicts_with = "notes_file",
196 + required_unless_present = "notes_file"
197 + )]
198 + notes: Option<String>,
199 + /// Read the notes from a UTF-8 file; one trailing newline is dropped.
200 + #[arg(long)]
201 + notes_file: Option<PathBuf>,
202 + },
203 +}
204 +
205 #[derive(Subcommand)]
206 enum Command {
207 + /// Google Play store listings: text, images, and release notes. Uses a
208 + /// Play service account, not the App Store Connect key.
209 + Play {
210 + /// Path to the Play service-account JSON key. Falls back to
211 + /// `PLAY_SERVICE_ACCOUNT_JSON_PATH`, then to the key contents in
212 + /// `PLAY_SERVICE_ACCOUNT_JSON`.
213 + #[arg(long, global = true)]
214 + play_service_account: Option<PathBuf>,
215 + #[command(subcommand)]
216 + command: PlayCommand,
217 + },
218 /// Look up apps by bundle ID, or a specific app by its ASC app ID.
219 Apps {
220 #[command(subcommand)]
@@ -159,6 +297,119 @@ enum Command {
297 },
298 }
299
300 +async fn run_play(
301 + service_account: Option<&std::path::Path>,
302 + command: PlayCommand,
303 + dry_run: bool,
304 +) -> Result<()> {
305 + use smbcloud_ascapi_frontend::play;
306 + let client = play::play_client(service_account).map_err(|e| anyhow::anyhow!(e))?;
307 + let fail = |e: String| anyhow::anyhow!(e);
308 + match command {
309 + PlayCommand::Listings { command } => match command {
310 + PlayListingsCommand::List { package } => {
311 + print_json(&play::read_listings(&client, &package).await.map_err(fail)?)
312 + }
313 + PlayListingsCommand::Update {
314 + package,
315 + locale,
316 + title,
317 + title_file,
318 + short_description,
319 + short_description_file,
320 + full_description,
321 + full_description_file,
322 + } => {
323 + let fields = PlayListingFields {
324 + title: text_arg(title, title_file, "--title-file")?,
325 + short_description: text_arg(
326 + short_description,
327 + short_description_file,
328 + "--short-description-file",
329 + )?,
330 + full_description: text_arg(
331 + full_description,
332 + full_description_file,
333 + "--full-description-file",
334 + )?,
335 + };
336 + let result = play::update_listing(&client, &package, &locale, fields, dry_run)
337 + .await
338 + .map_err(fail)?;
339 + if result.dry_run {
340 + println!("# dry run: nothing was committed; body that would be sent:");
341 + }
342 + print_json(&result)
343 + }
344 + },
345 + PlayCommand::Images { command } => match command {
346 + PlayImagesCommand::List {
347 + package,
348 + locale,
349 + image_type,
350 + } => print_json(
351 + &play::read_images(&client, &package, &locale, image_type.into())
352 + .await
353 + .map_err(fail)?,
354 + ),
355 + PlayImagesCommand::Replace {
356 + package,
357 + locale,
358 + image_type,
359 + files,
360 + } => {
361 + let result = play::replace_images(
362 + &client,
363 + &package,
364 + &locale,
365 + image_type.into(),
366 + &files,
367 + dry_run,
368 + )
369 + .await
370 + .map_err(fail)?;
371 + if result.dry_run {
372 + println!("# dry run: nothing was committed; images that would be uploaded:");
373 + }
374 + print_json(&result)
375 + }
376 + },
377 + PlayCommand::Tracks { command } => match command {
378 + PlayTracksCommand::Get { package, track } => print_json(
379 + &play::read_track(&client, &package, &track)
380 + .await
381 + .map_err(fail)?,
382 + ),
383 + PlayTracksCommand::SetNotes {
384 + package,
385 + track,
386 + version_code,
387 + locale,
388 + notes,
389 + notes_file,
390 + } => {
391 + let notes = text_arg(notes, notes_file, "--notes-file")?
392 + .context("--notes or --notes-file is required")?;
393 + let result = play::set_track_notes(
394 + &client,
395 + &package,
396 + &track,
397 + &version_code,
398 + &locale,
399 + &notes,
400 + dry_run,
401 + )
402 + .await
403 + .map_err(fail)?;
404 + if result.dry_run {
405 + println!("# dry run: nothing was committed; track as it would be:");
406 + }
407 + print_json(&result)
408 + }
409 + },
410 + }
411 +}
412 +
413 /// macOS-only signing diagnostics.
414 #[cfg(target_os = "macos")]
415 #[derive(Subcommand)]
@@ -1231,6 +1482,14 @@ async fn main() -> Result<()> {
1482 return smbcloud_ascapi_mcp::serve().await;
1483 }
1484
1485 + if let Some(Command::Play {
1486 + play_service_account,
1487 + command,
1488 + }) = cli.command
1489 + {
1490 + return run_play(play_service_account.as_deref(), command, cli.dry_run).await;
1491 + }
1492 +
1493 let key_id = cli
1494 .key_id
1495 .clone()
@@ -1262,6 +1521,7 @@ async fn main() -> Result<()> {
1521 };
1522
1523 match command {
1524 + Command::Play { .. } => unreachable!("handled before App Store Connect credentials"),
1525 Command::Apps { command } => run_apps(&client, &altool_auth, command, cli.dry_run).await,
1526 Command::BundleIds { command } => run_bundle_ids(&client, command, cli.dry_run).await,
1527 Command::AppStoreVersions { command } => {
crates/frontend/Cargo.toml
+2
@@ -16,7 +16,9 @@ base64 = { workspace = true }
16 schemars = { workspace = true }
17 serde = { workspace = true, features = ["derive"] }
18 serde_json = { workspace = true }
19 +sha2 = "0.10"
20 smbcloud-ascapi-aso = { workspace = true }
21 smbcloud-ascapi-core = { workspace = true }
22 +smbcloud-ascapi-play = { workspace = true }
23 smbcloud-ascapi-signing = { workspace = true }
24 tokio = { workspace = true, features = ["time"] }
crates/frontend/src/lib.rs
+1
@@ -27,6 +27,7 @@ pub mod enums;
27 pub mod env;
28 #[cfg(target_os = "macos")]
29 pub mod keychain;
30 +pub mod play;
31 pub mod profiles;
32 pub mod review;
33 pub mod time;
crates/frontend/src/play.rs new
+418
@@ -0,0 +1,418 @@
1 +//! Google Play listing operations shared by `asc play` and the MCP tools.
2 +//!
3 +//! Play does everything through edits: open one, change things, `validate`,
4 +//! then `commit`. Every function here opens exactly one edit and closes it
5 +//! again, so a failure or a dry run leaves nothing behind. Reads open an edit
6 +//! too (the API has no edit-less read for listings) and delete it after.
7 +
8 +use std::path::{Path, PathBuf};
9 +
10 +use serde::Serialize;
11 +use sha2::{Digest, Sha256};
12 +use smbcloud_ascapi_play::{
13 + png_dimensions, Image, ImageType, Listing, ListingFields, PlayClient, ServiceAccount, Track,
14 +};
15 +
16 +/// Resolve the Play client. `path` wins; then `PLAY_SERVICE_ACCOUNT_JSON_PATH`;
17 +/// then the key contents inline in `PLAY_SERVICE_ACCOUNT_JSON`. Per call, like
18 +/// the App Store Connect credentials, so an unconfigured MCP server still
19 +/// lists its tools. The key itself never appears in an error.
20 +pub fn play_client(path: Option<&Path>) -> Result<PlayClient, String> {
21 + let account = if let Some(path) = path {
22 + ServiceAccount::from_json_file(path)
23 + } else if let Ok(path) = std::env::var("PLAY_SERVICE_ACCOUNT_JSON_PATH") {
24 + ServiceAccount::from_json_file(path)
25 + } else if let Ok(json) = std::env::var("PLAY_SERVICE_ACCOUNT_JSON") {
26 + ServiceAccount::from_json(&json)
27 + } else {
28 + return Err(
29 + "no Google Play credentials: pass --play-service-account, or set \
30 + PLAY_SERVICE_ACCOUNT_JSON_PATH (or PLAY_SERVICE_ACCOUNT_JSON)"
31 + .to_string(),
32 + );
33 + };
34 + account.map(PlayClient::new).map_err(|e| e.to_string())
35 +}
36 +
37 +/// Parse an image bucket name given as `phone-screenshots` or
38 +/// `phoneScreenshots`.
39 +pub fn image_type_from_str(value: &str) -> Result<ImageType, String> {
40 + match value.replace('-', "").to_ascii_lowercase().as_str() {
41 + "phonescreenshots" => Ok(ImageType::PhoneScreenshots),
42 + "seveninchscreenshots" => Ok(ImageType::SevenInchScreenshots),
43 + "teninchscreenshots" => Ok(ImageType::TenInchScreenshots),
44 + "featuregraphic" => Ok(ImageType::FeatureGraphic),
45 + "icon" => Ok(ImageType::Icon),
46 + other => Err(format!(
47 + "unknown image type {other:?}; use phone-screenshots, seven-inch-screenshots, \
48 + ten-inch-screenshots, feature-graphic or icon"
49 + )),
50 + }
51 +}
52 +
53 +fn err(error: impl std::fmt::Display) -> String {
54 + error.to_string()
55 +}
56 +
57 +/// Open an edit, run the body inside it, then commit (after `validate`) when
58 +/// `commit` is set, or delete the edit otherwise. A failure anywhere deletes
59 +/// the edit and returns the first error. A macro rather than a function so the
60 +/// body can borrow from the caller's scope.
61 +macro_rules! in_edit {
62 + ($client:expr, $package:expr, $commit:expr, |$c:ident, $id:ident| $body:expr) => {{
63 + let $c: &PlayClient = $client;
64 + let edit = $c.insert_edit($package).await.map_err(err)?;
65 + let $id: &str = edit.id.as_str();
66 + let outcome: Result<_, String> = $body.await;
67 + finish($c, $package, $id, $commit, outcome).await
68 + }};
69 +}
70 +
71 +async fn finish<T>(
72 + client: &PlayClient,
73 + package: &str,
74 + edit_id: &str,
75 + commit: bool,
76 + outcome: Result<T, String>,
77 +) -> Result<T, String> {
78 + let outcome = match outcome {
79 + Ok(value) if commit => {
80 + async {
81 + client.validate_edit(package, edit_id).await.map_err(err)?;
82 + client.commit_edit(package, edit_id).await.map_err(err)?;
83 + Ok(value)
84 + }
85 + .await
86 + }
87 + other => other,
88 + };
89 + if !(commit && outcome.is_ok()) {
90 + // Best effort: an edit that fails to delete simply expires.
91 + let _ = client.delete_edit(package, edit_id).await;
92 + }
93 + outcome
94 +}
95 +
96 +pub async fn read_listings(client: &PlayClient, package: &str) -> Result<Vec<Listing>, String> {
97 + in_edit!(client, package, false, |c, id| {
98 + Box::pin(async move { c.list_listings(package, id).await.map_err(err) })
99 + })
100 +}
101 +
102 +pub async fn read_images(
103 + client: &PlayClient,
104 + package: &str,
105 + locale: &str,
106 + image_type: ImageType,
107 +) -> Result<Vec<Image>, String> {
108 + in_edit!(client, package, false, |c, id| {
109 + Box::pin(async move {
110 + c.list_images(package, id, locale, image_type)
111 + .await
112 + .map_err(err)
113 + })
114 + })
115 +}
116 +
117 +pub async fn read_track(client: &PlayClient, package: &str, track: &str) -> Result<Track, String> {
118 + in_edit!(client, package, false, |c, id| {
119 + Box::pin(async move { c.get_track(package, id, track).await.map_err(err) })
120 + })
121 +}
122 +
123 +/// The result of a listing update.
124 +#[derive(Debug, Serialize)]
125 +#[serde(rename_all = "camelCase")]
126 +pub struct ListingUpdate {
127 + pub dry_run: bool,
128 + /// What was (or, on a dry run, would be) sent.
129 + pub sent: ListingFields,
130 + /// The listing before the change, if the locale existed.
131 + pub before: Option<Listing>,
132 + /// The listing read back after commit. `None` on a dry run.
133 + pub after: Option<Listing>,
134 +}
135 +
136 +pub async fn update_listing(
137 + client: &PlayClient,
138 + package: &str,
139 + locale: &str,
140 + fields: ListingFields,
141 + dry_run: bool,
142 +) -> Result<ListingUpdate, String> {
143 + fields.validate()?;
144 + if fields.is_empty() {
145 + return Err("nothing to update: pass at least one listing field".to_string());
146 + }
147 + let sent = fields.clone();
148 + let before = in_edit!(client, package, !dry_run, |c, id| {
149 + Box::pin(async move {
150 + let before = c.get_listing(package, id, locale).await.ok();
151 + if !dry_run {
152 + c.patch_listing(package, id, locale, &fields)
153 + .await
154 + .map_err(err)?;
155 + }
156 + Ok(before)
157 + })
158 + })?;
159 + let after = if dry_run {
160 + None
161 + } else {
162 + let stored = in_edit!(client, package, false, |c, id| {
163 + Box::pin(async move { c.get_listing(package, id, locale).await.map_err(err) })
164 + })?;
165 + let wrong = sent.diff(&stored);
166 + if !wrong.is_empty() {
167 + return Err(format!(
168 + "committed, but the read-back differs in: {}",
169 + wrong.join(", ")
170 + ));
171 + }
172 + Some(stored)
173 + };
174 + Ok(ListingUpdate {
175 + dry_run,
176 + sent,
177 + before,
178 + after,
179 + })
180 +}
181 +
182 +/// One local file picked for upload.
183 +#[derive(Debug, Clone, Serialize)]
184 +#[serde(rename_all = "camelCase")]
185 +pub struct ImageFile {
186 + pub path: PathBuf,
187 + pub bytes: usize,
188 + pub sha256: String,
189 + pub width: Option<u32>,
190 + pub height: Option<u32>,
191 + #[serde(skip)]
192 + content_type: &'static str,
193 + #[serde(skip)]
194 + data: Vec<u8>,
195 +}
196 +
197 +/// Read and lint a local image: PNG or JPEG, and for PNGs the pixel size
198 +/// against the bucket's rules.
199 +pub fn load_image(path: &Path, image_type: ImageType) -> Result<ImageFile, String> {
200 + let data = std::fs::read(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
201 + let extension = path
202 + .extension()
203 + .and_then(|e| e.to_str())
204 + .map(str::to_ascii_lowercase);
205 + let content_type = match extension.as_deref() {
206 + Some("png") => "image/png",
207 + Some("jpg") | Some("jpeg") => "image/jpeg",
208 + _ => return Err(format!("{}: Play accepts only PNG or JPEG", path.display())),
209 + };
210 + let dims = png_dimensions(&data);
211 + if let Some((w, h)) = dims {
212 + image_type
213 + .check_dimensions(w, h)
214 + .map_err(|m| format!("{}: {m}", path.display()))?;
215 + } else if content_type == "image/png" {
216 + return Err(format!("{}: not a valid PNG", path.display()));
217 + }
218 + Ok(ImageFile {
219 + path: path.to_path_buf(),
220 + bytes: data.len(),
221 + sha256: hex(&Sha256::digest(&data)),
222 + width: dims.map(|d| d.0),
223 + height: dims.map(|d| d.1),
224 + content_type,
225 + data,
226 + })
227 +}
228 +
229 +fn hex(bytes: &[u8]) -> String {
230 + bytes.iter().map(|b| format!("{b:02x}")).collect()
231 +}
232 +
233 +#[derive(Debug, Serialize)]
234 +#[serde(rename_all = "camelCase")]
235 +pub struct ImagesReplaced {
236 + pub dry_run: bool,
237 + pub image_type: String,
238 + pub locale: String,
239 + pub files: Vec<ImageFile>,
240 + /// How many images the bucket held before the replace.
241 + pub previously: usize,
242 + /// The bucket read back after commit. `None` on a dry run.
243 + pub after: Option<Vec<Image>>,
244 +}
245 +
246 +/// Delete the bucket's images and upload `paths` in order, inside one edit,
247 +/// so a failed upload leaves the old set in place.
248 +pub async fn replace_images(
249 + client: &PlayClient,
250 + package: &str,
251 + locale: &str,
252 + image_type: ImageType,
253 + paths: &[PathBuf],
254 + dry_run: bool,
255 +) -> Result<ImagesReplaced, String> {
256 + if paths.is_empty() {
257 + return Err("no image files given".to_string());
258 + }
259 + let files = paths
260 + .iter()
261 + .map(|p| load_image(p, image_type))
262 + .collect::<Result<Vec<_>, _>>()?;
263 + let previously = in_edit!(client, package, !dry_run, |c, id| {
264 + let files = &files;
265 + Box::pin(async move {
266 + let previously = c
267 + .list_images(package, id, locale, image_type)
268 + .await
269 + .map_err(err)?
270 + .len();
271 + if !dry_run {
272 + c.delete_all_images(package, id, locale, image_type)
273 + .await
274 + .map_err(err)?;
275 + for file in files {
276 + c.upload_image(
277 + package,
278 + id,
279 + locale,
280 + image_type,
281 + file.content_type,
282 + file.data.clone(),
283 + )
284 + .await
285 + .map_err(|e| format!("{}: {e}", file.path.display()))?;
286 + }
287 + }
288 + Ok(previously)
289 + })
290 + })?;
291 + let after = if dry_run {
292 + None
293 + } else {
294 + let images = read_images(client, package, locale, image_type).await?;
295 + if images.len() != files.len() {
296 + return Err(format!(
297 + "committed, but the bucket holds {} images; expected {}",
298 + images.len(),
299 + files.len()
300 + ));
301 + }
302 + for (image, file) in images.iter().zip(&files) {
303 + if image.sha256.as_deref().is_some_and(|s| s != file.sha256) {
304 + return Err(format!(
305 + "committed, but the stored copy of {} has a different sha256",
306 + file.path.display()
307 + ));
308 + }
309 + }
310 + Some(images)
311 + };
312 + Ok(ImagesReplaced {
313 + dry_run,
314 + image_type: image_type.api_name().to_string(),
315 + locale: locale.to_string(),
316 + files,
317 + previously,
318 + after,
319 + })
320 +}
321 +
322 +#[derive(Debug, Serialize)]
323 +#[serde(rename_all = "camelCase")]
324 +pub struct NotesSet {
325 + pub dry_run: bool,
326 + /// The track as it will be (dry run) or is (read back after commit).
327 + pub track: Track,
328 +}
329 +
330 +pub async fn set_track_notes(
331 + client: &PlayClient,
332 + package: &str,
333 + track_name: &str,
334 + version_code: &str,
335 + locale: &str,
336 + notes: &str,
337 + dry_run: bool,
338 +) -> Result<NotesSet, String> {
339 + let planned = in_edit!(client, package, !dry_run, |c, id| {
340 + Box::pin(async move {
341 + let mut track = c.get_track(package, id, track_name).await.map_err(err)?;
342 + track.set_release_notes(version_code, locale, notes)?;
343 + if !dry_run {
344 + c.update_track(package, id, &track).await.map_err(err)?;
345 + }
346 + Ok(track)
347 + })
348 + })?;
349 + if dry_run {
350 + return Ok(NotesSet {
351 + dry_run,
352 + track: planned,
353 + });
354 + }
355 + let track = read_track(client, package, track_name).await?;
356 + let stored = track
357 + .releases
358 + .iter()
359 + .filter(|r| r.version_codes.iter().any(|c| c == version_code))
360 + .flat_map(|r| &r.release_notes)
361 + .any(|n| n.language == locale && n.text == notes);
362 + if !stored {
363 + return Err("committed, but the read-back release notes differ".to_string());
364 + }
365 + Ok(NotesSet { dry_run, track })
366 +}
367 +
368 +#[cfg(test)]
369 +mod tests {
370 + use super::*;
371 +
372 + #[test]
373 + fn image_types_parse_in_both_spellings() {
374 + assert_eq!(
375 + image_type_from_str("phone-screenshots"),
376 + Ok(ImageType::PhoneScreenshots)
377 + );
378 + assert_eq!(
379 + image_type_from_str("featureGraphic"),
380 + Ok(ImageType::FeatureGraphic)
381 + );
382 + assert!(image_type_from_str("tv").is_err());
383 + }
384 +
385 + #[test]
386 + fn load_image_rejects_other_formats() {
387 + let dir = std::env::temp_dir();
388 + let path = dir.join("ascapi-play-test.gif");
389 + std::fs::write(&path, b"GIF89a").unwrap();
390 + let error = load_image(&path, ImageType::PhoneScreenshots).unwrap_err();
391 + assert!(error.contains("PNG or JPEG"));
392 + let _ = std::fs::remove_file(path);
393 + }
394 +
395 + #[test]
396 + fn load_image_lints_png_size_for_the_bucket() {
397 + let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
398 + png.extend_from_slice(b"IHDR");
399 + png.extend_from_slice(&1000u32.to_be_bytes());
400 + png.extend_from_slice(&500u32.to_be_bytes());
401 + let path = std::env::temp_dir().join("ascapi-play-test.png");
402 + std::fs::write(&path, &png).unwrap();
403 + assert!(load_image(&path, ImageType::FeatureGraphic).is_err());
404 + assert!(load_image(&path, ImageType::PhoneScreenshots).is_ok());
405 + let _ = std::fs::remove_file(path);
406 + }
407 +
408 + #[test]
409 + fn missing_credentials_name_the_options_without_a_key() {
410 + // Only meaningful when the variables are unset, as in CI.
411 + if std::env::var_os("PLAY_SERVICE_ACCOUNT_JSON_PATH").is_none()
412 + && std::env::var_os("PLAY_SERVICE_ACCOUNT_JSON").is_none()
413 + {
414 + let error = play_client(None).err().unwrap();
415 + assert!(error.contains("PLAY_SERVICE_ACCOUNT_JSON_PATH"));
416 + }
417 + }
418 +}
crates/mcp/src/lib.rs
+77 -1
@@ -69,7 +69,10 @@ macro_rules! ascapi_mcp_tools {
69 $certificate_list_name:literal,
70 $certificate_create_name:literal,
71 $beta_group_list_name:literal,
72 - $beta_app_review_detail_get_name:literal
72 + $beta_app_review_detail_get_name:literal,
73 + $play_listing_list_name:literal,
74 + $play_images_list_name:literal,
75 + $play_track_get_name:literal
76 ) => {
77 #[::rmcp::tool_router(router = ascapi_tool_router, vis = "pub(crate)")]
78 impl $server {
@@ -965,6 +968,79 @@ macro_rules! ascapi_mcp_tools {
968 ::rmcp::model::ContentBlock::json(&detail)?,
969 ]))
970 }
971 +
972 + #[::rmcp::tool(
973 + name = $play_listing_list_name,
974 + title = "List Play listings",
975 + annotations(title = "List Play listings", read_only_hint = true, idempotent_hint = true),
976 + description = "Purpose: read every localized Google Play store listing of an app. When to use vs siblings: use this for listing text; play_images_list reads screenshots and graphics, play_track_get reads release notes. Behavior: opens a Play edit, reads the listings (language, title, short and full description, video) and discards the edit, so nothing changes. Prerequisites: a Google Play service account with access to the app, given as a key file at PLAY_SERVICE_ACCOUNT_JSON_PATH or inline in PLAY_SERVICE_ACCOUNT_JSON. Failure modes: missing credentials return an invalid-request error naming what is absent; Google Play errors are returned verbatim with their status. Limitations: read-only; changing a listing is available only through the asc play listings update command."
977 + )]
978 + async fn play_listing_list(
979 + &self,
980 + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::PlayPackageRequest>,
981 + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> {
982 + use ::smbcloud_ascapi_frontend::play;
983 + let client = play::play_client(None)
984 + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?;
985 + let request = parameters.0;
986 + let value = play::read_listings(&client, &request.package_name)
987 + .await
988 + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error, None))?;
989 + Ok(::rmcp::model::CallToolResult::success(vec![
990 + ::rmcp::model::ContentBlock::json(&value)?,
991 + ]))
992 + }
993 +
994 + #[::rmcp::tool(
995 + name = $play_images_list_name,
996 + title = "List Play listing images",
997 + annotations(title = "List Play listing images", read_only_hint = true, idempotent_hint = true),
998 + description = "Purpose: list the images in one bucket (phone screenshots, tablet screenshots, feature graphic or icon) of one Google Play listing locale. When to use vs siblings: use play_listing_list for text and play_track_get for release notes. Behavior: opens a Play edit, reads the bucket's image ids, URLs and hashes, and discards the edit. Prerequisites: a Google Play service account with access to the app, given as a key file at PLAY_SERVICE_ACCOUNT_JSON_PATH or inline in PLAY_SERVICE_ACCOUNT_JSON. Failure modes: missing credentials return an invalid-request error naming what is absent; Google Play errors are returned verbatim with their status. Limitations: read-only; replacing images is available only through the asc play images replace command."
999 + )]
1000 + async fn play_images_list(
1001 + &self,
1002 + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::PlayImagesListRequest>,
1003 + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> {
1004 + use ::smbcloud_ascapi_frontend::play;
1005 + let client = play::play_client(None)
1006 + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?;
1007 + let request = parameters.0;
1008 + let image_type = play::image_type_from_str(&request.image_type)
1009 + .map_err(|error| ::rmcp::model::ErrorData::invalid_params(error, None))?;
1010 + let value = play::read_images(
1011 + &client,
1012 + &request.package_name,
1013 + &request.locale,
1014 + image_type,
1015 + )
1016 + .await
1017 + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error, None))?;
1018 + Ok(::rmcp::model::CallToolResult::success(vec![
1019 + ::rmcp::model::ContentBlock::json(&value)?,
1020 + ]))
1021 + }
1022 +
1023 + #[::rmcp::tool(
1024 + name = $play_track_get_name,
1025 + title = "Get a Play track",
1026 + annotations(title = "Get a Play track", read_only_hint = true, idempotent_hint = true),
1027 + description = "Purpose: read a Google Play release track with its releases, version codes, status and per-language release notes. When to use vs siblings: use play_listing_list for store listing text and play_images_list for images. Behavior: opens a Play edit, reads the track and discards the edit. Prerequisites: a Google Play service account with access to the app, given as a key file at PLAY_SERVICE_ACCOUNT_JSON_PATH or inline in PLAY_SERVICE_ACCOUNT_JSON. Failure modes: missing credentials return an invalid-request error naming what is absent; Google Play errors are returned verbatim with their status. Limitations: read-only; setting release notes is available only through the asc play tracks set-notes command."
1028 + )]
1029 + async fn play_track_get(
1030 + &self,
1031 + parameters: ::rmcp::handler::server::wrapper::Parameters<$crate::PlayTrackGetRequest>,
1032 + ) -> ::std::result::Result<::rmcp::model::CallToolResult, ::rmcp::model::ErrorData> {
1033 + use ::smbcloud_ascapi_frontend::play;
1034 + let client = play::play_client(None)
1035 + .map_err(|error| ::rmcp::model::ErrorData::invalid_request(error, None))?;
1036 + let request = parameters.0;
1037 + let value = play::read_track(&client, &request.package_name, &request.track)
1038 + .await
1039 + .map_err(|error| ::rmcp::model::ErrorData::internal_error(error, None))?;
1040 + Ok(::rmcp::model::CallToolResult::success(vec![
1041 + ::rmcp::model::ContentBlock::json(&value)?,
1042 + ]))
1043 + }
1044 }
1045 };
1046 }
crates/mcp/src/requests.rs
+25
@@ -275,3 +275,28 @@ pub struct BetaAppReviewDetailGetRequest {
275 /// retrieve.
276 pub app_id: String,
277 }
278 +
279 +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
280 +pub struct PlayPackageRequest {
281 + /// Android application id, e.g. xyz.smbcloud.SiGit.
282 + pub package_name: String,
283 +}
284 +
285 +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
286 +pub struct PlayImagesListRequest {
287 + /// Android application id, e.g. xyz.smbcloud.SiGit.
288 + pub package_name: String,
289 + /// Listing locale, e.g. en-US or id.
290 + pub locale: String,
291 + /// Image bucket: phone-screenshots, seven-inch-screenshots,
292 + /// ten-inch-screenshots, feature-graphic or icon.
293 + pub image_type: String,
294 +}
295 +
296 +#[derive(Debug, Clone, Deserialize, schemars::JsonSchema)]
297 +pub struct PlayTrackGetRequest {
298 + /// Android application id, e.g. xyz.smbcloud.SiGit.
299 + pub package_name: String,
300 + /// Track name: internal, alpha, beta or production.
301 + pub track: String,
302 +}
crates/mcp/src/server.rs
+8 -2
@@ -59,7 +59,10 @@ ascapi_mcp_tools!(
59 "certificate_list",
60 "certificate_create",
61 "beta_group_list",
62 - "beta_app_review_detail_get"
62 + "beta_app_review_detail_get",
63 + "play_listing_list",
64 + "play_images_list",
65 + "play_track_get"
66 );
67
68 #[rmcp::tool_handler(router = Self::ascapi_tool_router())]
@@ -108,7 +111,7 @@ mod tests {
111 /// The canonical tool names. Adding or renaming one is a
112 /// public-contract change, and this list is what makes that
113 /// deliberate rather than accidental.
111 - const CANONICAL_TOOL_NAMES: [&str; 31] = [
114 + const CANONICAL_TOOL_NAMES: [&str; 34] = [
115 "app_list",
116 "app_get",
117 "app_update",
@@ -140,6 +143,9 @@ mod tests {
143 "certificate_create",
144 "beta_group_list",
145 "beta_app_review_detail_get",
146 + "play_listing_list",
147 + "play_images_list",
148 + "play_track_get",
149 ];
150
151 /// Never a tool, at any point, for any caller.
crates/play/Cargo.toml new
+20
@@ -0,0 +1,20 @@
1 +[package]
2 +name = "smbcloud-ascapi-play"
3 +version.workspace = true
4 +edition.workspace = true
5 +authors.workspace = true
6 +license.workspace = true
7 +repository.workspace = true
8 +description = "Google Play Developer API (Edits v3): store listings, listing images, and track release notes, authenticated with a service account."
9 +readme = "README.md"
10 +
11 +[lib]
12 +path = "src/lib.rs"
13 +
14 +[dependencies]
15 +jsonwebtoken = { workspace = true }
16 +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] }
17 +serde = { workspace = true, features = ["derive"] }
18 +serde_json = { workspace = true }
19 +thiserror = { workspace = true }
20 +tokio = { workspace = true, features = ["sync", "time"] }
crates/play/README.md new
+19
@@ -0,0 +1,19 @@
1 +# smbcloud-ascapi-play
2 +
3 +Google Play Developer API (Edits v3) for store-listing work: localized
4 +listings, listing images, and release notes on a track release. It is a
5 +sibling of `smbcloud-ascapi-aso` and shares nothing with `smbcloud-ascapi-core`:
6 +Play uses OAuth2 service-account tokens and its own error envelope.
7 +
8 +```rust
9 +use smbcloud_ascapi_play::{PlayClient, ServiceAccount};
10 +
11 +let account = ServiceAccount::from_json_file("play-service-account.json")?;
12 +let client = PlayClient::new(account);
13 +let edit = client.insert_edit("com.example.app").await?;
14 +let listings = client.list_listings("com.example.app", &edit.id).await?;
15 +client.delete_edit("com.example.app", &edit.id).await?;
16 +```
17 +
18 +Every read and write happens inside an edit. Nothing is visible to users until
19 +`commit_edit`; `delete_edit` throws the edit away.
crates/play/src/auth.rs new
+198
@@ -0,0 +1,198 @@
1 +use std::path::Path;
2 +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3 +
4 +use jsonwebtoken::{Algorithm, EncodingKey, Header};
5 +use serde::{Deserialize, Serialize};
6 +use tokio::sync::Mutex;
7 +
8 +use crate::error::{Error, Result};
9 +
10 +pub const SCOPE: &str = "https://www.googleapis.com/auth/androidpublisher";
11 +const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
12 +const JWT_LIFETIME_SECS: u64 = 3600;
13 +/// Refresh this long before the token expires.
14 +const REFRESH_MARGIN: Duration = Duration::from_secs(60);
15 +
16 +/// The fields of a Google service-account key file this crate uses.
17 +#[derive(Clone, Deserialize)]
18 +pub struct ServiceAccount {
19 + pub client_email: String,
20 + private_key: String,
21 + #[serde(default)]
22 + token_uri: Option<String>,
23 +}
24 +
25 +// Hand-written so `{:?}` can never print the private key.
26 +impl std::fmt::Debug for ServiceAccount {
27 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 + f.debug_struct("ServiceAccount")
29 + .field("client_email", &self.client_email)
30 + .field("private_key", &"<redacted>")
31 + .finish()
32 + }
33 +}
34 +
35 +impl ServiceAccount {
36 + pub fn from_json(json: &str) -> Result<Self> {
37 + let account: Self = serde_json::from_str(json).map_err(|_| Error::ServiceAccountInvalid)?;
38 + if account.client_email.is_empty() || account.private_key.is_empty() {
39 + return Err(Error::ServiceAccountInvalid);
40 + }
41 + Ok(account)
42 + }
43 +
44 + pub fn from_json_file(path: impl AsRef<Path>) -> Result<Self> {
45 + let path = path.as_ref();
46 + let json = std::fs::read_to_string(path).map_err(|source| Error::ServiceAccountRead {
47 + path: path.display().to_string(),
48 + source,
49 + })?;
50 + Self::from_json(&json)
51 + }
52 +
53 + pub fn token_uri(&self) -> &str {
54 + self.token_uri.as_deref().unwrap_or(DEFAULT_TOKEN_URI)
55 + }
56 +
57 + /// Sign the RS256 assertion exchanged for an access token.
58 + pub fn assertion(&self, now_secs: u64) -> Result<String> {
59 + let claims = Claims::new(&self.client_email, self.token_uri(), now_secs);
60 + let mut header = Header::new(Algorithm::RS256);
61 + header.typ = Some("JWT".to_string());
62 + let key = EncodingKey::from_rsa_pem(self.private_key.as_bytes())?;
63 + Ok(jsonwebtoken::encode(&header, &claims, &key)?)
64 + }
65 +}
66 +
67 +#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
68 +pub(crate) struct Claims {
69 + pub iss: String,
70 + pub scope: String,
71 + pub aud: String,
72 + pub iat: u64,
73 + pub exp: u64,
74 +}
75 +
76 +impl Claims {
77 + pub(crate) fn new(email: &str, audience: &str, now_secs: u64) -> Self {
78 + Self {
79 + iss: email.to_string(),
80 + scope: SCOPE.to_string(),
81 + aud: audience.to_string(),
82 + iat: now_secs,
83 + exp: now_secs + JWT_LIFETIME_SECS,
84 + }
85 + }
86 +}
87 +
88 +#[derive(Deserialize)]
89 +struct TokenResponse {
90 + access_token: String,
91 + #[serde(default)]
92 + expires_in: Option<u64>,
93 +}
94 +
95 +/// Mints and caches the access token until shortly before it expires.
96 +pub(crate) struct TokenCache {
97 + cached: Mutex<Option<(String, Instant)>>,
98 +}
99 +
100 +impl TokenCache {
101 + pub(crate) fn new() -> Self {
102 + Self {
103 + cached: Mutex::new(None),
104 + }
105 + }
106 +
107 + pub(crate) async fn get(
108 + &self,
109 + http: &reqwest::Client,
110 + account: &ServiceAccount,
111 + ) -> Result<String> {
112 + let mut cached = self.cached.lock().await;
113 + if let Some((token, expires)) = cached.as_ref() {
114 + if Instant::now() < *expires {
115 + return Ok(token.clone());
116 + }
117 + }
118 + let now = SystemTime::now()
119 + .duration_since(UNIX_EPOCH)
120 + .map(|d| d.as_secs())
121 + .unwrap_or(0);
122 + let assertion = account.assertion(now)?;
123 + let response = http
124 + .post(account.token_uri())
125 + .form(&[
126 + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
127 + ("assertion", assertion.as_str()),
128 + ])
129 + .send()
130 + .await?;
131 + let status = response.status();
132 + if !status.is_success() {
133 + return Err(Error::Token {
134 + status: status.as_u16(),
135 + detail: response.text().await.unwrap_or_default(),
136 + });
137 + }
138 + let body: TokenResponse = response.json().await?;
139 + let lifetime = Duration::from_secs(body.expires_in.unwrap_or(3600));
140 + let expires = Instant::now() + lifetime.saturating_sub(REFRESH_MARGIN);
141 + *cached = Some((body.access_token.clone(), expires));
142 + Ok(body.access_token)
143 + }
144 +}
145 +
146 +#[cfg(test)]
147 +mod tests {
148 + use super::*;
149 +
150 + #[test]
151 + fn claims_carry_scope_audience_and_one_hour_lifetime() {
152 + let claims = Claims::new(
153 + "svc@example.iam.gserviceaccount.com",
154 + DEFAULT_TOKEN_URI,
155 + 1000,
156 + );
157 + assert_eq!(claims.iss, "svc@example.iam.gserviceaccount.com");
158 + assert_eq!(
159 + claims.scope,
160 + "https://www.googleapis.com/auth/androidpublisher"
161 + );
162 + assert_eq!(claims.aud, "https://oauth2.googleapis.com/token");
163 + assert_eq!(claims.iat, 1000);
164 + assert_eq!(claims.exp, 4600);
165 + }
166 +
167 + #[test]
168 + fn token_uri_defaults_when_the_key_file_omits_it() {
169 + let account =
170 + ServiceAccount::from_json(r#"{"client_email":"a@b","private_key":"k"}"#).unwrap();
171 + assert_eq!(account.token_uri(), "https://oauth2.googleapis.com/token");
172 + }
173 +
174 + #[test]
175 + fn a_key_file_missing_the_key_is_rejected_without_echoing_it() {
176 + let err = ServiceAccount::from_json(r#"{"client_email":"a@b","private_key":""}"#)
177 + .unwrap_err()
178 + .to_string();
179 + assert!(!err.contains("a@b"));
180 + assert!(ServiceAccount::from_json("not json").is_err());
181 + }
182 +
183 + #[test]
184 + fn debug_output_never_contains_the_private_key() {
185 + let account =
186 + ServiceAccount::from_json(r#"{"client_email":"a@b","private_key":"SECRET"}"#).unwrap();
187 + let shown = format!("{account:?}");
188 + assert!(!shown.contains("SECRET"));
189 + assert!(shown.contains("a@b"));
190 + }
191 +
192 + #[test]
193 + fn a_malformed_pem_fails_to_sign_instead_of_panicking() {
194 + let account =
195 + ServiceAccount::from_json(r#"{"client_email":"a@b","private_key":"nope"}"#).unwrap();
196 + assert!(account.assertion(0).is_err());
197 + }
198 +}
crates/play/src/client.rs new
+293
@@ -0,0 +1,293 @@
1 +use reqwest::Method;
2 +use serde::de::DeserializeOwned;
3 +use serde::{Deserialize, Serialize};
4 +
5 +use crate::auth::{ServiceAccount, TokenCache};
6 +use crate::error::{Error, Result};
7 +use crate::image::{Image, ImageType};
8 +use crate::listing::{Listing, ListingFields};
9 +use crate::track::Track;
10 +
11 +const API_ROOT: &str = "https://androidpublisher.googleapis.com";
12 +
13 +/// An open edit. Nothing in it is visible to users until it is committed.
14 +#[derive(Debug, Clone, Deserialize)]
15 +pub struct Edit {
16 + pub id: String,
17 + #[serde(default, rename = "expiryTimeSeconds")]
18 + pub expiry_time_seconds: Option<String>,
19 +}
20 +
21 +/// Build `/androidpublisher/v3/applications/{package}/edits…`. `suffix` is
22 +/// appended verbatim, so it carries its own leading `/` or `:`.
23 +pub(crate) fn edits_path(package: &str, suffix: &str) -> String {
24 + format!("/androidpublisher/v3/applications/{package}/edits{suffix}")
25 +}
26 +
27 +pub(crate) fn upload_path(package: &str, edit_id: &str, locale: &str, image: ImageType) -> String {
28 + format!(
29 + "/upload/androidpublisher/v3/applications/{package}/edits/{edit_id}/listings/{locale}/{}?uploadType=media",
30 + image.api_name()
31 + )
32 +}
33 +
34 +pub struct PlayClient {
35 + http: reqwest::Client,
36 + account: ServiceAccount,
37 + tokens: TokenCache,
38 + root: String,
39 +}
40 +
41 +impl PlayClient {
42 + pub fn new(account: ServiceAccount) -> Self {
43 + Self {
44 + http: reqwest::Client::new(),
45 + account,
46 + tokens: TokenCache::new(),
47 + root: API_ROOT.to_string(),
48 + }
49 + }
50 +
51 + /// The service account's email, for diagnostics.
52 + pub fn account_email(&self) -> &str {
53 + &self.account.client_email
54 + }
55 +
56 + async fn send(
57 + &self,
58 + method: Method,
59 + path_and_query: &str,
60 + body: Option<(&str, Vec<u8>)>,
61 + ) -> Result<reqwest::Response> {
62 + let token = self.tokens.get(&self.http, &self.account).await?;
63 + let mut request = self
64 + .http
65 + .request(method, format!("{}{path_and_query}", self.root))
66 + .bearer_auth(token);
67 + if let Some((content_type, bytes)) = body {
68 + request = request.header("Content-Type", content_type).body(bytes);
69 + }
70 + let response = request.send().await?;
71 + let status = response.status();
72 + if status.is_success() {
73 + Ok(response)
74 + } else {
75 + Err(Error::Api {
76 + status: status.as_u16(),
77 + detail: response.text().await.unwrap_or_default(),
78 + })
79 + }
80 + }
81 +
82 + async fn json<T: DeserializeOwned>(
83 + &self,
84 + method: Method,
85 + path: &str,
86 + body: Option<&impl Serialize>,
87 + ) -> Result<T> {
88 + let body = match body {
89 + Some(value) => Some(("application/json", serde_json::to_vec(value)?)),
90 + None => (method == Method::POST).then_some(("application/json", b"{}".to_vec())),
91 + };
92 + let response = self.send(method, path, body).await?;
93 + Ok(response.json().await?)
94 + }
95 +
96 + async fn no_body(&self, method: Method, path: &str) -> Result<()> {
97 + self.send(method, path, None).await?;
98 + Ok(())
99 + }
100 +
101 + pub async fn insert_edit(&self, package: &str) -> Result<Edit> {
102 + self.json(Method::POST, &edits_path(package, ""), None::<&()>)
103 + .await
104 + }
105 +
106 + pub async fn get_edit(&self, package: &str, edit_id: &str) -> Result<Edit> {
107 + self.json(
108 + Method::GET,
109 + &edits_path(package, &format!("/{edit_id}")),
110 + None::<&()>,
111 + )
112 + .await
113 + }
114 +
115 + pub async fn validate_edit(&self, package: &str, edit_id: &str) -> Result<()> {
116 + let path = edits_path(package, &format!("/{edit_id}:validate"));
117 + self.send(
118 + Method::POST,
119 + &path,
120 + Some(("application/json", b"{}".to_vec())),
121 + )
122 + .await?;
123 + Ok(())
124 + }
125 +
126 + pub async fn commit_edit(&self, package: &str, edit_id: &str) -> Result<()> {
127 + let path = edits_path(package, &format!("/{edit_id}:commit"));
128 + self.send(
129 + Method::POST,
130 + &path,
131 + Some(("application/json", b"{}".to_vec())),
132 + )
133 + .await?;
134 + Ok(())
135 + }
136 +
137 + pub async fn delete_edit(&self, package: &str, edit_id: &str) -> Result<()> {
138 + self.no_body(Method::DELETE, &edits_path(package, &format!("/{edit_id}")))
139 + .await
140 + }
141 +
142 + pub async fn list_listings(&self, package: &str, edit_id: &str) -> Result<Vec<Listing>> {
143 + #[derive(Deserialize)]
144 + struct Page {
145 + #[serde(default)]
146 + listings: Vec<Listing>,
147 + }
148 + let page: Page = self
149 + .json(
150 + Method::GET,
151 + &edits_path(package, &format!("/{edit_id}/listings")),
152 + None::<&()>,
153 + )
154 + .await?;
155 + Ok(page.listings)
156 + }
157 +
158 + pub async fn get_listing(&self, package: &str, edit_id: &str, locale: &str) -> Result<Listing> {
159 + self.json(
160 + Method::GET,
161 + &edits_path(package, &format!("/{edit_id}/listings/{locale}")),
162 + None::<&()>,
163 + )
164 + .await
165 + }
166 +
167 + /// `listings.patch`; a locale the app does not have yet is created with
168 + /// `listings.update` (PUT), which Play treats as an upsert.
169 + pub async fn patch_listing(
170 + &self,
171 + package: &str,
172 + edit_id: &str,
173 + locale: &str,
174 + fields: &ListingFields,
175 + ) -> Result<Listing> {
176 + let path = edits_path(package, &format!("/{edit_id}/listings/{locale}"));
177 + match self.json(Method::PATCH, &path, Some(fields)).await {
178 + Err(Error::Api { status: 404, .. }) => {
179 + self.json(Method::PUT, &path, Some(fields)).await
180 + }
181 + other => other,
182 + }
183 + }
184 +
185 + pub async fn list_images(
186 + &self,
187 + package: &str,
188 + edit_id: &str,
189 + locale: &str,
190 + image_type: ImageType,
191 + ) -> Result<Vec<Image>> {
192 + #[derive(Deserialize)]
193 + struct Page {
194 + #[serde(default)]
195 + images: Vec<Image>,
196 + }
197 + let path = edits_path(
198 + package,
199 + &format!("/{edit_id}/listings/{locale}/{}", image_type.api_name()),
200 + );
201 + let page: Page = self.json(Method::GET, &path, None::<&()>).await?;
202 + Ok(page.images)
203 + }
204 +
205 + pub async fn upload_image(
206 + &self,
207 + package: &str,
208 + edit_id: &str,
209 + locale: &str,
210 + image_type: ImageType,
211 + content_type: &str,
212 + bytes: Vec<u8>,
213 + ) -> Result<Image> {
214 + #[derive(Deserialize)]
215 + struct Uploaded {
216 + image: Image,
217 + }
218 + let path = upload_path(package, edit_id, locale, image_type);
219 + let response = self
220 + .send(Method::POST, &path, Some((content_type, bytes)))
221 + .await?;
222 + let uploaded: Uploaded = response.json().await?;
223 + Ok(uploaded.image)
224 + }
225 +
226 + /// Remove every image in a bucket; returns how many Play reports deleted.
227 + pub async fn delete_all_images(
228 + &self,
229 + package: &str,
230 + edit_id: &str,
231 + locale: &str,
232 + image_type: ImageType,
233 + ) -> Result<usize> {
234 + #[derive(Deserialize)]
235 + struct Deleted {
236 + #[serde(default)]
237 + deleted: Vec<Image>,
238 + }
239 + let path = edits_path(
240 + package,
241 + &format!("/{edit_id}/listings/{locale}/{}", image_type.api_name()),
242 + );
243 + let deleted: Deleted = self.json(Method::DELETE, &path, None::<&()>).await?;
244 + Ok(deleted.deleted.len())
245 + }
246 +
247 + pub async fn get_track(&self, package: &str, edit_id: &str, track: &str) -> Result<Track> {
248 + self.json(
249 + Method::GET,
250 + &edits_path(package, &format!("/{edit_id}/tracks/{track}")),
251 + None::<&()>,
252 + )
253 + .await
254 + }
255 +
256 + pub async fn update_track(&self, package: &str, edit_id: &str, track: &Track) -> Result<Track> {
257 + self.json(
258 + Method::PUT,
259 + &edits_path(package, &format!("/{edit_id}/tracks/{}", track.track)),
260 + Some(track),
261 + )
262 + .await
263 + }
264 +}
265 +
266 +#[cfg(test)]
267 +mod tests {
268 + use super::*;
269 +
270 + #[test]
271 + fn edit_urls_follow_the_v3_layout() {
272 + assert_eq!(
273 + edits_path("xyz.smbcloud.SiGit", ""),
274 + "/androidpublisher/v3/applications/xyz.smbcloud.SiGit/edits"
275 + );
276 + assert_eq!(
277 + edits_path("p", "/E1:commit"),
278 + "/androidpublisher/v3/applications/p/edits/E1:commit"
279 + );
280 + assert_eq!(
281 + edits_path("p", "/E1/listings/en-US"),
282 + "/androidpublisher/v3/applications/p/edits/E1/listings/en-US"
283 + );
284 + }
285 +
286 + #[test]
287 + fn uploads_use_the_upload_host_path_with_media_type() {
288 + assert_eq!(
289 + upload_path("p", "E1", "en-US", ImageType::PhoneScreenshots),
290 + "/upload/androidpublisher/v3/applications/p/edits/E1/listings/en-US/phoneScreenshots?uploadType=media"
291 + );
292 + }
293 +}
crates/play/src/error.rs new
+33
@@ -0,0 +1,33 @@
1 +use thiserror::Error;
2 +
3 +#[derive(Debug, Error)]
4 +pub enum Error {
5 + #[error("failed to read the service account file {path}: {source}")]
6 + ServiceAccountRead {
7 + path: String,
8 + #[source]
9 + source: std::io::Error,
10 + },
11 +
12 + /// Deliberately carries no part of the file: a parse error could
13 + /// otherwise quote key material.
14 + #[error("the service account JSON is not valid or lacks client_email/private_key")]
15 + ServiceAccountInvalid,
16 +
17 + #[error("failed to sign the service account JWT: {0}")]
18 + Jwt(#[from] jsonwebtoken::errors::Error),
19 +
20 + #[error("http request failed: {0}")]
21 + Http(#[from] reqwest::Error),
22 +
23 + #[error("failed to decode JSON response: {0}")]
24 + Json(#[from] serde_json::Error),
25 +
26 + #[error("Google OAuth token exchange returned {status}: {detail}")]
27 + Token { status: u16, detail: String },
28 +
29 + #[error("Google Play API returned {status}: {detail}")]
30 + Api { status: u16, detail: String },
31 +}
32 +
33 +pub type Result<T> = std::result::Result<T, Error>;
crates/play/src/image.rs new
+134
@@ -0,0 +1,134 @@
1 +use serde::Deserialize;
2 +
3 +/// The image buckets of a listing that this crate manages.
4 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5 +pub enum ImageType {
6 + PhoneScreenshots,
7 + SevenInchScreenshots,
8 + TenInchScreenshots,
9 + FeatureGraphic,
10 + Icon,
11 +}
12 +
13 +impl ImageType {
14 + /// The path segment Play uses for this bucket.
15 + pub fn api_name(self) -> &'static str {
16 + match self {
17 + Self::PhoneScreenshots => "phoneScreenshots",
18 + Self::SevenInchScreenshots => "sevenInchScreenshots",
19 + Self::TenInchScreenshots => "tenInchScreenshots",
20 + Self::FeatureGraphic => "featureGraphic",
21 + Self::Icon => "icon",
22 + }
23 + }
24 +
25 + pub fn is_screenshot(self) -> bool {
26 + !matches!(self, Self::FeatureGraphic | Self::Icon)
27 + }
28 +
29 + /// Check an image's pixel size against Play's rules for this bucket.
30 + pub fn check_dimensions(self, width: u32, height: u32) -> std::result::Result<(), String> {
31 + match self {
32 + Self::FeatureGraphic if (width, height) != (1024, 500) => Err(format!(
33 + "feature graphic must be exactly 1024x500, got {width}x{height}"
34 + )),
35 + Self::Icon if (width, height) != (512, 512) => Err(format!(
36 + "icon must be exactly 512x512, got {width}x{height}"
37 + )),
38 + _ if self.is_screenshot() => {
39 + let (short, long) = (width.min(height), width.max(height));
40 + if short < 320 || long > 3840 {
41 + Err(format!(
42 + "screenshot sides must be between 320 and 3840 pixels, got {width}x{height}"
43 + ))
44 + } else if long > short * 2 {
45 + Err(format!(
46 + "screenshot long side may be at most twice the short side, got {width}x{height}"
47 + ))
48 + } else {
49 + Ok(())
50 + }
51 + }
52 + _ => Ok(()),
53 + }
54 + }
55 +}
56 +
57 +/// Width and height from a PNG's IHDR chunk, or `None` for anything else.
58 +pub fn png_dimensions(bytes: &[u8]) -> Option<(u32, u32)> {
59 + const SIGNATURE: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
60 + if bytes.len() < 24 || bytes[..8] != SIGNATURE || &bytes[12..16] != b"IHDR" {
61 + return None;
62 + }
63 + let width = u32::from_be_bytes(bytes[16..20].try_into().ok()?);
64 + let height = u32::from_be_bytes(bytes[20..24].try_into().ok()?);
65 + Some((width, height))
66 +}
67 +
68 +/// One uploaded image as Play lists it.
69 +#[derive(Debug, Clone, Deserialize, serde::Serialize, PartialEq, Eq)]
70 +pub struct Image {
71 + #[serde(default)]
72 + pub id: String,
73 + #[serde(default)]
74 + pub url: Option<String>,
75 + #[serde(default)]
76 + pub sha1: Option<String>,
77 + #[serde(default)]
78 + pub sha256: Option<String>,
79 +}
80 +
81 +#[cfg(test)]
82 +mod tests {
83 + use super::*;
84 +
85 + fn png_header(width: u32, height: u32) -> Vec<u8> {
86 + let mut bytes = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13];
87 + bytes.extend_from_slice(b"IHDR");
88 + bytes.extend_from_slice(&width.to_be_bytes());
89 + bytes.extend_from_slice(&height.to_be_bytes());
90 + bytes
91 + }
92 +
93 + #[test]
94 + fn png_dimensions_read_the_ihdr_chunk() {
95 + assert_eq!(png_dimensions(&png_header(1080, 2400)), Some((1080, 2400)));
96 + assert_eq!(png_dimensions(b"not a png at all, but long enough"), None);
97 + }
98 +
99 + #[test]
100 + fn api_names_match_the_play_path_segments() {
101 + assert_eq!(ImageType::PhoneScreenshots.api_name(), "phoneScreenshots");
102 + assert_eq!(
103 + ImageType::SevenInchScreenshots.api_name(),
104 + "sevenInchScreenshots"
105 + );
106 + assert_eq!(
107 + ImageType::TenInchScreenshots.api_name(),
108 + "tenInchScreenshots"
109 + );
110 + assert_eq!(ImageType::FeatureGraphic.api_name(), "featureGraphic");
111 + assert_eq!(ImageType::Icon.api_name(), "icon");
112 + }
113 +
114 + #[test]
115 + fn feature_graphic_and_icon_need_exact_sizes() {
116 + assert!(ImageType::FeatureGraphic
117 + .check_dimensions(1024, 500)
118 + .is_ok());
119 + assert!(ImageType::FeatureGraphic
120 + .check_dimensions(1024, 501)
121 + .is_err());
122 + assert!(ImageType::Icon.check_dimensions(512, 512).is_ok());
123 + assert!(ImageType::Icon.check_dimensions(511, 512).is_err());
124 + }
125 +
126 + #[test]
127 + fn screenshots_are_bounded_in_size_and_aspect() {
128 + let phone = ImageType::PhoneScreenshots;
129 + assert!(phone.check_dimensions(1080, 2160).is_ok());
130 + assert!(phone.check_dimensions(1080, 2161).is_err());
131 + assert!(phone.check_dimensions(319, 600).is_err());
132 + assert!(phone.check_dimensions(1000, 3841).is_err());
133 + }
134 +}
crates/play/src/lib.rs new
+25
@@ -0,0 +1,25 @@
1 +//! Google Play Developer API (Edits v3) for store-listing work.
2 +//!
3 +//! Localized listings, listing images, and release notes on a track release.
4 +//! Authentication is a service-account key exchanged for an OAuth2 token
5 +//! (RS256 JWT), cached until shortly before it expires. Every read and write
6 +//! happens inside an edit: [`PlayClient::insert_edit`] opens one,
7 +//! [`PlayClient::commit_edit`] publishes it, and [`PlayClient::delete_edit`]
8 +//! discards it.
9 +
10 +pub mod auth;
11 +pub mod client;
12 +pub mod error;
13 +pub mod image;
14 +pub mod listing;
15 +pub mod track;
16 +
17 +pub use auth::ServiceAccount;
18 +pub use client::{Edit, PlayClient};
19 +pub use error::{Error, Result};
20 +pub use image::{png_dimensions, Image, ImageType};
21 +pub use listing::{
22 + Listing, ListingFields, FULL_DESCRIPTION_MAX_LEN, RELEASE_NOTES_MAX_LEN,
23 + SHORT_DESCRIPTION_MAX_LEN, TITLE_MAX_LEN,
24 +};
25 +pub use track::{LocalizedText, Track, TrackRelease};
crates/play/src/listing.rs new
+167
@@ -0,0 +1,167 @@
1 +use serde::{Deserialize, Serialize};
2 +
3 +/// Maximum `title` length Google Play accepts, in characters.
4 +pub const TITLE_MAX_LEN: usize = 30;
5 +/// Maximum `shortDescription` length, in characters.
6 +pub const SHORT_DESCRIPTION_MAX_LEN: usize = 80;
7 +/// Maximum `fullDescription` length, in characters.
8 +pub const FULL_DESCRIPTION_MAX_LEN: usize = 4000;
9 +/// Maximum length of one release note, per language, in characters.
10 +pub const RELEASE_NOTES_MAX_LEN: usize = 500;
11 +
12 +/// One localized store listing, as Play returns it.
13 +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
14 +#[serde(rename_all = "camelCase")]
15 +pub struct Listing {
16 + #[serde(default)]
17 + pub language: String,
18 + #[serde(default)]
19 + pub title: Option<String>,
20 + #[serde(default)]
21 + pub short_description: Option<String>,
22 + #[serde(default)]
23 + pub full_description: Option<String>,
24 + #[serde(default)]
25 + pub video: Option<String>,
26 +}
27 +
28 +/// The fields of a listing patch. Unset fields are left off the body, so Play
29 +/// keeps their current value.
30 +#[derive(Debug, Clone, Default, Serialize)]
31 +#[serde(rename_all = "camelCase")]
32 +pub struct ListingFields {
33 + #[serde(skip_serializing_if = "Option::is_none")]
34 + pub title: Option<String>,
35 + #[serde(skip_serializing_if = "Option::is_none")]
36 + pub short_description: Option<String>,
37 + #[serde(skip_serializing_if = "Option::is_none")]
38 + pub full_description: Option<String>,
39 +}
40 +
41 +impl ListingFields {
42 + pub fn is_empty(&self) -> bool {
43 + self.title.is_none() && self.short_description.is_none() && self.full_description.is_none()
44 + }
45 +
46 + /// Check the fields against Play's length limits before anything is sent.
47 + /// Returns `Err` naming the first field over its limit.
48 + pub fn validate(&self) -> std::result::Result<(), String> {
49 + let limits = [
50 + ("title", &self.title, TITLE_MAX_LEN),
51 + (
52 + "shortDescription",
53 + &self.short_description,
54 + SHORT_DESCRIPTION_MAX_LEN,
55 + ),
56 + (
57 + "fullDescription",
58 + &self.full_description,
59 + FULL_DESCRIPTION_MAX_LEN,
60 + ),
61 + ];
62 + for (name, value, max) in limits {
63 + let len = value.as_deref().map_or(0, |v| v.chars().count());
64 + if len > max {
65 + return Err(format!(
66 + "{name} is {len} characters; Google Play accepts at most {max}"
67 + ));
68 + }
69 + }
70 + Ok(())
71 + }
72 +
73 + /// Names of the fields sent whose stored value is not an exact match.
74 + pub fn diff(&self, stored: &Listing) -> Vec<&'static str> {
75 + [
76 + ("title", &self.title, &stored.title),
77 + (
78 + "shortDescription",
79 + &self.short_description,
80 + &stored.short_description,
81 + ),
82 + (
83 + "fullDescription",
84 + &self.full_description,
85 + &stored.full_description,
86 + ),
87 + ]
88 + .into_iter()
89 + .filter(|(_, sent, stored)| match sent {
90 + Some(sent) => stored.as_deref() != Some(sent.as_str()),
91 + None => false,
92 + })
93 + .map(|(name, _, _)| name)
94 + .collect()
95 + }
96 +}
97 +
98 +#[cfg(test)]
99 +mod tests {
100 + use super::*;
101 +
102 + #[test]
103 + fn each_field_over_its_limit_is_rejected_by_name() {
104 + let cases = [
105 + (
106 + ListingFields {
107 + title: Some("x".repeat(31)),
108 + ..Default::default()
109 + },
110 + "title",
111 + ),
112 + (
113 + ListingFields {
114 + short_description: Some("x".repeat(81)),
115 + ..Default::default()
116 + },
117 + "shortDescription",
118 + ),
119 + (
120 + ListingFields {
121 + full_description: Some("x".repeat(4001)),
122 + ..Default::default()
123 + },
124 + "fullDescription",
125 + ),
126 + ];
127 + for (fields, name) in cases {
128 + let message = fields.validate().unwrap_err();
129 + assert!(message.starts_with(name), "{message}");
130 + }
131 + }
132 +
133 + #[test]
134 + fn fields_at_their_limit_pass_and_lengths_count_characters() {
135 + let fields = ListingFields {
136 + title: Some("é".repeat(30)),
137 + short_description: Some("x".repeat(80)),
138 + full_description: Some("x".repeat(4000)),
139 + };
140 + assert!(fields.validate().is_ok());
141 + }
142 +
143 + #[test]
144 + fn unset_fields_are_left_off_the_body() {
145 + let fields = ListingFields {
146 + title: Some("T".into()),
147 + ..Default::default()
148 + };
149 + assert_eq!(serde_json::to_string(&fields).unwrap(), r#"{"title":"T"}"#);
150 + }
151 +
152 + #[test]
153 + fn diff_names_only_the_fields_that_were_sent_and_differ() {
154 + let sent = ListingFields {
155 + title: Some("A".into()),
156 + short_description: Some("S".into()),
157 + full_description: None,
158 + };
159 + let stored = Listing {
160 + title: Some("A".into()),
161 + short_description: Some("S ".into()),
162 + full_description: Some("other".into()),
163 + ..Default::default()
164 + };
165 + assert_eq!(sent.diff(&stored), vec!["shortDescription"]);
166 + }
167 +}
crates/play/src/track.rs new
+117
@@ -0,0 +1,117 @@
1 +use serde::{Deserialize, Serialize};
2 +
3 +/// Release notes for one language on a track release.
4 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5 +pub struct LocalizedText {
6 + pub language: String,
7 + pub text: String,
8 +}
9 +
10 +/// One release on a track. Fields this crate does not edit are carried
11 +/// through untouched so a `tracks.update` does not drop them.
12 +#[derive(Debug, Clone, Serialize, Deserialize)]
13 +#[serde(rename_all = "camelCase")]
14 +pub struct TrackRelease {
15 + #[serde(default, skip_serializing_if = "Option::is_none")]
16 + pub name: Option<String>,
17 + #[serde(default)]
18 + pub version_codes: Vec<String>,
19 + #[serde(default, skip_serializing_if = "Option::is_none")]
20 + pub status: Option<String>,
21 + #[serde(default, skip_serializing_if = "Option::is_none")]
22 + pub user_fraction: Option<f64>,
23 + #[serde(default)]
24 + pub release_notes: Vec<LocalizedText>,
25 + #[serde(default, skip_serializing_if = "Option::is_none")]
26 + pub country_targeting: Option<serde_json::Value>,
27 +}
28 +
29 +#[derive(Debug, Clone, Serialize, Deserialize)]
30 +pub struct Track {
31 + pub track: String,
32 + #[serde(default)]
33 + pub releases: Vec<TrackRelease>,
34 +}
35 +
36 +impl Track {
37 + /// Set `locale`'s release notes on the release carrying `version_code`,
38 + /// replacing any existing note for that language. Returns an error if no
39 + /// release carries the code, or the text is over Play's limit.
40 + pub fn set_release_notes(
41 + &mut self,
42 + version_code: &str,
43 + locale: &str,
44 + text: &str,
45 + ) -> std::result::Result<(), String> {
46 + let len = text.chars().count();
47 + if len > crate::listing::RELEASE_NOTES_MAX_LEN {
48 + return Err(format!(
49 + "release notes are {len} characters; Google Play accepts at most {}",
50 + crate::listing::RELEASE_NOTES_MAX_LEN
51 + ));
52 + }
53 + let release = self
54 + .releases
55 + .iter_mut()
56 + .find(|r| r.version_codes.iter().any(|c| c == version_code))
57 + .ok_or_else(|| {
58 + format!(
59 + "no release on track {} carries version code {version_code}",
60 + self.track
61 + )
62 + })?;
63 + release.release_notes.retain(|n| n.language != locale);
64 + release.release_notes.push(LocalizedText {
65 + language: locale.to_string(),
66 + text: text.to_string(),
67 + });
68 + Ok(())
69 + }
70 +}
71 +
72 +#[cfg(test)]
73 +mod tests {
74 + use super::*;
75 +
76 + fn track() -> Track {
77 + serde_json::from_str(
78 + r#"{"track":"internal","releases":[{"name":"1.2.0","versionCodes":["120"],
79 + "status":"draft","releaseNotes":[{"language":"en-US","text":"old"},
80 + {"language":"id","text":"lama"}]}]}"#,
81 + )
82 + .unwrap()
83 + }
84 +
85 + #[test]
86 + fn notes_replace_only_the_given_language() {
87 + let mut t = track();
88 + t.set_release_notes("120", "en-US", "new").unwrap();
89 + let notes = &t.releases[0].release_notes;
90 + assert_eq!(notes.len(), 2);
91 + assert!(notes.iter().any(|n| n.language == "id" && n.text == "lama"));
92 + assert!(notes
93 + .iter()
94 + .any(|n| n.language == "en-US" && n.text == "new"));
95 + }
96 +
97 + #[test]
98 + fn an_unknown_version_code_is_an_error() {
99 + assert!(track().set_release_notes("999", "en-US", "x").is_err());
100 + }
101 +
102 + #[test]
103 + fn notes_over_five_hundred_characters_are_rejected() {
104 + assert!(track()
105 + .set_release_notes("120", "en-US", &"x".repeat(501))
106 + .is_err());
107 + }
108 +
109 + #[test]
110 + fn untouched_release_fields_survive_a_round_trip() {
111 + let mut t = track();
112 + t.set_release_notes("120", "id", "baru").unwrap();
113 + let value = serde_json::to_value(&t).unwrap();
114 + assert_eq!(value["releases"][0]["status"], "draft");
115 + assert_eq!(value["releases"][0]["name"], "1.2.0");
116 + }
117 +}