crt-ship 0.1.0: CLI, Claude Code skills and templates for shipping mobile games to Google Play and the App Store
This commit is contained in:
commit
3cd31b7601
64 changed files with 4509 additions and 0 deletions
15
.claude-plugin/marketplace.json
Normal file
15
.claude-plugin/marketplace.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"name": "crtkafa",
|
||||||
|
"description": "CRTkafa's open-sourced tools.",
|
||||||
|
"owner": { "name": "CRTkafa" },
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "crt-ship",
|
||||||
|
"source": ".",
|
||||||
|
"description": "Ship mobile games to Google Play and the App Store: skills for every store step plus the crt-ship CLI.",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"category": "development",
|
||||||
|
"tags": ["mobile", "google-play", "app-store", "release"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
10
.claude-plugin/plugin.json
Normal file
10
.claude-plugin/plugin.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"name": "crt-ship",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Ship mobile games to Google Play and the App Store: store listings, in-app products, data safety, screenshots, icons and first-release checklists, as skills plus a Bun CLI (crt-ship). CRTkafa's own toolchain, open-sourced.",
|
||||||
|
"author": { "name": "CRTkafa" },
|
||||||
|
"homepage": "https://git.crt.fyi/CRT/crt-ship",
|
||||||
|
"repository": "https://git.crt.fyi/CRT/crt-ship.git",
|
||||||
|
"license": "MIT",
|
||||||
|
"keywords": ["google-play", "app-store", "app-store-connect", "mobile-games", "iap", "screenshots", "app-icons", "release"]
|
||||||
|
}
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules/
|
||||||
|
.crt-ship/
|
||||||
|
*.p8
|
||||||
|
*service-account*.json
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
*.p12
|
||||||
|
.DS_Store
|
||||||
15
CHANGELOG.md
Normal file
15
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## 0.1.0 · 2026-10-10
|
||||||
|
|
||||||
|
First public cut, extracted from the scripts I used to ship my own game.
|
||||||
|
|
||||||
|
- `crt-ship` CLI: `init`, `check`; Play `products`, `listing`, `images`, `data-safety`, `release`, `status`;
|
||||||
|
App Store `bundle`, `iap`, `iap-review`, `listing`, `screenshots`, `appinfo`, `review-details`, `status`
|
||||||
|
(incl. canceling a review submission); `icons`; `shots capture | fonts | render | jpeg`.
|
||||||
|
- Per-project `crt-ship.config.ts`; credentials only from env or `.crt-ship/credentials.json`.
|
||||||
|
- Claude Code plugin + marketplace with 8 skills: play-first-release, appstore-first-release, store-iap-setup,
|
||||||
|
store-screenshots, app-icons, store-declarations, privacy-policy, ads-levelplay-checks.
|
||||||
|
- Templates: config, Play + App Store listing copy, screenshot copy (5 languages incl. Arabic + Japanese), Data
|
||||||
|
safety CSV, App Privacy answers, Play declarations, studio-wide privacy policy page.
|
||||||
|
- Tests for config validation, copy parsing, Data safety CSV, price mapping, script fonts, args.
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 CRTkafa
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
136
README.md
Normal file
136
README.md
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
# crt-ship
|
||||||
|
|
||||||
|
Hey. This is my toolchain for shipping mobile games to Google Play and the App Store. I built it while getting my
|
||||||
|
own game through both stores, hit every wall on the way (draft apps, edits that die when you touch the Console,
|
||||||
|
the "first IAP must ship with a version" dance, data safety CSVs that silently need one more question...), and wrote
|
||||||
|
each fix down as code or as a checklist. It works for me, so I open-sourced it. Friends, use it too.
|
||||||
|
|
||||||
|
It is not a product. No dashboard, no account, no telemetry. A small Bun CLI that talks to the official store APIs,
|
||||||
|
plus Claude Code skills that walk you (or Claude) through the parts that are still clicks in a web console.
|
||||||
|
|
||||||
|
· CRTkafa
|
||||||
|
|
||||||
|
## What is in the box
|
||||||
|
|
||||||
|
- **`crt-ship` CLI** (Bun + TypeScript, zero runtime dependencies): store listings, in-app products with regional
|
||||||
|
prices, Play Data safety, AAB releases, App Store versions / screenshots / age rating / review details, icons from
|
||||||
|
one artwork, and a screenshot renderer (captioned scenes, device frames, 14+ languages incl. RTL and CJK).
|
||||||
|
- **Claude Code skills**: ordered steps for each job, which steps are API and which are UI-only (with the exact
|
||||||
|
console navigation), the gotchas, and how to verify.
|
||||||
|
- **Templates**: config, listing copy, screenshot copy, Data safety CSV, App Privacy answers, Play declarations,
|
||||||
|
a studio-wide privacy policy page.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
As a Claude Code plugin (skills + the `crt-ship` command on Claude's PATH):
|
||||||
|
|
||||||
|
```
|
||||||
|
/plugin marketplace add https://git.crt.fyi/CRT/crt-ship.git
|
||||||
|
/plugin install crt-ship@crtkafa
|
||||||
|
```
|
||||||
|
|
||||||
|
As a CLI in your game repo (needs [Bun](https://bun.sh) 1.1+):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun add -d git+https://git.crt.fyi/CRT/crt-ship.git
|
||||||
|
bunx crt-ship help
|
||||||
|
```
|
||||||
|
|
||||||
|
Extra tools some commands use: `ffmpeg` (icons, screenshots), `cwebp` (optional), `playwright` in your project for
|
||||||
|
screenshots (`bun add -d playwright`; it uses Playwright's Chromium or your installed Chrome).
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bunx crt-ship init # crt-ship.config.ts, store/*.md, store/shots/copy.json, privacy policy, .crt-ship/ (gitignored)
|
||||||
|
# edit crt-ship.config.ts and the store/ files
|
||||||
|
bunx crt-ship check # offline: config, copy limits, data safety, files
|
||||||
|
bunx crt-ship play listing --dry
|
||||||
|
bunx crt-ship apple iap --dry
|
||||||
|
```
|
||||||
|
|
||||||
|
Or just ask Claude: "ship my game to Google Play" / "set up the in-app purchases on both stores" / "make store
|
||||||
|
screenshots in 5 languages", and the matching skill kicks in.
|
||||||
|
|
||||||
|
## Credentials (never in the repo)
|
||||||
|
|
||||||
|
Read from environment variables, or from `.crt-ship/credentials.json` in your project (gitignored by `init`):
|
||||||
|
|
||||||
|
| name | what |
|
||||||
|
|---|---|
|
||||||
|
| `PLAY_SERVICE_ACCOUNT` | path to the Google Cloud service account JSON key (or the JSON itself) with access to your app in Play Console |
|
||||||
|
| `ASC_KEY_ID`, `ASC_ISSUER_ID` | App Store Connect API team key (Users and Access > Integrations) |
|
||||||
|
| `ASC_P8` | path to `AuthKey_<id>.p8` (default `~/.appstoreconnect/private_keys/AuthKey_<id>.p8`) |
|
||||||
|
| `reviewContact` | (file only) App Review contact name / phone / email |
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
Every write command takes `--dry`; re-runs are idempotent; output is one timestamped line per step, so long uploads
|
||||||
|
can run in the background with a log (`crt-ship play images > .crt-ship/images.log 2>&1 &`).
|
||||||
|
|
||||||
|
| command | API / local | what |
|
||||||
|
|---|---|---|
|
||||||
|
| `init` | local | copy the templates, gitignore `.crt-ship/` |
|
||||||
|
| `check` | local | validate config, copy limits, data safety, products, files |
|
||||||
|
| `play products` | API | one-time products via `monetization.onetimeproducts`: regional prices from `convertRegionPrices` + your overrides, activate |
|
||||||
|
| `play listing` | API | app details + title / short / full description per language |
|
||||||
|
| `play images` | API | icon, feature graphic, phone / 7" / 10" screenshots, one edit per language |
|
||||||
|
| `play data-safety` | API | Data safety form (CSV import); `--csv` to write the file |
|
||||||
|
| `play release <aab>` | API | upload + tracks (`--internal completed --production draft`), release notes from the listing |
|
||||||
|
| `play status` | API | tracks, listings, products |
|
||||||
|
| `apple bundle` | API | register the bundle id, find the app record |
|
||||||
|
| `apple iap` | API | IAPs, localizations, price schedules (USA base + overrides), availability |
|
||||||
|
| `apple iap-review` | API | IAP review screenshots + states |
|
||||||
|
| `apple listing --version x.y.z [--build N]` | API | version, build, name / subtitle / description / keywords / promo per locale |
|
||||||
|
| `apple screenshots` | API | 6.9" + 6.5" screenshots per locale (MD5-idempotent) |
|
||||||
|
| `apple appinfo` | API | categories, age rating, content rights, Free, all territories |
|
||||||
|
| `apple review-details` | API | App Review contact + notes |
|
||||||
|
| `apple status` | API | versions, builds, IAPs, review submissions (`--cancel-submission <id>`) |
|
||||||
|
| `icons` | local | App Store 1024 (no alpha), Play 512, web, Capacitor sources, Android legacy launcher icons |
|
||||||
|
| `shots capture` | local | raw frames from your running game |
|
||||||
|
| `shots fonts` | local | font subsets for your copy (Noto for CJK / Arabic / Cyrillic / Devanagari ...) |
|
||||||
|
| `shots render` | local | every store size, every language, contact sheet |
|
||||||
|
| `shots jpeg` | local | JPEG q92 copies for Play (about 4x faster uploads) |
|
||||||
|
|
||||||
|
## What is API and what stays manual
|
||||||
|
|
||||||
|
Scripted: almost everything listed above. Still clicks (the skills give the exact path):
|
||||||
|
|
||||||
|
- **Google Play**: creating the app, API access + service account invite, the first upload in some cases, App content
|
||||||
|
declarations (privacy policy, ads, IARC content rating, target audience, advertising ID, sign-in details,
|
||||||
|
government / financial / health), countries for production, and **Send for review** on a draft app's first release.
|
||||||
|
- **App Store**: creating the app record, App Privacy labels (+ Publish), TestFlight group setup, adding first IAPs to
|
||||||
|
the submission ("Add for Review" on the IAP page), and **Submit for Review**.
|
||||||
|
|
||||||
|
Public or irreversible steps are left to you on purpose. The skills tell Claude to ask before any of them.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
| skill | for |
|
||||||
|
|---|---|
|
||||||
|
| `play-first-release` | Google Play from zero to "In review" |
|
||||||
|
| `appstore-first-release` | App Store from API key to "Waiting for review", incl. xcodebuild with the API key |
|
||||||
|
| `store-iap-setup` | in-app products on both stores, prices, testers, the first-IAP-with-version rule |
|
||||||
|
| `store-screenshots` | capture, copy, fonts, render, upload |
|
||||||
|
| `app-icons` | one artwork to every icon, adaptive / legacy launcher pitfalls |
|
||||||
|
| `store-declarations` | Data safety, App Privacy, IARC, target audience, ads / advertising ID |
|
||||||
|
| `privacy-policy` | one studio-wide policy page with a per-game services table |
|
||||||
|
| `ads-levelplay-checks` | LevelPlay / Unity Ads ids, adapters, app-ads.txt, SKAdNetwork, no-fill |
|
||||||
|
|
||||||
|
## Develop
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
bun test # pure parts: config validation, copy parsing, CSV, prices, fonts, args
|
||||||
|
bun run typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
|
||||||
|
Built with Bun and plain `fetch` against the Google Play Developer API v3 and the App Store Connect API. Screenshot
|
||||||
|
fonts come from Google Fonts at render time (Noto, Lilita One, Montserrat; SIL Open Font License 1.1). Not affiliated
|
||||||
|
with Google or Apple.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT, see [LICENSE](LICENSE). Copyright (c) 2026 CRTkafa.
|
||||||
4
bin/crt-ship
Executable file
4
bin/crt-ship
Executable file
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# crt-ship launcher. On PATH while the Claude Code plugin is enabled; or symlink it anywhere.
|
||||||
|
here=$(cd "$(dirname "$0")" && pwd -P)
|
||||||
|
exec bun "$here/../cli/index.ts" "$@"
|
||||||
71
cli/apple/appinfo.ts
Normal file
71
cli/apple/appinfo.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
// App-level App Store settings (idempotent), from apple.info in crt-ship.config.ts:
|
||||||
|
// crt-ship apple appinfo [--dry]
|
||||||
|
// - categories (primary + up to two game subcategories, secondary)
|
||||||
|
// - age rating declaration: every content question NONE / false unless overridden in apple.info.ageRating
|
||||||
|
// - content rights declaration
|
||||||
|
// - price Free (USA base) and availability in every territory + new ones (when apple.info.free !== false)
|
||||||
|
// App Privacy ("nutrition label") has no API: web UI only (store-declarations skill).
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log } from '../lib/log';
|
||||||
|
import { ascClient } from './asc';
|
||||||
|
|
||||||
|
/** Every question of the current age rating declaration at its "nothing" answer. */
|
||||||
|
export const AGE_RATING_NONE: Record<string, string | boolean | null> = {
|
||||||
|
advertising: false, ageAssurance: false, alcoholTobaccoOrDrugUseOrReferences: 'NONE', contests: 'NONE', gambling: false,
|
||||||
|
gamblingSimulated: 'NONE', gunsOrOtherWeapons: 'NONE', healthOrWellnessTopics: false, horrorOrFearThemes: 'NONE', lootBox: false,
|
||||||
|
matureOrSuggestiveThemes: 'NONE', medicalOrTreatmentInformation: 'NONE', messagingAndChat: false, parentalControls: false,
|
||||||
|
profanityOrCrudeHumor: 'NONE', sexualContentGraphicAndNudity: 'NONE', sexualContentOrNudity: 'NONE', unrestrictedWebAccess: false,
|
||||||
|
userGeneratedContent: false, violenceCartoonOrFantasy: 'NONE', violenceRealistic: 'NONE',
|
||||||
|
violenceRealisticProlongedGraphicOrSadistic: 'NONE', kidsAgeBand: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const info = config.apple?.info ?? {};
|
||||||
|
const dry = ctx.args.has('dry');
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const appInfo = await c.editableAppInfo();
|
||||||
|
const say = (m: string) => log('apple', dry ? `${m} (dry)` : m);
|
||||||
|
|
||||||
|
if (info.categories) {
|
||||||
|
const k = info.categories;
|
||||||
|
const rel = (id?: string) => ({ data: id ? { type: 'appCategories', id } : null });
|
||||||
|
if (!dry) await c.asc('PATCH', `/v1/appInfos/${appInfo.id}`, { data: { type: 'appInfos', id: appInfo.id, relationships: {
|
||||||
|
primaryCategory: rel(k.primary), primarySubcategoryOne: rel(k.primarySubOne), primarySubcategoryTwo: rel(k.primarySubTwo), secondaryCategory: rel(k.secondary) } } });
|
||||||
|
say(`categories: ${[k.primary, k.primarySubOne, k.primarySubTwo].filter(Boolean).join(' / ')}${k.secondary ? `, secondary ${k.secondary}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attributes = { ...AGE_RATING_NONE, ...(info.ageRating ?? {}) };
|
||||||
|
if (!dry) await c.asc('PATCH', `/v1/ageRatingDeclarations/${appInfo.id}`, { data: { type: 'ageRatingDeclarations', id: appInfo.id, attributes } });
|
||||||
|
say(`age rating declaration: ${Object.entries(info.ageRating ?? {}).map(([q, v]) => `${q}=${v}`).join(', ') || 'all NONE'}`);
|
||||||
|
|
||||||
|
if (info.contentRights) {
|
||||||
|
if (!dry) await c.asc('PATCH', `/v1/apps/${appId}`, { data: { type: 'apps', id: appId, attributes: { contentRightsDeclaration: info.contentRights } } });
|
||||||
|
say(`content rights: ${info.contentRights}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.free !== false) {
|
||||||
|
const sched = await c.maybe(`/v1/apps/${appId}/appPriceSchedule?include=manualPrices`);
|
||||||
|
if (!sched?.included?.some((x: { type: string }) => x.type === 'appPrices')) {
|
||||||
|
const free = (await c.all(`/v1/apps/${appId}/appPricePoints?filter[territory]=USA&limit=200`)).find(p => Number(p.attributes.customerPrice) === 0);
|
||||||
|
if (!free) throw new Error('no free price point for USA');
|
||||||
|
if (!dry) await c.asc('POST', '/v1/appPriceSchedules', { data: { type: 'appPriceSchedules', relationships: {
|
||||||
|
app: { data: { type: 'apps', id: appId } }, baseTerritory: { data: { type: 'territories', id: 'USA' } },
|
||||||
|
manualPrices: { data: [{ type: 'appPrices', id: '${free}' }] } } },
|
||||||
|
included: [{ type: 'appPrices', id: '${free}', attributes: { startDate: null }, relationships: { appPricePoint: { data: { type: 'appPricePoints', id: free.id } } } }] });
|
||||||
|
say('price: Free (USA base)');
|
||||||
|
} else log('apple', 'price schedule exists');
|
||||||
|
|
||||||
|
const avail = await c.maybe(`/v1/apps/${appId}/appAvailabilityV2`);
|
||||||
|
if (!avail?.data) {
|
||||||
|
const ts: string[] = (await c.all('/v1/territories?limit=200')).map(t => t.id);
|
||||||
|
if (!dry) await c.asc('POST', '/v2/appAvailabilities', { data: { type: 'appAvailabilities', attributes: { availableInNewTerritories: true },
|
||||||
|
relationships: { app: { data: { type: 'apps', id: appId } }, territoryAvailabilities: { data: ts.map(t => ({ type: 'territoryAvailabilities', id: '${' + t + '}' })) } } },
|
||||||
|
included: ts.map(t => ({ type: 'territoryAvailabilities', id: '${' + t + '}', attributes: { available: true }, relationships: { territory: { data: { type: 'territories', id: t } } } })) });
|
||||||
|
say(`availability: ${ts.length} territories + new ones`);
|
||||||
|
} else log('apple', 'availability exists');
|
||||||
|
}
|
||||||
|
done('apple', 'appinfo done (App Privacy is UI-only: store-declarations skill)');
|
||||||
|
}
|
||||||
89
cli/apple/asc.ts
Normal file
89
cli/apple/asc.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
// App Store Connect API client: ES256 JWT from a team API key (Users and Access > Integrations > App Store Connect
|
||||||
|
// API; role Admin or App Manager), paging, retries, the asset upload protocol, and the app / version lookups.
|
||||||
|
import { createHash, createPrivateKey, sign } from 'node:crypto';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { ascKey } from '../lib/credentials';
|
||||||
|
|
||||||
|
const b64 = (b: Buffer | string): string => Buffer.from(b).toString('base64url');
|
||||||
|
export const md5 = (b: Buffer | Uint8Array): string => createHash('md5').update(b).digest('hex');
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export type J = any;
|
||||||
|
export type Asc = Awaited<ReturnType<typeof ascClient>>;
|
||||||
|
|
||||||
|
/** Versions that can still be edited (being prepared or sent back). */
|
||||||
|
export const EDITABLE = new Set(['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY']);
|
||||||
|
|
||||||
|
export async function ascClient(root: string, bundleId: string, appIdHint?: string) {
|
||||||
|
const k = ascKey(root);
|
||||||
|
const key = createPrivateKey(k.pem);
|
||||||
|
let cached = { jwt: '', until: 0 };
|
||||||
|
function jwt(): string {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (cached.jwt && now < cached.until) return cached.jwt;
|
||||||
|
const h = b64(JSON.stringify({ alg: 'ES256', kid: k.keyId, typ: 'JWT' }));
|
||||||
|
const p = b64(JSON.stringify({ iss: k.issuerId, iat: now, exp: now + 1100, aud: 'appstoreconnect-v1' }));
|
||||||
|
cached = { jwt: `${h}.${p}.${b64(sign('sha256', Buffer.from(h + '.' + p), { key, dsaEncoding: 'ieee-p1363' }))}`, until: now + 900 };
|
||||||
|
return cached.jwt;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function asc(method: string, path: string, body?: unknown, attempt = 1): Promise<J> {
|
||||||
|
const r = await fetch(path.startsWith('http') ? path : 'https://api.appstoreconnect.apple.com' + path, {
|
||||||
|
method, headers: { Authorization: 'Bearer ' + jwt(), 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const t = await r.text();
|
||||||
|
if ((r.status >= 500 || r.status === 429) && attempt < 5) { await Bun.sleep(2000 * attempt); return asc(method, path, body, attempt + 1); }
|
||||||
|
if (!r.ok) throw new Error(`${method} ${path} -> ${r.status} ${t.slice(0, 1500)}`);
|
||||||
|
return t ? JSON.parse(t) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every item of a paged GET. */
|
||||||
|
async function all(path: string): Promise<J[]> {
|
||||||
|
const out: J[] = [];
|
||||||
|
let url: string | null = path;
|
||||||
|
while (url) { const page: J = await asc('GET', url); out.push(...(page.data ?? [])); url = page.links?.next ?? null; }
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** null on 404 (relationship not created yet). */
|
||||||
|
async function maybe(path: string): Promise<J | null> {
|
||||||
|
try { return await asc('GET', path); } catch (e) { if (/-> 404/.test(String(e))) return null; throw e; }
|
||||||
|
}
|
||||||
|
|
||||||
|
let appId = appIdHint ?? '';
|
||||||
|
async function app(): Promise<string> {
|
||||||
|
if (appId) return appId;
|
||||||
|
const found = await asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`);
|
||||||
|
const hit = (found.data as J[]).find(a => a.attributes.bundleId === bundleId);
|
||||||
|
if (!hit) throw new Error(`no App Store Connect app with bundle id ${bundleId}: create the app record in the web UI first (Apps > + > New App); see the appstore-first-release skill`);
|
||||||
|
appId = hit.id;
|
||||||
|
return appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editableVersion(): Promise<J | null> {
|
||||||
|
const vs = await all(`/v1/apps/${await app()}/appStoreVersions?filter[platform]=IOS&limit=50`);
|
||||||
|
return vs.find(v => EDITABLE.has(v.attributes.appVersionState ?? v.attributes.appStoreState)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editableAppInfo(): Promise<J> {
|
||||||
|
const infos = await all(`/v1/apps/${await app()}/appInfos`);
|
||||||
|
return infos.find(i => !['READY_FOR_DISTRIBUTION', 'REPLACED_WITH_NEW_INFO'].includes(i.attributes.state ?? i.attributes.appStoreState)) ?? infos[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reserve -> PUT the parts -> commit with the MD5 (screenshots, IAP review screenshots). */
|
||||||
|
async function uploadAsset(type: string, rel: Record<string, unknown>, file: string): Promise<string> {
|
||||||
|
const buf = readFileSync(file);
|
||||||
|
const name = file.split('/').pop()!;
|
||||||
|
const res = (await asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: name, fileSize: buf.length }, relationships: rel } })).data;
|
||||||
|
for (const op of res.attributes.uploadOperations as J[]) {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
for (const h of op.requestHeaders) headers[h.name] = h.value;
|
||||||
|
const r = await fetch(op.url, { method: op.method, headers, body: buf.subarray(op.offset, op.offset + op.length) });
|
||||||
|
if (!r.ok) throw new Error(`upload ${name} part ${op.offset} -> ${r.status}`);
|
||||||
|
}
|
||||||
|
await asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true, sourceFileChecksum: md5(buf) } } });
|
||||||
|
return res.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { asc, all, maybe, app, editableVersion, editableAppInfo, uploadAsset, keyId: k.keyId };
|
||||||
|
}
|
||||||
26
cli/apple/bundle.ts
Normal file
26
cli/apple/bundle.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Register the bundle id (Certificates, Identifiers & Profiles) through the API. Idempotent.
|
||||||
|
// crt-ship apple bundle [--name "My Game"] [--dry]
|
||||||
|
// The App Store Connect app record itself can NOT be created through the API: do that in the web UI afterwards
|
||||||
|
// (Apps > + > New App, pick this bundle id), see the appstore-first-release skill.
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log } from '../lib/log';
|
||||||
|
import { ascClient } from './asc';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
|
||||||
|
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||||
|
const found = (await c.asc('GET', `/v1/bundleIds?filter[identifier]=${encodeURIComponent(bundleId)}`)).data
|
||||||
|
.filter((b: { attributes: { identifier: string } }) => b.attributes.identifier === bundleId);
|
||||||
|
if (found.length) { log('apple', `bundle id ${bundleId} already registered (${found[0].attributes.platform})`); }
|
||||||
|
else if (ctx.args.has('dry')) { log('apple', `bundle id ${bundleId} missing (dry run, not registering)`); return; }
|
||||||
|
else {
|
||||||
|
await c.asc('POST', '/v1/bundleIds', { data: { type: 'bundleIds', attributes: { identifier: bundleId, name: ctx.args.str('name', config.app.name).replace(/[^A-Za-z0-9 ]/g, ' ').trim(), platform: 'IOS' } } });
|
||||||
|
log('apple', `bundle id ${bundleId} registered`);
|
||||||
|
}
|
||||||
|
const apps = (await c.asc('GET', `/v1/apps?filter[bundleId]=${encodeURIComponent(bundleId)}`)).data;
|
||||||
|
if (apps.length) log('apple', `app record: ${apps[0].attributes.name} (Apple ID ${apps[0].id}); put it in apple.appId`);
|
||||||
|
else log('apple', 'no app record yet: App Store Connect > Apps > + > New App (UI only), then re-run `apple status`');
|
||||||
|
done('apple', 'bundle done');
|
||||||
|
}
|
||||||
35
cli/apple/iap-review.ts
Normal file
35
cli/apple/iap-review.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Upload the App Review screenshot of every in-app purchase (idempotent: a screenshot whose MD5 matches is kept) and
|
||||||
|
// print each product's state. Nothing is submitted.
|
||||||
|
// crt-ship apple iap-review [--dry]
|
||||||
|
// Screenshot per product: products[].reviewScreenshot, else apple.reviewShots/<product id>.(png|jpg).
|
||||||
|
// Size: a real device screenshot size (e.g. 1290 x 2796); other sizes fail with IMAGE_INCORRECT_DIMENSIONS.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log, warn } from '../lib/log';
|
||||||
|
import { ascClient, md5 } from './asc';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const defs = new Map((config.products ?? []).map(p => [p.id, p]));
|
||||||
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) {
|
||||||
|
const sku: string = p.attributes.productId;
|
||||||
|
const def = defs.get(sku);
|
||||||
|
const file = def?.reviewScreenshot ? resolve(def.reviewScreenshot)
|
||||||
|
: ['png', 'jpg', 'jpeg'].map(e => resolve(join(config.apple?.reviewShots ?? 'store/iap-review', `${sku}.${e}`))).find(existsSync);
|
||||||
|
if (!file || !existsSync(file)) { warn('apple', `${sku}: no review screenshot (set products[].reviewScreenshot)`); continue; }
|
||||||
|
const buf = readFileSync(file);
|
||||||
|
const cur = (await c.maybe(`/v2/inAppPurchases/${p.id}/appStoreReviewScreenshot`))?.data;
|
||||||
|
if (cur && cur.attributes.sourceFileChecksum === md5(buf) && cur.attributes.assetDeliveryState?.state !== 'FAILED') { log('apple', `${sku}: screenshot up to date`); continue; }
|
||||||
|
if (ctx.args.has('dry')) { log('apple', `${sku}: would upload ${file}`); continue; }
|
||||||
|
if (cur) await c.asc('DELETE', `/v1/inAppPurchaseAppStoreReviewScreenshots/${cur.id}`);
|
||||||
|
await c.uploadAsset('inAppPurchaseAppStoreReviewScreenshots', { inAppPurchaseV2: { data: { type: 'inAppPurchases', id: p.id } } }, file);
|
||||||
|
log('apple', `${sku}: screenshot uploaded`);
|
||||||
|
}
|
||||||
|
await Bun.sleep(3000);
|
||||||
|
for (const p of await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`)) log('apple', `${p.attributes.productId}: ${p.attributes.state}`);
|
||||||
|
done('apple', 'iap-review done (READY_TO_SUBMIT = complete; first IAPs go to review with a version, from the IAP page)');
|
||||||
|
}
|
||||||
81
cli/apple/iap.ts
Normal file
81
cli/apple/iap.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// In-app purchases on App Store Connect: create missing products, localizations, price schedule (USA base + your
|
||||||
|
// overrides; Apple equalizes every other storefront) and availability (every territory + new ones). Idempotent:
|
||||||
|
// existing products and schedules are left alone (prices of an existing schedule are not changed; do that in the UI).
|
||||||
|
// crt-ship apple iap [--dry] [--only id1,id2]
|
||||||
|
// Then `apple iap-review` for the review screenshots. First IAPs can only be submitted together with an app version,
|
||||||
|
// from the IAP page in the web UI (see the store-iap-setup skill).
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log, step } from '../lib/log';
|
||||||
|
import { appleTerritory, nearestPrices, samePrice } from '../lib/money';
|
||||||
|
import { ascClient, type J } from './asc';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const bundleId = need(config.apple?.bundleId, 'apple.bundleId');
|
||||||
|
const only = ctx.args.list('only');
|
||||||
|
const products = need(config.products, 'products').filter(p => !only.length || only.includes(p.id));
|
||||||
|
const dry = ctx.args.has('dry');
|
||||||
|
const c = await ascClient(root, bundleId, config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const langs = new Map(config.languages.map(l => [l.id, l]));
|
||||||
|
const territories: string[] = (await c.all('/v1/territories?limit=200')).map(t => t.id);
|
||||||
|
|
||||||
|
async function pricePoint(iapId: string, territory: string, price: number): Promise<string> {
|
||||||
|
const seen: number[] = [];
|
||||||
|
for (const p of await c.all(`/v2/inAppPurchases/${iapId}/pricePoints?filter[territory]=${territory}&limit=200`)) {
|
||||||
|
if (samePrice(p.attributes.customerPrice, price)) return p.id;
|
||||||
|
seen.push(Number(p.attributes.customerPrice));
|
||||||
|
}
|
||||||
|
throw new Error(`${territory} has no ${price} price point (nearest ${nearestPrices(seen, price).join(', ')})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [i, p] of products.entries()) {
|
||||||
|
const tag = step(i + 1, products.length, p.id);
|
||||||
|
const found = await c.asc('GET', `/v1/apps/${appId}/inAppPurchasesV2?filter[productId]=${encodeURIComponent(p.id)}`);
|
||||||
|
let id: string = found.data[0]?.id;
|
||||||
|
if (dry) { log('apple', `${tag}: ${id ? `exists (${found.data[0].attributes.state})` : 'missing'} (dry)`); continue; }
|
||||||
|
if (!id) {
|
||||||
|
const first = Object.values(p.listings)[0]!;
|
||||||
|
id = (await c.asc('POST', '/v2/inAppPurchases', { data: { type: 'inAppPurchases',
|
||||||
|
attributes: { name: p.referenceName ?? first.title, productId: p.id, inAppPurchaseType: p.type === 'consumable' ? 'CONSUMABLE' : 'NON_CONSUMABLE',
|
||||||
|
familySharable: p.familySharable ?? false, ...(p.reviewNote ? { reviewNote: p.reviewNote } : {}) },
|
||||||
|
relationships: { app: { data: { type: 'apps', id: appId } } } } })).data.id;
|
||||||
|
log('apple', `${tag}: created`);
|
||||||
|
} else log('apple', `${tag}: exists`);
|
||||||
|
|
||||||
|
const have = new Set((await c.all(`/v2/inAppPurchases/${id}/inAppPurchaseLocalizations`)).map((l: J) => l.attributes.locale));
|
||||||
|
for (const [langId, l] of Object.entries(p.listings)) {
|
||||||
|
const locale = langs.get(langId)?.apple;
|
||||||
|
if (!locale || have.has(locale)) continue;
|
||||||
|
if ([...l.description].length > 55) throw new Error(`${p.id} ${locale}: description over 55 characters (Apple limit)`);
|
||||||
|
await c.asc('POST', '/v1/inAppPurchaseLocalizations', { data: { type: 'inAppPurchaseLocalizations', attributes: { locale, name: l.title, description: l.description },
|
||||||
|
relationships: { inAppPurchaseV2: { data: { type: 'inAppPurchases', id } } } } });
|
||||||
|
log('apple', ` localization ${locale}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sched = await c.maybe(`/v2/inAppPurchases/${id}/iapPriceSchedule?include=manualPrices`);
|
||||||
|
if (!sched?.included?.length) {
|
||||||
|
const manual = [{ territory: 'USA', price: p.usd }, ...(p.overrides ?? []).map(o => ({ territory: appleTerritory(o), price: o.price }))];
|
||||||
|
const points = await Promise.all(manual.map(m => pricePoint(id, m.territory, m.price)));
|
||||||
|
await c.asc('POST', '/v1/inAppPurchasePriceSchedules', {
|
||||||
|
data: { type: 'inAppPurchasePriceSchedules', relationships: {
|
||||||
|
inAppPurchase: { data: { type: 'inAppPurchases', id } },
|
||||||
|
baseTerritory: { data: { type: 'territories', id: 'USA' } },
|
||||||
|
manualPrices: { data: manual.map(m => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}' })) } } },
|
||||||
|
included: manual.map((m, k) => ({ type: 'inAppPurchasePrices', id: '${' + m.territory + '}', attributes: { startDate: null },
|
||||||
|
relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: points[k] } } } })),
|
||||||
|
});
|
||||||
|
log('apple', ` prices ${manual.map(m => `${m.territory} ${m.price}`).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const avail = await c.maybe(`/v2/inAppPurchases/${id}/inAppPurchaseAvailability`);
|
||||||
|
if (!avail?.data) {
|
||||||
|
await c.asc('POST', '/v1/inAppPurchaseAvailabilities', { data: { type: 'inAppPurchaseAvailabilities', attributes: { availableInNewTerritories: true },
|
||||||
|
relationships: { inAppPurchase: { data: { type: 'inAppPurchases', id } },
|
||||||
|
availableTerritories: { data: territories.map(t => ({ type: 'territories', id: t })) } } } });
|
||||||
|
log('apple', ` available in ${territories.length} territories + new ones`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done('apple', dry ? 'iap checked (dry run)' : 'iap done (next: apple iap-review for the review screenshots)');
|
||||||
|
}
|
||||||
77
cli/apple/listing.ts
Normal file
77
cli/apple/listing.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// App Store version + listing text (idempotent; re-run after editing the App Store listing file).
|
||||||
|
// crt-ship apple listing --version 1.0.0 [--build 3] [--dry]
|
||||||
|
// - the editable iOS version: versionString, copyright, release type MANUAL; with --build that processed build is attached
|
||||||
|
// - appInfoLocalizations: name, subtitle, privacy policy URL per locale
|
||||||
|
// - appStoreVersionLocalizations: description, keywords, promotional text, what's new (not on a first version),
|
||||||
|
// support + marketing URL per locale
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { parseAppStoreListing } from '../lib/copy';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log } from '../lib/log';
|
||||||
|
import { ascClient, type J } from './asc';
|
||||||
|
|
||||||
|
export function readAppleCopy(ctx: Ctx) {
|
||||||
|
const { config, resolve } = ctx.loaded;
|
||||||
|
const only = ctx.args.list('only');
|
||||||
|
const all = parseAppStoreListing(readFileSync(resolve(config.apple?.listing ?? 'store/appstore.md'), 'utf8'));
|
||||||
|
return Object.fromEntries(Object.entries(all).filter(([l]) => !only.length || only.includes(l)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upsertLocs(c: Awaited<ReturnType<typeof ascClient>>, list: string, type: string, parentRel: string, parentType: string, parentId: string,
|
||||||
|
want: Record<string, Record<string, string | undefined>>): Promise<void> {
|
||||||
|
const have = new Map((await c.all(`${list}?limit=200`)).map((l: J) => [l.attributes.locale, l] as const));
|
||||||
|
for (const [locale, raw] of Object.entries(want)) {
|
||||||
|
const attributes = Object.fromEntries(Object.entries(raw).filter(([, v]) => v !== undefined));
|
||||||
|
const cur = have.get(locale);
|
||||||
|
if (!cur) {
|
||||||
|
await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { locale, ...attributes }, relationships: { [parentRel]: { data: { type: parentType, id: parentId } } } } });
|
||||||
|
log('apple', `${type} ${locale}: created`);
|
||||||
|
} else if (Object.entries(attributes).some(([k, v]) => cur.attributes[k] !== v)) {
|
||||||
|
await c.asc('PATCH', `/v1/${type}/${cur.id}`, { data: { type, id: cur.id, attributes } });
|
||||||
|
log('apple', `${type} ${locale}: updated`);
|
||||||
|
} else log('apple', `${type} ${locale}: up to date`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const version = a.get('version');
|
||||||
|
if (!version) throw new Error('usage: apple listing --version 1.0.0 [--build N] [--dry]');
|
||||||
|
const listing = readAppleCopy(ctx);
|
||||||
|
log('apple', `listing: ${Object.keys(listing).length} locales (${Object.keys(listing).join(', ')})`);
|
||||||
|
if (a.has('dry')) return;
|
||||||
|
const app = config.app;
|
||||||
|
const copyright = app.copyright ?? `${new Date().getFullYear()} ${app.name}`;
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
|
||||||
|
let v = await c.editableVersion();
|
||||||
|
if (!v) {
|
||||||
|
v = (await c.asc('POST', '/v1/appStoreVersions', { data: { type: 'appStoreVersions',
|
||||||
|
attributes: { platform: 'IOS', versionString: version, copyright, releaseType: 'MANUAL' },
|
||||||
|
relationships: { app: { data: { type: 'apps', id: appId } } } } })).data;
|
||||||
|
log('apple', `version ${version} created`);
|
||||||
|
} else if (v.attributes.versionString !== version || v.attributes.copyright !== copyright || v.attributes.releaseType !== 'MANUAL') {
|
||||||
|
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}`, { data: { type: 'appStoreVersions', id: v.id, attributes: { versionString: version, copyright, releaseType: 'MANUAL' } } });
|
||||||
|
log('apple', `version ${v.attributes.versionString} -> ${version}, copyright, release MANUAL`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const build = a.get('build');
|
||||||
|
if (build) {
|
||||||
|
const b = (await c.all(`/v1/builds?filter[app]=${appId}&filter[version]=${build}&filter[preReleaseVersion.version]=${version}&filter[preReleaseVersion.platform]=IOS`))[0];
|
||||||
|
if (!b) throw new Error(`build ${version} (${build}) not found (still uploading? check TestFlight)`);
|
||||||
|
if (b.attributes.processingState !== 'VALID') throw new Error(`build ${build} is ${b.attributes.processingState}; wait until VALID`);
|
||||||
|
await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: b.id } });
|
||||||
|
log('apple', `build ${version} (${build}) attached`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await c.editableAppInfo();
|
||||||
|
await upsertLocs(c, `/v1/appInfos/${info.id}/appInfoLocalizations`, 'appInfoLocalizations', 'appInfo', 'appInfos', info.id,
|
||||||
|
Object.fromEntries(Object.entries(listing).map(([l, f]) => [l, { name: f.name ?? app.name, subtitle: f.subtitle, privacyPolicyUrl: app.privacyPolicyUrl }])));
|
||||||
|
await upsertLocs(c, `/v1/appStoreVersions/${v.id}/appStoreVersionLocalizations`, 'appStoreVersionLocalizations', 'appStoreVersion', 'appStoreVersions', v.id,
|
||||||
|
Object.fromEntries(Object.entries(listing).map(([l, f]) => [l, { description: f.description, keywords: f.keywords, promotionalText: f.promotionalText,
|
||||||
|
whatsNew: f.whatsNew, supportUrl: app.supportUrl, marketingUrl: app.marketingUrl ?? app.website }])));
|
||||||
|
done('apple', 'listing done');
|
||||||
|
}
|
||||||
39
cli/apple/review-details.ts
Normal file
39
cli/apple/review-details.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// App Review information on the editable version: contact, demo account (none), notes. Idempotent.
|
||||||
|
// crt-ship apple review-details [--contact-from <Apple ID of another app>] [--dry]
|
||||||
|
// The contact (name, phone, email) is personal data, so it is never in the config: it comes from
|
||||||
|
// .crt-ship/credentials.json "reviewContact", or is copied from another app of the same team with --contact-from.
|
||||||
|
// Notes: apple.info.reviewNotes in crt-ship.config.ts.
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { reviewContact } from '../lib/credentials';
|
||||||
|
import { done, log } from '../lib/log';
|
||||||
|
import { ascClient } from './asc';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const version = await c.editableVersion();
|
||||||
|
if (!version) throw new Error('no editable App Store version (apple listing --version x.y.z creates it)');
|
||||||
|
|
||||||
|
let contact: Record<string, string> | null = null;
|
||||||
|
const from = ctx.args.get('contact-from');
|
||||||
|
if (from) {
|
||||||
|
for (const v of await c.all(`/v1/apps/${from}/appStoreVersions?limit=10`)) {
|
||||||
|
const a = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes;
|
||||||
|
if (a?.contactEmail) { contact = { contactFirstName: a.contactFirstName, contactLastName: a.contactLastName, contactPhone: a.contactPhone, contactEmail: a.contactEmail }; break; }
|
||||||
|
}
|
||||||
|
if (!contact) throw new Error(`app ${from} has no review contact on its recent versions`);
|
||||||
|
} else {
|
||||||
|
const rc = reviewContact(root);
|
||||||
|
if (rc) contact = { contactFirstName: rc.firstName, contactLastName: rc.lastName, contactPhone: rc.phone, contactEmail: rc.email };
|
||||||
|
}
|
||||||
|
if (!contact) throw new Error('no review contact: add "reviewContact" to .crt-ship/credentials.json or pass --contact-from <app id>');
|
||||||
|
|
||||||
|
const attributes = { ...contact, demoAccountRequired: false, demoAccountName: null, demoAccountPassword: null, notes: config.apple?.info?.reviewNotes ?? '' };
|
||||||
|
if (ctx.args.has('dry')) { log('apple', `would set review details (contact ${from ? 'copied from ' + from : 'from credentials'}, notes ${attributes.notes.length} chars)`); return; }
|
||||||
|
const cur = (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data;
|
||||||
|
if (cur) await c.asc('PATCH', `/v1/appStoreReviewDetails/${cur.id}`, { data: { type: 'appStoreReviewDetails', id: cur.id, attributes } });
|
||||||
|
else await c.asc('POST', '/v1/appStoreReviewDetails', { data: { type: 'appStoreReviewDetails', attributes,
|
||||||
|
relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: version.id } } } } });
|
||||||
|
done('apple', `review details set on ${version.attributes.versionString} (no demo account, notes ${attributes.notes.length} chars)`);
|
||||||
|
}
|
||||||
70
cli/apple/screenshots.ts
Normal file
70
cli/apple/screenshots.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
// Store screenshots of every locale on the editable App Store version (idempotent: a set whose files already match
|
||||||
|
// by MD5 and order is left alone, otherwise it is emptied and re-uploaded).
|
||||||
|
// crt-ship apple screenshots [--src store/renders] [--only en-US,tr] [--jobs 4]
|
||||||
|
// Source per language: <renders>/<language id>/ios69/NN.png (6.9", 1320 x 2868 -> APP_IPHONE_67) and
|
||||||
|
// ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65). Up to 10 per set. Apple processes uploads asynchronously; failed
|
||||||
|
// ones are reported at the end. Long: run in the background with a log file.
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { byAppleLocale, need } from '../lib/ctx';
|
||||||
|
import { done, log, warn } from '../lib/log';
|
||||||
|
import { numberedImages } from '../play/images';
|
||||||
|
import { ascClient, md5, type J } from './asc';
|
||||||
|
|
||||||
|
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const src = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
|
||||||
|
const only = ctx.args.list('only');
|
||||||
|
const jobs = ctx.args.num('jobs', 4);
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const version = await c.editableVersion();
|
||||||
|
if (!version) throw new Error('no editable App Store version (run apple listing --version x.y.z first)');
|
||||||
|
const langOf = byAppleLocale(config.languages);
|
||||||
|
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
|
||||||
|
|
||||||
|
async function syncSet(locId: string, sets: J[], type: string, files: string[], tag: string): Promise<void> {
|
||||||
|
let set = sets.find(s => s.attributes.screenshotDisplayType === type);
|
||||||
|
if (!set) set = (await c.asc('POST', '/v1/appScreenshotSets', { data: { type: 'appScreenshotSets', attributes: { screenshotDisplayType: type },
|
||||||
|
relationships: { appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: locId } } } } })).data;
|
||||||
|
const existing = await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots`);
|
||||||
|
const want = files.map(f => md5(readFileSync(f)));
|
||||||
|
const have = existing.map(s => s.attributes.sourceFileChecksum);
|
||||||
|
const ok = existing.every(s => ['COMPLETE', 'UPLOAD_COMPLETE'].includes(s.attributes.assetDeliveryState?.state));
|
||||||
|
if (ok && have.length === want.length && have.every((h, i) => h === want[i])) { log('apple', `${tag}: ${files.length} up to date`); return; }
|
||||||
|
if (ctx.args.has('dry')) { log('apple', `${tag}: would replace ${existing.length} with ${files.length}`); return; }
|
||||||
|
for (const s of existing) await c.asc('DELETE', `/v1/appScreenshots/${s.id}`);
|
||||||
|
for (const f of files) await c.uploadAsset('appScreenshots', { appScreenshotSet: { data: { type: 'appScreenshotSets', id: set.id } } }, f);
|
||||||
|
log('apple', `${tag}: uploaded ${files.length} (removed ${existing.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: (() => Promise<void>)[] = [];
|
||||||
|
for (const loc of locs) {
|
||||||
|
const locale: string = loc.attributes.locale;
|
||||||
|
if (only.length && !only.includes(locale)) continue;
|
||||||
|
const lang = langOf.get(locale);
|
||||||
|
if (!lang) { warn('apple', `${locale}: no language with apple: "${locale}", skipped`); continue; }
|
||||||
|
tasks.push(async () => {
|
||||||
|
const sets = await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`);
|
||||||
|
for (const [dir, type] of Object.entries(DISPLAY)) {
|
||||||
|
const d = join(src, lang.id, dir);
|
||||||
|
const files = numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10);
|
||||||
|
if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; }
|
||||||
|
await syncSet(loc.id, sets, type, files, `${locale} ${type}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
log('apple', `${tasks.length} locales, ${jobs} in parallel`);
|
||||||
|
let next = 0;
|
||||||
|
await Promise.all(Array.from({ length: jobs }, async () => { while (next < tasks.length) await tasks[next++]!(); }));
|
||||||
|
|
||||||
|
let bad = 0;
|
||||||
|
for (const loc of locs) for (const set of await c.all(`/v1/appStoreVersionLocalizations/${loc.id}/appScreenshotSets`))
|
||||||
|
for (const s of await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots`)) {
|
||||||
|
const st = s.attributes.assetDeliveryState;
|
||||||
|
if (st?.state === 'FAILED') { bad++; warn('apple', `${loc.attributes.locale} ${set.attributes.screenshotDisplayType} ${s.attributes.fileName}: ${JSON.stringify(st.errors)}`); }
|
||||||
|
}
|
||||||
|
done('apple', bad ? `${bad} screenshots failed processing (re-run to retry)` : 'screenshots done');
|
||||||
|
}
|
||||||
36
cli/apple/status.ts
Normal file
36
cli/apple/status.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Read-only overview: app record, versions + attached build, recent builds, in-app purchases, review submissions.
|
||||||
|
// crt-ship apple status [--json]
|
||||||
|
// And the one write it offers: cancel a review submission (to re-add first IAPs to it from the web UI):
|
||||||
|
// crt-ship apple status --cancel-submission <id> [--yes]
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { confirm, need } from '../lib/ctx';
|
||||||
|
import { log } from '../lib/log';
|
||||||
|
import { ascClient } from './asc';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
|
||||||
|
const cancel = ctx.args.get('cancel-submission');
|
||||||
|
if (cancel) {
|
||||||
|
if (!(await confirm(ctx, `Cancel review submission ${cancel}? (the version goes back to "Prepare for Submission")`))) return;
|
||||||
|
await c.asc('PATCH', `/v1/reviewSubmissions/${cancel}`, { data: { type: 'reviewSubmissions', id: cancel, attributes: { canceled: true } } });
|
||||||
|
log('apple', `review submission ${cancel} canceled; it takes a minute to settle`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = (await c.asc('GET', `/v1/apps/${appId}`)).data;
|
||||||
|
const versions = await c.all(`/v1/apps/${appId}/appStoreVersions?limit=10&include=build`);
|
||||||
|
const builds = (await c.asc('GET', `/v1/builds?filter[app]=${appId}&sort=-uploadedDate&limit=5&include=preReleaseVersion`));
|
||||||
|
const iaps = await c.all(`/v1/apps/${appId}/inAppPurchasesV2?limit=200`);
|
||||||
|
const subs = await c.all(`/v1/reviewSubmissions?filter[app]=${appId}&limit=20`);
|
||||||
|
if (ctx.args.has('json')) { console.log(JSON.stringify({ app, versions, builds: builds.data, iaps, subs }, null, 2)); return; }
|
||||||
|
|
||||||
|
console.log(`${app.attributes.name} · ${app.attributes.bundleId} · Apple ID ${appId}`);
|
||||||
|
for (const v of versions) console.log(` version ${v.attributes.versionString.padEnd(8)} ${v.attributes.appVersionState ?? v.attributes.appStoreState}${v.relationships?.build?.data ? ' (build attached)' : ''}`);
|
||||||
|
const pre = new Map((builds.included ?? []).map((p: { id: string; attributes: { version: string } }) => [p.id, p.attributes.version]));
|
||||||
|
for (const b of builds.data) console.log(` build ${String(pre.get(b.relationships?.preReleaseVersion?.data?.id) ?? '?')} (${b.attributes.version}) ${b.attributes.processingState} ${b.attributes.uploadedDate?.slice(0, 10) ?? ''}`);
|
||||||
|
for (const p of iaps) console.log(` iap ${p.attributes.productId.padEnd(40)} ${p.attributes.inAppPurchaseType} ${p.attributes.state}`);
|
||||||
|
for (const s of subs) console.log(` review submission ${s.id} ${s.attributes.state} ${s.attributes.submittedDate?.slice(0, 10) ?? ''}`);
|
||||||
|
}
|
||||||
52
cli/check.ts
Normal file
52
cli/check.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// Offline sanity check of everything the store commands read: config, both listing files, the screenshot copy, data
|
||||||
|
// safety answers, product texts vs store limits, and the files the config points at. No network, no credentials.
|
||||||
|
// crt-ship check
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { playCodes } from './lib/config';
|
||||||
|
import { len, parseAppStoreListing, parsePlayListing } from './lib/copy';
|
||||||
|
import type { Ctx } from './lib/ctx';
|
||||||
|
import { dataSafetyRows } from './lib/data-safety';
|
||||||
|
import { log, warn } from './lib/log';
|
||||||
|
import { type CopyFile, validateCopy } from './shots/lib';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, resolve, path } = ctx.loaded;
|
||||||
|
log('check', `config ok: ${path}`);
|
||||||
|
const problems: string[] = [];
|
||||||
|
const tryDo = (what: string, fn: () => string): void => { try { log('check', `${what}: ${fn()}`); } catch (e) { problems.push(`${what}: ${(e as Error).message}`); } };
|
||||||
|
|
||||||
|
if (config.play) tryDo('play listing', () => {
|
||||||
|
const f = resolve(config.play!.listing ?? 'store/listing.md');
|
||||||
|
const copy = parsePlayListing(readFileSync(f, 'utf8'), config.app.name);
|
||||||
|
const want = config.languages.flatMap(playCodes);
|
||||||
|
const missing = want.filter(c => !copy.some(x => x.lang === c));
|
||||||
|
if (missing.length) warn('check', `play listing has no section for ${missing.join(', ')}`);
|
||||||
|
return `${copy.length} languages`;
|
||||||
|
});
|
||||||
|
if (config.apple) tryDo('appstore listing', () => {
|
||||||
|
const f = resolve(config.apple!.listing ?? 'store/appstore.md');
|
||||||
|
const copy = parseAppStoreListing(readFileSync(f, 'utf8'));
|
||||||
|
const missing = config.languages.filter(l => l.apple && !copy[l.apple]).map(l => l.apple);
|
||||||
|
if (missing.length) warn('check', `appstore listing has no section for ${missing.join(', ')}`);
|
||||||
|
return `${Object.keys(copy).length} locales`;
|
||||||
|
});
|
||||||
|
if (config.dataSafety) tryDo('data safety', () => `${dataSafetyRows(config.dataSafety!).length - 1} CSV rows`);
|
||||||
|
for (const p of config.products ?? []) for (const [lang, l] of Object.entries(p.listings)) {
|
||||||
|
if (config.apple && len(l.description) > 55) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > 55 (App Store limit)`);
|
||||||
|
if (len(l.description) > 200) problems.push(`product ${p.id} ${lang}: description ${len(l.description)} > 200 (Play limit)`);
|
||||||
|
}
|
||||||
|
if (config.shots) tryDo('shots copy', () => {
|
||||||
|
const copy = JSON.parse(readFileSync(resolve(config.shots!.copy), 'utf8')) as CopyFile;
|
||||||
|
const e = validateCopy(copy, config.shots!, config.languages.map(l => l.id));
|
||||||
|
if (e.length) throw new Error(e.join('; '));
|
||||||
|
return `${Object.keys(copy).length} languages x ${config.shots!.scenes.length} scenes`;
|
||||||
|
});
|
||||||
|
const files = [config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo,
|
||||||
|
...(config.products ?? []).map(p => p.reviewScreenshot)].filter((f): f is string => !!f);
|
||||||
|
for (const f of new Set(files)) if (!existsSync(resolve(f))) problems.push(`missing file: ${f}`);
|
||||||
|
if (existsSync(resolve('.gitignore')) && !/^\/?\.crt-ship\/?$/m.test(readFileSync(resolve('.gitignore'), 'utf8')))
|
||||||
|
problems.push('.gitignore does not ignore .crt-ship/ (credentials live there)');
|
||||||
|
|
||||||
|
if (problems.length) { for (const p of problems) console.error(` - ${p}`); throw new Error(`${problems.length} problems`); }
|
||||||
|
log('check', 'all good');
|
||||||
|
}
|
||||||
118
cli/icons.ts
Normal file
118
cli/icons.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
// One artwork -> every icon the stores, the native shells and the web build need. Needs ffmpeg (cwebp optional).
|
||||||
|
// crt-ship icons [--dry]
|
||||||
|
// In (icons in crt-ship.config.ts): composite (1024 opaque full icon), foreground (1024 RGBA subject on transparent)
|
||||||
|
// and background (1024 opaque) for the adaptive / maskable variants.
|
||||||
|
// Out:
|
||||||
|
// <out>/app-store-1024.png App Store icon, rgb24 (Apple rejects an alpha channel)
|
||||||
|
// <out>/play-512.png Play listing icon
|
||||||
|
// <web>/icon-512|192|180.png, favicon-64|32.png (256-colour PNGs), maskable-512.webp (or .png without cwebp)
|
||||||
|
// <capacitorAssets>/icon-only.png, icon-foreground.png, icon-background.png (then capacitorCommand runs)
|
||||||
|
// <androidRes>/mipmap-*/ic_launcher{,_round}.png redrawn from the adaptive layers (capacitor-assets crops the full
|
||||||
|
// square artwork into the legacy icons, which cuts off anything near the corners)
|
||||||
|
import { mkdirSync, rmSync, statSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import type { Ctx } from './lib/ctx';
|
||||||
|
import { need } from './lib/ctx';
|
||||||
|
import { done, log } from './lib/log';
|
||||||
|
|
||||||
|
/** The anydpi-v26 adaptive icon xml insets both layers 16.7 %, so the launcher mask (often a circle) sees the
|
||||||
|
* foreground's middle ~66 %: at ~0.7 the subject's corners sit just inside a circle mask. */
|
||||||
|
export const ADAPTIVE_FG = 0.7;
|
||||||
|
/** Web maskable icon: the subject inside the 80 % safe zone. */
|
||||||
|
export const MASKABLE_FG = 0.8;
|
||||||
|
export const LEGACY_SIZES = [['mdpi', 48], ['hdpi', 72], ['xhdpi', 96], ['xxhdpi', 144], ['xxxhdpi', 192]] as const;
|
||||||
|
|
||||||
|
const has = (bin: string): boolean => spawnSync('sh', ['-c', `command -v ${bin}`]).status === 0;
|
||||||
|
|
||||||
|
/** geq expressions for 4x supersampled masks: circle, and rounded square with radius 22 %. */
|
||||||
|
export function maskExpr(shape: 'circle' | 'squircle', S: number): string {
|
||||||
|
const h = S / 2;
|
||||||
|
if (shape === 'circle') return `if(lte(hypot(X-${h}+0.5,Y-${h}+0.5),${h}),255,0)`;
|
||||||
|
const r = S * 0.22, e = h - r;
|
||||||
|
return `if(lte(hypot(max(abs(X-${h}+0.5)-${e},0),max(abs(Y-${h}+0.5)-${e},0)),${r}),255,0)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, resolve, root } = ctx.loaded;
|
||||||
|
const ic = need(config.icons, 'icons');
|
||||||
|
if (!has('ffmpeg')) throw new Error('ffmpeg is required (brew install ffmpeg / apt install ffmpeg)');
|
||||||
|
const dry = ctx.args.has('dry');
|
||||||
|
const outs: string[] = [];
|
||||||
|
const ff = (args: string[], out: string): void => {
|
||||||
|
outs.push(out);
|
||||||
|
if (dry) return;
|
||||||
|
mkdirSync(join(out, '..'), { recursive: true });
|
||||||
|
const r = spawnSync('ffmpeg', ['-v', 'error', '-y', ...args, out], { encoding: 'utf8' });
|
||||||
|
if (r.status !== 0) throw new Error(`ffmpeg failed for ${out}\n${r.stderr}`);
|
||||||
|
};
|
||||||
|
const composite = resolve(ic.composite);
|
||||||
|
const fg = ic.foreground ? resolve(ic.foreground) : '';
|
||||||
|
const bg = ic.background ? resolve(ic.background) : '';
|
||||||
|
const adaptiveK = ic.adaptiveScale ?? ADAPTIVE_FG;
|
||||||
|
const tmp = join(root, '.crt-ship', 'tmp-icons');
|
||||||
|
mkdirSync(tmp, { recursive: true });
|
||||||
|
|
||||||
|
const opaque = (size: number, out: string, palette = false): void => {
|
||||||
|
const scale = `scale=${size}:${size}:flags=lanczos`;
|
||||||
|
if (!palette) ff(['-i', composite, '-vf', scale, '-pix_fmt', 'rgb24'], out);
|
||||||
|
else ff(['-i', composite, '-vf', `${scale},split[a][b];[a]palettegen=max_colors=256:stats_mode=full[p];[b][p]paletteuse=dither=sierra2_4a`, '-frames:v', '1'], out);
|
||||||
|
};
|
||||||
|
/** foreground scaled to k, centred on the background (opaque) or on transparency. */
|
||||||
|
const inset = (k: number, out: string, onBackground: boolean): void => {
|
||||||
|
const s = Math.round(1024 * k), o = Math.round((1024 - s) / 2);
|
||||||
|
const base = onBackground ? ['-i', bg] : ['-f', 'lavfi', '-i', 'color=c=black@0.0:s=1024x1024,format=rgba'];
|
||||||
|
ff([...base, '-i', fg, '-filter_complex', `[1:v]scale=${s}:${s}:flags=lanczos[f];[0:v][f]overlay=${o}:${o}:format=auto`, '-frames:v', '1',
|
||||||
|
'-pix_fmt', onBackground ? 'rgb24' : 'rgba'], out);
|
||||||
|
};
|
||||||
|
|
||||||
|
const out = resolve(ic.out);
|
||||||
|
opaque(1024, join(out, 'app-store-1024.png'));
|
||||||
|
opaque(512, join(out, 'play-512.png'));
|
||||||
|
|
||||||
|
if (ic.web) {
|
||||||
|
const web = resolve(ic.web);
|
||||||
|
for (const n of [512, 192, 180]) opaque(n, join(web, `icon-${n}.png`), true);
|
||||||
|
for (const n of [64, 32]) opaque(n, join(web, `favicon-${n}.png`), true);
|
||||||
|
if (fg && bg) {
|
||||||
|
inset(ic.maskableScale ?? MASKABLE_FG, join(tmp, 'maskable-1024.png'), true);
|
||||||
|
const png512 = has('cwebp') ? join(tmp, 'maskable-512.png') : join(web, 'maskable-512.png');
|
||||||
|
ff(['-i', join(tmp, 'maskable-1024.png'), '-vf', 'scale=512:512:flags=lanczos'], png512);
|
||||||
|
if (has('cwebp')) {
|
||||||
|
outs.push(join(web, 'maskable-512.webp'));
|
||||||
|
if (!dry && spawnSync('cwebp', ['-quiet', '-q', '86', png512, '-o', join(web, 'maskable-512.webp')]).status !== 0) throw new Error('cwebp failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ic.capacitorAssets) {
|
||||||
|
if (!fg || !bg) throw new Error('icons.capacitorAssets needs icons.foreground and icons.background');
|
||||||
|
const cap = resolve(ic.capacitorAssets);
|
||||||
|
opaque(1024, join(cap, 'icon-only.png'));
|
||||||
|
ff(['-i', bg, '-vf', 'scale=1024:1024:flags=lanczos', '-pix_fmt', 'rgb24'], join(cap, 'icon-background.png'));
|
||||||
|
inset(adaptiveK, join(cap, 'icon-foreground.png'), false);
|
||||||
|
if (ic.capacitorCommand && !dry) {
|
||||||
|
log('icons', `running: ${ic.capacitorCommand}`);
|
||||||
|
const r = spawnSync('sh', ['-c', ic.capacitorCommand], { cwd: root, encoding: 'utf8', stdio: 'inherit', env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' } });
|
||||||
|
if (r.status !== 0) throw new Error('capacitorCommand failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// after capacitor-assets, so these legacy icons win
|
||||||
|
if (ic.androidRes) {
|
||||||
|
const res = resolve(ic.androidRes);
|
||||||
|
const adaptive = join(tmp, 'adaptive.png');
|
||||||
|
inset(adaptiveK, adaptive, true);
|
||||||
|
for (const [dpi, n] of LEGACY_SIZES) {
|
||||||
|
const S = n * 4;
|
||||||
|
for (const [name, shape] of [['ic_launcher_round', 'circle'], ['ic_launcher', 'squircle']] as const)
|
||||||
|
ff(['-i', adaptive, '-f', 'lavfi', '-i', `color=c=black:s=${S}x${S},format=gray,geq=lum='${maskExpr(shape, S)}'`, '-filter_complex',
|
||||||
|
`[0:v]scale=${S}:${S}:flags=lanczos,format=rgba[i];[i][1:v]alphamerge,scale=${n}:${n}:flags=area`, '-frames:v', '1', '-pix_fmt', 'rgba'],
|
||||||
|
join(res, `mipmap-${dpi}`, `${name}.png`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(tmp, { recursive: true, force: true });
|
||||||
|
for (const f of outs.filter(f => !f.startsWith(tmp))) log('icons', dry ? `would write ${f.slice(root.length + 1)}` : `${f.slice(root.length + 1).padEnd(60)} ${(statSync(f).size / 1024).toFixed(1)} KB`);
|
||||||
|
done('icons', dry ? 'icons planned (dry run)' : 'icons done');
|
||||||
|
}
|
||||||
57
cli/index.ts
Executable file
57
cli/index.ts
Executable file
|
|
@ -0,0 +1,57 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
// crt-ship: ship a mobile game to Google Play and the App Store from the terminal.
|
||||||
|
// Run `crt-ship help` for the command list. Config: crt-ship.config.ts; credentials: env or .crt-ship/.
|
||||||
|
import { parseArgs } from './lib/args';
|
||||||
|
import { loadConfig } from './lib/config';
|
||||||
|
import type { Ctx } from './lib/ctx';
|
||||||
|
|
||||||
|
type Mod = { run(ctx: Ctx): Promise<void> };
|
||||||
|
const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||||
|
'check': [() => import('./check'), 'validate config, copy files, data safety answers and products (offline)'],
|
||||||
|
'play products': [() => import('./play/products'), 'one-time products: create / update, regional prices, activate'],
|
||||||
|
'play listing': [() => import('./play/listing'), 'app details + title / short / full description per language'],
|
||||||
|
'play images': [() => import('./play/images'), 'icon, feature graphic, phone / 7" / 10" screenshots per language'],
|
||||||
|
'play data-safety': [() => import('./play/data-safety'), 'Data safety form (CSV import through the API)'],
|
||||||
|
'play release': [() => import('./play/release'), 'upload an AAB and put it on tracks with release notes'],
|
||||||
|
'play status': [() => import('./play/status'), 'tracks, listings, products (read-only)'],
|
||||||
|
'apple bundle': [() => import('./apple/bundle'), 'register the bundle id; finds the app record'],
|
||||||
|
'apple iap': [() => import('./apple/iap'), 'in-app purchases, localizations, price schedules, availability'],
|
||||||
|
'apple iap-review': [() => import('./apple/iap-review'), 'IAP review screenshots + product states'],
|
||||||
|
'apple listing': [() => import('./apple/listing'), 'version, build, name / subtitle / description / keywords per locale'],
|
||||||
|
'apple screenshots': [() => import('./apple/screenshots'), '6.9" + 6.5" screenshots per locale'],
|
||||||
|
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
|
||||||
|
'apple review-details': [() => import('./apple/review-details'), 'App Review contact + notes'],
|
||||||
|
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--cancel-submission <id>)'],
|
||||||
|
'icons': [() => import('./icons'), 'App Store / Play / web / adaptive / legacy launcher icons from one artwork'],
|
||||||
|
'shots capture': [() => import('./shots/capture'), 'raw frames from a running build of your game (playwright)'],
|
||||||
|
'shots fonts': [() => import('./shots/fonts'), 'download font subsets for the copy (CJK / Arabic / Cyrillic ...)'],
|
||||||
|
'shots render': [() => import('./shots/render'), 'store screenshots + feature graphic in every size and language'],
|
||||||
|
'shots jpeg': [() => import('./shots/jpeg'), 'JPEG q92 copies of the Play renders (4x faster uploads)'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function help(): void {
|
||||||
|
console.log(`crt-ship · ship mobile games to Google Play and the App Store
|
||||||
|
|
||||||
|
usage: crt-ship <command> [options]
|
||||||
|
|
||||||
|
init copy the templates (config, listing, copy, privacy policy) into this project
|
||||||
|
${Object.entries(COMMANDS).map(([k, [, d]]) => ` ${k.padEnd(25)} ${d}`).join('\n')}
|
||||||
|
|
||||||
|
common options: --config <path> --dry (no writes) --only <ids> --yes (skip confirmations)
|
||||||
|
credentials: PLAY_SERVICE_ACCOUNT, ASC_KEY_ID, ASC_ISSUER_ID, ASC_P8 (env or .crt-ship/credentials.json)
|
||||||
|
docs: README.md and the skills/ folder of the toolchain`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const argv = process.argv.slice(2);
|
||||||
|
const [a, b] = argv;
|
||||||
|
if (!a || a === 'help' || a === '--help' || a === '-h') return help();
|
||||||
|
if (a === 'init') return (await import('./init')).run(parseArgs(argv.slice(1)));
|
||||||
|
const key = COMMANDS[`${a} ${b}`] ? `${a} ${b}` : COMMANDS[a] ? a : '';
|
||||||
|
if (!key) { console.error(`unknown command: ${argv.slice(0, 2).join(' ')}\n`); help(); process.exit(2); }
|
||||||
|
const args = parseArgs(argv.slice(key.split(' ').length));
|
||||||
|
const loaded = await loadConfig(args.get('config'));
|
||||||
|
await (await COMMANDS[key]![0]()).run({ loaded, args });
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(e => { console.error(`\nerror: ${(e as Error).message}`); process.exit(1); });
|
||||||
37
cli/init.ts
Normal file
37
cli/init.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// Copies the templates into the current project without overwriting anything, and makes sure .crt-ship/ is ignored.
|
||||||
|
// crt-ship init [--dir .]
|
||||||
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, join, resolve } from 'node:path';
|
||||||
|
import type { Args } from './lib/args';
|
||||||
|
import { log } from './lib/log';
|
||||||
|
|
||||||
|
const T = join(import.meta.dir, '..', 'templates');
|
||||||
|
const FILES: [string, string][] = [
|
||||||
|
['crt-ship.config.ts', 'crt-ship.config.ts'],
|
||||||
|
['listing.md', 'store/listing.md'],
|
||||||
|
['appstore.md', 'store/appstore.md'],
|
||||||
|
['shots-copy.json', 'store/shots/copy.json'],
|
||||||
|
['app-privacy.md', 'store/app-privacy.md'],
|
||||||
|
['declarations.md', 'store/declarations.md'],
|
||||||
|
['privacy-policy.html', 'store/privacy-policy.html'],
|
||||||
|
['credentials.example.json', '.crt-ship/credentials.json'],
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function run(args: Args): Promise<void> {
|
||||||
|
const root = resolve(args.str('dir', '.'));
|
||||||
|
for (const [from, to] of FILES) {
|
||||||
|
const dest = join(root, to);
|
||||||
|
if (existsSync(dest)) { log('init', `keep ${to} (exists)`); continue; }
|
||||||
|
mkdirSync(dirname(dest), { recursive: true });
|
||||||
|
copyFileSync(join(T, from), dest);
|
||||||
|
log('init', `create ${to}`);
|
||||||
|
}
|
||||||
|
const gi = join(root, '.gitignore');
|
||||||
|
const cur = existsSync(gi) ? readFileSync(gi, 'utf8') : '';
|
||||||
|
if (!/^\/?\.crt-ship\/?$/m.test(cur)) {
|
||||||
|
if (cur) appendFileSync(gi, `${cur.endsWith('\n') ? '' : '\n'}# crt-ship: credentials, fonts, temp files\n.crt-ship/\n`);
|
||||||
|
else writeFileSync(gi, '# crt-ship: credentials, fonts, temp files\n.crt-ship/\n');
|
||||||
|
log('init', '.gitignore: added .crt-ship/');
|
||||||
|
}
|
||||||
|
log('init', 'next: edit crt-ship.config.ts and .crt-ship/credentials.json, then `crt-ship check`');
|
||||||
|
}
|
||||||
34
cli/lib/args.ts
Normal file
34
cli/lib/args.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Tiny argv parser: positionals, --flag, --key value, --key=value.
|
||||||
|
export interface Args {
|
||||||
|
_: string[];
|
||||||
|
has(name: string): boolean;
|
||||||
|
get(name: string): string | undefined;
|
||||||
|
str(name: string, d: string): string;
|
||||||
|
num(name: string, d: number): number;
|
||||||
|
list(name: string): string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseArgs(argv: string[], booleans: string[] = []): Args {
|
||||||
|
const flags = new Map<string, string>();
|
||||||
|
const _: string[] = [];
|
||||||
|
const bool = new Set(['dry', 'yes', 'help', 'json', 'verbose', ...booleans]);
|
||||||
|
for (let i = 0; i < argv.length; i++) {
|
||||||
|
const a = argv[i]!;
|
||||||
|
if (!a.startsWith('--')) { _.push(a); continue; }
|
||||||
|
const eq = a.indexOf('=');
|
||||||
|
if (eq > 0) { flags.set(a.slice(2, eq), a.slice(eq + 1)); continue; }
|
||||||
|
const name = a.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (bool.has(name) || name.startsWith('no-') || next === undefined || next.startsWith('--')) flags.set(name, 'true');
|
||||||
|
else { flags.set(name, next); i++; }
|
||||||
|
}
|
||||||
|
const get = (n: string) => flags.get(n);
|
||||||
|
return {
|
||||||
|
_,
|
||||||
|
has: n => flags.has(n),
|
||||||
|
get,
|
||||||
|
str: (n, d) => get(n) ?? d,
|
||||||
|
num: (n, d) => { const v = get(n); const x = v === undefined ? d : Number(v); if (Number.isNaN(x)) throw new Error(`--${n}: not a number`); return x; },
|
||||||
|
list: n => (get(n) ?? '').split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
};
|
||||||
|
}
|
||||||
252
cli/lib/config.ts
Normal file
252
cli/lib/config.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
||||||
|
// Per-project config (crt-ship.config.ts at the project root). Everything game-specific lives here; credentials never
|
||||||
|
// do (see credentials.ts). The file default-exports a plain object; `import type { ShipConfig }` is optional.
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
||||||
|
|
||||||
|
export type ProductType = 'consumable' | 'non_consumable';
|
||||||
|
|
||||||
|
export interface PriceOverride {
|
||||||
|
/** ISO 3166-1 alpha-2 region for Play, e.g. "TR". */
|
||||||
|
region: string;
|
||||||
|
/** App Store territory (alpha-3), e.g. "TUR". Looked up from `region` when left out. */
|
||||||
|
territory?: string;
|
||||||
|
/** ISO 4217 currency, e.g. "TRY". */
|
||||||
|
currency: string;
|
||||||
|
/** Customer price in that currency, e.g. 129.99. Apple needs an existing price point with exactly this value. */
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductListing { title: string; description: string }
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
/** Store product id (same on both stores), e.g. "com.example.game.no_ads". */
|
||||||
|
id: string;
|
||||||
|
type: ProductType;
|
||||||
|
/** Base price in USD. Play converts it per region (convertRegionPrices); Apple equalizes it from the USA base. */
|
||||||
|
usd: number;
|
||||||
|
overrides?: PriceOverride[];
|
||||||
|
/** Language id (see `languages`) -> title + description. Apple caps the description at 55 characters. */
|
||||||
|
listings: Record<string, ProductListing>;
|
||||||
|
/** Apple only: internal reference name (defaults to the first listing title). */
|
||||||
|
referenceName?: string;
|
||||||
|
/** Apple only: note for the reviewer on this product. */
|
||||||
|
reviewNote?: string;
|
||||||
|
/** Apple only: review screenshot (path relative to the project root). */
|
||||||
|
reviewScreenshot?: string;
|
||||||
|
familySharable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Language {
|
||||||
|
/** Your id for the language; also the folder name under the renders dir and the key in copy files. */
|
||||||
|
id: string;
|
||||||
|
/** Play listing language code(s), e.g. "en-US" or ["es-ES", "es-419"]. Leave out to skip on Play. */
|
||||||
|
play?: string | string[];
|
||||||
|
/** App Store Connect locale, e.g. "en-US", "tr", "zh-Hans". Leave out to skip on the App Store. */
|
||||||
|
apple?: string;
|
||||||
|
rtl?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayPurpose = 'functionality' | 'analytics' | 'communications' | 'fraud' | 'advertising' | 'personalization' | 'account';
|
||||||
|
|
||||||
|
export interface DataUsage {
|
||||||
|
/** Transferred to a third party that is not your service provider (ad networks usually are "shared"). */
|
||||||
|
shared: boolean;
|
||||||
|
ephemeral?: boolean;
|
||||||
|
/** Only true when the user can really choose (Play rejects "optional" otherwise). */
|
||||||
|
optional?: boolean;
|
||||||
|
collect: PlayPurpose[];
|
||||||
|
share?: PlayPurpose[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataSafety {
|
||||||
|
collectsData: boolean;
|
||||||
|
encryptedInTransit: boolean;
|
||||||
|
deletionRequest: boolean;
|
||||||
|
/** Response ids for PSL_SUPPORTED_ACCOUNT_CREATION_METHODS. Default ["PSL_ACM_NONE"] (no accounts). */
|
||||||
|
accountCreation?: string[];
|
||||||
|
/** Play data type id (e.g. "PSL_DEVICE_ID") -> usage. Types not listed are "not collected". */
|
||||||
|
data: Record<string, DataUsage>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShotBadgeSlot { y: number; side: 'l' | 'r'; icon?: string; iconBg?: string; hot?: boolean; stars?: boolean; rot?: number }
|
||||||
|
export interface ShotFrame { src: string; x?: number; dy?: number; scale?: number; rotate?: number }
|
||||||
|
export interface Palette { bg: [string, string, string]; glow: string; ink: string; shadow: string }
|
||||||
|
export interface ShotScene { id: string; palette: string | Palette; hero?: boolean; frames: ShotFrame[]; badges?: ShotBadgeSlot[]; over?: number }
|
||||||
|
|
||||||
|
export interface ShotsConfig {
|
||||||
|
/** JSON file: { [languageId]: { [sceneId]: { eyebrow, title: [line1, line2], sub, badges: [[big, small?]] } } } */
|
||||||
|
copy: string;
|
||||||
|
/** Raw frames: <frames>/<languageId>/<phone|tablet>/<name>.png */
|
||||||
|
frames: string;
|
||||||
|
/** Output: <out>/<languageId>/<size>/NN.png and feature/1024x500.png */
|
||||||
|
out: string;
|
||||||
|
/** Optional logo image for the hero scene and the feature graphic. */
|
||||||
|
logo?: string;
|
||||||
|
/** Google Fonts families for titles / body text (downloaded and subset by `shots fonts`). */
|
||||||
|
fonts?: { title?: string; body?: string };
|
||||||
|
scenes: ShotScene[];
|
||||||
|
feature?: { frame: string; palette?: string | Palette };
|
||||||
|
/** Phone screen size of the raw frames in CSS px (default 440 x 902, iPhone 6.9" minus the 54 px status bar). */
|
||||||
|
phone?: { w: number; h: number; dpr?: number };
|
||||||
|
tablet?: { w: number; h: number; dpr?: number };
|
||||||
|
/** Optional capture of raw frames from a running build of your game. */
|
||||||
|
capture?: {
|
||||||
|
baseUrl: string;
|
||||||
|
/** Appended to every URL, e.g. "noads=1&noanalytics=1". "{lang}" is replaced. */
|
||||||
|
query?: string;
|
||||||
|
shots: Record<string, { path: string; waitFor?: string; delayMs?: number; eval?: string[]; device?: 'phone' | 'tablet' | 'both' }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IconsConfig {
|
||||||
|
/** 1024 opaque composite (the full icon). */
|
||||||
|
composite: string;
|
||||||
|
/** 1024 RGBA subject on transparent background (adaptive foreground). */
|
||||||
|
foreground?: string;
|
||||||
|
/** 1024 opaque background (adaptive background). */
|
||||||
|
background?: string;
|
||||||
|
/** Output dir for the store icons (app-store-1024.png, play-512.png). */
|
||||||
|
out: string;
|
||||||
|
/** Web icons (icon-512/192/180, favicon-64/32, maskable-512). */
|
||||||
|
web?: string;
|
||||||
|
/** @capacitor/assets source dir (icon-only, icon-foreground, icon-background). */
|
||||||
|
capacitorAssets?: string;
|
||||||
|
/** Command to run after writing the capacitor sources, e.g. "bunx @capacitor/assets generate". */
|
||||||
|
capacitorCommand?: string;
|
||||||
|
/** Android res dir; legacy ic_launcher{,_round}.png get redrawn from the adaptive layers. */
|
||||||
|
androidRes?: string;
|
||||||
|
adaptiveScale?: number;
|
||||||
|
maskableScale?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleAppInfo {
|
||||||
|
categories?: { primary: string; primarySubOne?: string; primarySubTwo?: string; secondary?: string };
|
||||||
|
/** Overrides for the age rating declaration; everything else is NONE / false. */
|
||||||
|
ageRating?: Record<string, string | boolean | null>;
|
||||||
|
contentRights?: 'DOES_NOT_USE_THIRD_PARTY_CONTENT' | 'USES_THIRD_PARTY_CONTENT';
|
||||||
|
/** Free app, available in every territory and new ones. */
|
||||||
|
free?: boolean;
|
||||||
|
reviewNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShipConfig {
|
||||||
|
app: {
|
||||||
|
name: string;
|
||||||
|
copyright?: string;
|
||||||
|
privacyPolicyUrl: string;
|
||||||
|
supportUrl: string;
|
||||||
|
marketingUrl?: string;
|
||||||
|
website: string;
|
||||||
|
contactEmail: string;
|
||||||
|
};
|
||||||
|
languages: Language[];
|
||||||
|
play?: { packageName: string; defaultLanguage?: string; listing?: string; icon?: string };
|
||||||
|
apple?: { bundleId: string; appId?: string; primaryLocale?: string; listing?: string; info?: AppleAppInfo; reviewShots?: string };
|
||||||
|
products?: Product[];
|
||||||
|
dataSafety?: DataSafety;
|
||||||
|
/** Rendered store graphics dir (<renders>/<languageId>/<size>/NN.png|jpg). Defaults to shots.out. */
|
||||||
|
renders?: string;
|
||||||
|
shots?: ShotsConfig;
|
||||||
|
icons?: IconsConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Identity helper for typed configs: `export default defineConfig({...})`. */
|
||||||
|
export const defineConfig = (c: ShipConfig): ShipConfig => c;
|
||||||
|
|
||||||
|
const ID_RE = /^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z0-9_]+)+$/;
|
||||||
|
const PRODUCT_RE = /^[A-Za-z0-9._]+$/;
|
||||||
|
const URL_RE = /^https:\/\/\S+$/;
|
||||||
|
|
||||||
|
/** Every problem with a config, empty when it is fine. Pure (no IO). */
|
||||||
|
export function validateConfig(c: unknown): string[] {
|
||||||
|
const e: string[] = [];
|
||||||
|
const cfg = c as Partial<ShipConfig>;
|
||||||
|
if (!cfg || typeof cfg !== 'object') return ['config must be an object'];
|
||||||
|
const app = cfg.app;
|
||||||
|
if (!app) e.push('app: missing');
|
||||||
|
else {
|
||||||
|
if (!app.name) e.push('app.name: missing');
|
||||||
|
for (const k of ['privacyPolicyUrl', 'supportUrl', 'website'] as const) if (!URL_RE.test(app[k] ?? '')) e.push(`app.${k}: must be an https URL`);
|
||||||
|
if (app.marketingUrl && !URL_RE.test(app.marketingUrl)) e.push('app.marketingUrl: must be an https URL');
|
||||||
|
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(app.contactEmail ?? '')) e.push('app.contactEmail: not an email address');
|
||||||
|
}
|
||||||
|
const langs = cfg.languages;
|
||||||
|
if (!Array.isArray(langs) || !langs.length) e.push('languages: at least one language');
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const [i, l] of (langs ?? []).entries()) {
|
||||||
|
if (!l.id) e.push(`languages[${i}].id: missing`);
|
||||||
|
if (ids.has(l.id)) e.push(`languages[${i}].id: duplicate "${l.id}"`);
|
||||||
|
ids.add(l.id);
|
||||||
|
}
|
||||||
|
if (cfg.play && !ID_RE.test(cfg.play.packageName ?? '')) e.push('play.packageName: not a package name (com.example.game)');
|
||||||
|
if (cfg.apple) {
|
||||||
|
if (!ID_RE.test(cfg.apple.bundleId ?? '')) e.push('apple.bundleId: not a bundle id (com.example.game)');
|
||||||
|
if (cfg.apple.appId && !/^\d+$/.test(cfg.apple.appId)) e.push('apple.appId: numeric Apple ID of the app (App Store Connect > App Information)');
|
||||||
|
}
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const [i, p] of (cfg.products ?? []).entries()) {
|
||||||
|
const at = `products[${i}]`;
|
||||||
|
if (!PRODUCT_RE.test(p.id ?? '')) e.push(`${at}.id: letters, digits, "." and "_" only`);
|
||||||
|
if (seen.has(p.id)) e.push(`${at}.id: duplicate "${p.id}"`);
|
||||||
|
seen.add(p.id);
|
||||||
|
if (p.type !== 'consumable' && p.type !== 'non_consumable') e.push(`${at}.type: consumable | non_consumable`);
|
||||||
|
if (!(typeof p.usd === 'number' && p.usd > 0)) e.push(`${at}.usd: positive number`);
|
||||||
|
if (!p.listings || !Object.keys(p.listings).length) e.push(`${at}.listings: at least one language`);
|
||||||
|
for (const [lang, l] of Object.entries(p.listings ?? {})) {
|
||||||
|
if (!ids.has(lang)) e.push(`${at}.listings.${lang}: not in languages`);
|
||||||
|
if (!l.title) e.push(`${at}.listings.${lang}.title: missing`);
|
||||||
|
if ([...(l.title ?? '')].length > 30) e.push(`${at}.listings.${lang}.title: over 30 characters (App Store limit)`);
|
||||||
|
if (!l.description) e.push(`${at}.listings.${lang}.description: missing`);
|
||||||
|
}
|
||||||
|
for (const o of p.overrides ?? []) {
|
||||||
|
if (!/^[A-Z]{2}$/.test(o.region)) e.push(`${at}.overrides: region "${o.region}" must be ISO alpha-2 (TR)`);
|
||||||
|
if (!/^[A-Z]{3}$/.test(o.currency)) e.push(`${at}.overrides: currency "${o.currency}" must be ISO 4217 (TRY)`);
|
||||||
|
if (!(o.price > 0)) e.push(`${at}.overrides: price must be positive`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cfg.dataSafety) {
|
||||||
|
for (const [t, u] of Object.entries(cfg.dataSafety.data ?? {})) {
|
||||||
|
if (!/^PSL_[A-Z_]+$/.test(t)) e.push(`dataSafety.data.${t}: use the Play type id (PSL_DEVICE_ID, ...)`);
|
||||||
|
if (!u.collect?.length) e.push(`dataSafety.data.${t}: at least one collection purpose`);
|
||||||
|
if (u.shared && !u.share?.length) e.push(`dataSafety.data.${t}: shared, so it needs sharing purposes`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cfg.shots) {
|
||||||
|
const s = cfg.shots;
|
||||||
|
for (const k of ['copy', 'frames', 'out'] as const) if (!s[k]) e.push(`shots.${k}: missing`);
|
||||||
|
if (!s.scenes?.length) e.push('shots.scenes: at least one scene');
|
||||||
|
for (const sc of s.scenes ?? []) if (!sc.frames?.length) e.push(`shots.scenes[${sc.id}].frames: at least one frame`);
|
||||||
|
}
|
||||||
|
if (cfg.icons) {
|
||||||
|
if (!cfg.icons.composite) e.push('icons.composite: missing');
|
||||||
|
if (!cfg.icons.out) e.push('icons.out: missing');
|
||||||
|
if (cfg.icons.androidRes && !(cfg.icons.foreground && cfg.icons.background)) e.push('icons.androidRes needs icons.foreground + icons.background');
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Loaded { config: ShipConfig; root: string; path: string; resolve(p: string): string }
|
||||||
|
|
||||||
|
const NAMES = ['crt-ship.config.ts', 'crt-ship.config.js', 'crt-ship.config.json'];
|
||||||
|
|
||||||
|
/** Finds crt-ship.config.* from `start` upwards (or uses `explicit`), imports and validates it. */
|
||||||
|
export async function loadConfig(explicit?: string, start = process.cwd()): Promise<Loaded> {
|
||||||
|
let path = explicit ? resolve(explicit) : '';
|
||||||
|
if (!path) {
|
||||||
|
for (let d = resolve(start); ; d = dirname(d)) {
|
||||||
|
const hit = NAMES.map(n => join(d, n)).find(existsSync);
|
||||||
|
if (hit) { path = hit; break; }
|
||||||
|
if (dirname(d) === d) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!path || !existsSync(path)) throw new Error('no crt-ship.config.ts found (run `crt-ship init` or pass --config)');
|
||||||
|
const mod = await import(path);
|
||||||
|
const config = (mod.default ?? mod) as ShipConfig;
|
||||||
|
const errors = validateConfig(config);
|
||||||
|
if (errors.length) throw new Error(`${path}:\n ` + errors.join('\n '));
|
||||||
|
const root = dirname(path);
|
||||||
|
return { config, root, path, resolve: (p: string) => (isAbsolute(p) ? p : join(root, p)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play language codes of a language entry. */
|
||||||
|
export const playCodes = (l: Language): string[] => (l.play === undefined ? [] : Array.isArray(l.play) ? l.play : [l.play]);
|
||||||
81
cli/lib/copy.ts
Normal file
81
cli/lib/copy.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
// Store copy lives in two human-edited Markdown files (templates/listing.md, templates/appstore.md). Pure parsers +
|
||||||
|
// limit checks; the consoles count characters (code points), so do we.
|
||||||
|
|
||||||
|
export const PLAY_LIMITS = { title: 30, short: 80, full: 4000, whatsNew: 500 } as const;
|
||||||
|
export const APPLE_LIMITS = { name: 30, subtitle: 30, promotionalText: 170, keywords: 100, description: 4000, whatsNew: 4000 } as const;
|
||||||
|
|
||||||
|
export interface PlayCopy { lang: string; title: string; short: string; full: string; whatsNew: string }
|
||||||
|
export type AppleField = keyof typeof APPLE_LIMITS;
|
||||||
|
export type AppleCopy = Partial<Record<AppleField, string>>;
|
||||||
|
|
||||||
|
export const len = (s: string): number => [...s].length;
|
||||||
|
|
||||||
|
/** "### heading" sub-sections of a "## " section, each with its first fenced block. */
|
||||||
|
function blocks(section: string): { heading: string; text: string }[] {
|
||||||
|
const out: { heading: string; text: string }[] = [];
|
||||||
|
for (const part of section.split(/^### /m).slice(1)) {
|
||||||
|
const heading = part.split('\n')[0]!.trim();
|
||||||
|
const m = /^```[^\n]*\n([\s\S]*?)\n```/m.exec(part);
|
||||||
|
if (m) out.push({ heading, text: m[1]!.trim() });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play copy. Every "## ..." section with a `<!-- play: en-US[, en-GB] -->` line contributes, under "### " headings
|
||||||
|
* that contain (case-insensitive): "title" (optional, defaults to `defaultTitle`), "short", "full", "what's new"
|
||||||
|
* (or "release notes"). One section can serve several Play languages.
|
||||||
|
*/
|
||||||
|
export function parsePlayListing(md: string, defaultTitle: string): PlayCopy[] {
|
||||||
|
const out: PlayCopy[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const sec of md.split(/^## /m).slice(1)) {
|
||||||
|
const m = /^<!--\s*play:\s*([^>]+?)\s*-->$/m.exec(sec);
|
||||||
|
if (!m) continue;
|
||||||
|
const name = sec.split('\n')[0]!.trim();
|
||||||
|
const bs = blocks(sec);
|
||||||
|
const find = (re: RegExp): string | undefined => bs.find(b => re.test(b.heading))?.text;
|
||||||
|
const title = find(/\btitle\b/i) ?? defaultTitle;
|
||||||
|
const short = find(/\bshort\b/i);
|
||||||
|
const full = find(/\bfull\b/i);
|
||||||
|
const whatsNew = find(/what'?s new|release notes/i) ?? '';
|
||||||
|
if (!short) errors.push(`"${name}": no "### ... short ..." block`);
|
||||||
|
if (!full) errors.push(`"${name}": no "### ... full ..." block`);
|
||||||
|
if (!short || !full) continue;
|
||||||
|
for (const lang of m[1]!.split(',').map(s => s.trim()).filter(Boolean)) {
|
||||||
|
const c: PlayCopy = { lang, title, short, full, whatsNew };
|
||||||
|
for (const k of Object.keys(PLAY_LIMITS) as (keyof typeof PLAY_LIMITS)[])
|
||||||
|
if (len(c[k]) > PLAY_LIMITS[k]) errors.push(`${lang}: ${k} is ${len(c[k])} > ${PLAY_LIMITS[k]}`);
|
||||||
|
if (out.some(o => o.lang === lang)) errors.push(`${lang}: listed in two sections`);
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (errors.length) throw new Error('listing: ' + errors.join('; '));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const APPLE_REQUIRED: AppleField[] = ['subtitle', 'promotionalText', 'keywords', 'description'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Store copy. One "## <ASC locale> ..." section per locale (the first word is the locale), with "### <field>"
|
||||||
|
* headings named exactly like the API attributes: name (optional), subtitle, promotionalText, keywords, description,
|
||||||
|
* whatsNew (optional; not allowed on a first version).
|
||||||
|
*/
|
||||||
|
export function parseAppStoreListing(md: string): Record<string, AppleCopy> {
|
||||||
|
const out: Record<string, AppleCopy> = {};
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const sec of md.split(/^## /m).slice(1)) {
|
||||||
|
const locale = sec.split(/[\s·]/)[0]!.trim();
|
||||||
|
if (!/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/.test(locale)) continue; // prose sections
|
||||||
|
const f: AppleCopy = {};
|
||||||
|
for (const b of blocks(sec)) if (b.heading in APPLE_LIMITS) f[b.heading as AppleField] = b.text;
|
||||||
|
for (const k of APPLE_REQUIRED) if (!f[k]) errors.push(`${locale}: missing ${k}`);
|
||||||
|
for (const [k, v] of Object.entries(f) as [AppleField, string][])
|
||||||
|
if (len(v) > APPLE_LIMITS[k]) errors.push(`${locale}: ${k} is ${len(v)} > ${APPLE_LIMITS[k]}`);
|
||||||
|
if (f.keywords && Buffer.byteLength(f.keywords) > 100) errors.push(`${locale}: keywords are ${Buffer.byteLength(f.keywords)} bytes > 100`);
|
||||||
|
if (f.keywords && /,\s/.test(f.keywords)) errors.push(`${locale}: keywords: no spaces after commas (they count)`);
|
||||||
|
out[locale] = f;
|
||||||
|
}
|
||||||
|
if (errors.length) throw new Error('appstore listing: ' + errors.join('; '));
|
||||||
|
return out;
|
||||||
|
}
|
||||||
61
cli/lib/credentials.ts
Normal file
61
cli/lib/credentials.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Credentials never live in crt-ship.config.ts or in git. Sources, first hit wins:
|
||||||
|
// 1. environment: PLAY_SERVICE_ACCOUNT (path to the JSON key, or the JSON itself), ASC_KEY_ID, ASC_ISSUER_ID,
|
||||||
|
// ASC_P8 (path to AuthKey_<id>.p8, or the PEM itself)
|
||||||
|
// 2. <project>/.crt-ship/credentials.json (gitignored), same names:
|
||||||
|
// { "PLAY_SERVICE_ACCOUNT": "...", "ASC_KEY_ID": "...", "ASC_ISSUER_ID": "...", "ASC_P8": "...",
|
||||||
|
// "reviewContact": { "firstName": "", "lastName": "", "phone": "", "email": "" } }
|
||||||
|
// 3. ASC_P8 only: ~/.appstoreconnect/private_keys/AuthKey_<ASC_KEY_ID>.p8 (where Xcode / altool keep it)
|
||||||
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
export interface ServiceAccount { client_email: string; private_key: string; token_uri?: string }
|
||||||
|
export interface AscKey { keyId: string; issuerId: string; pem: string }
|
||||||
|
export interface ReviewContact { firstName: string; lastName: string; phone: string; email: string }
|
||||||
|
|
||||||
|
type Store = Record<string, unknown>;
|
||||||
|
|
||||||
|
function fileStore(root: string): Store {
|
||||||
|
const p = join(root, '.crt-ship', 'credentials.json');
|
||||||
|
if (!existsSync(p)) return {};
|
||||||
|
try { return JSON.parse(readFileSync(p, 'utf8')) as Store; } catch (e) { throw new Error(`${p}: ${(e as Error).message}`); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function value(root: string, name: string): string | undefined {
|
||||||
|
const env = process.env[name];
|
||||||
|
if (env) return env;
|
||||||
|
const v = fileStore(root)[name];
|
||||||
|
return typeof v === 'string' && v ? v : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A path or the inline content itself. */
|
||||||
|
const inline = (v: string, marker: RegExp): string => (marker.test(v) ? v : readFileSync(v.replace(/^~(?=\/)/, homedir()), 'utf8'));
|
||||||
|
|
||||||
|
export function parseServiceAccount(text: string): ServiceAccount {
|
||||||
|
const j = JSON.parse(text) as Partial<ServiceAccount>;
|
||||||
|
if (typeof j.client_email !== 'string' || typeof j.private_key !== 'string' || !j.private_key.includes('PRIVATE KEY'))
|
||||||
|
throw new Error('not a Google service account JSON key (client_email / private_key missing)');
|
||||||
|
return { client_email: j.client_email, private_key: j.private_key, token_uri: j.token_uri };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playServiceAccount(root: string): ServiceAccount {
|
||||||
|
const v = value(root, 'PLAY_SERVICE_ACCOUNT');
|
||||||
|
if (!v) throw new Error('PLAY_SERVICE_ACCOUNT is not set (env, or .crt-ship/credentials.json): path to the service account JSON key');
|
||||||
|
return parseServiceAccount(inline(v, /^\s*\{/));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ascKey(root: string): AscKey {
|
||||||
|
const keyId = value(root, 'ASC_KEY_ID');
|
||||||
|
const issuerId = value(root, 'ASC_ISSUER_ID');
|
||||||
|
if (!keyId || !issuerId) throw new Error('ASC_KEY_ID and ASC_ISSUER_ID are not set (env, or .crt-ship/credentials.json)');
|
||||||
|
const p8 = value(root, 'ASC_P8') ?? join(homedir(), '.appstoreconnect/private_keys', `AuthKey_${keyId}.p8`);
|
||||||
|
if (!/BEGIN PRIVATE KEY/.test(p8) && !existsSync(p8.replace(/^~(?=\/)/, homedir()))) throw new Error(`App Store Connect key not found: ${p8} (set ASC_P8)`);
|
||||||
|
return { keyId, issuerId, pem: inline(p8, /BEGIN PRIVATE KEY/) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App Review contact from .crt-ship/credentials.json (personal data, so never in the config). */
|
||||||
|
export function reviewContact(root: string): ReviewContact | null {
|
||||||
|
const c = fileStore(root).reviewContact as Partial<ReviewContact> | undefined;
|
||||||
|
if (!c?.email) return null;
|
||||||
|
return { firstName: c.firstName ?? '', lastName: c.lastName ?? '', phone: c.phone ?? '', email: c.email };
|
||||||
|
}
|
||||||
31
cli/lib/ctx.ts
Normal file
31
cli/lib/ctx.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import type { Args } from './args';
|
||||||
|
import type { Language, Loaded } from './config';
|
||||||
|
import { playCodes } from './config';
|
||||||
|
|
||||||
|
export interface Ctx { loaded: Loaded; args: Args }
|
||||||
|
|
||||||
|
export function need<T>(v: T | undefined, what: string): T {
|
||||||
|
if (v === undefined || v === null) throw new Error(`crt-ship.config.ts: ${what} is not set`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Play language code -> language entry. */
|
||||||
|
export function byPlayCode(langs: Language[]): Map<string, Language> {
|
||||||
|
const m = new Map<string, Language>();
|
||||||
|
for (const l of langs) for (const c of playCodes(l)) m.set(c, l);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** App Store locale -> language entry. */
|
||||||
|
export function byAppleLocale(langs: Language[]): Map<string, Language> {
|
||||||
|
return new Map(langs.filter(l => l.apple).map(l => [l.apple!, l] as const));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asks on the terminal unless --yes. Store actions that are public or irreversible go through this. */
|
||||||
|
export async function confirm(ctx: Ctx, question: string): Promise<boolean> {
|
||||||
|
if (ctx.args.has('yes')) return true;
|
||||||
|
if (!process.stdin.isTTY) { console.log(`${question}\n not a terminal: re-run with --yes to confirm`); return false; }
|
||||||
|
process.stdout.write(`${question} [y/N] `);
|
||||||
|
for await (const line of console) return /^y(es)?$/i.test(line.trim());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
69
cli/lib/data-safety.ts
Normal file
69
cli/lib/data-safety.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Google Play Data safety form as Play's CSV import format (the same layout Play Console exports from the Data safety
|
||||||
|
// page: "Export to CSV"). One row per question / choice. Importing replaces every earlier answer. Pure.
|
||||||
|
// Play names a missing answer "Response missing for <ID>" and a wrong choice "Invalid response ID <ID>".
|
||||||
|
import type { DataSafety, PlayPurpose } from './config';
|
||||||
|
|
||||||
|
export const PURPOSE_IDS: Record<PlayPurpose, string> = {
|
||||||
|
functionality: 'PSL_APP_FUNCTIONALITY',
|
||||||
|
analytics: 'PSL_ANALYTICS',
|
||||||
|
communications: 'PSL_DEVELOPER_COMMUNICATIONS',
|
||||||
|
fraud: 'PSL_FRAUD_PREVENTION_SECURITY',
|
||||||
|
advertising: 'PSL_ADVERTISING',
|
||||||
|
personalization: 'PSL_PERSONALIZATION',
|
||||||
|
account: 'PSL_ACCOUNT_MANAGEMENT',
|
||||||
|
};
|
||||||
|
const PURPOSES = Object.values(PURPOSE_IDS);
|
||||||
|
|
||||||
|
/** Every category and data type of the form, in form order. */
|
||||||
|
export const CATEGORIES: [string, string[]][] = [
|
||||||
|
['PSL_DATA_TYPES_PERSONAL', ['PSL_NAME', 'PSL_EMAIL', 'PSL_USER_ACCOUNT', 'PSL_ADDRESS', 'PSL_PHONE', 'PSL_RACE_ETHNICITY', 'PSL_POLITICAL_RELIGIOUS', 'PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY', 'PSL_OTHER_PERSONAL']],
|
||||||
|
['PSL_DATA_TYPES_FINANCIAL', ['PSL_PURCHASE_HISTORY', 'PSL_CREDIT_SCORE', 'PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER', 'PSL_OTHER']],
|
||||||
|
['PSL_DATA_TYPES_LOCATION', ['PSL_APPROX_LOCATION', 'PSL_PRECISE_LOCATION']],
|
||||||
|
['PSL_DATA_TYPES_SEARCH_AND_BROWSING', ['PSL_WEB_BROWSING_HISTORY']],
|
||||||
|
['PSL_DATA_TYPES_EMAIL_AND_TEXT', ['PSL_EMAILS', 'PSL_SMS_CALL_LOG', 'PSL_OTHER_MESSAGES']],
|
||||||
|
['PSL_DATA_TYPES_PHOTOS_AND_VIDEOS', ['PSL_PHOTOS', 'PSL_VIDEOS']],
|
||||||
|
['PSL_DATA_TYPES_AUDIO', ['PSL_AUDIO', 'PSL_MUSIC', 'PSL_OTHER_AUDIO']],
|
||||||
|
['PSL_DATA_TYPES_HEALTH_AND_FITNESS', ['PSL_HEALTH', 'PSL_FITNESS']],
|
||||||
|
['PSL_DATA_TYPES_CONTACTS', ['PSL_CONTACTS']],
|
||||||
|
['PSL_DATA_TYPES_CALENDAR', ['PSL_CALENDAR']],
|
||||||
|
['PSL_DATA_TYPES_APP_PERFORMANCE', ['PSL_CRASH_LOGS', 'PSL_PERFORMANCE_DIAGNOSTICS', 'PSL_OTHER_PERFORMANCE']],
|
||||||
|
['PSL_DATA_TYPES_FILES_AND_DOCS', ['PSL_FILES_AND_DOCS']],
|
||||||
|
['PSL_DATA_TYPES_APP_ACTIVITY', ['PSL_USER_INTERACTION', 'PSL_IN_APP_SEARCH_HISTORY', 'PSL_APPS_ON_DEVICE', 'PSL_USER_GENERATED_CONTENT', 'PSL_OTHER_APP_ACTIVITY']],
|
||||||
|
['PSL_DATA_TYPES_IDENTIFIERS', ['PSL_DEVICE_ID']],
|
||||||
|
];
|
||||||
|
export const ALL_TYPES = new Set(CATEGORIES.flatMap(([, t]) => t));
|
||||||
|
|
||||||
|
const HEADER = ['Question ID (machine readable)', 'Response ID (machine readable)', 'Response value', 'Answer requirement', 'Human-friendly question label'];
|
||||||
|
|
||||||
|
export function csvCell(s: string): string { return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; }
|
||||||
|
|
||||||
|
export function dataSafetyRows(ds: DataSafety): string[][] {
|
||||||
|
for (const t of Object.keys(ds.data)) if (!ALL_TYPES.has(t)) throw new Error(`dataSafety: unknown data type ${t}`);
|
||||||
|
const b = (v: boolean) => (v ? 'TRUE' : 'FALSE');
|
||||||
|
const pick = (v: boolean) => (v ? 'TRUE' : '');
|
||||||
|
const rows: string[][] = [HEADER];
|
||||||
|
rows.push(['PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA', '', b(ds.collectsData), 'REQUIRED', 'Does your app collect or share any of the required user data types?']);
|
||||||
|
rows.push(['PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT', '', b(ds.encryptedInTransit), 'MAYBE_REQUIRED', 'Is all of the user data collected by your app encrypted in transit?']);
|
||||||
|
rows.push(['PSL_DATA_COLLECTION_USER_REQUEST_DELETE', '', b(ds.deletionRequest), 'MAYBE_REQUIRED', 'Do you provide a way for users to request that their data is deleted?']);
|
||||||
|
// Newer form question that Google's older sample CSV does not have; the import fails without it.
|
||||||
|
for (const id of ds.accountCreation ?? ['PSL_ACM_NONE'])
|
||||||
|
rows.push(['PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', id, 'TRUE', 'MULTIPLE_CHOICE', `Account creation / ${id}`]);
|
||||||
|
for (const [cat, types] of CATEGORIES) for (const t of types) rows.push([cat, t, pick(!!ds.data[t]), 'MULTIPLE_CHOICE', `${cat} / ${t}`]);
|
||||||
|
for (const [, types] of CATEGORIES) for (const t of types) {
|
||||||
|
const u = ds.data[t];
|
||||||
|
const q = (s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`;
|
||||||
|
const label = `Data usage and handling (${t})`;
|
||||||
|
const collect = new Set((u?.collect ?? []).map(p => PURPOSE_IDS[p]));
|
||||||
|
const share = new Set((u?.shared ? u.share ?? [] : []).map(p => PURPOSE_IDS[p]));
|
||||||
|
rows.push([q('PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_COLLECTED', pick(!!u), 'MULTIPLE_CHOICE', label + ' / Collected']);
|
||||||
|
rows.push([q('PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_SHARED', pick(!!u?.shared), 'MULTIPLE_CHOICE', label + ' / Shared']);
|
||||||
|
rows.push([q('PSL_DATA_USAGE_EPHEMERAL'), '', u ? b(!!u.ephemeral) : '', 'MAYBE_REQUIRED', label + ' / Processed ephemerally']);
|
||||||
|
rows.push([q('DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_OPTIONAL', pick(!!u?.optional), 'SINGLE_CHOICE', label + ' / Optional']);
|
||||||
|
rows.push([q('DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_REQUIRED', pick(!!u && !u.optional), 'SINGLE_CHOICE', label + ' / Required']);
|
||||||
|
for (const p of PURPOSES) rows.push([q('DATA_USAGE_COLLECTION_PURPOSE'), p, pick(collect.has(p)), 'MULTIPLE_CHOICE', label + ' / Collection purpose']);
|
||||||
|
for (const p of PURPOSES) rows.push([q('DATA_USAGE_SHARING_PURPOSE'), p, pick(share.has(p)), 'MULTIPLE_CHOICE', label + ' / Sharing purpose']);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dataSafetyCsv = (ds: DataSafety): string => dataSafetyRows(ds).map(r => r.map(csvCell).join(',')).join('\n') + '\n';
|
||||||
40
cli/lib/google-auth.ts
Normal file
40
cli/lib/google-auth.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
// OAuth 2.0 service account flow (RS256 JWT assertion -> access token) with WebCrypto, no googleapis dependency.
|
||||||
|
import type { ServiceAccount } from './credentials';
|
||||||
|
|
||||||
|
export const PLAY_SCOPE = 'https://www.googleapis.com/auth/androidpublisher';
|
||||||
|
const b64u = (b: ArrayBuffer | string): string => Buffer.from(typeof b === 'string' ? b : new Uint8Array(b)).toString('base64url');
|
||||||
|
|
||||||
|
function pemToDer(pem: string): ArrayBuffer {
|
||||||
|
const bytes = Buffer.from(pem.replace(/-----(BEGIN|END) PRIVATE KEY-----/g, '').replace(/\s+/g, ''), 'base64');
|
||||||
|
const out = new ArrayBuffer(bytes.length);
|
||||||
|
new Uint8Array(out).set(bytes);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function signAssertion(sa: ServiceAccount, nowS: number, scope = PLAY_SCOPE): Promise<string> {
|
||||||
|
const key = await crypto.subtle.importKey('pkcs8', pemToDer(sa.private_key), { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign']);
|
||||||
|
const head = b64u(JSON.stringify({ alg: 'RS256', typ: 'JWT' }));
|
||||||
|
const claims = b64u(JSON.stringify({ iss: sa.client_email, scope, aud: sa.token_uri || 'https://oauth2.googleapis.com/token', iat: nowS, exp: nowS + 3600 }));
|
||||||
|
const sig = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', key, new TextEncoder().encode(`${head}.${claims}`));
|
||||||
|
return `${head}.${claims}.${b64u(sig)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cached access token getter (refreshes 5 minutes before expiry). */
|
||||||
|
export function tokenSource(sa: ServiceAccount): () => Promise<string> {
|
||||||
|
let token = '';
|
||||||
|
let until = 0;
|
||||||
|
return async () => {
|
||||||
|
if (token && Date.now() < until) return token;
|
||||||
|
const assertion = await signAssertion(sa, Math.floor(Date.now() / 1000));
|
||||||
|
const r = await fetch(sa.token_uri || 'https://oauth2.googleapis.com/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion }),
|
||||||
|
});
|
||||||
|
const j = (await r.json()) as { access_token?: string; expires_in?: number; error_description?: string };
|
||||||
|
if (!j.access_token) throw new Error('Google token: ' + (j.error_description ?? r.status));
|
||||||
|
token = j.access_token;
|
||||||
|
until = Date.now() + ((j.expires_in ?? 3600) - 300) * 1000;
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
}
|
||||||
12
cli/lib/log.ts
Normal file
12
cli/lib/log.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// Plain, line-based progress output: easy to follow live and easy to tail when a long upload runs in the background
|
||||||
|
// (`crt-ship play images > .crt-ship/images.log 2>&1 &`). Every line carries a timestamp and a tag.
|
||||||
|
const t0 = Date.now();
|
||||||
|
const stamp = (): string => new Date().toISOString().slice(11, 19);
|
||||||
|
const since = (): string => `${((Date.now() - t0) / 1000).toFixed(0)}s`;
|
||||||
|
|
||||||
|
export const log = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg}`);
|
||||||
|
export const warn = (tag: string, msg: string): void => console.warn(`${stamp()} [${tag}] WARN ${msg}`);
|
||||||
|
export const done = (tag: string, msg: string): void => console.log(`${stamp()} [${tag}] ${msg} (${since()})`);
|
||||||
|
|
||||||
|
/** "3/14 de-DE" style counters. */
|
||||||
|
export const step = (i: number, n: number, what: string): string => `${String(i).padStart(String(n).length)}/${n} ${what}`;
|
||||||
55
cli/lib/money.ts
Normal file
55
cli/lib/money.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
// Price helpers shared by the Play and App Store product commands. Pure.
|
||||||
|
import type { PriceOverride } from './config';
|
||||||
|
|
||||||
|
export interface Money { currencyCode: string; units: string; nanos?: number }
|
||||||
|
|
||||||
|
/** 2.99 -> { units: "2", nanos: 990000000 } without float noise (works in cents). */
|
||||||
|
export function toMoney(currencyCode: string, value: number): Money {
|
||||||
|
if (!(value >= 0)) throw new Error(`bad price ${value}`);
|
||||||
|
const cents = Math.round(value * 100);
|
||||||
|
return { currencyCode, units: String(Math.floor(cents / 100)), nanos: (cents % 100) * 10_000_000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fromMoney = (m: Money): number => Number(m.units) + (m.nanos ?? 0) / 1e9;
|
||||||
|
|
||||||
|
export interface RegionConfig { regionCode: string; availability: 'AVAILABLE'; price: Money }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play: take convertRegionPrices' result (every region Play sells in, rounded to local price points) and replace the
|
||||||
|
* regions you set explicitly. Throws when an override names a region Play does not offer.
|
||||||
|
*/
|
||||||
|
export function playRegionConfigs(converted: Record<string, { regionCode: string; price: Money }>, overrides: PriceOverride[] = []): RegionConfig[] {
|
||||||
|
const byRegion = new Map(Object.values(converted).map(r => [r.regionCode, r.price] as const));
|
||||||
|
for (const o of overrides) if (!byRegion.has(o.region)) throw new Error(`Play does not sell in region ${o.region}`);
|
||||||
|
const ov = new Map(overrides.map(o => [o.region, toMoney(o.currency, o.price)] as const));
|
||||||
|
return [...byRegion.entries()].sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([regionCode, price]) => ({ regionCode, availability: 'AVAILABLE' as const, price: ov.get(regionCode) ?? price }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apple price points carry customerPrice as a string ("2.99", "30.0"); compare as cents. */
|
||||||
|
export const samePrice = (customerPrice: string | number, want: number): boolean =>
|
||||||
|
Math.round(Number(customerPrice) * 100) === Math.round(want * 100);
|
||||||
|
|
||||||
|
/** Nearest few price points, for a helpful error. */
|
||||||
|
export function nearestPrices(have: number[], want: number, n = 3): number[] {
|
||||||
|
return [...have].sort((a, b) => Math.abs(a - want) - Math.abs(b - want)).slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO 3166 alpha-2 -> alpha-3 for the App Store territories (the 175 storefronts). Unknown: set `territory` yourself.
|
||||||
|
const A3 = `AE ARE AF AFG AG ATG AI AIA AL ALB AM ARM AO AGO AR ARG AT AUT AU AUS AZ AZE BA BIH BB BRB BE BEL BF BFA BG BGR
|
||||||
|
BH BHR BJ BEN BM BMU BN BRN BO BOL BR BRA BS BHS BT BTN BW BWA BY BLR BZ BLZ CA CAN CD COD CG COG CH CHE CI CIV CL CHL CM CMR
|
||||||
|
CN CHN CO COL CR CRI CV CPV CY CYP CZ CZE DE DEU DK DNK DM DMA DO DOM DZ DZA EC ECU EE EST EG EGY ES ESP FI FIN FJ FJI FM FSM
|
||||||
|
FR FRA GA GAB GB GBR GD GRD GE GEO GH GHA GM GMB GR GRC GT GTM GW GNB GY GUY HK HKG HN HND HR HRV HU HUN ID IDN IE IRL IL ISR
|
||||||
|
IN IND IQ IRQ IS ISL IT ITA JM JAM JO JOR JP JPN KE KEN KG KGZ KH KHM KN KNA KR KOR KW KWT KY CYM KZ KAZ LA LAO LB LBN LC LCA
|
||||||
|
LK LKA LR LBR LT LTU LU LUX LV LVA LY LBY MA MAR MD MDA ME MNE MG MDG MK MKD ML MLI MM MMR MN MNG MO MAC MR MRT MS MSR MT MLT
|
||||||
|
MU MUS MV MDV MW MWI MX MEX MY MYS MZ MOZ NA NAM NE NER NG NGA NI NIC NL NLD NO NOR NP NPL NR NRU NZ NZL OM OMN PA PAN PE PER
|
||||||
|
PG PNG PH PHL PK PAK PL POL PT PRT PW PLW PY PRY QA QAT RO ROU RS SRB RU RUS RW RWA SA SAU SB SLB SC SYC SE SWE SG SGP SI SVN
|
||||||
|
SK SVK SL SLE SN SEN SR SUR ST STP SV SLV SZ SWZ TC TCA TD TCD TH THA TJ TJK TM TKM TN TUN TO TON TR TUR TT TTO TW TWN TZ TZA
|
||||||
|
UA UKR UG UGA US USA UY URY UZ UZB VC VCT VE VEN VG VGB VN VNM VU VUT XK XKS YE YEM ZA ZAF ZM ZMB ZW ZWE`.split(/\s+/);
|
||||||
|
export const ALPHA3: Record<string, string> = Object.fromEntries(Array.from({ length: A3.length / 2 }, (_, i) => [A3[2 * i]!, A3[2 * i + 1]!]));
|
||||||
|
|
||||||
|
export function appleTerritory(o: PriceOverride): string {
|
||||||
|
const t = o.territory ?? ALPHA3[o.region];
|
||||||
|
if (!t) throw new Error(`no App Store territory known for region ${o.region}; set "territory" (alpha-3) on the override`);
|
||||||
|
return t;
|
||||||
|
}
|
||||||
68
cli/play/client.ts
Normal file
68
cli/play/client.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// Google Play Developer API (androidpublisher v3) client: JSON + media calls with retry, and the edit lifecycle.
|
||||||
|
import { playServiceAccount } from '../lib/credentials';
|
||||||
|
import { tokenSource } from '../lib/google-auth';
|
||||||
|
import { log } from '../lib/log';
|
||||||
|
|
||||||
|
export type PlayClient = Awaited<ReturnType<typeof playClient>>;
|
||||||
|
|
||||||
|
export async function playClient(root: string, packageName: string) {
|
||||||
|
const token = tokenSource(playServiceAccount(root));
|
||||||
|
const API = `https://androidpublisher.googleapis.com/androidpublisher/v3/applications/${packageName}`;
|
||||||
|
const UPLOAD = `https://androidpublisher.googleapis.com/upload/androidpublisher/v3/applications/${packageName}`;
|
||||||
|
|
||||||
|
/** JSON call (object body) or media upload (Uint8Array body + Content-Type). Throws with the API's text. */
|
||||||
|
async function call<T = unknown>(method: string, url: string, body?: unknown, headers: Record<string, string> = {}): Promise<T> {
|
||||||
|
const bin = body instanceof Uint8Array;
|
||||||
|
for (let attempt = 0; ; attempt++) {
|
||||||
|
const r = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: { Authorization: 'Bearer ' + (await token()), ...(body === undefined || bin ? {} : { 'Content-Type': 'application/json' }), ...headers },
|
||||||
|
body: body === undefined ? undefined : bin ? (body as unknown as BodyInit) : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await r.text();
|
||||||
|
if (r.ok) return (text ? JSON.parse(text) : {}) as T;
|
||||||
|
if ((r.status === 429 || r.status >= 500) && attempt < 4) { await Bun.sleep(2000 * (attempt + 1)); continue; }
|
||||||
|
const short = url.replace(API, '').replace(UPLOAD, '');
|
||||||
|
// A wrong path casing answers with an HTML 404 page, not JSON: say so instead of dumping the HTML.
|
||||||
|
const msg = /^\s*</.test(text) ? `(HTML ${r.status} page: wrong path or casing?)` : text.slice(0, 1500);
|
||||||
|
throw new Error(`${method} ${short} -> ${r.status} ${msg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editOnce<T>(fn: (id: string) => Promise<T>, o: { dry?: boolean; commitQuery?: string }): Promise<T> {
|
||||||
|
const e = await call<{ id: string }>('POST', `${API}/edits`, {});
|
||||||
|
log('play', `edit ${e.id} opened`);
|
||||||
|
try {
|
||||||
|
const out = await fn(e.id);
|
||||||
|
if (o.dry) {
|
||||||
|
await call('POST', `${API}/edits/${e.id}:validate`);
|
||||||
|
log('play', 'dry run: edit validated, discarding');
|
||||||
|
await call('DELETE', `${API}/edits/${e.id}`);
|
||||||
|
} else {
|
||||||
|
await call('POST', `${API}/edits/${e.id}:commit${o.commitQuery ?? ''}`);
|
||||||
|
log('play', `edit ${e.id} committed`);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch (err) {
|
||||||
|
await call('DELETE', `${API}/edits/${e.id}`).catch(() => {});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
API, UPLOAD, packageName, call,
|
||||||
|
/**
|
||||||
|
* Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit
|
||||||
|
* invalidates an open edit ("A change was made to the application outside of this Edit"); then the whole edit is
|
||||||
|
* redone, up to `retries` times. Keep edits small (e.g. one per language for images) so a retry is cheap.
|
||||||
|
*/
|
||||||
|
async edit<T>(fn: (id: string) => Promise<T>, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise<T> {
|
||||||
|
for (let left = o.retries ?? 3; ; left--) {
|
||||||
|
try { return await editOnce(fn, o); } catch (err) {
|
||||||
|
if (left <= 0 || !/outside of this Edit|Edit has been deleted|edit.*(expired|not found)/i.test(String(err))) throw err;
|
||||||
|
log('play', `app changed outside the edit (Console?), redoing it (${left} retries left)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
22
cli/play/data-safety.ts
Normal file
22
cli/play/data-safety.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// Play Data safety form through applications.dataSafety (CSV import; replaces every earlier answer).
|
||||||
|
// crt-ship play data-safety [--csv out.csv] [--dry]
|
||||||
|
// Answers come from `dataSafety` in crt-ship.config.ts. --dry only writes / prints the CSV.
|
||||||
|
import { writeFileSync } from 'node:fs';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { dataSafetyCsv } from '../lib/data-safety';
|
||||||
|
import { done, log } from '../lib/log';
|
||||||
|
import { playClient } from './client';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const csv = dataSafetyCsv(need(config.dataSafety, 'dataSafety'));
|
||||||
|
const out = ctx.args.get('csv');
|
||||||
|
if (out) { writeFileSync(resolve(out), csv); log('play', `wrote ${out}`); }
|
||||||
|
const types = Object.keys(config.dataSafety!.data);
|
||||||
|
log('play', `data safety: ${types.length} data types declared (${types.join(', ') || 'none'})`);
|
||||||
|
if (ctx.args.has('dry')) { if (!out) process.stdout.write(csv); return; }
|
||||||
|
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
|
||||||
|
await play.call('POST', `${play.API}/dataSafety`, { safetyLabels: csv });
|
||||||
|
done('play', `data safety updated (${csv.split('\n').length - 2} rows)`);
|
||||||
|
}
|
||||||
72
cli/play/images.ts
Normal file
72
cli/play/images.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// Play store graphics per language, one edit per language (a Console save in parallel then only costs a retry of that
|
||||||
|
// language, not of the whole upload).
|
||||||
|
// crt-ship play images [--dry] [--only en-US,de-DE] [--src store/renders]
|
||||||
|
// Per Play language, from <renders>/<language id>/: icon (play.icon, 512x512), featureGraphic (feature/1024x500.*),
|
||||||
|
// phoneScreenshots (play/NN.*), sevenInchScreenshots (tab7/NN.*), tenInchScreenshots (tab10/NN.*). A .jpg next to a
|
||||||
|
// .png wins (JPEG q92 uploads about 4x faster; `crt-ship shots jpeg` makes them). Each image type is cleared
|
||||||
|
// before uploading, so re-runs are idempotent. Uploads are slow: run in the background with a log file.
|
||||||
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { byPlayCode, need } from '../lib/ctx';
|
||||||
|
import { done, log, step } from '../lib/log';
|
||||||
|
import { playClient } from './client';
|
||||||
|
import { readPlayCopy } from './listing';
|
||||||
|
|
||||||
|
export const PLAY_IMAGE_DIRS = { phoneScreenshots: 'play', sevenInchScreenshots: 'tab7', tenInchScreenshots: 'tab10' } as const;
|
||||||
|
|
||||||
|
/** NN.png / NN.jpg in a dir, sorted, JPEG preferred over PNG of the same number. */
|
||||||
|
export function numberedImages(dir: string): string[] {
|
||||||
|
if (!existsSync(dir)) return [];
|
||||||
|
const files = readdirSync(dir).filter(f => /^\d+\.(png|jpe?g)$/i.test(f));
|
||||||
|
const byNum = new Map<string, string>();
|
||||||
|
for (const f of files.sort()) {
|
||||||
|
const n = f.split('.')[0]!;
|
||||||
|
const cur = byNum.get(n);
|
||||||
|
if (!cur || /\.jpe?g$/i.test(f)) byNum.set(n, f);
|
||||||
|
}
|
||||||
|
return [...byNum.keys()].sort((a, b) => Number(a) - Number(b)).map(n => join(dir, byNum.get(n)!));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const pkg = need(config.play?.packageName, 'play.packageName');
|
||||||
|
const src = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
|
||||||
|
const icon = resolve(config.play?.icon ?? join(config.icons?.out ?? 'store/icons', 'play-512.png'));
|
||||||
|
const langOf = byPlayCode(config.languages);
|
||||||
|
const codes = readPlayCopy(ctx).map(c => c.lang);
|
||||||
|
|
||||||
|
const sets = new Map<string, [string, string[]][]>();
|
||||||
|
for (const code of codes) {
|
||||||
|
const l = langOf.get(code);
|
||||||
|
if (!l) throw new Error(`${code}: no entry in languages[] has this Play code`);
|
||||||
|
const dir = join(src, l.id);
|
||||||
|
const feature = ['jpg', 'jpeg', 'png'].map(e => join(dir, `feature/1024x500.${e}`)).find(existsSync);
|
||||||
|
const set: [string, string[]][] = [['icon', [icon]], ['featureGraphic', feature ? [feature] : []],
|
||||||
|
...Object.entries(PLAY_IMAGE_DIRS).map(([type, sub]) => [type, numberedImages(join(dir, sub)).slice(0, 8)] as [string, string[]])];
|
||||||
|
for (const [type, files] of set) {
|
||||||
|
if (!files.length || !files.every(existsSync)) throw new Error(`${code}: no ${type} in ${dir} (render first: crt-ship shots render)`);
|
||||||
|
if (type.endsWith('Screenshots') && files.length < 2) throw new Error(`${code}: ${type} needs at least 2 images`);
|
||||||
|
}
|
||||||
|
sets.set(code, set);
|
||||||
|
}
|
||||||
|
log('play', `${codes.length} languages, ${[...sets.values()][0]?.reduce((n, [, f]) => n + f.length, 0) ?? 0} images each`);
|
||||||
|
|
||||||
|
const play = await playClient(root, pkg);
|
||||||
|
for (const [i, code] of codes.entries()) {
|
||||||
|
const set = sets.get(code)!;
|
||||||
|
await play.edit(async id => {
|
||||||
|
let n = 0;
|
||||||
|
await Promise.all(set.map(async ([type, files]) => {
|
||||||
|
await play.call('DELETE', `${play.API}/edits/${id}/listings/${code}/${type}`);
|
||||||
|
for (const f of files) {
|
||||||
|
await play.call('POST', `${play.UPLOAD}/edits/${id}/listings/${code}/${type}?uploadType=media`, new Uint8Array(readFileSync(f)),
|
||||||
|
{ 'Content-Type': /\.png$/i.test(f) ? 'image/png' : 'image/jpeg' });
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
log('play', `${step(i + 1, codes.length, code)}: ${n} images`);
|
||||||
|
}, { dry: ctx.args.has('dry') });
|
||||||
|
}
|
||||||
|
done('play', 'images done');
|
||||||
|
}
|
||||||
36
cli/play/listing.ts
Normal file
36
cli/play/listing.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
// Play store listing text + app details in one edit.
|
||||||
|
// crt-ship play listing [--dry] [--only en-US,de-DE]
|
||||||
|
// App details: default language, contact email, website (must be the domain that serves app-ads.txt).
|
||||||
|
// Listings: title, short and full description of every `<!-- play: ... -->` section of the listing file.
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { parsePlayListing } from '../lib/copy';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log, step } from '../lib/log';
|
||||||
|
import { playClient } from './client';
|
||||||
|
|
||||||
|
export function readPlayCopy(ctx: Ctx) {
|
||||||
|
const { config, resolve } = ctx.loaded;
|
||||||
|
const file = resolve(config.play?.listing ?? 'store/listing.md');
|
||||||
|
const only = ctx.args.list('only');
|
||||||
|
return parsePlayListing(readFileSync(file, 'utf8'), config.app.name).filter(c => !only.length || only.includes(c.lang));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const pkg = need(config.play?.packageName, 'play.packageName');
|
||||||
|
const copy = readPlayCopy(ctx);
|
||||||
|
const details = { defaultLanguage: config.play?.defaultLanguage ?? 'en-US', contactEmail: config.app.contactEmail, contactWebsite: config.app.website };
|
||||||
|
if (!copy.some(c => c.lang === details.defaultLanguage) && !ctx.args.has('only')) throw new Error(`listing has no section for the default language ${details.defaultLanguage}`);
|
||||||
|
log('play', `${copy.length} listings: ${copy.map(c => c.lang).join(', ')}`);
|
||||||
|
const play = await playClient(root, pkg);
|
||||||
|
await play.edit(async id => {
|
||||||
|
await play.call('PUT', `${play.API}/edits/${id}/details`, details);
|
||||||
|
log('play', `details: ${details.defaultLanguage} · ${details.contactEmail} · ${details.contactWebsite}`);
|
||||||
|
for (const [i, c] of copy.entries()) {
|
||||||
|
await play.call('PUT', `${play.API}/edits/${id}/listings/${c.lang}`, { language: c.lang, title: c.title, shortDescription: c.short, fullDescription: c.full });
|
||||||
|
log('play', step(i + 1, copy.length, c.lang));
|
||||||
|
}
|
||||||
|
}, { dry: ctx.args.has('dry') });
|
||||||
|
done('play', 'listing done');
|
||||||
|
}
|
||||||
77
cli/play/products.ts
Normal file
77
cli/play/products.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// In-app products on Google Play through monetization.onetimeproducts (the legacy inappproducts API answers 403
|
||||||
|
// "Please migrate to the new publishing API" for apps on the new model).
|
||||||
|
// crt-ship play products [--dry] [--only id1,id2]
|
||||||
|
// Per product: convertRegionPrices (USD base -> every region at local price points, plus regionsVersion), your
|
||||||
|
// explicit overrides on top, PATCH ...?allowMissing=true (create or update), then activate the purchase option
|
||||||
|
// (new options start as DRAFT) with purchaseOptions:batchUpdateStates. Idempotent.
|
||||||
|
import { playCodes } from '../lib/config';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log, step, warn } from '../lib/log';
|
||||||
|
import { type Money, playRegionConfigs, toMoney } from '../lib/money';
|
||||||
|
import { playClient } from './client';
|
||||||
|
|
||||||
|
const OPTION = 'buy';
|
||||||
|
|
||||||
|
interface Converted {
|
||||||
|
convertedRegionPrices?: Record<string, { regionCode: string; price: Money }>;
|
||||||
|
convertedOtherRegionsPrice?: { usdPrice: Money; eurPrice: Money };
|
||||||
|
regionVersion?: { version: string };
|
||||||
|
}
|
||||||
|
interface Existing { productId: string; purchaseOptions?: { purchaseOptionId: string; state?: string }[] }
|
||||||
|
|
||||||
|
export async function listOneTimeProducts(play: Awaited<ReturnType<typeof playClient>>): Promise<Existing[]> {
|
||||||
|
const out: Existing[] = [];
|
||||||
|
let page = '';
|
||||||
|
do {
|
||||||
|
// camelCase "oneTimeProducts" here; the lowercase path answers with an HTML 404
|
||||||
|
const r = await play.call<{ oneTimeProducts?: Existing[]; nextPageToken?: string }>('GET', `${play.API}/oneTimeProducts?pageSize=100${page ? '&pageToken=' + page : ''}`);
|
||||||
|
out.push(...(r.oneTimeProducts ?? []));
|
||||||
|
page = r.nextPageToken ?? '';
|
||||||
|
} while (page);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const pkg = need(config.play?.packageName, 'play.packageName');
|
||||||
|
const only = ctx.args.list('only');
|
||||||
|
const products = need(config.products, 'products').filter(p => !only.length || only.includes(p.id));
|
||||||
|
const dry = ctx.args.has('dry');
|
||||||
|
const play = await playClient(root, pkg);
|
||||||
|
const existing = new Map((await listOneTimeProducts(play)).map(p => [p.productId, p]));
|
||||||
|
const langs = new Map(config.languages.map(l => [l.id, l]));
|
||||||
|
|
||||||
|
for (const [i, p] of products.entries()) {
|
||||||
|
const tag = step(i + 1, products.length, p.id);
|
||||||
|
const conv = await play.call<Converted>('POST', `${play.API}/pricing:convertRegionPrices`, { price: toMoney('USD', p.usd) });
|
||||||
|
if (!conv.convertedRegionPrices || !conv.convertedOtherRegionsPrice) throw new Error(`${p.id}: convertRegionPrices returned no prices`);
|
||||||
|
const regions = playRegionConfigs(conv.convertedRegionPrices, p.overrides);
|
||||||
|
const listings = Object.entries(p.listings).flatMap(([id, l]) =>
|
||||||
|
playCodes(langs.get(id)!).map(languageCode => ({ languageCode, title: l.title, description: l.description })));
|
||||||
|
if (!listings.length) throw new Error(`${p.id}: no listing language has a Play code`);
|
||||||
|
const body = {
|
||||||
|
packageName: pkg, productId: p.id, listings,
|
||||||
|
purchaseOptions: [{
|
||||||
|
purchaseOptionId: OPTION,
|
||||||
|
buyOption: { legacyCompatible: true, multiQuantityEnabled: false },
|
||||||
|
regionalPricingAndAvailabilityConfigs: regions,
|
||||||
|
newRegionsConfig: { usdPrice: conv.convertedOtherRegionsPrice.usdPrice, eurPrice: conv.convertedOtherRegionsPrice.eurPrice, availability: 'AVAILABLE' },
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const cur = existing.get(p.id);
|
||||||
|
if (dry) { log('play', `${tag}: ${cur ? 'update' : 'create'} · ${regions.length} regions · ${listings.length} listings (dry)`); continue; }
|
||||||
|
const v = encodeURIComponent(conv.regionVersion?.version ?? '2022/02');
|
||||||
|
await play.call('PATCH', `${play.API}/onetimeproducts/${encodeURIComponent(p.id)}?allowMissing=true®ionsVersion.version=${v}&updateMask=listings,purchaseOptions`, body);
|
||||||
|
log('play', `${tag}: ${cur ? 'updated' : 'created'} · ${regions.length} regions · ${listings.length} listings`);
|
||||||
|
const active = cur?.purchaseOptions?.find(o => o.purchaseOptionId === OPTION)?.state === 'ACTIVE';
|
||||||
|
if (active) continue;
|
||||||
|
try {
|
||||||
|
await play.call('POST', `${play.API}/oneTimeProducts/${encodeURIComponent(p.id)}/purchaseOptions:batchUpdateStates`, {
|
||||||
|
requests: [{ activatePurchaseOptionRequest: { packageName: pkg, productId: p.id, purchaseOptionId: OPTION } }],
|
||||||
|
});
|
||||||
|
log('play', `${tag}: purchase option activated`);
|
||||||
|
} catch (e) { warn('play', `${tag}: activate failed: ${(e as Error).message}`); }
|
||||||
|
}
|
||||||
|
done('play', dry ? 'products checked (dry run, nothing written)' : 'products done');
|
||||||
|
}
|
||||||
60
cli/play/release.ts
Normal file
60
cli/play/release.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
// Upload an AAB (or reuse an uploaded versionCode) and put it on one or more tracks in one edit, with the "What's new"
|
||||||
|
// of every listing language as release notes.
|
||||||
|
// crt-ship play release app-release.aab [--internal completed] [--production draft] [--name "1.0.0 (3)"] [--dry]
|
||||||
|
// crt-ship play release --version-code 3 --production draft
|
||||||
|
// Tracks: internal, alpha (closed), beta (open), production; status completed | draft | inProgress | halted.
|
||||||
|
// Without a track flag: internal, completed. A production release that is not a draft asks for confirmation.
|
||||||
|
// Draft app (never published): Play only accepts status "draft" on any track; the first production release is sent
|
||||||
|
// from the Console (see the play-first-release skill).
|
||||||
|
import { readFileSync, statSync } from 'node:fs';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { confirm, need } from '../lib/ctx';
|
||||||
|
import { done, log } from '../lib/log';
|
||||||
|
import { playClient } from './client';
|
||||||
|
import { readPlayCopy } from './listing';
|
||||||
|
|
||||||
|
const TRACKS = ['internal', 'alpha', 'beta', 'production'];
|
||||||
|
const STATUSES = ['completed', 'draft', 'inProgress', 'halted'];
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const pkg = need(config.play?.packageName, 'play.packageName');
|
||||||
|
const a = ctx.args;
|
||||||
|
const aab = a._.find(x => x.endsWith('.aab'));
|
||||||
|
const fixed = a.get('version-code');
|
||||||
|
if (!aab && !fixed) throw new Error('usage: play release <app.aab> | --version-code N [--internal completed] [--production draft] [--name text] [--dry]');
|
||||||
|
const tracks = TRACKS.filter(t => a.has(t)).map(t => ({ track: t, status: a.get(t) === 'true' ? 'draft' : a.get(t)! }));
|
||||||
|
if (!tracks.length) tracks.push({ track: 'internal', status: 'completed' });
|
||||||
|
for (const t of tracks) if (!STATUSES.includes(t.status)) throw new Error(`--${t.track}: status must be one of ${STATUSES.join(', ')}`);
|
||||||
|
const fraction = a.get('fraction');
|
||||||
|
const live = tracks.find(t => t.track === 'production' && t.status !== 'draft');
|
||||||
|
if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return;
|
||||||
|
|
||||||
|
const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew }));
|
||||||
|
const play = await playClient(root, pkg);
|
||||||
|
try {
|
||||||
|
await play.edit(async id => {
|
||||||
|
let code = fixed ?? '';
|
||||||
|
if (aab) {
|
||||||
|
log('play', `uploading ${aab} (${(statSync(aab).size / 1048576).toFixed(1)} MB), this can take minutes`);
|
||||||
|
const b = await play.call<{ versionCode: number }>('POST', `${play.UPLOAD}/edits/${id}/bundles?uploadType=media`,
|
||||||
|
new Uint8Array(readFileSync(aab)), { 'Content-Type': 'application/octet-stream' });
|
||||||
|
code = String(b.versionCode);
|
||||||
|
log('play', `uploaded versionCode ${code}`);
|
||||||
|
}
|
||||||
|
for (const t of tracks) {
|
||||||
|
const release: Record<string, unknown> = { versionCodes: [code], status: t.status };
|
||||||
|
if (notes.length) release.releaseNotes = notes;
|
||||||
|
if (a.get('name')) release.name = a.get('name');
|
||||||
|
if (t.status === 'inProgress') release.userFraction = Number(fraction ?? 0.1);
|
||||||
|
await play.call('PUT', `${play.API}/edits/${id}/tracks/${t.track}`, { track: t.track, releases: [release] });
|
||||||
|
log('play', `${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`);
|
||||||
|
}
|
||||||
|
}, { dry: a.has('dry'), retries: aab ? 0 : 3 });
|
||||||
|
} catch (e) {
|
||||||
|
if (/draft app|status draft/i.test(String(e)))
|
||||||
|
throw new Error(`${(e as Error).message}\n\nThis app has never been published: the API may only create DRAFT releases. Re-run with --production draft, then send it from the Console (skill play-first-release).`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
done('play', 'release done');
|
||||||
|
}
|
||||||
31
cli/play/status.ts
Normal file
31
cli/play/status.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Read-only overview: tracks and releases, listing languages, one-time products and their purchase option states.
|
||||||
|
// crt-ship play status [--json]
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { playClient } from './client';
|
||||||
|
import { listOneTimeProducts } from './products';
|
||||||
|
|
||||||
|
interface Track { track: string; releases?: { name?: string; versionCodes?: string[]; status: string; userFraction?: number }[] }
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root } = ctx.loaded;
|
||||||
|
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
|
||||||
|
const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {});
|
||||||
|
try {
|
||||||
|
const tracks = (await play.call<{ tracks?: Track[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? [];
|
||||||
|
const listings = (await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${e.id}/listings`)).listings ?? [];
|
||||||
|
const details = await play.call<Record<string, string>>('GET', `${play.API}/edits/${e.id}/details`);
|
||||||
|
const products = await listOneTimeProducts(play);
|
||||||
|
if (ctx.args.has('json')) { console.log(JSON.stringify({ details, tracks, listings, products }, null, 2)); return; }
|
||||||
|
console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`);
|
||||||
|
console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`);
|
||||||
|
for (const t of tracks) {
|
||||||
|
const rs = (t.releases ?? []).map(r => `${r.name ?? '?'} [${r.versionCodes?.join(',') ?? '-'}] ${r.status}${r.userFraction ? ' ' + r.userFraction * 100 + '%' : ''}`);
|
||||||
|
console.log(` track ${t.track.padEnd(11)} ${rs.join(' | ') || '-'}`);
|
||||||
|
}
|
||||||
|
console.log(` products (${products.length}):`);
|
||||||
|
for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`);
|
||||||
|
} finally {
|
||||||
|
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
23
cli/shots/browser.ts
Normal file
23
cli/shots/browser.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Playwright is not a dependency of crt-ship: it is resolved from the project (or the crt-ship folder), so a project
|
||||||
|
// that never renders screenshots never downloads a browser. Launch order: Playwright's own Chromium, then an installed
|
||||||
|
// Google Chrome (channel "chrome"), so `bunx playwright install chromium` is optional when Chrome is there.
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
async function chromium(root: string): Promise<any> {
|
||||||
|
for (const base of [root, join(import.meta.dir, '../..')]) {
|
||||||
|
try {
|
||||||
|
const p = Bun.resolveSync('playwright', base);
|
||||||
|
return (await import(p)).chromium;
|
||||||
|
} catch { /* next */ }
|
||||||
|
}
|
||||||
|
throw new Error('playwright not found: in your project run `bun add -d playwright` (and `bunx playwright install chromium` if Google Chrome is not installed)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export async function launch(root: string, opts: Record<string, unknown>): Promise<any> {
|
||||||
|
const c = await chromium(root);
|
||||||
|
try { return await c.launch(opts); } catch (e) {
|
||||||
|
try { return await c.launch({ ...opts, channel: 'chrome' }); } catch { throw e; }
|
||||||
|
}
|
||||||
|
}
|
||||||
53
cli/shots/capture.ts
Normal file
53
cli/shots/capture.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// Raw frames from a running build of your game (headless Chromium, mobile viewport, touch, device scale).
|
||||||
|
// crt-ship shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet]
|
||||||
|
// Each entry of shots.capture.shots: { path, waitFor?: JS expression that turns true, eval?: [JS], delayMs?, device? }.
|
||||||
|
// URL = baseUrl + path + (? or &) + query, with "{lang}" replaced. Frames go to <shots.frames>/<lang>/<device>/<name>.png.
|
||||||
|
// The frame is the screen BELOW the status bar (the template draws one), so the viewport is phone.w x phone.h.
|
||||||
|
// Tip: give your game URL switches for screenshots (fixed seed, no ads, no tutorial, freeze at a moment).
|
||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log, warn } from '../lib/log';
|
||||||
|
import { launch } from './browser';
|
||||||
|
import { DEFAULT_PHONE, DEFAULT_TABLET } from './lib';
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const shots = need(config.shots, 'shots');
|
||||||
|
const cap = need(shots.capture, 'shots.capture');
|
||||||
|
const a = ctx.args;
|
||||||
|
const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id);
|
||||||
|
const only = a.list('only');
|
||||||
|
const devices = a.get('device') ? [a.get('device') as 'phone' | 'tablet'] : (['phone', 'tablet'] as const);
|
||||||
|
const dims = { phone: { ...DEFAULT_PHONE, ...shots.phone }, tablet: { ...DEFAULT_TABLET, ...shots.tablet } };
|
||||||
|
const browser = await launch(root, { headless: true, args: ['--mute-audio', '--ignore-gpu-blocklist', '--enable-webgl', '--enable-gpu'] });
|
||||||
|
let failed = 0;
|
||||||
|
try {
|
||||||
|
for (const lang of langs) for (const device of devices) for (const [name, s] of Object.entries(cap.shots)) {
|
||||||
|
if (only.length && !only.includes(name)) continue;
|
||||||
|
const want = s.device ?? 'phone';
|
||||||
|
if (want !== 'both' && want !== device) continue;
|
||||||
|
const d = dims[device];
|
||||||
|
const out = resolve(join(shots.frames, lang, device));
|
||||||
|
mkdirSync(out, { recursive: true });
|
||||||
|
const q = (cap.query ?? '').replaceAll('{lang}', lang);
|
||||||
|
const url = cap.baseUrl.replace(/\/$/, '') + s.path.replaceAll('{lang}', lang) + (q ? (s.path.includes('?') ? '&' : '?') + q : '');
|
||||||
|
const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: lang });
|
||||||
|
const page = await c.newPage();
|
||||||
|
const errs: string[] = [];
|
||||||
|
page.on('pageerror', (e: Error) => errs.push(e.message));
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 90000 });
|
||||||
|
if (s.waitFor) await page.waitForFunction(s.waitFor, null, { timeout: 120000, polling: 50 });
|
||||||
|
for (const js of s.eval ?? []) await page.evaluate(js);
|
||||||
|
await page.waitForTimeout(s.delayMs ?? 1000);
|
||||||
|
await page.screenshot({ path: join(out, `${name}.png`) });
|
||||||
|
log('shots', `${lang}/${device}/${name}.png${errs.length ? ' (page errors: ' + errs.join(' | ').slice(0, 200) + ')' : ''}`);
|
||||||
|
} catch (e) { failed++; warn('shots', `${lang}/${device}/${name}: ${(e as Error).message.slice(0, 300)}`); }
|
||||||
|
await c.close();
|
||||||
|
}
|
||||||
|
} finally { await browser.close(); }
|
||||||
|
if (failed) throw new Error(`${failed} captures failed`);
|
||||||
|
done('shots', 'capture done');
|
||||||
|
}
|
||||||
63
cli/shots/feature.html
Normal file
63
cli/shots/feature.html
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||||
|
<!-- Play feature graphic, 1024 x 500: logo (or the app name) on the left, a tilted phone with one frame on the right.
|
||||||
|
feature.html?lang=<id>. Rendered by `crt-ship shots render`. Keep text out of it: Play overlays its own UI. -->
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { width: 1024px; height: 500px; overflow: hidden; background: #000; }
|
||||||
|
#c { position: relative; width: 1024px; height: 500px; overflow: hidden; isolation: isolate; background: var(--bg); }
|
||||||
|
.rays { position: absolute; left: 360px; top: 250px; width: 1700px; height: 1700px; transform: translate(-50%, -50%) rotate(8deg); z-index: -4;
|
||||||
|
opacity: .55; background: repeating-conic-gradient(rgba(255,255,255,.22) 0deg 7deg, rgba(255,255,255,0) 7deg 18deg);
|
||||||
|
-webkit-mask-image: radial-gradient(closest-side, #000 0%, rgba(0,0,0,.6) 30%, transparent 75%); }
|
||||||
|
.glow { position: absolute; left: 360px; top: 250px; width: 900px; height: 700px; transform: translate(-50%, -50%); z-index: -3;
|
||||||
|
background: radial-gradient(closest-side, var(--glow), transparent); }
|
||||||
|
.vign { position: absolute; inset: 0; z-index: -2; background: radial-gradient(90% 90% at 45% 50%, transparent 60%, rgba(0,0,0,.28) 100%); }
|
||||||
|
.logo { position: absolute; left: 360px; top: 252px; width: 600px; transform: translate(-50%, -50%) rotate(-3deg); z-index: 5;
|
||||||
|
filter: drop-shadow(0 14px 22px rgba(0,0,0,.4)); }
|
||||||
|
.name { position: absolute; left: 360px; top: 250px; transform: translate(-50%, -50%) rotate(-3deg); z-index: 5; width: 640px; text-align: center;
|
||||||
|
font-family: var(--tf); font-size: 104px; line-height: .95; color: #fff; -webkit-text-stroke: 9px var(--ink); paint-order: stroke fill;
|
||||||
|
filter: drop-shadow(0 8px 0 var(--ink)) drop-shadow(0 14px 22px rgba(0,0,0,.35)); }
|
||||||
|
.tel { position: absolute; left: 748px; top: 36px; width: 232px; aspect-ratio: var(--ar); transform: rotate(7deg); z-index: 3;
|
||||||
|
border-radius: 40px; padding: 6px;
|
||||||
|
background: linear-gradient(140deg, #f4f4f6 0%, #9da0a8 8%, #3c3e44 20%, #1a1b1f 50%, #3c3e44 82%, #c8cad0 100%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255,255,255,.6), inset 0 0 0 3px #101114, 0 20px 50px var(--shadow); }
|
||||||
|
.screen { position: relative; width: 100%; height: 100%; overflow: hidden; border-radius: 34px; background: #000; }
|
||||||
|
.screen img { position: absolute; left: 0; bottom: 0; width: 100%; height: var(--appH); object-fit: cover; object-position: top; }
|
||||||
|
.screen .bar { position: absolute; left: 0; right: 0; top: 0; height: var(--barH); background: #0b0b10 center top / 100% 60000% no-repeat; }
|
||||||
|
.island { position: absolute; top: 6px; left: 50%; transform: translateX(-50%); width: 30%; height: 20px; background: #000; border-radius: 99px; }
|
||||||
|
.spark { position: absolute; width: var(--s); height: var(--s); transform: translate(-50%, -50%); background: #fff; z-index: -1;
|
||||||
|
clip-path: polygon(50% 0, 58% 42%, 100% 50%, 58% 58%, 50% 100%, 42% 58%, 0 50%, 42% 42%); opacity: var(--o); filter: drop-shadow(0 0 6px #fff); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="c">
|
||||||
|
<div class="glow"></div><div class="rays"></div><div class="vign"></div>
|
||||||
|
<div class="tel"><div class="screen"><div class="bar" id="bar"></div><img id="f"><div class="island"></div></div></div>
|
||||||
|
</div>
|
||||||
|
<script type="module">
|
||||||
|
const lang = new URLSearchParams(location.search).get('lang');
|
||||||
|
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(lang)}&feature=1`)).json();
|
||||||
|
const P = job.palette, R = document.documentElement.style, dev = job.phone;
|
||||||
|
R.setProperty('--bg', `linear-gradient(160deg, ${P.bg[0]} 0%, ${P.bg[1]} 50%, ${P.bg[2]} 100%)`);
|
||||||
|
R.setProperty('--glow', P.glow); R.setProperty('--ink', P.ink); R.setProperty('--shadow', P.shadow);
|
||||||
|
R.setProperty('--tf', `'${job.fonts.title}', sans-serif`);
|
||||||
|
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
|
||||||
|
R.setProperty('--appH', `${100 * dev.h / (dev.h + dev.bar)}%`);
|
||||||
|
R.setProperty('--barH', `${100 * dev.bar / (dev.h + dev.bar)}%`);
|
||||||
|
const c = document.getElementById('c');
|
||||||
|
const src = `/frames/${encodeURIComponent(lang)}/phone/${encodeURIComponent(job.frame)}`;
|
||||||
|
document.getElementById('f').src = src;
|
||||||
|
document.getElementById('bar').style.backgroundImage = `url('${src}')`;
|
||||||
|
c.insertAdjacentHTML('beforeend', job.logo ? '<img class="logo" src="/logo">' : `<div class="name">${String(job.appName).replace(/</g, '<')}</div>`);
|
||||||
|
let seed = 7; const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
|
||||||
|
for (let i = 0; i < 22; i++)
|
||||||
|
c.insertAdjacentHTML('beforeend', `<i class="spark" style="left:${rnd() * 1024}px;top:${rnd() * 500}px;--s:${8 + rnd() * 18}px;--o:${.5 + rnd() * .5}"></i>`);
|
||||||
|
await document.fonts.ready;
|
||||||
|
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
|
||||||
|
document.body.dataset.ready = '1';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
61
cli/shots/fonts.ts
Normal file
61
cli/shots/fonts.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
// Downloads the fonts the screenshot template needs, subset by Google Fonts (css2 `text=`) to exactly the characters
|
||||||
|
// your copy uses, so even CJK stays small: title + body family for Latin text, plus one Noto family per non-Latin
|
||||||
|
// script found in the copy (CJK, Arabic, Hebrew, Devanagari, Thai, Cyrillic / Greek).
|
||||||
|
// crt-ship shots fonts (re-run after editing the copy)
|
||||||
|
// Out: <project>/.crt-ship/fonts/*.woff2 + fonts.css. All of these fonts are SIL Open Font License 1.1.
|
||||||
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log } from '../lib/log';
|
||||||
|
import { type CopyFile, copyText, scriptFont } from './lib';
|
||||||
|
|
||||||
|
const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36';
|
||||||
|
const ASCII = Array.from({ length: 95 }, (_, i) => String.fromCharCode(32 + i)).join('') + '·';
|
||||||
|
|
||||||
|
async function fetchSubset(family: string, chars: string, variable: boolean): Promise<ArrayBuffer> {
|
||||||
|
const fam = encodeURIComponent(family) + (variable ? ':wght@100..900' : '');
|
||||||
|
let res = await fetch(`https://fonts.googleapis.com/css2?family=${fam}&text=${encodeURIComponent(chars)}&display=block`, { headers: { 'user-agent': UA } });
|
||||||
|
if (!res.ok && variable) return fetchSubset(family, chars, false);
|
||||||
|
if (!res.ok) throw new Error(`${family}: ${res.status} ${await res.text()}`);
|
||||||
|
const src = /url\((https:[^)]+)\)/.exec(await res.text())?.[1];
|
||||||
|
if (!src) throw new Error(`${family}: no font url`);
|
||||||
|
res = await fetch(src, { headers: { 'user-agent': UA } });
|
||||||
|
return res.arrayBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const shots = need(config.shots, 'shots');
|
||||||
|
const copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile;
|
||||||
|
const dir = join(root, '.crt-ship', 'fonts');
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
const title = shots.fonts?.title ?? 'Lilita One', body = shots.fonts?.body ?? 'Montserrat';
|
||||||
|
|
||||||
|
const latin = new Set(ASCII);
|
||||||
|
const scripts = new Map<string, { family: string; chars: Set<string> }>();
|
||||||
|
for (const [lang, c] of Object.entries(copy)) {
|
||||||
|
const text = copyText(lang, c);
|
||||||
|
const s = scriptFont(lang, text);
|
||||||
|
if (!s) { for (const ch of text) latin.add(ch); continue; }
|
||||||
|
const cur = scripts.get(s.slug) ?? { family: s.family, chars: new Set(ASCII) };
|
||||||
|
for (const ch of text) cur.chars.add(ch);
|
||||||
|
scripts.set(s.slug, cur);
|
||||||
|
}
|
||||||
|
for (const ch of config.app.name) latin.add(ch);
|
||||||
|
const latinChars = [...latin].sort().join('');
|
||||||
|
|
||||||
|
let css = '/* Generated by crt-ship shots fonts (Google Fonts, SIL Open Font License 1.1), subset to the copy. */\n';
|
||||||
|
const jobs: [string, string, string, boolean][] = [[title, 'title', latinChars, false], [body, 'body', latinChars, true],
|
||||||
|
...[...scripts.entries()].map(([slug, s]) => [s.family, slug, [...s.chars].sort().join(''), true] as [string, string, string, boolean])];
|
||||||
|
for (const [family, slug, chars, variable] of jobs) {
|
||||||
|
const bin = await fetchSubset(family, chars, variable);
|
||||||
|
const file = `${slug}.woff2`;
|
||||||
|
writeFileSync(join(dir, file), Buffer.from(bin));
|
||||||
|
const face = slug === 'title' ? title : slug === 'body' ? body : `Store ${slug}`;
|
||||||
|
css += `@font-face { font-family: '${face}'; font-weight: 100 900; src: url(${file}) format('woff2'); }\n`;
|
||||||
|
log('shots', `${file.padEnd(18)} ${family.padEnd(22)} ${(bin.byteLength / 1024).toFixed(1)} KB, ${[...chars].length} chars`);
|
||||||
|
}
|
||||||
|
writeFileSync(join(dir, 'fonts.css'), css);
|
||||||
|
done('shots', 'fonts done');
|
||||||
|
}
|
||||||
34
cli/shots/jpeg.ts
Normal file
34
cli/shots/jpeg.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// JPEG copies of the Play renders (play, tab7, tab10, feature): about 4x smaller, so `play images` uploads much faster.
|
||||||
|
// The App Store screenshots stay PNG.
|
||||||
|
// crt-ship shots jpeg [--q 92] [--lang en,tr]
|
||||||
|
import { existsSync, readdirSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { done, log } from '../lib/log';
|
||||||
|
|
||||||
|
/** ffmpeg's mjpeg -q:v (2 best .. 31 worst) for a 0-100 quality. */
|
||||||
|
export const ffmpegQ = (q: number): number => Math.max(2, Math.min(31, Math.round(2 + (100 - q) * 0.29)));
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, resolve } = ctx.loaded;
|
||||||
|
const src = resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders');
|
||||||
|
const q = ctx.args.num('q', 92);
|
||||||
|
const langs = ctx.args.list('lang').length ? ctx.args.list('lang') : config.languages.map(l => l.id);
|
||||||
|
const sips = spawnSync('sh', ['-c', 'command -v sips']).status === 0;
|
||||||
|
let n = 0;
|
||||||
|
for (const lang of langs) for (const sub of ['play', 'tab7', 'tab10', 'feature']) {
|
||||||
|
const dir = join(src, lang, sub);
|
||||||
|
if (!existsSync(dir)) continue;
|
||||||
|
for (const f of readdirSync(dir).filter(f => f.endsWith('.png'))) {
|
||||||
|
const inp = join(dir, f), out = inp.replace(/\.png$/, '.jpg');
|
||||||
|
const r = sips
|
||||||
|
? spawnSync('sips', ['-s', 'format', 'jpeg', '-s', 'formatOptions', String(q), inp, '--out', out])
|
||||||
|
: spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', inp, '-q:v', String(ffmpegQ(q)), out]);
|
||||||
|
if (r.status !== 0) throw new Error(`jpeg failed for ${inp}`);
|
||||||
|
n++;
|
||||||
|
}
|
||||||
|
log('shots', `${lang}/${sub}: jpeg q${q}`);
|
||||||
|
}
|
||||||
|
done('shots', `${n} JPEG copies`);
|
||||||
|
}
|
||||||
76
cli/shots/lib.ts
Normal file
76
cli/shots/lib.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
|
||||||
|
import type { Palette, ShotsConfig } from '../lib/config';
|
||||||
|
|
||||||
|
/** Store image sizes. ios69 = 6.9" (APP_IPHONE_67), ios65 = 6.5" (APP_IPHONE_65), play = Play phone 9:16,
|
||||||
|
* tab7 / tab10 = Play 7" / 10" tablets (tablet frames), feature = Play feature graphic. */
|
||||||
|
export const SIZES: Record<string, { w: number; h: number; device?: 'tablet'; feature?: true }> = {
|
||||||
|
ios69: { w: 1320, h: 2868 },
|
||||||
|
ios65: { w: 1284, h: 2778 },
|
||||||
|
play: { w: 1080, h: 1920 },
|
||||||
|
tab7: { w: 1080, h: 1920, device: 'tablet' },
|
||||||
|
tab10: { w: 1440, h: 2560, device: 'tablet' },
|
||||||
|
feature: { w: 1024, h: 500, feature: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PALETTES: Record<string, Palette> = {
|
||||||
|
fire: { bg: ['#ff3d2e', '#ff7a1a', '#ffc43a'], glow: 'rgba(255,240,150,.95)', ink: '#6e1300', shadow: 'rgba(120,10,0,.45)' },
|
||||||
|
violet: { bg: ['#2b1676', '#6a2fe0', '#ff5fb8'], glow: 'rgba(255,170,240,.75)', ink: '#22095e', shadow: 'rgba(30,0,80,.55)' },
|
||||||
|
blast: { bg: ['#ff2d55', '#ff6a2a', '#ffb02e'], glow: 'rgba(255,236,140,.9)', ink: '#6a0a1e', shadow: 'rgba(110,0,30,.45)' },
|
||||||
|
green: { bg: ['#0fa35c', '#3cc96a', '#ff6f91'], glow: 'rgba(230,255,170,.85)', ink: '#0a4a2a', shadow: 'rgba(0,60,30,.45)' },
|
||||||
|
night: { bg: ['#14105a', '#4a2bd6', '#11b8ff'], glow: 'rgba(140,230,255,.85)', ink: '#170d55', shadow: 'rgba(10,0,70,.55)' },
|
||||||
|
sky: { bg: ['#0a6fe0', '#21a8ff', '#7be3ff'], glow: 'rgba(255,255,255,.85)', ink: '#06336e', shadow: 'rgba(0,40,100,.45)' },
|
||||||
|
gold: { bg: ['#ff8a00', '#ffb300', '#ffe14a'], glow: 'rgba(255,255,220,.95)', ink: '#6b2c00', shadow: 'rgba(120,50,0,.4)' },
|
||||||
|
candy: { bg: ['#d01f7a', '#ff4f8b', '#ff9f43'], glow: 'rgba(255,220,180,.9)', ink: '#5e0636', shadow: 'rgba(90,0,50,.45)' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const palette = (p: string | Palette | undefined): Palette => {
|
||||||
|
if (!p) return PALETTES.fire!;
|
||||||
|
if (typeof p !== 'string') return p;
|
||||||
|
const hit = PALETTES[p];
|
||||||
|
if (!hit) throw new Error(`unknown palette "${p}" (${Object.keys(PALETTES).join(', ')}) or pass { bg, glow, ink, shadow }`);
|
||||||
|
return hit;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SceneCopy { eyebrow?: string; title: [string, string]; sub?: string; badges?: [string, string?][] }
|
||||||
|
export type CopyFile = Record<string, Record<string, SceneCopy>>;
|
||||||
|
|
||||||
|
/** Noto family + slug for the first non-Latin script found in a language's text, or null for Latin-only text. */
|
||||||
|
export function scriptFont(lang: string, text: string): { family: string; slug: string } | null {
|
||||||
|
if (/\p{Script=Hangul}/u.test(text)) return { family: 'Noto Sans KR', slug: 'kr' };
|
||||||
|
if (/[\p{Script=Hiragana}\p{Script=Katakana}]/u.test(text)) return { family: 'Noto Sans JP', slug: 'jp' };
|
||||||
|
if (/\p{Script=Han}/u.test(text)) return /Hant|TW|HK/.test(lang) ? { family: 'Noto Sans TC', slug: 'tc' } : { family: 'Noto Sans SC', slug: 'sc' };
|
||||||
|
if (/\p{Script=Arabic}/u.test(text)) return { family: 'Noto Sans Arabic', slug: 'arabic' };
|
||||||
|
if (/\p{Script=Hebrew}/u.test(text)) return { family: 'Noto Sans Hebrew', slug: 'hebrew' };
|
||||||
|
if (/\p{Script=Devanagari}/u.test(text)) return { family: 'Noto Sans Devanagari', slug: 'devanagari' };
|
||||||
|
if (/\p{Script=Bengali}/u.test(text)) return { family: 'Noto Sans Bengali', slug: 'bengali' };
|
||||||
|
if (/\p{Script=Thai}/u.test(text)) return { family: 'Noto Sans Thai', slug: 'thai' };
|
||||||
|
if (/[\p{Script=Cyrillic}\p{Script=Greek}]/u.test(text)) return { family: 'Noto Sans', slug: 'cyrgreek' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every string of a language's copy (for font subsetting and script detection), upper-cased eyebrows included. */
|
||||||
|
export function copyText(lang: string, c: Record<string, SceneCopy> | undefined): string {
|
||||||
|
if (!c) return '';
|
||||||
|
return Object.values(c).flatMap(s => [s.eyebrow ?? '', (s.eyebrow ?? '').toLocaleUpperCase(lang), ...s.title, s.sub ?? '',
|
||||||
|
...(s.badges ?? []).flat().filter((x): x is string => !!x)]).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problems with the copy file for the configured scenes and languages. */
|
||||||
|
export function validateCopy(copy: CopyFile, shots: ShotsConfig, langs: string[]): string[] {
|
||||||
|
const e: string[] = [];
|
||||||
|
for (const lang of langs) {
|
||||||
|
const c = copy[lang];
|
||||||
|
if (!c) { e.push(`${lang}: no copy`); continue; }
|
||||||
|
for (const s of shots.scenes) {
|
||||||
|
const sc = c[s.id];
|
||||||
|
if (!sc) { e.push(`${lang} scene ${s.id}: missing`); continue; }
|
||||||
|
if (!Array.isArray(sc.title) || sc.title.length !== 2) e.push(`${lang} scene ${s.id}: title must be [line1, line2]`);
|
||||||
|
const slots = s.badges?.length ?? 0;
|
||||||
|
if ((sc.badges?.length ?? 0) > slots) e.push(`${lang} scene ${s.id}: ${sc.badges!.length} badges but the scene has ${slots} slots`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_PHONE = { w: 440, h: 902, dpr: 3, bar: 54 };
|
||||||
|
export const DEFAULT_TABLET = { w: 800, h: 1240, dpr: 2, bar: 40 };
|
||||||
129
cli/shots/render.ts
Normal file
129
cli/shots/render.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
// Store screenshots + Play feature graphic at exact pixel sizes, from raw game frames and per-language copy.
|
||||||
|
// crt-ship shots render [--lang en,tr] [--size ios69,play,feature] [--scenes 1,3] [--no-sheet] [--port 8792]
|
||||||
|
// Out: <shots.out>/<lang>/<size>/NN.png (24-bit RGB: Play rejects alpha / palette PNGs), <lang>/feature/1024x500.png,
|
||||||
|
// and <lang>/sheet.png (contact sheet of every size, for a quick review). Needs playwright + ffmpeg.
|
||||||
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
||||||
|
import { join, relative } from 'node:path';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { need } from '../lib/ctx';
|
||||||
|
import { done, log, warn } from '../lib/log';
|
||||||
|
import { launch } from './browser';
|
||||||
|
import { type CopyFile, DEFAULT_PHONE, DEFAULT_TABLET, SIZES, copyText, palette, scriptFont, validateCopy } from './lib';
|
||||||
|
|
||||||
|
const TYPES: Record<string, string> = { html: 'text/html; charset=utf-8', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
||||||
|
webp: 'image/webp', woff2: 'font/woff2', css: 'text/css', svg: 'image/svg+xml' };
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const shots = need(config.shots, 'shots');
|
||||||
|
const a = ctx.args;
|
||||||
|
const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id);
|
||||||
|
const sizes = a.list('size').length ? a.list('size') : Object.keys(SIZES);
|
||||||
|
const sceneIds = a.list('scenes').length ? a.list('scenes') : shots.scenes.map(s => s.id);
|
||||||
|
for (const s of sizes) if (!SIZES[s]) throw new Error(`unknown size ${s} (${Object.keys(SIZES).join(', ')})`);
|
||||||
|
const copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile;
|
||||||
|
const problems = validateCopy(copy, shots, langs);
|
||||||
|
if (problems.length) throw new Error('shots copy:\n ' + problems.join('\n '));
|
||||||
|
const framesDir = resolve(shots.frames), outDir = resolve(shots.out);
|
||||||
|
const fontsDir = join(root, '.crt-ship', 'fonts');
|
||||||
|
if (!existsSync(join(fontsDir, 'fonts.css'))) warn('shots', 'no .crt-ship/fonts/fonts.css: run `crt-ship shots fonts` first (falling back to system fonts)');
|
||||||
|
const phone = { ...DEFAULT_PHONE, ...shots.phone }, tablet = { ...DEFAULT_TABLET, ...shots.tablet };
|
||||||
|
const rtl = new Map(config.languages.map(l => [l.id, !!l.rtl] as const));
|
||||||
|
const fonts = { title: shots.fonts?.title ?? 'Lilita One', body: shots.fonts?.body ?? 'Montserrat' };
|
||||||
|
const tpl = import.meta.dir;
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: a.num('port', 8792), hostname: '127.0.0.1',
|
||||||
|
async fetch(req) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const p = decodeURIComponent(url.pathname);
|
||||||
|
if (p === '/job.json') {
|
||||||
|
const lang = url.searchParams.get('lang') ?? '';
|
||||||
|
const text = copyText(lang, copy[lang]);
|
||||||
|
const script = scriptFont(lang, text);
|
||||||
|
const base = { phone, tablet, rtl: rtl.get(lang), logo: !!shots.logo, appName: config.app.name,
|
||||||
|
fonts: { ...fonts, script: script ? `Store ${script.slug}` : null } };
|
||||||
|
if (url.searchParams.has('feature'))
|
||||||
|
return Response.json({ ...base, frame: shots.feature?.frame ?? shots.scenes[0]!.frames[0]!.src, palette: palette(shots.feature?.palette ?? shots.scenes[0]!.palette) });
|
||||||
|
const sid = url.searchParams.get('s') ?? '';
|
||||||
|
const scene = shots.scenes.find(s => s.id === sid)!;
|
||||||
|
const number = shots.scenes.filter(s => !s.hero).indexOf(scene) + 1;
|
||||||
|
return Response.json({ ...base, scene, number, palette: palette(scene.palette), copy: copy[lang]?.[sid] ?? {} });
|
||||||
|
}
|
||||||
|
let file = '';
|
||||||
|
if (p === '/logo' && shots.logo) file = resolve(shots.logo);
|
||||||
|
else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8));
|
||||||
|
else if (p.startsWith('/fonts/')) file = join(fontsDir, p.slice(7));
|
||||||
|
else if (p.startsWith('/out/')) file = join(outDir, p.slice(5));
|
||||||
|
else if (/^\/(template|feature)\.html$/.test(p)) file = join(tpl, p.slice(1));
|
||||||
|
if (!file || file.includes('..')) return new Response('no', { status: 404 });
|
||||||
|
const f = Bun.file(file);
|
||||||
|
if (!(await f.exists())) return new Response('missing ' + p, { status: 404 });
|
||||||
|
return new Response(f, { headers: { 'content-type': TYPES[file.split('.').pop()!.toLowerCase()] ?? 'application/octet-stream' } });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const base = `http://127.0.0.1:${server.port}`;
|
||||||
|
const browser = await launch(root, { headless: true });
|
||||||
|
const page = await (await browser.newContext({ deviceScaleFactor: 1 })).newPage();
|
||||||
|
const errs: string[] = [];
|
||||||
|
page.on('pageerror', (e: Error) => errs.push(e.message));
|
||||||
|
page.on('response', (r: { status(): number; url(): string }) => { if (r.status() >= 400 && !r.url().includes('/fonts/')) errs.push(`${r.status()} ${r.url()}`); });
|
||||||
|
let warnings = 0;
|
||||||
|
|
||||||
|
async function shoot(url: string, w: number, h: number, out: string): Promise<void> {
|
||||||
|
errs.length = 0;
|
||||||
|
mkdirSync(join(out, '..'), { recursive: true });
|
||||||
|
await page.setViewportSize({ width: w, height: h });
|
||||||
|
await page.goto(url, { waitUntil: 'load' });
|
||||||
|
await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 30000 });
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
const tmp = out + '.tmp.png';
|
||||||
|
await page.screenshot({ path: tmp, clip: { x: 0, y: 0, width: w, height: h } });
|
||||||
|
const r = spawnSync('ffmpeg', ['-v', 'error', '-y', '-i', tmp, '-pix_fmt', 'rgb24', out]);
|
||||||
|
rmSync(tmp, { force: true });
|
||||||
|
if (r.status !== 0) throw new Error('ffmpeg: ' + r.stderr);
|
||||||
|
if (errs.length) warnings++;
|
||||||
|
log('shots', `${errs.length ? 'WARN ' : ''}${relative(root, out)}${errs.length ? ' ' + errs.join(' | ') : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const lang of langs) {
|
||||||
|
const rows: { size: string; files: string[] }[] = [];
|
||||||
|
for (const size of sizes) {
|
||||||
|
const s = SIZES[size]!;
|
||||||
|
if (s.feature) {
|
||||||
|
const f = join(outDir, lang, 'feature', '1024x500.png');
|
||||||
|
await shoot(`${base}/feature.html?lang=${encodeURIComponent(lang)}`, s.w, s.h, f);
|
||||||
|
rows.push({ size, files: [f] });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const files: string[] = [];
|
||||||
|
for (const [i, id] of sceneIds.entries()) {
|
||||||
|
const f = join(outDir, lang, size, `${String(i + 1).padStart(2, '0')}.png`);
|
||||||
|
await shoot(`${base}/template.html?s=${encodeURIComponent(id)}&w=${s.w}&h=${s.h}&lang=${encodeURIComponent(lang)}${s.device ? '&device=tablet' : ''}`, s.w, s.h, f);
|
||||||
|
files.push(f);
|
||||||
|
}
|
||||||
|
rows.push({ size, files });
|
||||||
|
}
|
||||||
|
if (!a.has('no-sheet') && rows.length) {
|
||||||
|
const H0 = 900, gap = 24;
|
||||||
|
let y = gap, W = 0, imgs = '';
|
||||||
|
for (const row of rows) {
|
||||||
|
const d = SIZES[row.size]!, h = d.feature ? H0 / 2 : H0, w = Math.round(h * d.w / d.h);
|
||||||
|
row.files.forEach((f, i) => { imgs += `<img src="/out/${relative(outDir, f)}" style="position:absolute;left:${gap + i * (w + gap)}px;top:${y}px;width:${w}px;height:${h}px;border-radius:14px">`; });
|
||||||
|
W = Math.max(W, gap + row.files.length * (w + gap)); y += h + gap;
|
||||||
|
}
|
||||||
|
const sheet = join(outDir, lang, '.sheet.html');
|
||||||
|
await Bun.write(sheet, `<!doctype html><body style="margin:0;background:#141418;width:${W}px;height:${y}px;position:relative">${imgs}
|
||||||
|
<script>Promise.all([...document.images].map(i=>i.decode())).then(()=>document.body.dataset.ready='1')</script></body>`);
|
||||||
|
await shoot(`${base}/out/${relative(outDir, sheet)}`, W, y, join(outDir, lang, 'sheet.png'));
|
||||||
|
rmSync(sheet, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
server.stop(true);
|
||||||
|
}
|
||||||
|
done('shots', `render done${warnings ? `, ${warnings} with warnings (missing frames or fonts?)` : ''}`);
|
||||||
|
}
|
||||||
231
cli/shots/template.html
Normal file
231
cli/shots/template.html
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<link rel="stylesheet" href="/fonts/fonts.css">
|
||||||
|
<!--
|
||||||
|
Store screenshot template (rendered by `crt-ship shots render`, served by its local server).
|
||||||
|
template.html?s=<scene id>&w=<px>&h=<px>&lang=<id>[&device=tablet]
|
||||||
|
/job.json gives the scene (palette, frames, badge slots), the words for this language and the font families.
|
||||||
|
Layout: everything is in u = W / 100; the text block is measured first, then the phone fills the room below it.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { overflow: hidden; background: #000; }
|
||||||
|
body { font-family: var(--bf); -webkit-font-smoothing: antialiased; color: #fff; }
|
||||||
|
#c { position: relative; overflow: hidden; width: var(--W); height: var(--H); isolation: isolate; background: var(--bg); }
|
||||||
|
|
||||||
|
/* background: sunburst, glow, vignette, sparkles */
|
||||||
|
.rays { position: absolute; left: 50%; top: var(--rayY); width: calc(var(--u) * 260); height: calc(var(--u) * 260);
|
||||||
|
transform: translate(-50%, -50%) rotate(var(--rayRot, 0deg)); z-index: -4; opacity: .55;
|
||||||
|
background: repeating-conic-gradient(from 0deg, rgba(255,255,255,.20) 0deg 7deg, rgba(255,255,255,0) 7deg 18deg);
|
||||||
|
-webkit-mask-image: radial-gradient(closest-side, #000 0%, rgba(0,0,0,.65) 35%, transparent 80%); }
|
||||||
|
.glow { position: absolute; left: 50%; top: var(--rayY); width: calc(var(--u) * 150); height: calc(var(--u) * 150); z-index: -3;
|
||||||
|
transform: translate(-50%, -50%); background: radial-gradient(closest-side, var(--glow) 0%, transparent 100%); opacity: .9; }
|
||||||
|
.vign { position: absolute; inset: 0; z-index: -2; pointer-events: none;
|
||||||
|
background: radial-gradient(120% 70% at 50% 45%, transparent 55%, rgba(0,0,0,.28) 100%); }
|
||||||
|
.spark { position: absolute; z-index: -1; width: var(--s); height: var(--s); transform: translate(-50%, -50%) rotate(var(--r));
|
||||||
|
background: #fff; opacity: var(--o);
|
||||||
|
clip-path: polygon(50% 0, 58% 42%, 100% 50%, 58% 58%, 50% 100%, 42% 58%, 0 50%, 42% 42%);
|
||||||
|
filter: drop-shadow(0 0 calc(var(--u) * .8) rgba(255,255,255,.9)); }
|
||||||
|
.dot { position: absolute; z-index: -1; width: var(--s); height: var(--s); border-radius: 50%; transform: translate(-50%, -50%);
|
||||||
|
background: radial-gradient(circle, rgba(255,255,255,.55), rgba(255,255,255,0) 70%); }
|
||||||
|
|
||||||
|
/* text block */
|
||||||
|
.top { position: absolute; left: 0; right: 0; top: calc(var(--u) * var(--top)); text-align: center; z-index: 6;
|
||||||
|
padding: 0 calc(var(--u) * 5); display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.eyebrow { display: inline-flex; align-items: center; gap: calc(var(--u) * 1.6); margin-bottom: calc(var(--u) * 3);
|
||||||
|
padding: calc(var(--u) * 1.15) calc(var(--u) * 3) calc(var(--u) * 1.15) calc(var(--u) * 1.3); border-radius: 999px;
|
||||||
|
background: rgba(0,0,0,.26); box-shadow: inset 0 0 0 calc(var(--u) * .25) rgba(255,255,255,.28);
|
||||||
|
font-size: calc(var(--u) * 2.75); font-weight: 800; letter-spacing: var(--kls, .2em); text-transform: uppercase; line-height: 1.15; }
|
||||||
|
.eyebrow b { display: grid; place-items: center; width: calc(var(--u) * 5); height: calc(var(--u) * 5); border-radius: 50%;
|
||||||
|
background: linear-gradient(180deg, #fff3a8, #ffc31f 55%, #ff9500); color: #5a2300; font-weight: 900; letter-spacing: 0;
|
||||||
|
font-size: calc(var(--u) * 2.5); box-shadow: 0 calc(var(--u) * .35) 0 rgba(0,0,0,.25); }
|
||||||
|
h1 { font-family: var(--tf); font-weight: var(--tw); line-height: var(--lh); letter-spacing: var(--ls, .005em);
|
||||||
|
font-size: calc(var(--u) * var(--fs)); color: #fff;
|
||||||
|
-webkit-text-stroke: calc(var(--u) * var(--fs) * var(--sk, .085)) var(--ink); paint-order: stroke fill;
|
||||||
|
filter: drop-shadow(0 calc(var(--u) * .9) 0 var(--ink)) drop-shadow(0 calc(var(--u) * 1.6) calc(var(--u) * 2.2) rgba(0,0,0,.3)); }
|
||||||
|
h1 span { display: block; width: max-content; margin: 0 auto; white-space: nowrap; }
|
||||||
|
h1 span + span { color: #ffd23a; font-size: .86em; margin-top: .06em; }
|
||||||
|
p.sub { margin-top: calc(var(--u) * 2.6); max-width: calc(var(--u) * 80); font-size: calc(var(--u) * var(--sub));
|
||||||
|
line-height: 1.32; font-weight: 700; color: #fff; text-wrap: balance;
|
||||||
|
text-shadow: 0 calc(var(--u) * .3) calc(var(--u) * 1) rgba(0,0,0,.35); }
|
||||||
|
.logo { width: calc(var(--u) * 86); max-height: calc(var(--u) * 36); object-fit: contain; margin: calc(var(--u) * -1) 0 calc(var(--u) * 1);
|
||||||
|
filter: drop-shadow(0 calc(var(--u) * 1.5) calc(var(--u) * 2.5) rgba(0,0,0,.35)); }
|
||||||
|
|
||||||
|
/* phone */
|
||||||
|
.tel { position: absolute; z-index: 3; width: var(--pw); aspect-ratio: var(--ar); left: var(--px); top: var(--py);
|
||||||
|
transform: translateX(-50%) rotate(var(--pr, 0deg)); transform-origin: 50% 40%;
|
||||||
|
border-radius: calc(var(--pw) * .16); padding: calc(var(--pw) * .024);
|
||||||
|
background: linear-gradient(140deg, #f4f4f6 0%, #9da0a8 8%, #3c3e44 20%, #1a1b1f 50%, #3c3e44 82%, #c8cad0 100%);
|
||||||
|
box-shadow: inset 0 0 0 calc(var(--pw) * .004) rgba(255,255,255,.6), inset 0 0 0 calc(var(--pw) * .011) #101114,
|
||||||
|
0 calc(var(--u) * 3) calc(var(--u) * 6) rgba(0,0,0,.35), 0 calc(var(--u) * 6) calc(var(--u) * 14) var(--shadow); }
|
||||||
|
.tel.back { z-index: 2; filter: saturate(.9) brightness(.9); }
|
||||||
|
.screen { position: relative; width: 100%; height: 100%; overflow: hidden; background: #000; border-radius: calc(var(--pw) * .137); }
|
||||||
|
.screen img.app { position: absolute; left: 0; bottom: 0; width: 100%; height: var(--appH); object-fit: cover; object-position: top; }
|
||||||
|
.screen::after { content: ""; position: absolute; inset: 0; z-index: 4; pointer-events: none;
|
||||||
|
background: linear-gradient(115deg, rgba(255,255,255,.12) 0%, rgba(255,255,255,.03) 24%, transparent 40%); }
|
||||||
|
/* status bar: the frame's top pixel row stretched, so it matches the game's own top colour */
|
||||||
|
.bar { position: absolute; top: 0; left: 0; right: 0; height: var(--barH); display: flex; align-items: center;
|
||||||
|
justify-content: space-between; padding: 0 8.5% 0 11.5%; font-family: -apple-system, 'SF Pro Text', sans-serif;
|
||||||
|
font-weight: 600; font-size: calc(var(--pw) * .041); z-index: 2; background: #0b0b10 center top / 100% 60000% no-repeat; }
|
||||||
|
.bar svg { height: calc(var(--pw) * .03); width: auto; margin-left: calc(var(--pw) * .012); }
|
||||||
|
.island { position: absolute; top: calc(var(--pw) * .025); left: 50%; transform: translateX(-50%); width: 30%; height: calc(var(--pw) * .082);
|
||||||
|
background: #000; border-radius: 999px; z-index: 3; }
|
||||||
|
.tablet .tel { border-radius: calc(var(--pw) * .065); padding: calc(var(--pw) * .02); }
|
||||||
|
.tablet .screen { border-radius: calc(var(--pw) * .048); }
|
||||||
|
.tablet .bar { padding: 0 5%; font-size: calc(var(--pw) * .024); }
|
||||||
|
.tablet .bar svg { height: calc(var(--pw) * .018); }
|
||||||
|
.tablet .island { width: calc(var(--pw) * .016); height: calc(var(--pw) * .016); top: calc(var(--pw) * .007); }
|
||||||
|
|
||||||
|
/* badges */
|
||||||
|
.badge { position: absolute; z-index: 9; display: flex; align-items: center; gap: calc(var(--u) * 2.2);
|
||||||
|
padding: calc(var(--u) * 1.7) calc(var(--u) * 3.6) calc(var(--u) * 1.7) calc(var(--u) * 1.7); border-radius: calc(var(--u) * 4);
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #fff6e2 55%, #ffe3b0 100%);
|
||||||
|
box-shadow: inset 0 calc(var(--u) * -.7) 0 rgba(180,90,0,.18), 0 0 0 calc(var(--u) * .55) var(--ink),
|
||||||
|
0 calc(var(--u) * 1.1) 0 calc(var(--u) * .55) var(--ink), 0 calc(var(--u) * 3) calc(var(--u) * 5) rgba(0,0,0,.35);
|
||||||
|
transform: rotate(var(--br, 0deg)); color: var(--ink); white-space: nowrap; }
|
||||||
|
.badge .icon { flex: none; width: calc(var(--u) * 9); height: calc(var(--u) * 9); border-radius: calc(var(--u) * 2.6);
|
||||||
|
display: grid; place-items: center; background: var(--ic, linear-gradient(180deg, #ffd84d, #ff8a00));
|
||||||
|
box-shadow: inset 0 calc(var(--u) * .5) 0 rgba(255,255,255,.45), inset 0 calc(var(--u) * -.6) 0 rgba(0,0,0,.2); }
|
||||||
|
.badge .icon svg { width: 62%; height: 62%; overflow: visible; }
|
||||||
|
.badge b { display: block; font-family: var(--tf); font-weight: var(--tw); font-size: calc(var(--u) * 4.7); line-height: 1.02; }
|
||||||
|
.badge small { display: block; font-size: calc(var(--u) * 2.35); font-weight: 700; opacity: .72; margin-top: calc(var(--u) * .5); }
|
||||||
|
.badge.hot { background: linear-gradient(180deg, #fff27a 0%, #ffc21a 45%, #ff8a00 100%); padding: calc(var(--u) * 2) calc(var(--u) * 4.2); }
|
||||||
|
.badge.hot b { color: #fff; font-size: calc(var(--u) * 5.6); -webkit-text-stroke: calc(var(--u) * .5) #7a2c00; paint-order: stroke fill;
|
||||||
|
text-shadow: 0 calc(var(--u) * .5) 0 #7a2c00; }
|
||||||
|
.badge .stars { display: flex; gap: calc(var(--u) * .4); margin-right: calc(var(--u) * .6); }
|
||||||
|
.badge .stars svg { width: calc(var(--u) * 6.4); height: calc(var(--u) * 6.4); }
|
||||||
|
.badge .stars svg:nth-child(2) { transform: translateY(calc(var(--u) * -1)) scale(1.15); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="c"></div>
|
||||||
|
<script type="module">
|
||||||
|
const q = new URLSearchParams(location.search);
|
||||||
|
const W = +q.get('w'), H = +q.get('h'), LANG = q.get('lang'), SID = q.get('s');
|
||||||
|
const tablet = q.get('device') === 'tablet';
|
||||||
|
const job = await (await fetch(`/job.json?lang=${encodeURIComponent(LANG)}&s=${encodeURIComponent(SID)}`)).json();
|
||||||
|
const S = job.scene, C = job.copy, P = job.palette;
|
||||||
|
const u = W / 100, Hu = H / u;
|
||||||
|
const dev = tablet ? job.tablet : job.phone; // { w, h, bar } in CSS px of the raw frame
|
||||||
|
const FR = `/frames/${encodeURIComponent(LANG)}/${tablet ? 'tablet' : 'phone'}/`;
|
||||||
|
|
||||||
|
document.documentElement.lang = LANG;
|
||||||
|
const RTL = !!job.rtl;
|
||||||
|
const R = document.documentElement.style;
|
||||||
|
R.setProperty('--W', W + 'px'); R.setProperty('--H', H + 'px'); R.setProperty('--u', u + 'px');
|
||||||
|
R.setProperty('--ink', P.ink); R.setProperty('--glow', P.glow); R.setProperty('--shadow', P.shadow);
|
||||||
|
R.setProperty('--bg', `linear-gradient(180deg, ${P.bg[0]} 0%, ${P.bg[1]} 48%, ${P.bg[2]} 100%)`);
|
||||||
|
// A script the title font lacks (CJK, Arabic, Cyrillic, ...): the whole line switches to the Noto subset so a word
|
||||||
|
// never mixes two typefaces.
|
||||||
|
const T = job.fonts.title, B = job.fonts.body, X = job.fonts.script;
|
||||||
|
R.setProperty('--tf', X ? `'${X}', '${T}', sans-serif` : `'${T}', sans-serif`);
|
||||||
|
R.setProperty('--bf', X ? `'${X}', '${B}', sans-serif` : `'${B}', sans-serif`);
|
||||||
|
R.setProperty('--tw', X ? 900 : job.fonts.titleWeight ?? 400);
|
||||||
|
const CJK = /^(ja|ko|zh)/.test(LANG);
|
||||||
|
R.setProperty('--lh', /^(ar|hi|fa|ur|th)/.test(LANG) ? 1.28 : CJK ? 1.12 : .98);
|
||||||
|
if (X) { R.setProperty('--ls', '0'); R.setProperty('--kls', '0'); }
|
||||||
|
if (CJK) R.setProperty('--sk', '.06');
|
||||||
|
if (LANG.startsWith('ja')) document.documentElement.style.wordBreak = 'auto-phrase';
|
||||||
|
if (LANG.startsWith('ko')) document.documentElement.style.wordBreak = 'keep-all';
|
||||||
|
if (tablet) document.body.classList.add('tablet');
|
||||||
|
const tall = Hu > 200; // iOS 6.9" / 6.5" vs 16:9
|
||||||
|
R.setProperty('--top', tall ? 9 : 5.5);
|
||||||
|
R.setProperty('--fs', S.hero ? (tall ? 10.5 : 8.6) : (tall ? 12 : 9.8));
|
||||||
|
R.setProperty('--sub', tall ? 3.75 : 3.3);
|
||||||
|
const AR = dev.w / (dev.h + dev.bar);
|
||||||
|
R.setProperty('--ar', `${dev.w} / ${dev.h + dev.bar}`);
|
||||||
|
R.setProperty('--appH', `${100 * dev.h / (dev.h + dev.bar)}%`);
|
||||||
|
R.setProperty('--barH', `${100 * dev.bar / (dev.h + dev.bar)}%`);
|
||||||
|
|
||||||
|
const I = {
|
||||||
|
clock: '<svg viewBox="0 0 24 24"><circle cx="12" cy="13" r="8.5" fill="#fff" stroke="#5a2300" stroke-width="2"/><path d="M12 8v5l3.5 2.2" stroke="#5a2300" stroke-width="2.4" fill="none" stroke-linecap="round"/><rect x="9" y="1.5" width="6" height="3" rx="1.2" fill="#5a2300"/></svg>',
|
||||||
|
bolt: '<svg viewBox="0 0 24 24"><path d="M13.5 1.5 4 14h6.5L9.5 22.5 20 9.5h-6.8z" fill="#fff" stroke="#5a2300" stroke-width="1.8" stroke-linejoin="round"/></svg>',
|
||||||
|
burst: '<svg viewBox="0 0 24 24"><path d="M12 1l2.3 6 6.2-2-3.4 5.6 5.4 3.4-6.4.9.9 6.5L12 17l-5 4.4.9-6.5-6.4-.9 5.4-3.4L3.5 5l6.2 2z" fill="#fff" stroke="#5a2300" stroke-width="1.6" stroke-linejoin="round"/></svg>',
|
||||||
|
cube: '<svg viewBox="0 0 24 24"><path d="M12 2 21 7v10l-9 5-9-5V7z" fill="#fff" stroke="#0a4a2a" stroke-width="1.8" stroke-linejoin="round"/><path d="M3 7l9 5 9-5M12 12v10" stroke="#0a4a2a" stroke-width="1.8" fill="none"/></svg>',
|
||||||
|
map: '<svg viewBox="0 0 24 24"><path d="M3 6l6-2.5 6 2.5 6-2.5v14.5L15 20.5 9 18l-6 2.5z" fill="#fff" stroke="#06336e" stroke-width="1.8" stroke-linejoin="round"/><path d="M9 3.5V18M15 6v14.5" stroke="#06336e" stroke-width="1.8"/></svg>',
|
||||||
|
star: '<svg viewBox="0 0 24 24"><path d="M12 1.8l3 6.4 7 .9-5.1 4.8 1.3 6.9L12 17.4l-6.2 3.4 1.3-6.9L2 9.1l7-.9z" fill="url(#sg)" stroke="#6b2c00" stroke-width="1.6" stroke-linejoin="round"/><defs><linearGradient id="sg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff59a"/><stop offset=".55" stop-color="#ffcc1a"/><stop offset="1" stop-color="#ff9000"/></linearGradient></defs></svg>',
|
||||||
|
grid: '<svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="3" fill="#fff" stroke="#5e0636" stroke-width="1.8"/><path d="M3 9h18M3 15h18M9 3v18M15 3v18" stroke="#5e0636" stroke-width="1.5"/></svg>',
|
||||||
|
gift: '<svg viewBox="0 0 24 24"><rect x="3" y="9" width="18" height="12" rx="2" fill="#fff" stroke="#5e0636" stroke-width="1.8"/><rect x="2" y="6" width="20" height="4" rx="1.5" fill="#fff" stroke="#5e0636" stroke-width="1.8"/><path d="M12 6v15M12 6C10 2 6 3 7.5 6M12 6c2-4 6-3 4.5 0" stroke="#5e0636" stroke-width="1.8" fill="none"/></svg>',
|
||||||
|
offline: '<svg viewBox="0 0 24 24"><path d="M2.5 9a14 14 0 0 1 19 0M6 12.5a9 9 0 0 1 12 0M9.4 16a4 4 0 0 1 5.2 0" stroke="#fff" stroke-width="2.4" fill="none" stroke-linecap="round"/><circle cx="12" cy="19.3" r="1.6" fill="#fff"/><path d="M3 3l18 18" stroke="#6e1300" stroke-width="4.4" stroke-linecap="round"/><path d="M3 3l18 18" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></svg>',
|
||||||
|
flag: '<svg viewBox="0 0 24 24"><path d="M5 22V3" stroke="#6e1300" stroke-width="2.4" stroke-linecap="round"/><path d="M5 3.5h13l-3 4 3 4H5z" fill="#fff" stroke="#6e1300" stroke-width="1.8" stroke-linejoin="round"/></svg>',
|
||||||
|
trophy: '<svg viewBox="0 0 24 24"><path d="M7 3h10v5a5 5 0 0 1-10 0z" fill="#fff" stroke="#6b2c00" stroke-width="1.8" stroke-linejoin="round"/><path d="M7 5H3.5a3.5 3.5 0 0 0 4 4M17 5h3.5a3.5 3.5 0 0 1-4 4M12 13v4M8 21h8l-1-4H9z" stroke="#6b2c00" stroke-width="1.8" fill="#fff" stroke-linejoin="round"/></svg>',
|
||||||
|
heart: '<svg viewBox="0 0 24 24"><path d="M12 21s-8.5-5.3-8.5-11.2A4.8 4.8 0 0 1 12 6.6a4.8 4.8 0 0 1 8.5 3.2C20.5 15.7 12 21 12 21z" fill="#fff" stroke="#5e0636" stroke-width="1.8" stroke-linejoin="round"/></svg>',
|
||||||
|
};
|
||||||
|
const ICON_BG = {
|
||||||
|
gold: 'linear-gradient(180deg,#ffe05a,#ff8a00)', green: 'linear-gradient(180deg,#8ff06a,#16a34a)',
|
||||||
|
blue: 'linear-gradient(180deg,#7fe0ff,#1f7aff)', violet: 'linear-gradient(180deg,#c49bff,#6a2fe0)',
|
||||||
|
pink: 'linear-gradient(180deg,#ffa3c8,#e8306f)', red: 'linear-gradient(180deg,#ff8a6a,#e4251b)',
|
||||||
|
};
|
||||||
|
|
||||||
|
// seeded rng: every render of a scene is identical
|
||||||
|
let seed = 0; for (const ch of SID + JSON.stringify(P.bg)) seed = (seed * 31 + ch.charCodeAt(0)) >>> 0;
|
||||||
|
const rnd = () => ((seed = (seed * 1664525 + 1013904223) >>> 0) / 4294967296);
|
||||||
|
|
||||||
|
const c = document.getElementById('c');
|
||||||
|
// RTL: keep number runs ("72 / 80") left-to-right inside right-to-left text
|
||||||
|
const esc = s => { const t = String(s ?? '').replace(/&/g, '&').replace(/</g, '<');
|
||||||
|
return RTL ? t.replace(/\d+(?:\s*\/\s*\d+)?/g, m => '' + m + '') : t; };
|
||||||
|
let top = '';
|
||||||
|
if (S.hero && job.logo) top += `<img class="logo" src="/logo">`;
|
||||||
|
else if (!S.hero && C.eyebrow) top += `<div class="eyebrow"><b>${String(job.number).padStart(2, '0')}</b>${esc(C.eyebrow)}</div>`;
|
||||||
|
top += `<h1><span>${esc(C.title?.[0])}</span><span>${esc(C.title?.[1])}</span></h1>`;
|
||||||
|
if (!S.hero && C.sub) top += `<p class="sub">${esc(C.sub)}</p>`;
|
||||||
|
c.innerHTML = `<div class="glow"></div><div class="rays"></div><div class="vign"></div><div class="top"${RTL ? ' dir="rtl"' : ''}>${top}</div>`;
|
||||||
|
|
||||||
|
await document.fonts.ready;
|
||||||
|
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
|
||||||
|
for (const sp of c.querySelectorAll('h1 span')) { // shrink a line that would overflow (long words)
|
||||||
|
const max = W - 10 * u, wd = sp.getBoundingClientRect().width;
|
||||||
|
let f = 1; while (wd * f > max && f > .55) f -= .02;
|
||||||
|
if (f < 1) sp.style.fontSize = `calc(${getComputedStyle(sp).fontSize} * ${f.toFixed(2)})`;
|
||||||
|
}
|
||||||
|
const textBottom = c.querySelector('.top').getBoundingClientRect().bottom / u;
|
||||||
|
|
||||||
|
const frames = S.frames.map(f => ({ x: 0, dy: 0, scale: 1, rotate: 0, ...f }));
|
||||||
|
const front = frames[0];
|
||||||
|
const pTop = textBottom + (S.hero ? 4 : 5.5);
|
||||||
|
const room = Hu - pTop + (S.over ?? 7);
|
||||||
|
const pw = Math.min(tablet ? 82 : 78, Math.max(56, room * AR)) * front.scale;
|
||||||
|
const ph = pw / AR;
|
||||||
|
R.setProperty('--rayY', (pTop + ph * .3) * u + 'px');
|
||||||
|
R.setProperty('--rayRot', (rnd() * 20) + 'deg');
|
||||||
|
|
||||||
|
const BAR = `<span>9:41</span><span style="display:flex;align-items:center">
|
||||||
|
<svg viewBox="0 0 18 12"><g fill="#fff"><rect x="0" y="8" width="3" height="4" rx=".8"/><rect x="5" y="5.5" width="3" height="6.5" rx=".8"/><rect x="10" y="3" width="3" height="9" rx=".8"/><rect x="15" y="0" width="3" height="12" rx=".8"/></g></svg>
|
||||||
|
<svg viewBox="0 0 16 12"><path fill="#fff" d="M8 2.2c2.3 0 4.4.9 6 2.4l1.3-1.3A10.3 10.3 0 0 0 8 .4C5.2.4 2.6 1.5.7 3.3L2 4.6a8.5 8.5 0 0 1 6-2.4zm0 3.6c1.3 0 2.5.5 3.4 1.3l1.3-1.3A6.6 6.6 0 0 0 8 4c-1.8 0-3.4.7-4.7 1.8l1.3 1.3C5.5 6.3 6.7 5.8 8 5.8zm0 3.6c.4 0 .8.2 1.1.4L8 11.6 6.9 9.8c.3-.2.7-.4 1.1-.4z"/></svg>
|
||||||
|
<svg viewBox="0 0 27 13"><rect x=".5" y=".5" width="23" height="12" rx="3.5" fill="none" stroke="rgba(255,255,255,.45)"/><rect x="2" y="2" width="20" height="9" rx="2.2" fill="#fff"/><path d="M25 4.5v4c.8-.3 1.4-1.1 1.4-2s-.6-1.7-1.4-2z" fill="rgba(255,255,255,.45)"/></svg></span>`;
|
||||||
|
|
||||||
|
let html = '';
|
||||||
|
for (let i = 0; i < 26; i++) {
|
||||||
|
const x = rnd() * 100, y = textBottom + rnd() * (Hu - textBottom);
|
||||||
|
if (i % 2) html += `<i class="spark" style="left:${x * u}px;top:${y * u}px;--s:${(1.6 + rnd() * 3.2) * u}px;--r:${rnd() * 45}deg;--o:${.55 + rnd() * .45}"></i>`;
|
||||||
|
else html += `<i class="dot" style="left:${x * u}px;top:${y * u}px;--s:${(3 + rnd() * 9) * u}px"></i>`;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < 8; i++) {
|
||||||
|
const x = i % 2 ? 1 + rnd() * 6 : 93 + rnd() * 6, y = 2 + rnd() * (textBottom - 4);
|
||||||
|
html += `<i class="spark" style="left:${x * u}px;top:${y * u}px;--s:${(1.2 + rnd() * 2) * u}px;--r:0deg;--o:${.4 + rnd() * .4}"></i>`;
|
||||||
|
}
|
||||||
|
const phone = (f, cls) => {
|
||||||
|
const w = pw / front.scale * f.scale, src = FR + encodeURIComponent(f.src);
|
||||||
|
return `<div class="tel ${cls}" style="--pw:${w * u}px;--px:${(50 + f.x) * u}px;--py:${(pTop + f.dy) * u}px;--pr:${f.rotate}deg"><div class="screen">
|
||||||
|
<div class="bar" style="background-image:url('${src}')">${BAR}</div><div class="island"></div><img class="app" src="${src}"></div></div>`;
|
||||||
|
};
|
||||||
|
for (const f of frames.slice(1)) html += phone(f, 'back');
|
||||||
|
html += phone(front, 'front');
|
||||||
|
(S.badges ?? []).forEach((b, i) => {
|
||||||
|
const [big, small] = (C.badges ?? [])[i] ?? [''];
|
||||||
|
if (!big) return;
|
||||||
|
const ic = b.stars ? `<span class="stars">${I.star}${I.star}${I.star}</span>`
|
||||||
|
: b.hot ? '' : `<span class="icon" style="--ic:${ICON_BG[b.iconBg] ?? b.iconBg ?? ICON_BG.gold}">${I[b.icon] ?? I.star}</span>`;
|
||||||
|
const pos = b.side === 'l' ? `left:${4 * u}px` : `right:${4 * u}px`;
|
||||||
|
html += `<div class="badge${b.hot ? ' hot' : ''}"${RTL ? ' dir="rtl"' : ''} style="${pos};top:${Math.min(pTop + ph * b.y, Hu - 18) * u}px;--br:${b.rot || 0}deg">${ic}
|
||||||
|
<div${RTL ? ' dir="rtl"' : ''}><b>${esc(big)}</b>${small ? `<small>${esc(small)}</small>` : ''}</div></div>`;
|
||||||
|
});
|
||||||
|
c.insertAdjacentHTML('beforeend', html);
|
||||||
|
await Promise.all([...document.images].map(i => i.decode().catch(() => {})));
|
||||||
|
document.body.dataset.ready = '1';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
package.json
Normal file
23
package.json
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{
|
||||||
|
"name": "crt-ship",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "My toolchain for shipping mobile games to Google Play and the App Store: a Bun CLI plus Claude Code skills.",
|
||||||
|
"license": "MIT",
|
||||||
|
"author": "CRTkafa",
|
||||||
|
"type": "module",
|
||||||
|
"repository": { "type": "git", "url": "https://git.crt.fyi/CRT/crt-ship.git" },
|
||||||
|
"keywords": ["google-play", "app-store-connect", "mobile-games", "store-listing", "in-app-purchases", "screenshots", "claude-code-plugin"],
|
||||||
|
"bin": { "crt-ship": "./cli/index.ts" },
|
||||||
|
"exports": { ".": "./cli/lib/config.ts" },
|
||||||
|
"files": ["cli", "templates", "skills", "bin", ".claude-plugin", "README.md", "LICENSE", "CHANGELOG.md"],
|
||||||
|
"engines": { "bun": ">=1.1" },
|
||||||
|
"scripts": {
|
||||||
|
"test": "bun test",
|
||||||
|
"typecheck": "tsc --noEmit",
|
||||||
|
"check": "bun run typecheck && bun test"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "1.3.0",
|
||||||
|
"typescript": "5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
skills/ads-levelplay-checks/SKILL.md
Normal file
39
skills/ads-levelplay-checks/SKILL.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
name: ads-levelplay-checks
|
||||||
|
description: Diagnose and verify ad mediation (Unity LevelPlay / ironSource and its networks like Unity Ads, InMobi, Liftoff, AdMob) in a mobile game before and after store release - app keys, game ids, adapter versions, integration validation, "Invalid Game ID", "Mediation No fill" 509, app-ads.txt, SKAdNetwork ids, ATT, COPPA setting. Use when ads do not load, a network reports invalid/unknown ids, or the user prepares ads for launch.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Ad mediation checks (LevelPlay)
|
||||||
|
|
||||||
|
No CLI needed; these are checks to run and read. Ask the owner before changing dashboard settings.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
1. **Ids per platform.** LevelPlay app key + ad unit ids for iOS and Android are different; ship production ids only
|
||||||
|
in release builds and use the public demo app keys from Unity's Mediation demo apps during development so real units
|
||||||
|
never see test traffic.
|
||||||
|
2. **Integration.** After init call the SDK's `validateIntegration()`; the device log (Xcode console, Android logcat
|
||||||
|
tag `IntegrationHelper`) must list every network as "Adapter - Version x - VERIFIED". Adapter + network SDK versions
|
||||||
|
must be a pair from Unity's adapter tables / CHANGELOGs.
|
||||||
|
3. **Unity Ads "Invalid Game ID" / iOS "Initialization failed: not found".** Often account-side, not code: compare
|
||||||
|
`https://publisher-config.unityads.unity3d.com/games/<gameId>/configuration` for your id with Unity's demo id
|
||||||
|
`14851` (which answers with a config). If yours 404s or is empty, the Unity Ads project / game id is not set up or not
|
||||||
|
linked to the LevelPlay app: fix it in the Unity dashboard (Monetization > Projects > the game ids, and the LevelPlay
|
||||||
|
network setup using exactly those ids).
|
||||||
|
4. **509 "Mediation No fill"** before launch is normal: the LevelPlay app is not live / not linked to store URLs. After
|
||||||
|
both listings are public, set the store URLs in the LevelPlay dashboard.
|
||||||
|
5. **app-ads.txt** on the root of the developer website named in both listings (`https://<domain>/app-ads.txt`):
|
||||||
|
`OWNERDOMAIN=<domain>`, the LevelPlay DIRECT line with your publisher id, and each network's lines (Unity's
|
||||||
|
"App-Ads.txt" doc page lists them). Re-sync now and then. The listing website must match the domain exactly.
|
||||||
|
6. **iOS**: SKAdNetwork ids of every network in Info.plist (refresh from each network's official list when adding one),
|
||||||
|
`NSUserTrackingUsageDescription`, ATT prompt before the ad SDK starts, `-ObjC` linker flag if the SDK requires it.
|
||||||
|
7. **Android**: `AD_ID` permission (declared in Play's advertising ID form), network SDKs listed explicitly when the
|
||||||
|
adapter artifacts declare no dependencies.
|
||||||
|
8. **Privacy**: COPPA "not directed at children" unless the app is for kids; EU / UK / CH consent (CMP) or
|
||||||
|
non-personalized ads; Data safety + App Privacy list the ad data as shared / tracking (`store-declarations`).
|
||||||
|
9. **Placement sanity**: no ads in the first sessions / levels, never mid-round, rewarded always optional; reviewers
|
||||||
|
test the first minutes.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- Adding Google AdMob needs its app id first: the adapter crashes at launch without the manifest / Info.plist id.
|
||||||
|
- Test ads on real units can get the account flagged: use demo ids or registered test devices.
|
||||||
|
- A network failing to init does not break mediation; the others still fill once the app is live.
|
||||||
34
skills/app-icons/SKILL.md
Normal file
34
skills/app-icons/SKILL.md
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
---
|
||||||
|
name: app-icons
|
||||||
|
description: Generate every app icon a mobile game needs from one artwork - App Store 1024 (no alpha), Play 512, Android adaptive foreground/background, legacy round and square launcher icons, iOS sizes via @capacitor/assets, web favicons, apple-touch and maskable icons. Use when the user wants app icons, launcher icons, adaptive icons, favicons, or an icon looks cropped/cut off on Android or is rejected for alpha on iOS.
|
||||||
|
---
|
||||||
|
|
||||||
|
# App icons from one artwork
|
||||||
|
|
||||||
|
CLI: `crt-ship icons` (or `bun ${CLAUDE_PLUGIN_ROOT}/cli/index.ts icons`). Needs `ffmpeg` (`cwebp` optional).
|
||||||
|
|
||||||
|
## Inputs (`icons` in crt-ship.config.ts)
|
||||||
|
- `composite`: 1024 x 1024 opaque full icon (stores, iOS, web)
|
||||||
|
- `foreground`: 1024 RGBA, the subject on transparency (adaptive + maskable)
|
||||||
|
- `background`: 1024 opaque background layer
|
||||||
|
- outputs: `out` (store icons), `web`, `capacitorAssets` + `capacitorCommand`, `androidRes`
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. Put the three PNGs in the project, set the paths, `crt-ship check`.
|
||||||
|
2. `crt-ship icons --dry` lists every file it will write; then `crt-ship icons`.
|
||||||
|
3. With Capacitor: set `capacitorAssets` (e.g. `assets`) and
|
||||||
|
`capacitorCommand: 'bunx @capacitor/assets generate --assetPath assets'`; crt-ship writes the three sources, runs it,
|
||||||
|
and then redraws the Android legacy icons (step order matters).
|
||||||
|
4. Verify: `sips -g hasAlpha store/icons/app-store-1024.png` says no; install on an Android device with a circle
|
||||||
|
launcher mask and an old launcher (legacy icons); check the web favicon and the PWA maskable preview.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- **Adaptive foreground scale ~0.7**: the anydpi-v26 xml insets both layers 16.7 %, and launchers mask the visible
|
||||||
|
part (often a circle). At 0.7 the subject's corners sit just inside the circle. Tune with `adaptiveScale`.
|
||||||
|
- **Legacy icons**: @capacitor/assets crops the full square artwork into `ic_launcher` / `ic_launcher_round`, cutting
|
||||||
|
off anything near the corners. crt-ship redraws them from the adaptive composite with a circle and a 22 % rounded
|
||||||
|
square mask (4x supersampled).
|
||||||
|
- **App Store 1024 must have no alpha channel** (upload or review fails); crt-ship writes rgb24.
|
||||||
|
- Stores and launchers round the corners themselves: deliver full squares, never pre-rounded icons.
|
||||||
|
- Web icons as 256-colour PNGs (about a third of the size); maskable icon keeps the subject in the 80 % safe zone.
|
||||||
|
- Play listing icon is 512 x 512, separate from the launcher icons in the AAB.
|
||||||
67
skills/appstore-first-release/SKILL.md
Normal file
67
skills/appstore-first-release/SKILL.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
---
|
||||||
|
name: appstore-first-release
|
||||||
|
description: Step-by-step first (and later) App Store release of a mobile game - App Store Connect API key, bundle id, app record, signing and xcodebuild archive/upload with the API key, TestFlight, version + listing text in many locales, screenshots, age rating, App Privacy, review details, in-app purchases with the first version, and submitting for review. Use when the user wants to publish, release, upload or set up an iOS game/app on the App Store or TestFlight, or hits App Store Connect API errors.
|
||||||
|
---
|
||||||
|
|
||||||
|
# App Store: first release
|
||||||
|
|
||||||
|
The CLI is `crt-ship` (on PATH while this plugin is enabled; otherwise `bun ${CLAUDE_PLUGIN_ROOT}/cli/index.ts`).
|
||||||
|
Config: `crt-ship.config.ts`; credentials: `ASC_KEY_ID`, `ASC_ISSUER_ID`, `ASC_P8` (env or
|
||||||
|
`.crt-ship/credentials.json`, gitignored). All commands are idempotent and have `--dry`. Run `crt-ship check` first.
|
||||||
|
|
||||||
|
**Ask the owner before submitting for review, releasing, or canceling a submission.** The final "Submit for Review"
|
||||||
|
of a first version is the owner's click.
|
||||||
|
|
||||||
|
## Steps (API = crt-ship, UI = App Store Connect / Xcode by hand)
|
||||||
|
|
||||||
|
1. **UI · API key.** App Store Connect > Users and Access > Integrations > App Store Connect API > Team Keys > +
|
||||||
|
(Admin or App Manager). Download `AuthKey_<KEYID>.p8` once, keep it in `~/.appstoreconnect/private_keys/`, note the
|
||||||
|
Key ID and Issuer ID. The same key signs builds (`-allowProvisioningUpdates`).
|
||||||
|
2. **API · Bundle id.** `crt-ship apple bundle` registers it (Certificates, Identifiers & Profiles).
|
||||||
|
3. **UI · App record.** Apps > + > New App: iOS, name, primary language, the bundle id, SKU. **No API for this.**
|
||||||
|
Then `crt-ship apple status` finds it (put the Apple ID in `apple.appId` to skip the lookup).
|
||||||
|
4. **Local · Info.plist.** `ITSAppUsesNonExemptEncryption = NO` (HTTPS only) so builds skip export compliance;
|
||||||
|
`NSUserTrackingUsageDescription` if ads use the IDFA; privacy policy reachable inside the app (guideline 5.1.1).
|
||||||
|
5. **Local · Build + upload** (Mac, no Xcode account login needed, the API key does signing):
|
||||||
|
```sh
|
||||||
|
K="-allowProvisioningUpdates -authenticationKeyPath $ASC_P8 -authenticationKeyID $ASC_KEY_ID -authenticationKeyIssuerID $ASC_ISSUER_ID"
|
||||||
|
xcodebuild -project ios/App/App.xcodeproj -scheme App -configuration Release -destination generic/platform=iOS \
|
||||||
|
-archivePath build/App.xcarchive MARKETING_VERSION=1.0.0 CURRENT_PROJECT_VERSION=3 $K archive
|
||||||
|
xcodebuild -exportArchive -archivePath build/App.xcarchive -exportOptionsPlist ExportOptions.plist -exportPath build/export $K
|
||||||
|
```
|
||||||
|
ExportOptions.plist: `method` app-store-connect, `destination` upload, `teamID` <your team>, `signingStyle`
|
||||||
|
automatic. `MARKETING_VERSION` / `CURRENT_PROJECT_VERSION` overrides beat the project values; the build number must
|
||||||
|
grow per version. Processing takes 5-30 min (`crt-ship apple status` shows VALID).
|
||||||
|
6. **UI · TestFlight.** Create an internal group with "access to all builds" (hasAccessToAllBuilds) once: every new
|
||||||
|
build reaches the testers automatically. Sandbox testers: Users and Access > Sandbox.
|
||||||
|
7. **API · Version + text.** Fill `store/appstore.md`, then `crt-ship apple listing --version 1.0.0 --build 3`
|
||||||
|
(version, copyright, MANUAL release, build, name / subtitle / privacy URL, description / keywords / promo / URLs).
|
||||||
|
8. **API · Screenshots.** `store-screenshots` skill, then
|
||||||
|
`crt-ship apple screenshots > .crt-ship/asc-shots.log 2>&1 &` (6.9" + 6.5" per locale, MD5-idempotent).
|
||||||
|
9. **API · App info.** `crt-ship apple appinfo`: categories, age rating declaration, content rights, Free price,
|
||||||
|
all territories.
|
||||||
|
10. **API · Review details.** `crt-ship apple review-details` (contact from `.crt-ship/credentials.json`
|
||||||
|
`reviewContact`, or `--contact-from <Apple ID of another app of the team>`; notes from the config).
|
||||||
|
11. **API + UI · In-app purchases.** `store-iap-setup` skill. First IAPs go to review **with** this version.
|
||||||
|
12. **UI · App Privacy.** `store-declarations` skill (nutrition labels), then **Publish**.
|
||||||
|
13. **UI · Submit.** Version page > Add for Review (with the IAPs in the same draft submission) > Submit for Review
|
||||||
|
(owner). Release type MANUAL means the owner presses "Release" after approval.
|
||||||
|
14. **Verify.** `crt-ship apple status`: version WAITING_FOR_REVIEW, IAPs WAITING_FOR_REVIEW, one review submission.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- The app record cannot be created via API; the bundle id can.
|
||||||
|
- First consumable / non-consumable IAPs must be submitted together with an app version, only from the IAP page
|
||||||
|
("Add for Review" puts them into the draft submission). The API (`inAppPurchaseSubmissions`) answers
|
||||||
|
`FIRST_*_MUST_BE_SUBMITTED_ON_VERSION`. If the version was already submitted without them: cancel the review
|
||||||
|
submission (`crt-ship apple status --cancel-submission <id>`, PATCH canceled: true), wait a minute, add the IAPs
|
||||||
|
from their page, then resubmit.
|
||||||
|
- App Privacy (data types, purposes, linked, tracking) is UI-only and must be **published**, not just saved.
|
||||||
|
- Screenshot sizes: 6.9" 1320 x 2868 (`APP_IPHONE_67`) is required; 6.5" 1284 x 2778 (`APP_IPHONE_65`) recommended.
|
||||||
|
PNG without alpha. Up to 10 per set. Apple processes uploads async; failures show as FAILED later.
|
||||||
|
- App Store icon (1024) must have **no alpha channel** (`crt-ship icons` writes rgb24).
|
||||||
|
- Limits: name 30, subtitle 30, promotional text 170, keywords 100 bytes (UTF-8, no spaces after commas),
|
||||||
|
description 4000. No "What's New" on a first version.
|
||||||
|
- Guideline 5.1.1: the privacy policy link must be inside the app too (Settings > Privacy), not only in the listing.
|
||||||
|
- `xcodebuild` with Capacitor needs a UTF-8 locale (`export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8`).
|
||||||
|
- 500 / 503 from the API happen; crt-ship retries with backoff.
|
||||||
67
skills/play-first-release/SKILL.md
Normal file
67
skills/play-first-release/SKILL.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
---
|
||||||
|
name: play-first-release
|
||||||
|
description: Step-by-step first (and later) Google Play release of a mobile game - Play Console app creation, service account + API access, signing, AAB upload to internal testing, store listing, graphics, data safety, App content declarations, production draft and sending for review. Use when the user wants to publish, release, upload or set up an Android game/app on Google Play, push a Play listing or AAB, or hits Play Developer API errors (edits, "outside of this Edit", draft app releases).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Google Play: first release
|
||||||
|
|
||||||
|
The CLI is `crt-ship` (on PATH while this plugin is enabled; otherwise `bun ${CLAUDE_PLUGIN_ROOT}/cli/index.ts`).
|
||||||
|
It reads `crt-ship.config.ts` in the project (create with `crt-ship init`) and credentials from env or
|
||||||
|
`.crt-ship/credentials.json` (gitignored). Every write command has `--dry`. Run `crt-ship check` first.
|
||||||
|
|
||||||
|
**Ask the owner before anything public or irreversible**: production releases, "Send for review", publishing,
|
||||||
|
deleting products. Sending a first release for review is the owner's action in the Console, not yours.
|
||||||
|
|
||||||
|
## Steps (API = crt-ship does it, UI = Play Console by hand)
|
||||||
|
|
||||||
|
1. **UI · Create the app.** Play Console > Home > Create app: name, default language, Game, Free, declarations.
|
||||||
|
The package name is fixed by the first uploaded AAB.
|
||||||
|
2. **UI · API access.** Google Cloud project (any) > enable "Google Play Android Developer API" > create a service
|
||||||
|
account > JSON key (keep it outside the repo). Play Console > Users and permissions > Invite new users > the
|
||||||
|
service account email > app permissions: view app information, manage store presence, release to testing tracks,
|
||||||
|
manage testing tracks, release to production (only if you want API production drafts), manage orders (for purchase
|
||||||
|
checks). Put the key path in `PLAY_SERVICE_ACCOUNT`. Permissions can take a few minutes to apply.
|
||||||
|
3. **Local · Signing.** Generate an upload key (`keytool -genkeypair -v -storetype PKCS12 -keystore upload.p12
|
||||||
|
-alias upload -keyalg RSA -keysize 4096 -validity 10950`), keep keystore + passwords out of git and back them up
|
||||||
|
(encrypted, two places). Play App Signing holds the real app key; a lost upload key can be reset via the Console.
|
||||||
|
Check the AAB signer before uploading: `keytool -printcert -jarfile app-release.aab`.
|
||||||
|
4. **Local · Version.** versionName (e.g. 1.0.0) and a versionCode that always increases. Capacitor: set them in
|
||||||
|
`android/app/build.gradle` (or env-driven) before `./gradlew bundleRelease`.
|
||||||
|
5. **UI or API · First upload.** The very first AAB may need the Console (Testing > Internal testing > Create new
|
||||||
|
release) so Play links the package name; after that: `crt-ship play release app-release.aab` (internal, completed).
|
||||||
|
Add testers (internal testing list) and **license testers** (Settings > License testing) so test purchases are free.
|
||||||
|
6. **API · Listing text.** Fill `store/listing.md` (one section per language), then `crt-ship play listing`.
|
||||||
|
It also sets contact email + website (website = the domain serving app-ads.txt).
|
||||||
|
7. **API · Graphics.** Render (`store-screenshots` skill), `crt-ship shots jpeg`, then
|
||||||
|
`crt-ship play images > .crt-ship/play-images.log 2>&1 &` and follow the log (it is slow: one edit per language).
|
||||||
|
8. **API · In-app products.** `store-iap-setup` skill (`crt-ship play products`). Needs a build with the BILLING
|
||||||
|
permission on any track first.
|
||||||
|
9. **API · Data safety.** Answers in `dataSafety` of the config, `crt-ship play data-safety`, then check the summary
|
||||||
|
in Policy and programs > App content > Data safety.
|
||||||
|
10. **UI · App content.** `store-declarations` skill: privacy policy, ads, app access, content rating (IARC), target
|
||||||
|
audience, advertising ID, government / financial / health / news, data safety review.
|
||||||
|
11. **UI · Store settings.** Category + tags, contact details.
|
||||||
|
12. **API + UI · Production.** `crt-ship play release --version-code N --production draft`, then in the Console:
|
||||||
|
Production > Countries/regions > Add countries > select all > Save; Production > the draft release > Edit >
|
||||||
|
Next > Save; Publishing overview > **Send changes for review** (owner).
|
||||||
|
13. **Verify.** `crt-ship play status` (tracks, listings, products), Console dashboard has no open tasks,
|
||||||
|
Publishing overview shows "In review".
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **Draft app**: until the first release is reviewed, the API only accepts `status: draft` ("Only releases with
|
||||||
|
status draft may be created on draft app"). Create the production draft via API, finish in the Console (step 12).
|
||||||
|
- **"A change was made to the application outside of this Edit"**: any Console save or other edit commit invalidates
|
||||||
|
an open API edit. `crt-ship` redoes the edit automatically (3 retries) and uses one edit per language for images, but
|
||||||
|
do not save things in the Console while uploads run.
|
||||||
|
- Uploads (AAB, 100+ images) take minutes: run them in the background with a log file and watch progress lines.
|
||||||
|
- **Personal developer accounts** created after Nov 2023 need a closed test with 12+ testers for 14 days before
|
||||||
|
production; **organization accounts** skip that rule.
|
||||||
|
- Phone screenshots: 2-8, sides 320-3840 px, long side at most 2x the short side (1080 x 1920 fine, 1080 x 2400 not).
|
||||||
|
Feature graphic 1024 x 500 required. PNG without alpha, or JPEG (q92 is 4x smaller and uploads faster).
|
||||||
|
- Play listing limits: title 30, short 80, full 4000, release notes 500 (checked locally before upload).
|
||||||
|
- The legacy `inappproducts` API returns 403 "Please migrate to the new publishing API": use
|
||||||
|
`monetization.onetimeproducts` (what `crt-ship play products` does).
|
||||||
|
- Wrong path casing on the new product endpoints returns an HTML 404, not JSON (`oneTimeProducts` camelCase for list
|
||||||
|
and batchUpdateStates).
|
||||||
|
- Website in the listing must be the exact domain whose `/app-ads.txt` lists your ad networks.
|
||||||
31
skills/privacy-policy/SKILL.md
Normal file
31
skills/privacy-policy/SKILL.md
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
---
|
||||||
|
name: privacy-policy
|
||||||
|
description: Write and host one studio-wide privacy policy page for all of a developer's mobile games, with a per-game services table (analytics, ads, server, purchases), children, retention and contact sections, linked from both store listings and inside each game. Use when the user needs a privacy policy, privacy URL for Google Play / App Store, or must update the policy after adding an SDK.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Studio-wide privacy policy
|
||||||
|
|
||||||
|
Template: `store/privacy-policy.html` (from `crt-ship init`, or `${CLAUDE_PLUGIN_ROOT}/templates/privacy-policy.html`).
|
||||||
|
A single static page, light/dark, mobile friendly, English + a second language section.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. **One page for the studio, one row per game and service.** Columns: game, service (analytics, ad mediation +
|
||||||
|
networks, your server, app stores), what data and why, the player's choice (in-game switch, ATT prompt, OS ad
|
||||||
|
settings, "remove ads"). Remove what you do not use; never list services you do not ship.
|
||||||
|
2. Sections to keep: what we use (table), children (general audience, not directed at under 13 unless you really are
|
||||||
|
a kids app), retention and rights (how to request a copy / deletion, response time), security (HTTPS), changes
|
||||||
|
(date at the top), contact email.
|
||||||
|
3. **Host** it at a stable URL on your developer website, the same domain that serves `app-ads.txt` and is named as
|
||||||
|
website in both listings (e.g. `https://example.com/privacy`). Any static host works.
|
||||||
|
4. **Link it everywhere**: `app.privacyPolicyUrl` in `crt-ship.config.ts` (pushed by `crt-ship apple listing`), Play
|
||||||
|
Console > App content > Privacy policy, and **inside the game** (Settings > Privacy opens the URL): App Store
|
||||||
|
guideline 5.1.1 requires the in-app link.
|
||||||
|
5. **Verify**: the URL answers 200 over HTTPS without login or cookie wall, the table matches the Data safety /
|
||||||
|
App Privacy answers (`store-declarations` skill), the date is current.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- Update the policy first, then the store forms, whenever a game adds an SDK or starts storing something new.
|
||||||
|
- The policy must not be a PDF or a Google Doc behind a login; a plain page is safest for review.
|
||||||
|
- Do not put a personal address or private email in it; use a studio contact address.
|
||||||
|
- If a game uses ads in the EU / EEA / UK / CH, say how consent works (CMP or non-personalized ads).
|
||||||
|
- This template is a starting point, not legal advice.
|
||||||
43
skills/store-declarations/SKILL.md
Normal file
43
skills/store-declarations/SKILL.md
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
---
|
||||||
|
name: store-declarations
|
||||||
|
description: Answer the store compliance forms for a mobile game - Google Play Data safety (API CSV), App content declarations (ads, app access, content rating IARC, target audience, advertising ID, government/financial/health/news), App Store App Privacy nutrition labels, age rating, export compliance. Use when the user fills in data safety, privacy labels, content rating, target audience, ads or advertising ID declarations, or gets Data safety CSV import errors.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Store declarations
|
||||||
|
|
||||||
|
Templates: `store/declarations.md` (Play App content + IARC answers), `store/app-privacy.md` (Apple labels), both
|
||||||
|
from `crt-ship init`. Data safety answers live in `dataSafety` of `crt-ship.config.ts`.
|
||||||
|
CLI: `crt-ship` (or `bun ${CLAUDE_PLUGIN_ROOT}/cli/index.ts`).
|
||||||
|
|
||||||
|
## Order
|
||||||
|
1. **Inventory first.** List every SDK and server call: what leaves the device, who receives it, can the player turn
|
||||||
|
it off. The privacy policy (`privacy-policy` skill) must say the same; update it before the forms.
|
||||||
|
2. **Play Data safety (API).** Fill `dataSafety` (types not listed = not collected; `shared` = third party that is not
|
||||||
|
your service provider, e.g. ad networks; analytics vendors acting as processors are "collected" only).
|
||||||
|
`crt-ship play data-safety --dry --csv store/data-safety.csv` to review, then `crt-ship play data-safety`.
|
||||||
|
Then check the summary in Play Console > Policy and programs > App content > Data safety.
|
||||||
|
3. **Play App content (UI).** Policy and programs > App content, each card with `store/declarations.md`:
|
||||||
|
privacy policy, app access, ads, content rating (IARC questionnaire), target audience and content, news, COVID-19,
|
||||||
|
data safety, government apps, financial features, health, advertising ID.
|
||||||
|
4. **Apple age rating (API).** `crt-ship apple appinfo` (all NONE except your overrides, e.g. `advertising: true`).
|
||||||
|
5. **Apple App Privacy (UI only).** App Store Connect > the app > App Privacy > Get Started / Edit, from
|
||||||
|
`store/app-privacy.md`; per data type: purposes > linked to identity > used for tracking. Then **Publish**.
|
||||||
|
6. **Export compliance.** `ITSAppUsesNonExemptEncryption = NO` in Info.plist (standard HTTPS only).
|
||||||
|
|
||||||
|
## Typical F2P game with ads + IAP (no accounts)
|
||||||
|
Data safety: approximate location (IP country; shared, analytics + ads + fraud), app interactions (shared, analytics +
|
||||||
|
ads), other app activity (save backup, functionality), diagnostics (shared), device or other IDs (shared, ad id),
|
||||||
|
purchase history (if your server checks receipts). Encrypted in transit: yes. Deletion: yes, by email. Account
|
||||||
|
creation: none. IARC: everything No except digital purchases Yes; target audience 13+; ads Yes; advertising ID yes
|
||||||
|
(advertising, analytics, fraud prevention); sign-in details: no login needed.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- The Data safety CSV import **replaces** the whole form.
|
||||||
|
- `PSL_SUPPORTED_ACCOUNT_CREATION_METHODS` (e.g. `PSL_ACM_NONE`) is required even though Google's older sample CSV
|
||||||
|
omits it: without it the import fails with "Response missing". crt-ship always writes it.
|
||||||
|
- "Optional" data is only allowed when the user can really choose for that data; otherwise required.
|
||||||
|
- Ads SDKs: read each network's own Play data safety guidance; adding a network can add data types.
|
||||||
|
- Target audience under 13 pulls the app into the Families policy (certified ad networks, no ad id): only if true.
|
||||||
|
- Apple: "linked to the user" is No when there are no accounts and nothing ties data to a name / email / phone.
|
||||||
|
- App Privacy saved but not published = not submitted.
|
||||||
|
- Keep store answers, the privacy policy and the in-app privacy settings consistent; reviewers compare them.
|
||||||
52
skills/store-iap-setup/SKILL.md
Normal file
52
skills/store-iap-setup/SKILL.md
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
---
|
||||||
|
name: store-iap-setup
|
||||||
|
description: Create and price in-app purchases (consumables, non-consumables like "remove ads", coin/gem packs, starter packs) on Google Play and App Store Connect from one product list - regional prices, local overrides, activation, review screenshots, submitting first IAPs with a version, license/sandbox testers. Use when the user sets up, prices, updates or tests in-app products/IAPs, or hits Play onetimeproducts / inappproducts 403 or App Store IAP submission errors.
|
||||||
|
---
|
||||||
|
|
||||||
|
# In-app purchases on both stores
|
||||||
|
|
||||||
|
CLI: `crt-ship` (or `bun ${CLAUDE_PLUGIN_ROOT}/cli/index.ts`). One list in `crt-ship.config.ts` `products[]` drives
|
||||||
|
both stores: `id` (same on both), `type` (consumable | non_consumable), `usd` base price, optional `overrides`
|
||||||
|
(`{ region: 'TR', currency: 'TRY', price: 99.99 }`), `listings` per language (title <= 30, description <= 55 for
|
||||||
|
Apple), `reviewScreenshot`.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
- Play: a build with `com.android.vending.BILLING` uploaded to any track; payments profile set up.
|
||||||
|
- Apple: Paid Applications agreement + banking + tax active (Business > Agreements), the app record exists.
|
||||||
|
|
||||||
|
## Google Play (API)
|
||||||
|
1. `crt-ship check`, then `crt-ship play products --dry` (shows create/update and region count per product).
|
||||||
|
2. `crt-ship play products`. Per product: `pricing:convertRegionPrices` (USD -> every region at local price points,
|
||||||
|
and the `regionsVersion`), your overrides on top, `PATCH .../onetimeproducts/{id}?allowMissing=true®ionsVersion.version=...`
|
||||||
|
(create or update), then `oneTimeProducts/{id}/purchaseOptions:batchUpdateStates` to activate (new options are DRAFT).
|
||||||
|
3. Verify: `crt-ship play status` lists every product with `buy:ACTIVE`.
|
||||||
|
4. Test: Play Console > Settings > License testing: add tester Gmail accounts; install from the internal testing
|
||||||
|
link; purchases are free test orders.
|
||||||
|
|
||||||
|
## App Store (API + UI)
|
||||||
|
1. `crt-ship apple iap --dry`, then `crt-ship apple iap`: creates missing products, localizations, a price schedule
|
||||||
|
(USA base + overrides; Apple equalizes the rest) and availability. Existing schedules are not changed (change prices
|
||||||
|
in the UI if needed).
|
||||||
|
2. Review screenshot per product (a real device size, e.g. 1290 x 2796, showing the item in the shop):
|
||||||
|
`crt-ship apple iap-review`. States move to READY_TO_SUBMIT when complete.
|
||||||
|
3. **UI**: each IAP page > "Add for Review"; this puts them into the version's draft submission. Then submit the
|
||||||
|
version (owner, see appstore-first-release).
|
||||||
|
4. Test: Sandbox testers (Users and Access > Sandbox) on device; or a StoreKit configuration file in Xcode for local
|
||||||
|
runs (those transactions are signed locally and fail server-side receipt checks).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- Play legacy `inappproducts` returns 403 "Please migrate to the new publishing API" -> use
|
||||||
|
`monetization.onetimeproducts`.
|
||||||
|
- Path casing: PATCH works on `onetimeproducts`, but list + `purchaseOptions:batchUpdateStates` need `oneTimeProducts`;
|
||||||
|
the wrong casing answers with an HTML 404 page.
|
||||||
|
- Play has no consumable flag: the app consumes the purchase after granting it. Use `legacyCompatible: true` so older
|
||||||
|
billing libraries see the product by id.
|
||||||
|
- Apple: first IAPs must ship with an app version ("FIRST_*_MUST_BE_SUBMITTED_ON_VERSION" from
|
||||||
|
inAppPurchaseSubmissions). If the version is already in review without them: cancel the review submission
|
||||||
|
(`crt-ship apple status --cancel-submission <id>`, ask the owner), re-add via "Add for Review", resubmit.
|
||||||
|
- Apple needs an exact existing price point for each manual price; the error lists the nearest ones.
|
||||||
|
- Apple IAP description max 55 characters, display name max 30.
|
||||||
|
- Product ids can never be reused after deletion on either store. Never delete without the owner.
|
||||||
|
- Do not put prices or product names in store descriptions; say "offers optional in-app purchases".
|
||||||
|
- If the game ships without real billing yet, hide every purchase surface in store builds (review rejects mock
|
||||||
|
purchases) and answer "digital purchases: No" in IARC.
|
||||||
45
skills/store-screenshots/SKILL.md
Normal file
45
skills/store-screenshots/SKILL.md
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
---
|
||||||
|
name: store-screenshots
|
||||||
|
description: Produce store screenshots and the Play feature graphic for a mobile game in every required size and many languages - capture raw frames from the running game with playwright, write per-language marketing copy (incl. RTL Arabic and CJK fonts), render captioned scenes with device frames, JPEG copies for Play, and upload to both stores. Use when the user needs App Store / Google Play screenshots, preview images, a feature graphic, localized screenshot captions, or screenshot sizes.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Store screenshots
|
||||||
|
|
||||||
|
CLI: `crt-ship` (or `bun ${CLAUDE_PLUGIN_ROOT}/cli/index.ts`). Needs `ffmpeg`, and `playwright` in the project
|
||||||
|
(`bun add -d playwright`; it uses Playwright's Chromium or an installed Google Chrome).
|
||||||
|
|
||||||
|
## Sizes (what each store wants)
|
||||||
|
| size key | pixels | where |
|
||||||
|
|---|---|---|
|
||||||
|
| ios69 | 1320 x 2868 | App Store 6.9" (`APP_IPHONE_67`), required |
|
||||||
|
| ios65 | 1284 x 2778 | App Store 6.5" (`APP_IPHONE_65`) |
|
||||||
|
| play | 1080 x 1920 | Play phone (2-8, ratio at most 2:1) |
|
||||||
|
| tab7 | 1080 x 1920 | Play 7" tablet (tablet frames) |
|
||||||
|
| tab10 | 1440 x 2560 | Play 10" tablet |
|
||||||
|
| feature | 1024 x 500 | Play feature graphic (required) |
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
1. **Game switches.** Give the game URL params for screenshots: language, fixed seed, no ads / analytics / purchase
|
||||||
|
UI, skip tutorial, jump to a level, freeze at a moment. Frames must show real gameplay (store rule).
|
||||||
|
2. **Capture.** Configure `shots.capture` (baseUrl, query with `{lang}`, shots: path / waitFor / eval / delayMs /
|
||||||
|
device). Start the game (production build preview), then `crt-ship shots capture --lang en` (phone 440 x 902 @3x
|
||||||
|
and tablet 800 x 1240 @2x by default; the template draws the status bar above). Frames are local, not committed.
|
||||||
|
3. **Copy.** `store/shots/copy.json`: per language, per scene: `eyebrow`, `title` [white line, gold line], `sub`,
|
||||||
|
`badges` [[big, small]]. Short and true (only claim what is in the game); about 15 Latin characters per title line.
|
||||||
|
Natural marketing tone per language, not literal translation; use the game's own terms.
|
||||||
|
4. **Fonts.** `crt-ship shots fonts` downloads title/body fonts and a Noto subset for every non-Latin script found in
|
||||||
|
the copy (CJK, Arabic, Hebrew, Devanagari, Thai, Cyrillic/Greek). Re-run after copy edits.
|
||||||
|
5. **Scenes.** `shots.scenes`: palette (fire, violet, blast, green, night, sky, gold, candy or custom), frames (one
|
||||||
|
front phone + optional back phones with x / dy / scale / rotate), badge slots (y, side, icon, hot, stars).
|
||||||
|
6. **Render.** `crt-ship shots render --lang en` first, review `store/renders/en/sheet.png` (contact sheet), then
|
||||||
|
all languages in the background: `crt-ship shots render > .crt-ship/render.log 2>&1 &`.
|
||||||
|
7. **JPEG for Play.** `crt-ship shots jpeg` (q92, about 4x smaller than PNG; App Store stays PNG).
|
||||||
|
8. **Upload.** `crt-ship play images` and `crt-ship apple screenshots` (both idempotent, background + log).
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
- Output is 24-bit RGB PNG: Play rejects alpha / palette PNGs, Apple rejects alpha.
|
||||||
|
- Arabic: `rtl: true` on the language; numbers stay LTR inside RTL text automatically.
|
||||||
|
- Japanese breaks by phrase, Korean at spaces; long words shrink the line rather than wrap.
|
||||||
|
- Check every language's sheet for overflow before uploading; German / Russian titles run long.
|
||||||
|
- Play: 8 per type max; Apple: 10 per set max.
|
||||||
|
- Do not include prices, "free" badges for paid items, or other platforms' UI.
|
||||||
33
templates/app-privacy.md
Normal file
33
templates/app-privacy.md
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Example Game · App Store "App Privacy" answers
|
||||||
|
|
||||||
|
App Store Connect > your app > App Privacy. **Web UI only, there is no API.** Fill it from this table, then press
|
||||||
|
**Publish** at the top of the App Privacy page (saving alone does not publish it, and review checks the published one).
|
||||||
|
Keep it consistent with the privacy policy and the Play Data safety answers (`dataSafety` in `crt-ship.config.ts`).
|
||||||
|
|
||||||
|
Typical free-to-play game: analytics SDK + ad mediation (with ATT) + optional server backup, no accounts.
|
||||||
|
|
||||||
|
**Do you or your third-party partners collect data from this app?** Yes
|
||||||
|
|
||||||
|
**Tracking**: Yes, if any ad network uses the IDFA after the App Tracking Transparency prompt (third-party advertising).
|
||||||
|
Then `NSUserTrackingUsageDescription` must be in Info.plist and the prompt must come before the ad SDK starts.
|
||||||
|
|
||||||
|
| data type | collected | used to track | linked to the user | purposes |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Identifiers > Device ID (IDFA, analytics random id) | yes | yes (IDFA, with ATT consent) | no | Third-Party Advertising, Analytics, App Functionality |
|
||||||
|
| Location > Coarse Location (country from IP) | yes | yes | no | Third-Party Advertising, Analytics |
|
||||||
|
| Usage Data > Advertising Data (ads shown, ad taps) | yes | yes | no | Third-Party Advertising, Analytics |
|
||||||
|
| Usage Data > Product Interaction (sessions, level events) | yes | no | no | Analytics |
|
||||||
|
| User Content > Gameplay Content (save backup, scores) | only with a server backup | no | no | App Functionality |
|
||||||
|
| Diagnostics > Other Diagnostic Data (device model, OS version) | yes | no | no | Analytics, Third-Party Advertising |
|
||||||
|
| Purchases > Purchase History | only if your server stores receipts | no | yes if tied to an account | App Functionality |
|
||||||
|
|
||||||
|
"Linked to you" is **No** when there are no accounts and nothing is tied to a name, email or phone number.
|
||||||
|
Not collected (typical): Contact Info, Health and Fitness, Financial Info, Precise Location, Sensitive Info, Contacts,
|
||||||
|
Emails or Text Messages, Photos or Videos, Audio, Browsing History, Search History, Crash Data (unless you ship a crash
|
||||||
|
reporter).
|
||||||
|
|
||||||
|
Per data type the UI asks, in order: purposes (checkboxes) > "Is this data linked to the user's identity?" >
|
||||||
|
"Do you or your third-party partners use this data for tracking purposes?".
|
||||||
|
|
||||||
|
Privacy policy URL: App Information > Privacy Policy URL (set by `crt-ship apple listing`). Privacy choices URL:
|
||||||
|
optional, leave empty unless you have a page for it.
|
||||||
67
templates/appstore.md
Normal file
67
templates/appstore.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Example Game · App Store listing copy
|
||||||
|
|
||||||
|
Source for `crt-ship apple listing`. One `## <ASC locale>` section per locale (the first word is the locale code:
|
||||||
|
`en-US`, `tr`, `de-DE`, `ja`, `zh-Hans`, `ar-SA`, ...), one fenced block per `### <field>`, field names exactly:
|
||||||
|
`name` (optional, defaults to `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 bytes, comma
|
||||||
|
separated, no spaces after commas, do not repeat the app name), `description` (4000), `whatsNew` (optional; not allowed
|
||||||
|
on the first version). Shared values (privacy policy, support and marketing URL) come from `crt-ship.config.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## en-US · English (U.S.)
|
||||||
|
|
||||||
|
### subtitle
|
||||||
|
```
|
||||||
|
Short hook under the name
|
||||||
|
```
|
||||||
|
|
||||||
|
### promotionalText
|
||||||
|
```
|
||||||
|
One or two sentences you can change any time without a new version.
|
||||||
|
```
|
||||||
|
|
||||||
|
### keywords
|
||||||
|
```
|
||||||
|
puzzle,casual,relaxing,offline,brain,match,levels
|
||||||
|
```
|
||||||
|
|
||||||
|
### description
|
||||||
|
```
|
||||||
|
First paragraph: what the player does and why it feels good.
|
||||||
|
|
||||||
|
FEATURES
|
||||||
|
• One-thumb controls
|
||||||
|
• 100+ levels
|
||||||
|
• Play offline, no account needed
|
||||||
|
|
||||||
|
Free to play. Contains ads and offers optional in-app purchases.
|
||||||
|
```
|
||||||
|
|
||||||
|
## tr · Türkçe
|
||||||
|
|
||||||
|
### subtitle
|
||||||
|
```
|
||||||
|
İsmin altındaki kısa kanca
|
||||||
|
```
|
||||||
|
|
||||||
|
### promotionalText
|
||||||
|
```
|
||||||
|
Yeni sürüm göndermeden istediğin zaman değiştirebileceğin bir iki cümle.
|
||||||
|
```
|
||||||
|
|
||||||
|
### keywords
|
||||||
|
```
|
||||||
|
bulmaca,rahatlatıcı,internetsiz,zeka,eşleştirme,bölüm
|
||||||
|
```
|
||||||
|
|
||||||
|
### description
|
||||||
|
```
|
||||||
|
İlk paragraf: oyuncu ne yapıyor ve neden keyifli.
|
||||||
|
|
||||||
|
ÖZELLİKLER
|
||||||
|
• Tek parmakla kontrol
|
||||||
|
• 100+ bölüm
|
||||||
|
• İnternetsiz oyna, hesap gerekmez
|
||||||
|
|
||||||
|
Ücretsiz oyun. Reklam içerir ve isteğe bağlı uygulama içi satın alımlar sunar.
|
||||||
|
```
|
||||||
7
templates/credentials.example.json
Normal file
7
templates/credentials.example.json
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"PLAY_SERVICE_ACCOUNT": "/absolute/path/to/play-service-account.json",
|
||||||
|
"ASC_KEY_ID": "ABC123DEFG",
|
||||||
|
"ASC_ISSUER_ID": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"ASC_P8": "/absolute/path/to/AuthKey_<KEY_ID>.p8",
|
||||||
|
"reviewContact": { "firstName": "", "lastName": "", "phone": "+1 555 0100", "email": "review-contact@example.com" }
|
||||||
|
}
|
||||||
138
templates/crt-ship.config.ts
Normal file
138
templates/crt-ship.config.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
// crt-ship config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or
|
||||||
|
// .crt-ship/credentials.json, which must stay gitignored). Paths are relative to this file.
|
||||||
|
// The type import is optional: it only works when crt-ship is installed in the project (bun add -d <repo url>).
|
||||||
|
import type { ShipConfig } from 'crt-ship';
|
||||||
|
|
||||||
|
const config: ShipConfig = {
|
||||||
|
app: {
|
||||||
|
name: 'Example Game',
|
||||||
|
copyright: '2026 Example Studio',
|
||||||
|
privacyPolicyUrl: 'https://example.com/privacy',
|
||||||
|
supportUrl: 'https://example.com',
|
||||||
|
marketingUrl: 'https://example.com',
|
||||||
|
// Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads
|
||||||
|
website: 'https://example.com',
|
||||||
|
contactEmail: 'support@example.com',
|
||||||
|
},
|
||||||
|
|
||||||
|
// id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale
|
||||||
|
languages: [
|
||||||
|
{ id: 'en', play: 'en-US', apple: 'en-US' },
|
||||||
|
{ id: 'tr', play: 'tr-TR', apple: 'tr' },
|
||||||
|
{ id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' },
|
||||||
|
{ id: 'ja', play: 'ja-JP', apple: 'ja' },
|
||||||
|
{ id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true },
|
||||||
|
],
|
||||||
|
|
||||||
|
play: {
|
||||||
|
packageName: 'com.example.game',
|
||||||
|
defaultLanguage: 'en-US',
|
||||||
|
listing: 'store/listing.md',
|
||||||
|
icon: 'store/icons/play-512.png',
|
||||||
|
},
|
||||||
|
|
||||||
|
apple: {
|
||||||
|
bundleId: 'com.example.game',
|
||||||
|
// appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out
|
||||||
|
listing: 'store/appstore.md',
|
||||||
|
reviewShots: 'store/iap-review',
|
||||||
|
info: {
|
||||||
|
categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' },
|
||||||
|
ageRating: { advertising: true }, // everything else NONE / false
|
||||||
|
contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',
|
||||||
|
free: true,
|
||||||
|
reviewNotes: [
|
||||||
|
'Single-player game. No account or login is needed; there is no user-generated content or chat.',
|
||||||
|
'In-app purchases: Home > Shop. Restore Purchases is in Settings.',
|
||||||
|
'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',
|
||||||
|
'Privacy policy: Settings > Privacy.',
|
||||||
|
].join('\n\n'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a
|
||||||
|
// region by hand (Apple needs an existing price point with exactly that value).
|
||||||
|
products: [
|
||||||
|
{
|
||||||
|
id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99,
|
||||||
|
overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],
|
||||||
|
listings: {
|
||||||
|
en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' },
|
||||||
|
tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' },
|
||||||
|
},
|
||||||
|
reviewScreenshot: 'store/iap-review/shop.png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99,
|
||||||
|
listings: {
|
||||||
|
en: { title: '500 Coins', description: 'A small bag of coins.' },
|
||||||
|
tr: { title: '500 Altın', description: 'Küçük bir kese altın.' },
|
||||||
|
},
|
||||||
|
reviewScreenshot: 'store/iap-review/shop.png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications,
|
||||||
|
// fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation:
|
||||||
|
dataSafety: {
|
||||||
|
collectsData: true,
|
||||||
|
encryptedInTransit: true,
|
||||||
|
deletionRequest: true, // e.g. "email us"; no accounts, so no account deletion URL needed
|
||||||
|
accountCreation: ['PSL_ACM_NONE'],
|
||||||
|
data: {
|
||||||
|
PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
|
||||||
|
PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },
|
||||||
|
PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },
|
||||||
|
PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
|
||||||
|
PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },
|
||||||
|
PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
renders: 'store/renders',
|
||||||
|
|
||||||
|
shots: {
|
||||||
|
copy: 'store/shots/copy.json',
|
||||||
|
frames: 'store/shots/frames', // <lang>/phone/<name>.png, <lang>/tablet/<name>.png
|
||||||
|
out: 'store/renders',
|
||||||
|
logo: 'store/shots/logo.png', // optional: hero scene + feature graphic
|
||||||
|
fonts: { title: 'Lilita One', body: 'Montserrat' },
|
||||||
|
// phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar)
|
||||||
|
scenes: [
|
||||||
|
{ id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }],
|
||||||
|
badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] },
|
||||||
|
{ id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }],
|
||||||
|
badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] },
|
||||||
|
{ id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }],
|
||||||
|
badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] },
|
||||||
|
{ id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }],
|
||||||
|
badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] },
|
||||||
|
],
|
||||||
|
feature: { frame: 'hero.png', palette: 'fire' },
|
||||||
|
capture: {
|
||||||
|
baseUrl: 'http://127.0.0.1:5173',
|
||||||
|
query: 'lang={lang}&noads=1&seed=1',
|
||||||
|
shots: {
|
||||||
|
'hero': { path: '/?level=12', waitFor: "window.game && game.state === 'playing'", delayMs: 1500, device: 'both' },
|
||||||
|
'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' },
|
||||||
|
'level-a': { path: '/?level=20', delayMs: 2000 },
|
||||||
|
'level-b': { path: '/?level=40', delayMs: 2000 },
|
||||||
|
'level-c': { path: '/?level=60', delayMs: 2000 },
|
||||||
|
'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
icons: {
|
||||||
|
composite: 'store/icon/icon.png', // 1024 opaque
|
||||||
|
foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent
|
||||||
|
background: 'store/icon/background.png', // 1024 opaque
|
||||||
|
out: 'store/icons',
|
||||||
|
web: 'public/icons',
|
||||||
|
// capacitorAssets: 'native/assets',
|
||||||
|
// capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets',
|
||||||
|
// androidRes: 'native/android/app/src/main/res',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
765
templates/data-safety.csv
Normal file
765
templates/data-safety.csv
Normal file
|
|
@ -0,0 +1,765 @@
|
||||||
|
Question ID (machine readable),Response ID (machine readable),Response value,Answer requirement,Human-friendly question label
|
||||||
|
PSL_DATA_COLLECTION_COLLECTS_PERSONAL_DATA,,TRUE,REQUIRED,Does your app collect or share any of the required user data types?
|
||||||
|
PSL_DATA_COLLECTION_ENCRYPTED_IN_TRANSIT,,TRUE,MAYBE_REQUIRED,Is all of the user data collected by your app encrypted in transit?
|
||||||
|
PSL_DATA_COLLECTION_USER_REQUEST_DELETE,,TRUE,MAYBE_REQUIRED,Do you provide a way for users to request that their data is deleted?
|
||||||
|
PSL_SUPPORTED_ACCOUNT_CREATION_METHODS,PSL_ACM_NONE,TRUE,MULTIPLE_CHOICE,Account creation / PSL_ACM_NONE
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_NAME,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_NAME
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_EMAIL,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_EMAIL
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_USER_ACCOUNT,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_USER_ACCOUNT
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_ADDRESS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_ADDRESS
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_PHONE,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_PHONE
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_RACE_ETHNICITY,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_RACE_ETHNICITY
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_POLITICAL_RELIGIOUS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_POLITICAL_RELIGIOUS
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY
|
||||||
|
PSL_DATA_TYPES_PERSONAL,PSL_OTHER_PERSONAL,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PERSONAL / PSL_OTHER_PERSONAL
|
||||||
|
PSL_DATA_TYPES_FINANCIAL,PSL_PURCHASE_HISTORY,TRUE,MULTIPLE_CHOICE,PSL_DATA_TYPES_FINANCIAL / PSL_PURCHASE_HISTORY
|
||||||
|
PSL_DATA_TYPES_FINANCIAL,PSL_CREDIT_SCORE,,MULTIPLE_CHOICE,PSL_DATA_TYPES_FINANCIAL / PSL_CREDIT_SCORE
|
||||||
|
PSL_DATA_TYPES_FINANCIAL,PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER,,MULTIPLE_CHOICE,PSL_DATA_TYPES_FINANCIAL / PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER
|
||||||
|
PSL_DATA_TYPES_FINANCIAL,PSL_OTHER,,MULTIPLE_CHOICE,PSL_DATA_TYPES_FINANCIAL / PSL_OTHER
|
||||||
|
PSL_DATA_TYPES_LOCATION,PSL_APPROX_LOCATION,TRUE,MULTIPLE_CHOICE,PSL_DATA_TYPES_LOCATION / PSL_APPROX_LOCATION
|
||||||
|
PSL_DATA_TYPES_LOCATION,PSL_PRECISE_LOCATION,,MULTIPLE_CHOICE,PSL_DATA_TYPES_LOCATION / PSL_PRECISE_LOCATION
|
||||||
|
PSL_DATA_TYPES_SEARCH_AND_BROWSING,PSL_WEB_BROWSING_HISTORY,,MULTIPLE_CHOICE,PSL_DATA_TYPES_SEARCH_AND_BROWSING / PSL_WEB_BROWSING_HISTORY
|
||||||
|
PSL_DATA_TYPES_EMAIL_AND_TEXT,PSL_EMAILS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_EMAIL_AND_TEXT / PSL_EMAILS
|
||||||
|
PSL_DATA_TYPES_EMAIL_AND_TEXT,PSL_SMS_CALL_LOG,,MULTIPLE_CHOICE,PSL_DATA_TYPES_EMAIL_AND_TEXT / PSL_SMS_CALL_LOG
|
||||||
|
PSL_DATA_TYPES_EMAIL_AND_TEXT,PSL_OTHER_MESSAGES,,MULTIPLE_CHOICE,PSL_DATA_TYPES_EMAIL_AND_TEXT / PSL_OTHER_MESSAGES
|
||||||
|
PSL_DATA_TYPES_PHOTOS_AND_VIDEOS,PSL_PHOTOS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PHOTOS_AND_VIDEOS / PSL_PHOTOS
|
||||||
|
PSL_DATA_TYPES_PHOTOS_AND_VIDEOS,PSL_VIDEOS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_PHOTOS_AND_VIDEOS / PSL_VIDEOS
|
||||||
|
PSL_DATA_TYPES_AUDIO,PSL_AUDIO,,MULTIPLE_CHOICE,PSL_DATA_TYPES_AUDIO / PSL_AUDIO
|
||||||
|
PSL_DATA_TYPES_AUDIO,PSL_MUSIC,,MULTIPLE_CHOICE,PSL_DATA_TYPES_AUDIO / PSL_MUSIC
|
||||||
|
PSL_DATA_TYPES_AUDIO,PSL_OTHER_AUDIO,,MULTIPLE_CHOICE,PSL_DATA_TYPES_AUDIO / PSL_OTHER_AUDIO
|
||||||
|
PSL_DATA_TYPES_HEALTH_AND_FITNESS,PSL_HEALTH,,MULTIPLE_CHOICE,PSL_DATA_TYPES_HEALTH_AND_FITNESS / PSL_HEALTH
|
||||||
|
PSL_DATA_TYPES_HEALTH_AND_FITNESS,PSL_FITNESS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_HEALTH_AND_FITNESS / PSL_FITNESS
|
||||||
|
PSL_DATA_TYPES_CONTACTS,PSL_CONTACTS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_CONTACTS / PSL_CONTACTS
|
||||||
|
PSL_DATA_TYPES_CALENDAR,PSL_CALENDAR,,MULTIPLE_CHOICE,PSL_DATA_TYPES_CALENDAR / PSL_CALENDAR
|
||||||
|
PSL_DATA_TYPES_APP_PERFORMANCE,PSL_CRASH_LOGS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_APP_PERFORMANCE / PSL_CRASH_LOGS
|
||||||
|
PSL_DATA_TYPES_APP_PERFORMANCE,PSL_PERFORMANCE_DIAGNOSTICS,TRUE,MULTIPLE_CHOICE,PSL_DATA_TYPES_APP_PERFORMANCE / PSL_PERFORMANCE_DIAGNOSTICS
|
||||||
|
PSL_DATA_TYPES_APP_PERFORMANCE,PSL_OTHER_PERFORMANCE,,MULTIPLE_CHOICE,PSL_DATA_TYPES_APP_PERFORMANCE / PSL_OTHER_PERFORMANCE
|
||||||
|
PSL_DATA_TYPES_FILES_AND_DOCS,PSL_FILES_AND_DOCS,,MULTIPLE_CHOICE,PSL_DATA_TYPES_FILES_AND_DOCS / PSL_FILES_AND_DOCS
|
||||||
|
PSL_DATA_TYPES_APP_ACTIVITY,PSL_USER_INTERACTION,TRUE,MULTIPLE_CHOICE,PSL_DATA_TYPES_APP_ACTIVITY / PSL_USER_INTERACTION
|
||||||
|
PSL_DATA_TYPES_APP_ACTIVITY,PSL_IN_APP_SEARCH_HISTORY,,MULTIPLE_CHOICE,PSL_DATA_TYPES_APP_ACTIVITY / PSL_IN_APP_SEARCH_HISTORY
|
||||||
|
PSL_DATA_TYPES_APP_ACTIVITY,PSL_APPS_ON_DEVICE,,MULTIPLE_CHOICE,PSL_DATA_TYPES_APP_ACTIVITY / PSL_APPS_ON_DEVICE
|
||||||
|
PSL_DATA_TYPES_APP_ACTIVITY,PSL_USER_GENERATED_CONTENT,,MULTIPLE_CHOICE,PSL_DATA_TYPES_APP_ACTIVITY / PSL_USER_GENERATED_CONTENT
|
||||||
|
PSL_DATA_TYPES_APP_ACTIVITY,PSL_OTHER_APP_ACTIVITY,TRUE,MULTIPLE_CHOICE,PSL_DATA_TYPES_APP_ACTIVITY / PSL_OTHER_APP_ACTIVITY
|
||||||
|
PSL_DATA_TYPES_IDENTIFIERS,PSL_DEVICE_ID,TRUE,MULTIPLE_CHOICE,PSL_DATA_TYPES_IDENTIFIERS / PSL_DEVICE_ID
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_NAME) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_NAME) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_NAME) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_NAME:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_NAME) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_EMAIL) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_EMAIL) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_EMAIL) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAIL:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAIL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_USER_ACCOUNT) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_ACCOUNT:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_ACCOUNT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_ADDRESS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_ADDRESS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_ADDRESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_PHONE) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_PHONE) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_PHONE) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHONE:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHONE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_RACE_ETHNICITY) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_RACE_ETHNICITY:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_RACE_ETHNICITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_POLITICAL_RELIGIOUS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_POLITICAL_RELIGIOUS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_SEXUAL_ORIENTATION_GENDER_IDENTITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_OTHER_PERSONAL) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERSONAL:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERSONAL) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:PSL_DATA_USAGE_EPHEMERAL,,FALSE,MAYBE_REQUIRED,Data usage and handling (PSL_PURCHASE_HISTORY) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,TRUE,SINGLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PURCHASE_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PURCHASE_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_CREDIT_SCORE) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_SCORE:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_SCORE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CREDIT_DEBIT_BANK_ACCOUNT_NUMBER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_OTHER) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:PSL_DATA_USAGE_EPHEMERAL,,FALSE,MAYBE_REQUIRED,Data usage and handling (PSL_APPROX_LOCATION) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,TRUE,SINGLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPROX_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPROX_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_PRECISE_LOCATION) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PRECISE_LOCATION:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PRECISE_LOCATION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_WEB_BROWSING_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_WEB_BROWSING_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_EMAILS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_EMAILS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_EMAILS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_EMAILS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_EMAILS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_SMS_CALL_LOG) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_SMS_CALL_LOG:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_SMS_CALL_LOG) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_OTHER_MESSAGES) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_MESSAGES:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_MESSAGES) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_PHOTOS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PHOTOS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PHOTOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_VIDEOS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_VIDEOS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_VIDEOS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_AUDIO) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_AUDIO) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_AUDIO) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_MUSIC) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_MUSIC) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_MUSIC) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_MUSIC:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_MUSIC) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_OTHER_AUDIO) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_AUDIO:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_AUDIO) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_HEALTH) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_HEALTH) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_HEALTH) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_HEALTH:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_HEALTH) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_FITNESS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_FITNESS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_FITNESS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FITNESS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_FITNESS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_CONTACTS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CONTACTS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CONTACTS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_CALENDAR) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CALENDAR:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CALENDAR) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_CRASH_LOGS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_CRASH_LOGS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_CRASH_LOGS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:PSL_DATA_USAGE_EPHEMERAL,,FALSE,MAYBE_REQUIRED,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,TRUE,SINGLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_PERFORMANCE_DIAGNOSTICS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_PERFORMANCE_DIAGNOSTICS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_OTHER_PERFORMANCE) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_PERFORMANCE:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_PERFORMANCE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_FILES_AND_DOCS) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_FILES_AND_DOCS:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_FILES_AND_DOCS) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:PSL_DATA_USAGE_EPHEMERAL,,FALSE,MAYBE_REQUIRED,Data usage and handling (PSL_USER_INTERACTION) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,TRUE,SINGLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_INTERACTION:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_INTERACTION) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_IN_APP_SEARCH_HISTORY:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_IN_APP_SEARCH_HISTORY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_APPS_ON_DEVICE) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_APPS_ON_DEVICE:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_APPS_ON_DEVICE) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:PSL_DATA_USAGE_EPHEMERAL,,,MAYBE_REQUIRED,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,,SINGLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_USER_GENERATED_CONTENT:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_USER_GENERATED_CONTENT) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:PSL_DATA_USAGE_EPHEMERAL,,FALSE,MAYBE_REQUIRED,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,TRUE,SINGLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_OTHER_APP_ACTIVITY:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_OTHER_APP_ACTIVITY) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_COLLECTED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Collected
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:PSL_DATA_USAGE_COLLECTION_AND_SHARING,PSL_DATA_USAGE_ONLY_SHARED,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Shared
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:PSL_DATA_USAGE_EPHEMERAL,,FALSE,MAYBE_REQUIRED,Data usage and handling (PSL_DEVICE_ID) / Processed ephemerally
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_OPTIONAL,,SINGLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Optional
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_USER_CONTROL,PSL_DATA_USAGE_USER_CONTROL_REQUIRED,TRUE,SINGLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Required
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_COLLECTION_PURPOSE,PSL_APP_FUNCTIONALITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_COLLECTION_PURPOSE,PSL_ANALYTICS,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_COLLECTION_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_COLLECTION_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_COLLECTION_PURPOSE,PSL_ADVERTISING,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_COLLECTION_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_COLLECTION_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Collection purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_SHARING_PURPOSE,PSL_APP_FUNCTIONALITY,,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_SHARING_PURPOSE,PSL_ANALYTICS,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_SHARING_PURPOSE,PSL_DEVELOPER_COMMUNICATIONS,,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_SHARING_PURPOSE,PSL_FRAUD_PREVENTION_SECURITY,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_SHARING_PURPOSE,PSL_ADVERTISING,TRUE,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_SHARING_PURPOSE,PSL_PERSONALIZATION,,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Sharing purpose
|
||||||
|
PSL_DATA_USAGE_RESPONSES:PSL_DEVICE_ID:DATA_USAGE_SHARING_PURPOSE,PSL_ACCOUNT_MANAGEMENT,,MULTIPLE_CHOICE,Data usage and handling (PSL_DEVICE_ID) / Sharing purpose
|
||||||
|
67
templates/declarations.md
Normal file
67
templates/declarations.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Example Game · store declarations (answers to copy into the consoles)
|
||||||
|
|
||||||
|
Answers for a typical single-player free-to-play game with ads (mediation) and in-app purchases, no accounts, no chat,
|
||||||
|
no user-generated content, general audience 13+. Change what does not match your game. Everything marked **UI** has no
|
||||||
|
API: Play Console > your app > Policy and programs > **App content** (or Dashboard > "Set up your app"), and App Store
|
||||||
|
Connect for Apple. Data safety (Play) has an API: `crt-ship play data-safety`.
|
||||||
|
|
||||||
|
## Google Play · App content (UI)
|
||||||
|
|
||||||
|
| declaration | answer |
|
||||||
|
|---|---|
|
||||||
|
| Privacy policy | your policy URL (same one in the listing and inside the app) |
|
||||||
|
| App access | All functionality is available without special access (no login) |
|
||||||
|
| Ads | Yes, my app contains ads |
|
||||||
|
| Content rating | IARC questionnaire, below |
|
||||||
|
| Target audience and content | 13-15, 16-17, 18 and over; "Could your app unintentionally appeal to children?" No (only if true) |
|
||||||
|
| News app | No |
|
||||||
|
| COVID-19 contact tracing / status | No |
|
||||||
|
| Data safety | `crt-ship play data-safety` (then check the summary in the Console) |
|
||||||
|
| Government apps | No |
|
||||||
|
| Financial features | My app doesn't provide any financial features |
|
||||||
|
| Health apps | No health features |
|
||||||
|
| Advertising ID | Yes, uses it; purposes: Advertising or marketing, Analytics, Fraud prevention / security / compliance |
|
||||||
|
| Sign-in details (App access) | No sign-in needed |
|
||||||
|
| Store settings | App category Game > (Casual, Puzzle, Arcade ...); tags; contact email + website (website serves app-ads.txt) |
|
||||||
|
|
||||||
|
Families / Designed for Families: only if you really target children; it changes the ad SDK setup (certified networks,
|
||||||
|
no ad id). If you leave children out, the policy and the ad mediation must say "not directed at children" too.
|
||||||
|
|
||||||
|
## IARC content rating (Play UI; Apple has its own form, `crt-ship apple appinfo`)
|
||||||
|
|
||||||
|
Category: Game. Typical answers for a non-violent casual game:
|
||||||
|
|
||||||
|
| topic | answer |
|
||||||
|
|---|---|
|
||||||
|
| Violence (realistic / cartoon / fantasy) | No |
|
||||||
|
| Blood / gore | No |
|
||||||
|
| Fear / horror | No |
|
||||||
|
| Sexuality / nudity | No |
|
||||||
|
| Language (profanity, crude humour) | No |
|
||||||
|
| Controlled substances | No |
|
||||||
|
| Gambling: simulated / real money | No / No |
|
||||||
|
| Random paid items (loot boxes) | No (fixed contents only; answer Yes if a paid item has random contents) |
|
||||||
|
| Users interact or exchange content | No (no chat; OS share sheet does not count) |
|
||||||
|
| Shares location with other users | No |
|
||||||
|
| Digital purchases | Yes (in-app purchases) |
|
||||||
|
| Unrestricted internet / browser | No |
|
||||||
|
|
||||||
|
Expected: PEGI 3, ESRB Everyone, USK 0, IARC 3+. Apple age rating 4+ (all content questions None; ads are declared as
|
||||||
|
"advertising: true", which does not change 4+).
|
||||||
|
|
||||||
|
## App Store (UI-only parts)
|
||||||
|
|
||||||
|
- App record: App Store Connect > Apps > + > New App (platform, name, primary language, bundle id, SKU). No API.
|
||||||
|
- App Privacy: `app-privacy.md`, then **Publish**.
|
||||||
|
- Export compliance: put `ITSAppUsesNonExemptEncryption = NO` in Info.plist (only HTTPS / standard encryption) so
|
||||||
|
every build skips the question.
|
||||||
|
- First in-app purchases: from the IAP page "Add for Review" into the draft submission with the version (see the
|
||||||
|
store-iap-setup skill).
|
||||||
|
- Guideline 5.1.1: the privacy policy link must also be reachable **inside the app** (Settings > Privacy).
|
||||||
|
|
||||||
|
## Ads
|
||||||
|
|
||||||
|
- app-ads.txt at the root of the developer website named in both listings (`https://example.com/app-ads.txt`),
|
||||||
|
with the mediation's DIRECT line plus each network's lines.
|
||||||
|
- iOS: SKAdNetwork ids of every network in Info.plist, ATT prompt before the ad SDK starts.
|
||||||
|
- EU / EEA / UK / CH: consent (CMP) or non-personalized ads.
|
||||||
62
templates/listing.md
Normal file
62
templates/listing.md
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
# Example Game · Google Play listing copy
|
||||||
|
|
||||||
|
Source for `crt-ship play listing` (title, short + full description) and `crt-ship play release` (release notes).
|
||||||
|
Rules the parser follows:
|
||||||
|
- every `## ...` section with a `<!-- play: <codes> -->` line is a listing; one section can serve several Play codes
|
||||||
|
(`<!-- play: es-ES, es-419 -->`)
|
||||||
|
- under it, `### ...` headings that contain **title** (optional, defaults to `app.name`), **short**, **full** and
|
||||||
|
**what's new**, each followed by one fenced block
|
||||||
|
- limits (checked before anything is sent): title 30, short 80, full 4000, what's new 500 characters
|
||||||
|
Only claim what is really in the game. No prices, no "best / #1", no other games' names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## English (en-US)
|
||||||
|
<!-- play: en-US -->
|
||||||
|
|
||||||
|
### Play short description (max 80)
|
||||||
|
```
|
||||||
|
Short, punchy promise of the core loop in one line
|
||||||
|
```
|
||||||
|
|
||||||
|
### Play full description (max 4000)
|
||||||
|
```
|
||||||
|
First paragraph: what the player does and why it feels good, in two or three sentences.
|
||||||
|
|
||||||
|
FEATURES
|
||||||
|
• One-thumb controls
|
||||||
|
• 100+ levels and new ones every update
|
||||||
|
• Play offline, no account needed
|
||||||
|
|
||||||
|
Free to play. Contains ads and offers optional in-app purchases.
|
||||||
|
```
|
||||||
|
|
||||||
|
### What's new (max 500)
|
||||||
|
```
|
||||||
|
First release. Have fun!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Türkçe (tr-TR)
|
||||||
|
<!-- play: tr-TR -->
|
||||||
|
|
||||||
|
### Play kısa açıklama · short (max 80)
|
||||||
|
```
|
||||||
|
Oyunun özünü anlatan kısa ve vurucu bir cümle
|
||||||
|
```
|
||||||
|
|
||||||
|
### Play tam açıklama · full (max 4000)
|
||||||
|
```
|
||||||
|
İlk paragraf: oyuncu ne yapıyor ve neden keyifli, iki üç cümlede.
|
||||||
|
|
||||||
|
ÖZELLİKLER
|
||||||
|
• Tek parmakla kontrol
|
||||||
|
• 100+ bölüm, her güncellemede yenileri
|
||||||
|
• İnternetsiz oyna, hesap gerekmez
|
||||||
|
|
||||||
|
Ücretsiz oyun. Reklam içerir ve isteğe bağlı uygulama içi satın alımlar sunar.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Yenilikler · what's new (max 500)
|
||||||
|
```
|
||||||
|
İlk sürüm. İyi eğlenceler!
|
||||||
|
```
|
||||||
79
templates/privacy-policy.html
Normal file
79
templates/privacy-policy.html
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Privacy Policy · Example Studio</title>
|
||||||
|
<!--
|
||||||
|
One studio-wide privacy policy for every game, with a per-game services table. Host it at a stable URL
|
||||||
|
(https://example.com/privacy) and use that URL in both store listings, in crt-ship.config.ts and inside each game
|
||||||
|
(Settings > Privacy; App Store guideline 5.1.1). Replace every "Example" value and remove services you do not use.
|
||||||
|
Update the date and the table whenever a game starts collecting something new, then the store declarations.
|
||||||
|
This is a template, not legal advice.
|
||||||
|
-->
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: light dark; --fg: #1b1c20; --bg: #ffffff; --muted: #5d6170; --line: #e3e5ea; --accent: #2563eb; }
|
||||||
|
@media (prefers-color-scheme: dark) { :root { --fg: #eceef3; --bg: #15161a; --muted: #a3a8b6; --line: #2b2d35; --accent: #7aa2ff; } }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; background: var(--bg); color: var(--fg); font: 16px/1.6 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; }
|
||||||
|
main { max-width: 760px; margin: 0 auto; padding: 32px 16px 64px; }
|
||||||
|
h1 { font-size: 1.8rem; margin: 0 0 4px; } h2 { font-size: 1.2rem; margin-top: 2em; }
|
||||||
|
p.meta { color: var(--muted); margin: 0 0 24px; }
|
||||||
|
a { color: var(--accent); }
|
||||||
|
.table { overflow-x: auto; }
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: .95rem; }
|
||||||
|
th, td { text-align: left; vertical-align: top; padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||||
|
th { font-weight: 600; }
|
||||||
|
nav a { margin-right: 12px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<nav><a href="#en">English</a><a href="#tr">Türkçe</a></nav>
|
||||||
|
|
||||||
|
<section id="en">
|
||||||
|
<h1>Privacy Policy</h1>
|
||||||
|
<p class="meta">Example Studio · applies to all our games · last updated 2026-01-01</p>
|
||||||
|
|
||||||
|
<p>We make small games. We collect as little as we can, never sell personal data, and none of our games needs an
|
||||||
|
account. This page explains what each game collects, why, and what you can do about it.</p>
|
||||||
|
|
||||||
|
<h2>What our games use</h2>
|
||||||
|
<div class="table"><table>
|
||||||
|
<tr><th>Game</th><th>Service</th><th>What and why</th><th>Your choice</th></tr>
|
||||||
|
<tr><td>Example Game</td><td>Analytics (e.g. GameAnalytics)</td><td>Random player id, sessions, level events, device model, OS version, country derived from IP: to improve the game.</td><td>Settings > Analytics off</td></tr>
|
||||||
|
<tr><td>Example Game</td><td>Ads (e.g. a mediation SDK and its networks)</td><td>Advertising id (if you allow it), IP, device info, ad interactions: to show and measure ads.</td><td>iOS: tracking prompt; Android: OS ad settings; buy "Remove Ads"</td></tr>
|
||||||
|
<tr><td>Example Game</td><td>App stores</td><td>Purchases are handled by Google Play / the App Store; we never see payment details.</td><td>-</td></tr>
|
||||||
|
</table></div>
|
||||||
|
|
||||||
|
<h2>Children</h2>
|
||||||
|
<p>Our games are for a general audience and are not directed at children under 13. We do not knowingly collect
|
||||||
|
personal data from children. If you believe a child sent us data, contact us and we will delete it.</p>
|
||||||
|
|
||||||
|
<h2>Retention and your rights</h2>
|
||||||
|
<p>Analytics data is kept for up to 24 months. You can ask us for a copy of your data or to delete it by writing
|
||||||
|
to <a href="mailto:privacy@example.com">privacy@example.com</a>; we answer within 30 days. Include the game name and,
|
||||||
|
if you can, the player id shown in Settings.</p>
|
||||||
|
|
||||||
|
<h2>Security</h2>
|
||||||
|
<p>All network traffic of our games uses HTTPS.</p>
|
||||||
|
|
||||||
|
<h2>Changes</h2>
|
||||||
|
<p>When a game starts collecting something new we update this page and its date first.</p>
|
||||||
|
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<p>Example Studio · <a href="mailto:privacy@example.com">privacy@example.com</a></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="tr" lang="tr">
|
||||||
|
<h1>Gizlilik Politikası</h1>
|
||||||
|
<p class="meta">Example Studio · tüm oyunlarımız için geçerlidir · son güncelleme 2026-01-01</p>
|
||||||
|
<p>Küçük oyunlar yapıyoruz. Olabildiğince az veri topluyoruz, kişisel verileri asla satmıyoruz ve hiçbir oyunumuz
|
||||||
|
hesap istemiyor. Yukarıdaki tablo her oyunun neyi neden topladığını gösterir.</p>
|
||||||
|
<h2>Haklarınız</h2>
|
||||||
|
<p>Verilerinizin bir kopyasını veya silinmesini <a href="mailto:privacy@example.com">privacy@example.com</a> adresine
|
||||||
|
yazarak isteyebilirsiniz; 30 gün içinde yanıtlıyoruz.</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
32
templates/shots-copy.json
Normal file
32
templates/shots-copy.json
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
{
|
||||||
|
"en": {
|
||||||
|
"hero": { "title": ["Tap. Match.", "Repeat!"], "badges": [["100+ levels", "and more every update"], ["Play offline", "No account needed"]] },
|
||||||
|
"play": { "eyebrow": "Play", "title": ["Easy to learn,", "hard to put down"], "sub": "One thumb, short rounds, big satisfying moments.", "badges": [["Quick rounds", "Two minutes each"], ["Great!"]] },
|
||||||
|
"levels": { "eyebrow": "Worlds", "title": ["Explore new", "worlds"], "sub": "A fresh look and new rules every ten levels.", "badges": [["10 worlds", "A new one every 10 levels"]] },
|
||||||
|
"stars": { "eyebrow": "Stars", "title": ["Play smart,", "grab 3 stars"], "sub": "Finish with moves to spare and collect every star.", "badges": [["3 stars"]] }
|
||||||
|
},
|
||||||
|
"tr": {
|
||||||
|
"hero": { "title": ["Dokun. Eşleştir.", "Tekrarla!"], "badges": [["100+ bölüm", "her güncellemede yenileri"], ["İnternetsiz", "hesap gerekmez"]] },
|
||||||
|
"play": { "eyebrow": "Oyna", "title": ["Öğrenmesi kolay,", "bırakması zor"], "sub": "Tek parmak, kısa turlar, büyük keyifli anlar.", "badges": [["Kısa turlar", "Her biri iki dakika"], ["Harika!"]] },
|
||||||
|
"levels": { "eyebrow": "Dünyalar", "title": ["Yeni dünyaları", "keşfet"], "sub": "Her on bölümde yeni bir görünüm ve yeni kurallar.", "badges": [["10 dünya", "Her 10 bölümde yeni biri"]] },
|
||||||
|
"stars": { "eyebrow": "Yıldızlar", "title": ["Akıllı oyna,", "3 yıldızı kap"], "sub": "Hamle artırarak bitir, tüm yıldızları topla.", "badges": [["3 yıldız"]] }
|
||||||
|
},
|
||||||
|
"es": {
|
||||||
|
"hero": { "title": ["Toca. Combina.", "¡Repite!"], "badges": [["100+ niveles", "y más en cada versión"], ["Sin conexión", "Sin cuenta"]] },
|
||||||
|
"play": { "eyebrow": "Juega", "title": ["Fácil de aprender,", "difícil de soltar"], "sub": "Un pulgar, rondas cortas, grandes momentos.", "badges": [["Rondas cortas", "Dos minutos cada una"], ["¡Genial!"]] },
|
||||||
|
"levels": { "eyebrow": "Mundos", "title": ["Explora", "nuevos mundos"], "sub": "Un aspecto nuevo y nuevas reglas cada diez niveles.", "badges": [["10 mundos", "Uno nuevo cada 10 niveles"]] },
|
||||||
|
"stars": { "eyebrow": "Estrellas", "title": ["Juega con cabeza,", "logra 3 estrellas"], "sub": "Termina con movimientos de sobra y consigue todas las estrellas.", "badges": [["3 estrellas"]] }
|
||||||
|
},
|
||||||
|
"ja": {
|
||||||
|
"hero": { "title": ["タップして、そろえて、", "もう一回!"], "badges": [["100以上のステージ", "アップデートで追加"], ["オフラインで遊べる", "アカウント不要"]] },
|
||||||
|
"play": { "eyebrow": "プレイ", "title": ["かんたんなのに", "やめられない"], "sub": "片手で遊べる短いラウンド、気持ちいい瞬間がたくさん。", "badges": [["短いラウンド", "1回2分"], ["すごい!"]] },
|
||||||
|
"levels": { "eyebrow": "ワールド", "title": ["新しいワールドを", "冒険しよう"], "sub": "10ステージごとに新しい見た目と新しいルール。", "badges": [["10のワールド", "10ステージごとに追加"]] },
|
||||||
|
"stars": { "eyebrow": "スター", "title": ["かしこく遊んで", "星を3つゲット"], "sub": "手数を残してクリアし、すべての星を集めよう。", "badges": [["星3つ"]] }
|
||||||
|
},
|
||||||
|
"ar": {
|
||||||
|
"hero": { "title": ["انقر. طابق.", "كرّر!"], "badges": [["أكثر من 100 مرحلة", "والمزيد مع كل تحديث"], ["العب دون اتصال", "دون حساب"]] },
|
||||||
|
"play": { "eyebrow": "العب", "title": ["سهلة التعلّم،", "صعبة التوقف"], "sub": "إبهام واحد، جولات قصيرة، ولحظات ممتعة.", "badges": [["جولات قصيرة", "دقيقتان لكل جولة"], ["رائع!"]] },
|
||||||
|
"levels": { "eyebrow": "العوالم", "title": ["استكشف", "عوالم جديدة"], "sub": "شكل جديد وقواعد جديدة كل عشر مراحل.", "badges": [["10 عوالم", "عالم جديد كل 10 مراحل"]] },
|
||||||
|
"stars": { "eyebrow": "النجوم", "title": ["العب بذكاء،", "واجمع 3 نجوم"], "sub": "أنهِ المرحلة بحركات متبقية واجمع كل النجوم.", "badges": [["3 نجوم"]] }
|
||||||
|
}
|
||||||
|
}
|
||||||
175
tests/pure.test.ts
Normal file
175
tests/pure.test.ts
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { parseArgs } from '../cli/lib/args';
|
||||||
|
import { loadConfig, validateConfig } from '../cli/lib/config';
|
||||||
|
import { parseAppStoreListing, parsePlayListing } from '../cli/lib/copy';
|
||||||
|
import { dataSafetyCsv, dataSafetyRows } from '../cli/lib/data-safety';
|
||||||
|
import { ALPHA3, appleTerritory, fromMoney, nearestPrices, playRegionConfigs, samePrice, toMoney } from '../cli/lib/money';
|
||||||
|
import { maskExpr } from '../cli/icons';
|
||||||
|
import { ffmpegQ } from '../cli/shots/jpeg';
|
||||||
|
import { type CopyFile, copyText, palette, scriptFont, validateCopy } from '../cli/shots/lib';
|
||||||
|
import { numberedImages } from '../cli/play/images';
|
||||||
|
|
||||||
|
const T = join(import.meta.dir, '..', 'templates');
|
||||||
|
const tpl = (f: string) => readFileSync(join(T, f), 'utf8');
|
||||||
|
|
||||||
|
describe('config', () => {
|
||||||
|
test('template config loads and validates', async () => {
|
||||||
|
const l = await loadConfig(join(T, 'crt-ship.config.ts'));
|
||||||
|
expect(l.config.play?.packageName).toBe('com.example.game');
|
||||||
|
expect(l.resolve('store/x.md')).toBe(join(T, 'store/x.md'));
|
||||||
|
});
|
||||||
|
test('reports bad fields', () => {
|
||||||
|
const e = validateConfig({
|
||||||
|
app: { name: 'X', privacyPolicyUrl: 'http://insecure', supportUrl: 'https://a.b', website: 'https://a.b', contactEmail: 'nope' },
|
||||||
|
languages: [{ id: 'en' }, { id: 'en' }],
|
||||||
|
play: { packageName: 'notapackage' },
|
||||||
|
products: [{ id: 'bad id!', type: 'gift', usd: 0, listings: { de: { title: '', description: 'x' } }, overrides: [{ region: 'TUR', currency: 'tl', price: 1 }] }],
|
||||||
|
dataSafety: { collectsData: true, encryptedInTransit: true, deletionRequest: true, data: { DEVICE: { shared: true, collect: [] } } },
|
||||||
|
});
|
||||||
|
const text = e.join('\n');
|
||||||
|
for (const s of ['privacyPolicyUrl', 'contactEmail', 'duplicate "en"', 'packageName', 'products[0].id', 'products[0].type', 'products[0].usd',
|
||||||
|
'listings.de: not in languages', 'region "TUR"', 'currency "tl"', 'dataSafety.data.DEVICE', 'sharing purposes']) expect(text).toContain(s);
|
||||||
|
});
|
||||||
|
test('empty config', () => { expect(validateConfig(null)).toEqual(['config must be an object']); });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('copy parsing', () => {
|
||||||
|
test('play template', () => {
|
||||||
|
const c = parsePlayListing(tpl('listing.md'), 'Example Game');
|
||||||
|
expect(c.map(x => x.lang)).toEqual(['en-US', 'tr-TR']);
|
||||||
|
expect(c[0]!.title).toBe('Example Game');
|
||||||
|
expect(c[1]!.whatsNew).toContain('İlk sürüm');
|
||||||
|
});
|
||||||
|
test('play: one section, several codes, explicit title, limits', () => {
|
||||||
|
const md = '## Español\n<!-- play: es-ES, es-419 -->\n### title\n```\nJuego\n```\n### short\n```\nCorto\n```\n### full\n```\nLargo\n```\n';
|
||||||
|
const c = parsePlayListing(md, 'X');
|
||||||
|
expect(c.map(x => [x.lang, x.title])).toEqual([['es-ES', 'Juego'], ['es-419', 'Juego']]);
|
||||||
|
expect(() => parsePlayListing(md.replace('Corto', 'x'.repeat(81)), 'X')).toThrow('short is 81 > 80');
|
||||||
|
expect(() => parsePlayListing('## A\n<!-- play: en-US -->\n### short\n```\na\n```\n', 'X')).toThrow('no "### ... full ..." block');
|
||||||
|
});
|
||||||
|
test('play: emoji count as one character', () => {
|
||||||
|
const md = `## A\n<!-- play: en-US -->\n### short\n\`\`\`\n${'é'.repeat(80)}\n\`\`\`\n### full\n\`\`\`\nx\n\`\`\`\n`;
|
||||||
|
expect(parsePlayListing(md, 'X')[0]!.short.length).toBe(80);
|
||||||
|
});
|
||||||
|
test('appstore template', () => {
|
||||||
|
const c = parseAppStoreListing(tpl('appstore.md'));
|
||||||
|
expect(Object.keys(c)).toEqual(['en-US', 'tr']);
|
||||||
|
expect(c['en-US']!.subtitle).toBe('Short hook under the name');
|
||||||
|
});
|
||||||
|
test('appstore: missing field, keyword spaces, byte limit', () => {
|
||||||
|
const sec = (kw: string) => `## de-DE\n### subtitle\n\`\`\`\nA\n\`\`\`\n### promotionalText\n\`\`\`\nB\n\`\`\`\n### keywords\n\`\`\`\n${kw}\n\`\`\`\n### description\n\`\`\`\nD\n\`\`\`\n`;
|
||||||
|
expect(parseAppStoreListing(sec('a,b'))['de-DE']!.keywords).toBe('a,b');
|
||||||
|
expect(() => parseAppStoreListing(sec('a, b'))).toThrow('no spaces after commas');
|
||||||
|
expect(() => parseAppStoreListing(sec('ö'.repeat(60)))).toThrow('bytes > 100');
|
||||||
|
expect(() => parseAppStoreListing('## fr-FR\n### subtitle\n```\nx\n```\n')).toThrow('fr-FR: missing promotionalText');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('data safety csv', () => {
|
||||||
|
const ds = { collectsData: true, encryptedInTransit: true, deletionRequest: false,
|
||||||
|
data: { PSL_DEVICE_ID: { shared: true, collect: ['analytics' as const, 'advertising' as const], share: ['advertising' as const] },
|
||||||
|
PSL_OTHER_PERSONAL: { shared: false, optional: true, collect: ['functionality' as const] } } };
|
||||||
|
const rows = dataSafetyRows(ds);
|
||||||
|
const find = (q: string, r: string) => rows.find(x => x[0] === q && x[1] === r);
|
||||||
|
test('header + overview + account creation', () => {
|
||||||
|
expect(rows[0]![0]).toBe('Question ID (machine readable)');
|
||||||
|
expect(find('PSL_DATA_COLLECTION_USER_REQUEST_DELETE', '')![2]).toBe('FALSE');
|
||||||
|
expect(find('PSL_SUPPORTED_ACCOUNT_CREATION_METHODS', 'PSL_ACM_NONE')![2]).toBe('TRUE');
|
||||||
|
});
|
||||||
|
test('types and purposes', () => {
|
||||||
|
expect(find('PSL_DATA_TYPES_IDENTIFIERS', 'PSL_DEVICE_ID')![2]).toBe('TRUE');
|
||||||
|
expect(find('PSL_DATA_TYPES_LOCATION', 'PSL_PRECISE_LOCATION')![2]).toBe('');
|
||||||
|
const q = (t: string, s: string) => `PSL_DATA_USAGE_RESPONSES:${t}:${s}`;
|
||||||
|
expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_COLLECTION_PURPOSE'), 'PSL_ANALYTICS')![2]).toBe('TRUE');
|
||||||
|
expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_SHARING_PURPOSE'), 'PSL_ANALYTICS')![2]).toBe('');
|
||||||
|
expect(find(q('PSL_DEVICE_ID', 'DATA_USAGE_SHARING_PURPOSE'), 'PSL_ADVERTISING')![2]).toBe('TRUE');
|
||||||
|
expect(find(q('PSL_DEVICE_ID', 'PSL_DATA_USAGE_EPHEMERAL'), '')![2]).toBe('FALSE');
|
||||||
|
expect(find(q('PSL_OTHER_PERSONAL', 'DATA_USAGE_USER_CONTROL'), 'PSL_DATA_USAGE_USER_CONTROL_OPTIONAL')![2]).toBe('TRUE');
|
||||||
|
expect(find(q('PSL_OTHER_PERSONAL', 'PSL_DATA_USAGE_COLLECTION_AND_SHARING'), 'PSL_DATA_USAGE_ONLY_SHARED')![2]).toBe('');
|
||||||
|
expect(find(q('PSL_CONTACTS', 'PSL_DATA_USAGE_EPHEMERAL'), '')![2]).toBe('');
|
||||||
|
});
|
||||||
|
test('every row has 5 cells; quoting; unknown type', () => {
|
||||||
|
const csv = dataSafetyCsv(ds);
|
||||||
|
expect(csv.endsWith('\n')).toBe(true);
|
||||||
|
expect(csv).toContain('"Does your app collect or share any of the required user data types?"'.slice(1, -1));
|
||||||
|
for (const r of rows) expect(r.length).toBe(5);
|
||||||
|
expect(() => dataSafetyRows({ ...ds, data: { PSL_NOPE: { shared: false, collect: ['analytics'] } } })).toThrow('unknown data type');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prices', () => {
|
||||||
|
test('toMoney without float noise', () => {
|
||||||
|
expect(toMoney('USD', 2.99)).toEqual({ currencyCode: 'USD', units: '2', nanos: 990000000 });
|
||||||
|
expect(toMoney('TRY', 129.99)).toEqual({ currencyCode: 'TRY', units: '129', nanos: 990000000 });
|
||||||
|
expect(toMoney('JPY', 300)).toEqual({ currencyCode: 'JPY', units: '300', nanos: 0 });
|
||||||
|
expect(fromMoney(toMoney('EUR', 0.29))).toBeCloseTo(0.29);
|
||||||
|
expect(() => toMoney('USD', -1)).toThrow();
|
||||||
|
});
|
||||||
|
test('play region configs with overrides', () => {
|
||||||
|
const conv = { a: { regionCode: 'US', price: toMoney('USD', 2.99) }, b: { regionCode: 'TR', price: toMoney('TRY', 109.99) }, c: { regionCode: 'DE', price: toMoney('EUR', 2.99) } };
|
||||||
|
const r = playRegionConfigs(conv, [{ region: 'TR', currency: 'TRY', price: 99.99 }]);
|
||||||
|
expect(r.map(x => x.regionCode)).toEqual(['DE', 'TR', 'US']);
|
||||||
|
expect(r.find(x => x.regionCode === 'TR')!.price).toEqual(toMoney('TRY', 99.99));
|
||||||
|
expect(r.every(x => x.availability === 'AVAILABLE')).toBe(true);
|
||||||
|
expect(() => playRegionConfigs(conv, [{ region: 'XX', currency: 'USD', price: 1 }])).toThrow('region XX');
|
||||||
|
});
|
||||||
|
test('apple price matching + territories', () => {
|
||||||
|
expect(samePrice('2.99', 2.99)).toBe(true);
|
||||||
|
expect(samePrice('30.0', 30)).toBe(true);
|
||||||
|
expect(samePrice('2.98', 2.99)).toBe(false);
|
||||||
|
expect(nearestPrices([1.99, 2.49, 2.99, 3.49], 2.89, 2)).toEqual([2.99, 2.49]);
|
||||||
|
expect(appleTerritory({ region: 'TR', currency: 'TRY', price: 1 })).toBe('TUR');
|
||||||
|
expect(appleTerritory({ region: 'GB', currency: 'GBP', price: 1 })).toBe('GBR');
|
||||||
|
expect(appleTerritory({ region: 'ZZ', territory: 'ZZZ', currency: 'USD', price: 1 })).toBe('ZZZ');
|
||||||
|
expect(() => appleTerritory({ region: 'ZZ', currency: 'USD', price: 1 })).toThrow('set "territory"');
|
||||||
|
expect(Object.keys(ALPHA3).length).toBeGreaterThan(150);
|
||||||
|
for (const [k, v] of Object.entries(ALPHA3)) { expect(k).toMatch(/^[A-Z]{2}$/); expect(v).toMatch(/^[A-Z]{3}$/); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('screenshots', () => {
|
||||||
|
const copy = JSON.parse(tpl('shots-copy.json')) as CopyFile;
|
||||||
|
test('script fonts', () => {
|
||||||
|
expect(scriptFont('en', 'Hello')).toBeNull();
|
||||||
|
expect(scriptFont('tr', 'Dokun. Eşleştir.')).toBeNull();
|
||||||
|
expect(scriptFont('ja', copyText('ja', copy.ja))!.slug).toBe('jp');
|
||||||
|
expect(scriptFont('ar', copyText('ar', copy.ar))!.slug).toBe('arabic');
|
||||||
|
expect(scriptFont('ko', '안녕')!.slug).toBe('kr');
|
||||||
|
expect(scriptFont('zh-Hans', '你好')!.slug).toBe('sc');
|
||||||
|
expect(scriptFont('zh-Hant', '你好')!.slug).toBe('tc');
|
||||||
|
expect(scriptFont('ru', 'Привет')!.slug).toBe('cyrgreek');
|
||||||
|
expect(scriptFont('hi', 'नमस्ते')!.slug).toBe('devanagari');
|
||||||
|
});
|
||||||
|
test('template copy fits the template scenes', async () => {
|
||||||
|
const { config } = await loadConfig(join(T, 'crt-ship.config.ts'));
|
||||||
|
expect(validateCopy(copy, config.shots!, config.languages.map(l => l.id))).toEqual([]);
|
||||||
|
const broken = { en: { ...copy.en, play: { ...copy.en!.play!, badges: [['a'], ['b'], ['c']] } } } as CopyFile;
|
||||||
|
expect(validateCopy(broken, config.shots!, ['en', 'de']).join('\n')).toMatch(/3 badges but the scene has 2 slots[\s\S]*de: no copy/);
|
||||||
|
});
|
||||||
|
test('palettes', () => {
|
||||||
|
expect(palette('gold').ink).toBe('#6b2c00');
|
||||||
|
expect(() => palette('nope')).toThrow('unknown palette');
|
||||||
|
});
|
||||||
|
test('jpeg quality mapping', () => { expect(ffmpegQ(100)).toBe(2); expect(ffmpegQ(92)).toBe(4); expect(ffmpegQ(0)).toBe(31); });
|
||||||
|
test('numbered images prefer jpeg', () => {
|
||||||
|
expect(numberedImages('/nonexistent')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('icons + args', () => {
|
||||||
|
test('mask expressions', () => {
|
||||||
|
expect(maskExpr('circle', 192)).toBe('if(lte(hypot(X-96+0.5,Y-96+0.5),96),255,0)');
|
||||||
|
expect(maskExpr('squircle', 100)).toContain('22');
|
||||||
|
});
|
||||||
|
test('args', () => {
|
||||||
|
const a = parseArgs(['file.aab', '--production', 'draft', '--dry', '--only=a,b', '--internal']);
|
||||||
|
expect(a._).toEqual(['file.aab']);
|
||||||
|
expect(a.get('production')).toBe('draft');
|
||||||
|
expect(a.has('dry')).toBe(true);
|
||||||
|
expect(a.list('only')).toEqual(['a', 'b']);
|
||||||
|
expect(a.get('internal')).toBe('true');
|
||||||
|
expect(() => parseArgs(['--jobs', 'x']).num('jobs', 1)).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"types": ["bun"],
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["cli", "tests", "templates/crt-ship.config.ts"]
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue