storebound/cli/lib/scan/play-rules.ts

447 lines
45 KiB
TypeScript

// Google Play policy rules for `storebound scan` (rejections, update blocks, suspensions and policy strikes), next to
// the App Store rules in rules.ts. Pure, like rules.ts: project facts + config + listings in, findings out. A rule only
// reports what a file or the config proves; where the policy turns on something no file shows (the app's core purpose,
// what happens at runtime) the finding says what to check and which Play Console declaration it needs.
//
// Sources, each fetched live and checked 2026-10-11 (Play Console Help / Policy Center, developer.android.com):
// - Permissions and APIs that Access Sensitive Information: support.google.com/googleplay/android-developer/answer/9888170
// (index of the restricted permissions below).
// - Background location: answer/9799150 "Understanding location in the background permissions": core feature only,
// App content > Sensitive app permissions > Location permissions declaration with a video (30 s or less), a
// prominent disclosure before the runtime prompt that says "location" and "background" / "when the app is closed".
// - Photo and video permissions: answer/14115180: READ_MEDIA_IMAGES / READ_MEDIA_VIDEO only when the system photo
// picker is not enough for the app's core purpose (gallery-type apps), with a declaration; enforced since 2025-05-28.
// - All files access: answer/10467955: MANAGE_EXTERNAL_STORAGE only for file managers, backup and restore,
// anti-virus, document management, on-device search, disk encryption, device migration; declaration form.
// - AccessibilityService API: answer/10964491: isAccessibilityTool only for tools for people with disabilities; every
// other use needs an in-app disclosure, affirmative consent and the declaration (with a video).
// - REQUEST_INSTALL_PACKAGES: answer/12085295: core function must be sending / receiving packages and user-initiated
// installs; Permissions Declaration Form.
// - Health Connect: answer/12991134 (android.permission.health.*: each permission justified, permitted use cases,
// privacy policy) and the Health apps declaration every app completes: answer/14738291.
// - Full-screen intent: answer/13392821: on Android 14+ granted by default only to calling and alarm apps; App
// content declaration since 2024-05-31; other apps ask the user and handle a denial.
// - VpnService: answer/12564964: VPN as the core function (or the listed exceptions), disclosure + consent, declaration.
// - Device admin (BIND_DEVICE_ADMIN): no Play policy page covers it (checked the pages above, Device and Network Abuse
// answer/9888379 and User Data answer/10144311): no rule, nothing invented.
// - User Data: answer/10144311: prominent disclosure in the app, during normal use, right before the runtime request,
// describing the data and its use (not only in the privacy policy); a privacy policy for every app, in Play Console
// and inside the app; Data safety must include what SDKs collect.
// - Families: answer/9893335 (only Families Self-Certified Ads SDKs for children; child-only apps do not transmit the
// advertising ID and should not request AD_ID on API 33+; child-only apps may not request location) and the
// certified list answer/9859768 (in sdks.ts `families`; AppLovin left the program, Meta Audience Network is not on it).
// - Advertising ID: answer/6048248: apps targeting Android 13+ that use the advertising ID declare
// com.google.android.gms.permission.AD_ID (Google Mobile Ads merges it from its own manifest).
// - 16 KB page sizes: developer.android.com/guide/practices/page-sizes: apps targeting Android 15+ must support 16 KB
// pages on 64-bit devices; from 2027-02-01 updates that don't cannot be released. AGP 8.5.1+ zip-aligns uncompressed
// .so files (older: useLegacyPackaging), NDK r28+ aligns by default (r27 and older: flags). Unity: 6000.0.38f1,
// 2022.3.56f1, 2021.3.48f1, 6000.1+ (discussions.unity.com/t/info-unity-engine-support-for-16-kb-memory-page-sizes-android-15/1589588).
// React Native: 0.77 (reactnative.dev/blog/2025/01/21/version-0.77). Flutter: no official version found, not checked.
// - Testing for new personal developer accounts: answer/14151465: accounts created after 2023-11-13 run a closed test
// with at least 12 opted-in testers for 14 days in a row before applying for production.
// - Metadata: answer/9898842: title 30 characters or less; no emoji, emoticons or repeated special characters; no
// rank / performance / price / promotion / Play program terms ("#1", "best", "top", "popular", "free", "new", "sale",
// "% off") in the title, icon or developer name; avoid ALL CAPS unless it is the brand; no repeated or unrelated
// keywords, no comparisons with other apps, no other brands without permission.
// - Ads: answer/9857753 (no interstitials that show unexpectedly, before the splash screen or while the user does
// something else) and Better Ads Experiences answer/12271244 (apps without a splash screen wait for the home screen).
// Statically visible: an interstitial shown from the launcher activity's onCreate (Android native). Unity Start(),
// Flutter and React Native launch paths are not reliable from files: review-only.
// - Payments: answer/9858738 (Play Billing for digital goods; physical goods and services exempt; no steering outside
// user choice billing answer/13821247, external offers answer/14372887, US external content links answer/16470497).
// - Impersonation: answer/9888374 (don't imply a relation with someone you are not, e.g. in the title or developer
// name). The brand list below is storebound's own short list of well-known app brands, reported as low "check".
// Play Integrity / anti-tamper is not a policy: no rule.
import { consoleLink } from '../console-links';
import { PLAY_AGE_GROUPS } from '../config';
import { allSubscriptions } from '../subscriptions';
import { grep, type Hit, type ProjectFacts } from './project';
import type { ScanFinding, ScanInput } from './rules';
import { versionAtLeast } from './sdks';
const PLAY = 'https://support.google.com/googleplay/android-developer/answer/';
export const PLAY_REFS = {
sensitive: `${PLAY}9888170`, backgroundLocation: `${PLAY}9799150`, photoVideo: `${PLAY}14115180`, allFiles: `${PLAY}10467955`,
accessibility: `${PLAY}10964491`, installPackages: `${PLAY}12085295`, healthConnect: `${PLAY}12991134`, healthDeclaration: `${PLAY}14738291`,
fullScreenIntent: `${PLAY}13392821`, vpn: `${PLAY}12564964`, userData: `${PLAY}10144311`, families: `${PLAY}9893335`, familiesSdks: `${PLAY}9859768`,
adId: `${PLAY}6048248`, pageSize: 'https://developer.android.com/guide/practices/page-sizes', testing: `${PLAY}14151465`, metadata: `${PLAY}9898842`,
ads: `${PLAY}9857753`, betterAds: `${PLAY}12271244`, payments: `${PLAY}9858738`, impersonation: `${PLAY}9888374`,
} as const;
/** From this date Play refuses updates that don't support 16 KB pages (developer.android.com/guide/practices/page-sizes). */
export const PAGE_SIZE_DEADLINE = '2027-02-01';
/** Personal accounts created after this date have the closed-testing requirement (answer/14151465). */
export const PERSONAL_TESTING_SINCE = '2023-11-13';
export const PERSONAL_TESTING = { testers: 12, days: 14 } as const;
/** First Unity version of each stream with 16 KB page size support (Unity forum post above); older streams have none. */
export const UNITY_16KB: Record<string, string> = { '2021.3': '2021.3.48f1', '2022.3': '2022.3.56f1', '6000.0': '6000.0.38f1' };
const AD_ID = 'com.google.android.gms.permission.AD_ID';
const BRANDS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'pinterest', 'chatgpt', 'openai',
'minecraft', 'roblox', 'fortnite', 'pubg', 'pokemon', 'duolingo', 'gmail', 'google', 'playstation', 'xbox', 'nintendo'];
const OTHER_APPS = ['instagram', 'tiktok', 'whatsapp', 'facebook', 'snapchat', 'youtube', 'netflix', 'spotify', 'telegram', 'messenger', 'pinterest', 'uber', 'airbnb',
'chatgpt', 'openai', 'minecraft', 'roblox', 'fortnite', 'pubg', 'candy crush', 'clash of clans', 'pokemon', 'pokémon', 'among us', 'subway surfers', 'temple run',
'angry birds', 'tetris', 'wordle', 'duolingo', 'gmail', 'google maps', 'waze', 'shazam', 'tinder', 'evernote', 'photoshop', 'capcut'];
const CLAIMS = /#\s?1\b|\bno\.? ?1\b|\bnumber one\b|\bbest\b|\btop\b|\bpopular\b|app of the year|game of the year|best of play|editor'?s'? choice|\bsale\b|\bdiscount\b|\d+\s?% off|cash ?back|limited time|\$\d/i;
/** "free" and "new" are only examples in the policy as promotion ("free for limited time only") and Play program
* ("New") terms; a plain "free" or "new" can be part of a real name, so they are a low "check". */
const SOFT_CLAIMS = /\bfree\b|\bnew\b/i;
const EMOJI = /\p{Extended_Pictographic}|[:;]-?[)(DP]|\(\s*[╯°□・ω^]+|([!?*★☆♥✓✔•~_=+|-])\1{1,}/u;
const DISCLOSURE = /shouldShowRequestPermissionRationale|PermissionRationale|prominent[_ ]?disclosure|data[_ ]?disclosure|\b(collects?|transmits?|syncs?|stores?|shares?|uses?)\b[^"<\n]{0,80}\b(location|contacts?|calendar|phone numbers?)\b|\b(location|contacts?|calendar)\b[^"<\n]{0,80}\b(collected|transmitted|synced|stored|shared|used)\b/i;
const BACKGROUND_DISCLOSURE = /location[^"<\n]{0,160}(background|when the app is closed|not in use|always in use)|(background|when the app is closed|not in use|always in use)[^"<\n]{0,160}location/i;
const EXTERNAL_BUY = /\b(buy|subscribe|purchase|upgrade|pay)\b[^.\n]{0,40}\b(on|at|via|from) (our )?(website|web ?site|web|site)\b/i;
const SENSITIVE_DATA: Record<string, string> = {
ACCESS_FINE_LOCATION: 'location', ACCESS_COARSE_LOCATION: 'location', ACCESS_BACKGROUND_LOCATION: 'location', READ_CONTACTS: 'contacts',
READ_CALENDAR: 'calendar', READ_PHONE_NUMBERS: 'phone numbers',
};
type Add = (f: Omit<ScanFinding, 'ref' | 'store'> & { ref: string }) => void;
export interface PlayCtx {
add: Add;
cfg: (needle: string | RegExp, text?: string) => Hit[];
sdkHits: (pred: (s: ProjectFacts['sdks'][number]) => boolean) => Hit[];
listingHits: (file: { path: string; text: string } | undefined, re: RegExp, o?: { fenced?: boolean; field?: RegExp }) => Hit[];
/** Account creation evidence (rules.ts), restorable products / subscriptions, payment SDKs. */
creates: Hit[];
restorable: Hit[];
paySdk: Hit[];
}
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const permName = (n: string) => n.replace(/^android\.permission\./, '');
const ev = (x: { at: { file: string; line: number } }, text: string): Hit => ({ ...x.at, text });
/** Text of the first fenced block under every heading matching `field`. */
function fieldBlocks(file: { path: string; text: string } | undefined, field: RegExp): { at: Hit; text: string }[] {
if (!file) return [];
const out: { at: Hit; text: string }[] = [];
const lines = file.text.split('\n');
for (let i = 0; i < lines.length; i++) {
if (!/^###\s/.test(lines[i]!) || !field.test(lines[i]!.slice(4))) continue;
let j = i + 1;
while (j < lines.length && !/^```/.test(lines[j]!) && !/^##/.test(lines[j]!)) j++;
if (j >= lines.length || !/^```/.test(lines[j]!)) continue;
let k = j + 1;
while (k < lines.length && !/^```/.test(lines[k]!)) k++;
out.push({ at: { file: file.path, line: j + 2, text: (lines[j + 1] ?? '').trim().slice(0, 160) }, text: lines.slice(j + 1, k).join('\n') });
}
return out;
}
// function words of the listing languages (articles, pronouns, prepositions, conjunctions): repeating them is grammar,
// not keyword stuffing (a German description used "eine" 10x and was flagged)
const STOP = new Set([
'app', 'apps', 'game', 'games', 'spiel', 'spiele', 'jeu', 'jeux', 'juego', 'juegos', 'gioco', 'giochi', 'jogo', 'jogos', 'oyun', 'spel',
'the', 'and', 'for', 'with', 'your', 'you', 'that', 'this', 'from', 'have', 'are', 'will', 'can', 'all', 'more', 'just', 'into', 'every', 'each', 'one', 'when', 'what', 'they', 'them', 'their', 'our', 'out', 'not', 'any', 'also',
// de
'eine', 'einen', 'einem', 'einer', 'eines', 'der', 'die', 'das', 'dem', 'den', 'des', 'und', 'oder', 'aber', 'mit', 'für', 'von', 'auf', 'aus', 'bei', 'nach', 'über', 'unter', 'durch', 'ohne', 'gegen', 'dein', 'deine', 'deinen', 'deinem', 'deiner', 'ihre', 'ihren', 'ihr', 'sie', 'wir', 'ist', 'sind', 'wird', 'werden', 'kann', 'kannst', 'können', 'nicht', 'auch', 'noch', 'wie', 'was', 'wenn', 'dass', 'diese', 'dieser', 'dieses', 'jede', 'jeden', 'jeder', 'jedes', 'alle', 'mehr', 'sich', 'dich', 'dir', 'uns', 'zum', 'zur', 'im', 'ins', 'vom', 'beim', 'hat', 'hast', 'haben', 'bist', 'sein', 'seine',
// fr
'les', 'des', 'une', 'pour', 'avec', 'dans', 'sur', 'par', 'pas', 'plus', 'vous', 'votre', 'vos', 'nous', 'notre', 'nos', 'ton', 'ta', 'tes', 'est', 'sont', 'qui', 'que', 'quoi', 'mais', 'ou', 'chaque', 'tout', 'tous', 'toutes', 'toute', 'cette', 'ces', 'son', 'ses', 'leur', 'leurs', 'aux', 'du',
// es / pt / it
'una', 'uno', 'unos', 'unas', 'los', 'las', 'del', 'con', 'para', 'por', 'sin', 'sobre', 'entre', 'como', 'más', 'tus', 'tu', 'sus', 'que', 'cada', 'todo', 'todos', 'todas', 'esta', 'este', 'estos', 'estas', 'nuestro', 'nuestra', 'puedes', 'puede', 'pero', 'cuando',
'uma', 'umas', 'uns', 'dos', 'das', 'com', 'pelo', 'pela', 'seu', 'sua', 'seus', 'suas', 'você', 'mais', 'muito', 'isso', 'essa', 'esse', 'pode', 'quando', 'também', 'também',
'gli', 'della', 'delle', 'dello', 'degli', 'nel', 'nella', 'nelle', 'con', 'per', 'tra', 'fra', 'tuo', 'tua', 'tuoi', 'tue', 'ogni', 'tutto', 'tutti', 'tutte', 'questo', 'questa', 'sono', 'anche', 'quando', 'puoi', 'molto',
// nl
'een', 'het', 'van', 'voor', 'met', 'naar', 'jouw', 'jij', 'je', 'zijn', 'wordt', 'niet', 'ook', 'elke', 'alle', 'meer', 'deze', 'dit', 'als', 'maar', 'kan', 'kunt',
// tr
'bir', 've', 'ile', 'için', 'bu', 'şu', 'her', 'daha', 'çok', 'gibi', 'kadar', 'olan', 'olarak', 'veya', 'ama', 'sen', 'senin', 'sana', 'seni', 'biz', 'bizim', 'onu', 'onun', 'tüm', 'bütün', 'hem', 'da', 'de', 'ki',
]);
export function playRules(inp: ScanInput, x: PlayCtx): void {
const { facts: p, config: c } = inp;
const { add, cfg, sdkHits } = x;
const a = p.androidInfo;
const android = p.android;
const perms = new Map(a.permissions.map(q => [permName(q.name), q] as const));
const removed = new Map(a.removedPermissions.map(q => [permName(q.name), q] as const));
const target = a.targetSdk?.value ?? null;
const ages = c?.play?.targetAges ?? [];
const kidsAges = ages.filter(g => PLAY_AGE_GROUPS.indexOf(g) < 3);
const childOnly = ages.length > 0 && kidsAges.length === ages.length;
const mixed = kidsAges.length > 0 && !childOnly;
const textSources = [...p.sources, ...a.strings];
// ---- sensitive permissions and APIs (each needs a Play Console declaration) ----
if (android) {
const bg = perms.get('ACCESS_BACKGROUND_LOCATION');
if (bg)
add({ rule: 'play-background-location', guideline: 'Play: Location permissions', severity: 'high', title: 'ACCESS_BACKGROUND_LOCATION needs a core feature that uses location in the background, the Location permissions declaration and a video',
evidence: [ev(bg, bg.name)], ref: PLAY_REFS.backgroundLocation, skill: 'store-declarations',
fix: 'Keep it only for one core, user-visible feature (never for ads or analytics alone); fill ' + consoleLink('play.sensitive-permissions', '> Location permissions') + ' (one feature, a video of 30 s or less showing the prominent disclosure, the runtime prompt and the feature working in the background), and name the feature in the store description. Otherwise remove it (tools:node="remove" if a library adds it).' });
const media = ['READ_MEDIA_IMAGES', 'READ_MEDIA_VIDEO'].map(n => perms.get(n)).filter(q => !!q);
if (media.length)
add({ rule: 'play-photo-video', guideline: 'Play: Photo and Video Permissions', severity: 'high', title: `${media.map(q => permName(q.name)).join(' / ')}: only for apps whose core purpose needs broad photo / video access; others use the photo picker`,
evidence: media.map(q => ev(q, q.name)), ref: PLAY_REFS.photoVideo, skill: 'store-declarations',
fix: 'Use the Android photo picker (androidx.activity 1.7.0+ PickVisualMedia, no permission) for one-time or occasional picks and remove the permissions; keep them only for gallery-type core features and fill the Photo and video permissions declaration.' });
const mes = perms.get('MANAGE_EXTERNAL_STORAGE');
if (mes)
add({ rule: 'play-all-files-access', guideline: 'Play: All files access', severity: 'high', title: 'MANAGE_EXTERNAL_STORAGE (All files access) is only for file managers, backup, anti-virus, document management, search and similar apps',
evidence: [ev(mes, mes.name)], ref: PLAY_REFS.allFiles, skill: 'store-declarations',
fix: 'Use the Storage Access Framework or MediaStore and remove the permission; apps whose core function needs it fill the All files access declaration and explain why those APIs are not enough.' });
const acc = a.components.filter(q => q.kind === 'service' && (q.permission === 'android.permission.BIND_ACCESSIBILITY_SERVICE' || q.actions.includes('android.accessibilityservice.AccessibilityService')));
if (acc.length) {
const tool = a.accessibilityConfigs.find(q => q.isTool);
add({ rule: 'play-accessibility', guideline: 'Play: AccessibilityService API', severity: tool ? 'medium' : 'high',
title: tool ? 'AccessibilityService marked isAccessibilityTool: only for tools whose primary purpose is helping people with disabilities' : `AccessibilityService ${acc[0]!.name} needs an in-app disclosure, affirmative consent and the AccessibilityService declaration`,
evidence: [...acc.slice(0, 2).map(q => ev(q, `service ${q.name}`)), ...(tool ? [ev(tool, 'android:isAccessibilityTool="true"')] : a.accessibilityConfigs.slice(0, 1).map(q => ev(q, 'accessibility-service')))], ref: PLAY_REFS.accessibility, skill: 'store-declarations',
fix: tool ? 'Keep isAccessibilityTool only for screen readers, switch, voice or braille input and similar tools (not automation, antivirus, launchers, cleaners, password managers); otherwise remove it and add the disclosure and consent. Fill the AccessibilityService declaration either way.'
: 'Show a disclosure during normal use that says what the service accesses and why, get an affirmative "accept", keep it separate from other disclosures, and fill the AccessibilityService declaration with a video (open the app, disclosure, accept and decline, the feature). Autonomous AI-style actions through the API are not allowed.' });
}
const rip = perms.get('REQUEST_INSTALL_PACKAGES');
if (rip)
add({ rule: 'play-install-packages', guideline: 'Play: REQUEST_INSTALL_PACKAGES', severity: 'high', title: 'REQUEST_INSTALL_PACKAGES is only for apps whose core function is sending or receiving app packages for user-initiated installs',
evidence: [ev(rip, rip.name)], ref: PLAY_REFS.installPackages, skill: 'store-declarations',
fix: 'Remove it (self-updates must go through Play); browsers, file managers, messengers with attachments, backup and enterprise apps keep it and fill the Permissions Declaration Form.' });
const health = a.permissions.filter(q => /^android\.permission\.health\./.test(q.name));
if (health.length)
add({ rule: 'play-health-connect', guideline: 'Play: Health Connect permissions', severity: 'high', title: `Health Connect permissions (${health.slice(0, 3).map(q => permName(q.name).replace(/^health\./, '')).join(', ')}${health.length > 3 ? ', ...' : ''}) need a justification for each one and a permitted use case`,
evidence: health.slice(0, 3).map(q => ev(q, q.name)), ref: PLAY_REFS.healthConnect, skill: 'store-declarations',
fix: 'Request only the data types a feature uses, justify each permission in the Health Connect declaration, show the privacy policy in the app, and answer ' + consoleLink('play.health-apps') + ' (answer/14738291). Ads, data sales and child-only apps are not allowed to use it.' });
const fsi = perms.get('USE_FULL_SCREEN_INTENT');
if (fsi && (target === null || target >= 34))
add({ rule: 'play-full-screen-intent', guideline: 'Play: Full-screen intent', severity: 'high', title: 'USE_FULL_SCREEN_INTENT: on Android 14+ only calling and alarm apps get it by default, and every app declares it in Play Console',
evidence: [ev(fsi, fsi.name)], ref: PLAY_REFS.fullScreenIntent, skill: 'store-declarations',
fix: 'Remove it unless the core function is alarms or phone / video calls (an SDK may add it: check the merged manifest); otherwise fill ' + consoleLink('play.full-screen-intent') + ', ask the user with ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT and handle a denial. Never for ads.' });
const vpn = a.components.filter(q => q.kind === 'service' && (q.permission === 'android.permission.BIND_VPN_SERVICE' || q.actions.includes('android.net.VpnService')));
if (vpn.length)
add({ rule: 'play-vpn', guideline: 'Play: VpnService', severity: 'high', title: `VpnService ${vpn[0]!.name}: only for VPN apps and the listed exceptions, with a disclosure, consent and the VpnService declaration`,
evidence: vpn.slice(0, 2).map(q => ev(q, `service ${q.name}`)), ref: PLAY_REFS.vpn, skill: 'store-declarations',
fix: 'Use VpnService only when it is the core function (or parental control, enterprise, app usage tracking, device security, network tools, browsers); describe it in the listing, encrypt traffic to the tunnel, show an in-app disclosure with affirmative consent, and fill the VpnService declaration (videos of 90 s or less).' });
}
// ---- prominent disclosure (User Data) ----
if (android && (p.sources.length || a.strings.length)) {
const bg = perms.get('ACCESS_BACKGROUND_LOCATION');
const own = (q: { at: { file: string } }) => !a.mergedManifest || q.at.file !== a.mergedManifest;
if (bg && own(bg) && !grep(textSources, BACKGROUND_DISCLOSURE, 1).length)
add({ rule: 'play-prominent-disclosure', guideline: 'Play: User Data (prominent disclosure)', severity: 'high', title: 'Background location without a prominent disclosure that says "location" and "background" / "when the app is closed"',
evidence: [ev(bg, bg.name)], ref: PLAY_REFS.backgroundLocation, skill: 'store-declarations',
fix: 'Before the runtime prompt, show a dialog during normal use, e.g. "<App> collects location data to enable <feature> even when the app is closed or not in use." List every feature that uses it (and ads, if they do). It cannot live only in the privacy policy.' });
const sensitive = [...perms.entries()].filter(([n, q]) => SENSITIVE_DATA[n] && own(q) && n !== 'ACCESS_BACKGROUND_LOCATION');
if (sensitive.length && !grep(textSources, DISCLOSURE, 1).length)
add({ rule: 'play-prominent-disclosure', guideline: 'Play: User Data (prominent disclosure)', severity: 'medium',
title: `${[...new Set(sensitive.map(([n]) => SENSITIVE_DATA[n]))].join(', ')} access without a disclosure or rationale text in the app's sources or strings`,
evidence: sensitive.slice(0, 3).map(([, q]) => ev(q, q.name)), ref: PLAY_REFS.userData, skill: 'store-declarations',
fix: 'When people would not expect the collection, show an in-app disclosure right before the permission request that says which data is collected and how it is used or shared ("<App> collects <data> to enable <feature>, <when>."), and ask for an affirmative accept; the privacy policy alone is not enough.' });
}
// ---- Data safety: SDK disclosures the config's answers miss (additions are the privacy-labels rule in rules.ts) ----
for (const g of inp.dataMerges ?? [])
add({ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', severity: 'medium', title: `Data safety answer for ${g.type} is narrower than what is detected: ${g.missing}`, evidence: g.evidence.slice(0, 2),
ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', skill: 'store-declarations',
fix: 'Check the SDK\'s Data safety guidance (sdks.ts names the page), widen the answer with `storebound scan --write`, then `storebound play data-safety`.' });
if (c?.play && !c.dataSafety && p.sdks.some(s => s.sdk.data.length))
add({ rule: 'privacy-labels', guideline: 'Play: User Data (Data safety)', severity: 'medium', title: 'SDKs that collect data, but the config has no dataSafety answers', evidence: sdkHits(s => s.sdk.data.length > 0).slice(0, 3),
ref: 'https://support.google.com/googleplay/android-developer/answer/10787469', skill: 'store-declarations',
fix: 'Run `storebound scan` and read the declarations proposal, confirm it with each SDK\'s disclosure page, `storebound scan --write`, then `storebound play data-safety`.' });
// ---- privacy policy in the app ----
if (p.sources.length && android) {
const link = grep(textSources, /privacy|gizlilik|datenschutz|confidentialit|privacidad/i, 1);
const url = c?.app.privacyPolicyUrl ? grep(textSources, new RegExp(esc(c.app.privacyPolicyUrl)), 1) : [];
if (!link.length && !url.length) {
const collects = p.sdks.length > 0 || x.creates.length > 0 || [...perms.keys()].some(n => SENSITIVE_DATA[n]);
add({ rule: 'play-privacy-policy', guideline: 'Play: User Data (privacy policy)', severity: collects ? 'medium' : 'low', title: 'No privacy policy link found in the app\'s sources (Play wants it in Play Console and inside the app)',
evidence: [...cfg('privacyPolicyUrl'), ...sdkHits(() => true)].slice(0, 2).concat(p.sources.slice(0, 1).map(s => ({ file: s.path, line: 1, text: 'app sources' }))).slice(0, 3),
ref: PLAY_REFS.userData, skill: 'privacy-policy',
fix: 'Link app.privacyPolicyUrl from inside the app (e.g. Settings > Privacy policy, and the sign-up screen). It must be a public, non-geofenced https page, not a PDF, labelled as a privacy policy.' });
}
const insecure = grep(textSources, /["'>]http:\/\/[^"'<\s]*privacy/i, 1);
if (insecure.length)
add({ rule: 'play-privacy-policy', guideline: 'Play: User Data (privacy policy)', severity: 'medium', title: 'The in-app privacy policy link uses http://', evidence: insecure,
ref: PLAY_REFS.userData, skill: 'privacy-policy', fix: 'Use the https URL (the same as app.privacyPolicyUrl).' });
}
// ---- Families ----
const adSdks = p.sdks.filter(s => s.sdk.category === 'ads');
if (kidsAges.length) {
const agesEv = cfg('targetAges');
for (const s of adSdks) {
const fam = s.sdk.families;
const gradleDep = s.deps.find(d => d.eco === 'gradle' && d.version);
const old = fam?.certified && fam.since && gradleDep ? versionAtLeast(gradleDep.version!, fam.since) === false : false;
if (fam?.certified && !old) continue;
add({ rule: 'play-families-ads', guideline: 'Play: Families policy', severity: 'high',
title: old ? `${s.sdk.name} ${gradleDep!.version} is older than ${fam!.since}, the first Families self-certified version` : `${s.sdk.name} is not a Families Self-Certified Ads SDK (${fam?.note ?? 'not on the list'}), but the target audience includes children`,
evidence: [...sdkHits(q => q.sdk.id === s.sdk.id), ...agesEv].slice(0, 3), ref: PLAY_REFS.familiesSdks, skill: 'ads-mediation-checks',
fix: old ? `Update to ${fam!.since} or newer and turn on child-directed treatment.` : 'Ads shown to children (or users of unknown age) may only come from Families Self-Certified Ads SDKs (AdMob, ironSource / LevelPlay, Unity Ads and others on answer/9859768): remove this SDK and its mediation adapters, or take children out of the target audience.' });
}
const adIdFromSdk = p.sdks.filter(s => s.sdk.mergesAdId);
const adId = perms.get(AD_ID);
if (childOnly && (target === null || target >= 33) && !removed.has(AD_ID) && (adId || adIdFromSdk.length))
add({ rule: 'play-families-ad-id', guideline: 'Play: Families policy', severity: 'high', title: 'App only for children with the advertising ID permission (AD_ID): child-only apps must not transmit the advertising ID',
evidence: [...(adId ? [ev(adId, AD_ID)] : sdkHits(s => !!s.sdk.mergesAdId)), ...agesEv].slice(0, 3), ref: PLAY_REFS.families, skill: 'store-declarations',
fix: 'Remove it from the merged manifest: <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />, turn on child-directed treatment in every ad SDK, and answer the Advertising ID declaration accordingly.' });
else if (mixed && p.sdks.some(s => s.sdk.tracking && (s.sdk.category === 'ads' || s.sdk.category === 'attribution')))
add({ rule: 'play-families-ad-id', guideline: 'Play: Families policy', severity: 'medium', title: 'Mixed audience with ad / attribution SDKs: no advertising ID or interest-based ads for children or users of unknown age',
evidence: [...sdkHits(s => s.sdk.tracking && (s.sdk.category === 'ads' || s.sdk.category === 'attribution')), ...agesEv].slice(0, 3), ref: PLAY_REFS.families, skill: 'ads-mediation-checks',
fix: 'Add a neutral age screen; for children and unknown ages turn on child-directed treatment (no advertising ID, no personalized ads or remarketing) in every SDK before it starts.' });
const loc = ['ACCESS_FINE_LOCATION', 'ACCESS_COARSE_LOCATION', 'ACCESS_BACKGROUND_LOCATION'].map(n => perms.get(n)).filter(q => !!q);
if (childOnly && loc.length)
add({ rule: 'play-families-location', guideline: 'Play: Families policy', severity: 'high', title: 'App only for children requests location', evidence: [...loc.slice(0, 2).map(q => ev(q, q.name)), ...agesEv].slice(0, 3),
ref: PLAY_REFS.families, skill: 'store-declarations', fix: 'Remove the location permissions (tools:node="remove" for ones an SDK adds); apps only for children may not request location.' });
}
// ---- advertising ID permission (Android 13+) ----
if (android && !childOnly && (target === null || target >= 33)) {
const users = p.sdks.filter(s => s.sdk.tracking && (s.sdk.category === 'ads' || s.sdk.category === 'attribution'));
if (users.length && !perms.has(AD_ID) && !removed.has(AD_ID) && !users.some(s => s.sdk.mergesAdId)) {
const merged = !!a.mergedManifest;
add({ rule: 'play-ad-id', guideline: 'Play: Advertising ID', severity: merged ? 'medium' : 'low',
title: merged ? `The merged manifest has no AD_ID permission, but ${users.map(s => s.sdk.name).join(', ')} ${users.length > 1 ? 'use' : 'uses'} the advertising ID` : `${users.map(s => s.sdk.name).join(', ')} ${users.length > 1 ? 'use' : 'uses'} the advertising ID: check that the merged manifest declares AD_ID (targetSdk 33+)`,
evidence: [...sdkHits(s => users.includes(s)), ...(merged ? [{ file: a.mergedManifest!, line: 1, text: 'merged AndroidManifest.xml' }] : [])].slice(0, 3), ref: PLAY_REFS.adId, skill: 'store-declarations',
fix: 'Apps targeting Android 13+ that use the advertising ID declare <uses-permission android:name="com.google.android.gms.permission.AD_ID" /> (without it the ID reads as zeros); answer the Advertising ID question in App content the same way.' });
}
}
// ---- 16 KB page sizes ----
if (android) {
const n = p.native;
const ps: { title: string; evidence: Hit[]; fix: string; severity?: 'medium' }[] = [];
if (n.unityVersion) {
const v = n.unityVersion.value;
const stream = /^(\d+\.\d+)/.exec(v)?.[1] ?? '';
const min = UNITY_16KB[stream];
const ok = min ? unityPatch(v) >= unityPatch(min) : Number(stream.split('.')[0]) >= 6000;
if (!ok)
ps.push({ title: `Unity ${v} builds without 16 KB page size support (needs ${min ?? '6000.0.38f1, 2022.3.56f1 or 2021.3.48f1 (Extended LTS)'} or newer)`, evidence: [{ ...n.unityVersion.at, text: `m_EditorVersion: ${v}` }],
fix: `Update the editor to ${min ?? 'a supported LTS (6000.0.38f1+, 2022.3.56f1+)'} or newer and rebuild; plugins with their own .so files need 16 KB aligned builds too.` });
}
const rn = p.deps.find(d => d.eco === 'npm' && d.name === 'react-native' && d.version);
if (rn && versionAtLeast(rn.version!, '0.77') === false)
ps.push({ title: `React Native ${rn.version} is older than 0.77, the first version with full 16 KB page size support`, evidence: [{ file: rn.file, line: rn.line, text: `react-native ${rn.version}` }],
fix: 'Upgrade React Native to 0.77 or newer (and native modules with their own .so files to aligned builds).' });
const ndkMajor = n.ndkVersion ? Number(/^\d+/.exec(n.ndkVersion.value)?.[0]) : null;
if (n.nativeBuild && ndkMajor !== null && ndkMajor < 28 && !n.flexiblePageSizes)
ps.push({ title: `NDK ${n.ndkVersion!.value} (r${ndkMajor}) does not build 16 KB aligned libraries by default`, evidence: [{ ...n.ndkVersion!.at, text: `ndkVersion ${n.ndkVersion!.value}` }, { ...n.nativeBuild, text: 'native build' }],
fix: `Use NDK r28 or newer, or ${ndkMajor === 27 ? 'set -DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON' : 'add -Wl,-z,max-page-size=16384 -Wl,-z,common-page-size=16384 to the linker flags (and link libc++ statically on r26 and older)'}.` });
const hasNative = n.libs.length > 0 || !!n.nativeBuild || p.stacks.some(s => s === 'flutter' || s === 'react-native' || s === 'godot');
const compressed = n.legacyPackaging || (a.extractNativeLibs?.value ? a.extractNativeLibs.at : null);
if (hasNative && n.agp && versionAtLeast(n.agp.value, '8.5.1') === false && !compressed)
ps.push({ title: `Android Gradle Plugin ${n.agp.value} does not 16 KB zip-align uncompressed native libraries (needs 8.5.1+)`,
evidence: [{ ...n.agp.at, text: `AGP ${n.agp.value}` }, ...(a.extractNativeLibs ? [{ ...a.extractNativeLibs.at, text: `android:extractNativeLibs="${a.extractNativeLibs.value}"` }] : [])],
fix: 'Update AGP to 8.5.1 or newer; until then set packaging { jniLibs { useLegacyPackaging = true } } (compressed libraries, bigger install; on AGP 8.0 or lower also android.bundle.enableUncompressedNativeLibs=false in gradle.properties).' });
const own = n.libs.filter(f => !/(^|\/)(armeabi-v7a|armeabi|x86)\//.test(f));
if (!ps.length && own.length)
ps.push({ title: `${own.length} prebuilt native librar${own.length === 1 ? 'y' : 'ies'} (.so) in the project: check they are 16 KB aligned`, evidence: own.slice(0, 3).map(f => ({ file: f, line: 1, text: f.split('/').pop()! })),
severity: 'medium', fix: 'Check the built app: zipalign -v -c -P 16 4 app.apk and check_elf_alignment.sh (LOAD segments align 2**14), or APK Analyzer; ask the vendor for an aligned build of any library that fails.' });
for (const q of ps)
add({ rule: 'play-16kb', guideline: 'Play: 16 KB page sizes', severity: q.severity ?? 'high',
title: `${q.title}; from ${PAGE_SIZE_DEADLINE} Play blocks updates without 16 KB support (apps targeting Android 15+)`, evidence: q.evidence, fix: q.fix, ref: PLAY_REFS.pageSize, skill: 'play-first-release' });
}
// ---- new personal developer accounts: closed testing before production ----
if (c?.play) {
const t = c.play.accountType;
const created = c.play.accountCreated;
if (t === 'personal' && (!created || created > PERSONAL_TESTING_SINCE))
add({ rule: 'play-testing-requirement', guideline: 'Play: Testing for new personal accounts', severity: 'medium',
title: `Personal developer account${created ? ` created ${created}` : ''}: production needs a closed test with at least ${PERSONAL_TESTING.testers} opted-in testers for ${PERSONAL_TESTING.days} days in a row first`,
evidence: cfg('accountType'), ref: PLAY_REFS.testing, skill: 'play-first-release',
fix: `Start the closed test early (testers opted in for ${PERSONAL_TESTING.days} continuous days), then apply for production access on the Dashboard; accounts created on or before ${PERSONAL_TESTING_SINCE} and organization accounts are exempt. Ignore this when production access is already granted.` });
else if (!t)
add({ rule: 'play-testing-requirement', guideline: 'Play: Testing for new personal accounts', severity: 'low',
title: `Play account type unknown: new personal accounts need ${PERSONAL_TESTING.testers} testers for ${PERSONAL_TESTING.days} days in a closed test before production`,
evidence: cfg(/play\s*:/), ref: PLAY_REFS.testing, skill: 'play-first-release',
fix: `Set play.accountType ('personal' | 'organization') and play.accountCreated (YYYY-MM-DD) so scan can tell; personal accounts created after ${PERSONAL_TESTING_SINCE} need the closed test.` });
}
// ---- Play listing metadata ----
if (c?.play || inp.listings.play) {
const listing = inp.listings.play;
const titles: Hit[] = fieldBlocks(listing, /\btitle\b/i).map(b => ({ ...b.at, text: b.text.trim() }));
if (!titles.length && c?.app.name) titles.push(...cfg(/\bname\s*:/, c.app.name).map(h => ({ ...h, text: c.app.name })));
const long = titles.filter(t => [...t.text].length > 30);
if (long.length)
add({ rule: 'play-metadata', guideline: 'Play: Metadata', severity: 'high', title: `Play title longer than 30 characters: "${long[0]!.text}" (${[...long[0]!.text].length})`, evidence: long.slice(0, 2),
ref: PLAY_REFS.metadata, skill: 'aso-basics', fix: 'Shorten the title to 30 characters or less (a "### title" block in store/listing.md, else app.name).' });
const emoji = titles.filter(t => EMOJI.test(t.text));
if (emoji.length)
add({ rule: 'play-metadata', guideline: 'Play: Metadata', severity: 'high', title: 'Emoji, emoticons or repeated special characters in the Play title', evidence: emoji.slice(0, 2),
ref: PLAY_REFS.metadata, skill: 'aso-basics', fix: 'Use plain words in the title (and the developer name and icon).' });
const caps = titles.filter(t => /[A-Z]{4,}/.test(t.text) && t.text === t.text.toUpperCase());
if (caps.length)
add({ rule: 'play-metadata', guideline: 'Play: Metadata', severity: 'medium', title: `ALL CAPS Play title: "${caps[0]!.text}"`, evidence: caps.slice(0, 2),
ref: PLAY_REFS.metadata, skill: 'aso-basics', fix: 'Write the title in normal case unless the capitals are your registered brand.' });
const claims = titles.filter(t => CLAIMS.test(t.text));
if (claims.length)
add({ rule: 'play-metadata', guideline: 'Play: Metadata', severity: 'high', title: 'Rank, performance, price or promotion terms in the Play title ("#1", "best", "top", "popular", "sale", "% off")', evidence: claims.slice(0, 2),
ref: PLAY_REFS.metadata, skill: 'aso-basics', fix: 'Remove claims and promotions from the title (and the icon and developer name); describe what the app does instead.' });
const soft = titles.filter(t => !CLAIMS.test(t.text) && SOFT_CLAIMS.test(t.text));
if (soft.length)
add({ rule: 'play-metadata', guideline: 'Play: Metadata', severity: 'low', title: `"Free" or "New" in the Play title: "${soft[0]!.text}"`, evidence: soft.slice(0, 2),
ref: PLAY_REFS.metadata, skill: 'aso-basics', fix: 'Keep it only when it is part of the real name; as a price, promotion or "New" label it is not allowed in the title.' });
const others = new RegExp(`(^|[^\\p{L}])(${OTHER_APPS.map(esc).join('|')})(?=$|[^\\p{L}])`, 'iu');
const named = [...titles.filter(t => others.test(t.text)), ...fieldBlocks(listing, /\bshort\b/i).filter(b => others.test(b.text)).map(b => b.at)];
if (named.length)
add({ rule: 'play-metadata', guideline: 'Play: Metadata', severity: 'medium', title: 'Other apps or brands named in the Play title or short description', evidence: named.slice(0, 2),
ref: PLAY_REFS.metadata, skill: 'aso-basics', fix: 'Remove other apps\' names and comparisons; name a brand only for a real integration you are allowed to mention, and never in a way that implies an endorsement.' });
for (const b of fieldBlocks(listing, /\bfull\b/i)) {
const words = b.text.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(Boolean);
const counts = new Map<string, number>();
for (const w of words) if (w.length > 3 && !STOP.has(w)) counts.set(w, (counts.get(w) ?? 0) + 1);
const rep = words.length >= 80 ? [...counts].filter(([, k]) => k >= 6 && k / words.length > 0.03).sort((q, r) => r[1] - q[1]) : [];
const lists = b.text.split('\n').map((l, i) => ({ l, i })).filter(({ l }) => l.split(/[,|]/).length >= 8 && l.split(/[,|]/).every(s => s.trim().split(/\s+/).length <= 3));
if (rep.length || lists.length)
add({ rule: 'play-metadata', guideline: 'Play: Metadata', severity: 'medium',
title: [rep.length ? `repeated keywords (${rep.slice(0, 3).map(([w, k]) => `"${w}" ${k}x`).join(', ')})` : '', lists.length ? 'a keyword list' : ''].filter(Boolean).join(' and ').replace(/^./, s => s.toUpperCase()) + ' in the full description',
evidence: [...(rep.length ? [b.at] : []), ...(lists.length ? [{ file: b.at.file, line: b.at.line + lists[0]!.i, text: lists[0]!.l.trim().slice(0, 160) }] : [])], ref: PLAY_REFS.metadata, skill: 'aso-basics',
fix: 'Write the description for people: say each thing once, no keyword lists or repeated terms (Play treats them as spam).' });
}
}
// ---- ads at app launch (Android native: the launcher activity) ----
if (android) {
const launcher = a.components.find(q => q.kind === 'activity' && q.categories.includes('android.intent.category.LAUNCHER') && (!a.mergedManifest || q.at.file !== a.mergedManifest));
const cls = launcher?.name.split('.').pop();
const file = cls ? p.sources.find(s => /\.(kt|java)$/.test(s.path) && new RegExp(`\\bclass\\s+${esc(cls)}\\b`).test(s.text)) : undefined;
if (file) {
const lines = file.text.split('\n');
const start = lines.findIndex(l => /\bonCreate\s*\(/.test(l) && !/^\s*(\/\/|\*)/.test(l));
if (start >= 0) {
const indent = /^\s*/.exec(lines[start]!)![0].length;
let end = start + 1;
while (end < lines.length && !(/\S/.test(lines[end]!) && /^\s*/.exec(lines[end]!)![0].length <= indent && /^\s*(override\s+|public\s+|protected\s+|private\s+|fun\s|void\s|@)/.test(lines[end]!))) end++;
const body = lines.slice(start, end);
const show = body.findIndex(l => /interstitial\w*\??\.show(?:Ad)?\s*\(|showInterstitial\s*\(|IronSource\.showInterstitial|UnityAds\.show\s*\(|\bad\??\.show\s*\(\s*(this|this@\w+|activity|requireActivity\(\))/i.test(l) && !/^\s*(\/\/|\*)/.test(l));
if (show >= 0 && body.some(l => /interstitial/i.test(l)))
add({ rule: 'play-ads-launch', guideline: 'Play: Ads (Better Ads Experiences)', severity: 'medium', title: `An interstitial is shown from ${cls}.onCreate, the launcher activity (an ad before the app has loaded)`,
evidence: [{ file: file.path, line: start + show + 1, text: body[show]!.trim().slice(0, 160) }, ev(launcher!, `launcher ${launcher!.name}`)], ref: PLAY_REFS.betterAds, skill: 'ads-mediation-checks',
fix: 'Show interstitials only at natural breaks after the app has loaded (after a level or a finished task), never at launch before the home screen or while people are doing something else.' });
}
}
}
// ---- payments outside Play Billing ----
const programs = c?.play?.billingPrograms ?? [];
if (c?.play || android) {
const extPay = sdkHits(s => s.sdk.category === 'external-payments');
const digital = x.restorable.length || x.paySdk.length || (c?.products?.length ?? 0) > 0 || (c ? allSubscriptions(c.subscriptions).length > 0 : false);
if (extPay.length && digital && !programs.length)
add({ rule: 'play-payments', guideline: 'Play: Payments', severity: 'medium', title: 'A card / PayPal payment SDK next to in-app products: digital goods on Play must use Play Billing', evidence: [...extPay, ...x.restorable].slice(0, 3),
ref: PLAY_REFS.payments, skill: 'store-iap-setup',
fix: 'Sell unlocks, coins, subscriptions and ad removal through Play Billing; keep Stripe / PayPal for physical goods and services. Enrolled in user choice billing, external offers or US external content links? Set play.billingPrograms.' });
const nonApple = (q: string) => !/\.(swift|m|mm|h)$/.test(q);
const steer = programs.length ? [] : [...x.listingHits(inp.listings.play, EXTERNAL_BUY), ...grep(p.sources, EXTERNAL_BUY, 2, nonApple)];
if (steer.length && (c?.play || android))
add({ rule: 'play-payments', guideline: 'Play: Payments', severity: 'medium', title: 'A call to action to buy outside the app (website) in the Play listing or the app', evidence: steer.slice(0, 3),
ref: PLAY_REFS.payments, skill: 'review-rejections', fix: 'Outside the alternative billing and external offers programs, do not lead people to pay for digital content elsewhere; remove the text or enroll and set play.billingPrograms.' });
}
// ---- impersonation ----
if (c?.play) {
const pkg = c.play.packageName.toLowerCase();
const segs = pkg.split('.');
const brandIn = (s: string) => BRANDS.find(b => s.split(/[_\d]+/).includes(b) || (b.length >= 6 && s.includes(b)));
const owner = segs[1] ?? '';
const hitPkg = segs.slice(1).map(brandIn).find(b => b && b !== owner);
const nameRe = new RegExp(`(^|[^\\p{L}])(${BRANDS.map(esc).join('|')})(?=$|[^\\p{L}])`, 'iu');
const hitName = nameRe.exec(c.app.name)?.[2];
if ((hitPkg && !brandIn(owner)) || hitName)
add({ rule: 'play-impersonation', guideline: 'Play: Impersonation', severity: 'low', title: `${hitPkg ? `Package name ${c.play.packageName}` : `App name "${c.app.name}"`} contains "${hitPkg ?? hitName}": check that it does not suggest a relation with that brand`,
evidence: [...(hitPkg ? cfg('packageName') : []), ...(hitName ? cfg(/\bname\s*:/) : [])].slice(0, 2), ref: PLAY_REFS.impersonation, skill: 'review-rejections',
fix: 'Don\'t imply that the app is made or endorsed by another company: remove the brand from the name and package (it can\'t change after the first upload), or have written permission ready for an appeal.' });
}
}
/** Unity patch number for comparison inside one stream ("2022.3.56f1" -> 56). */
function unityPatch(v: string): number { return Number(/^\d+\.\d+\.(\d+)/.exec(v)?.[1] ?? 0); }