// 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 { 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 = { '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 = { 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 & { 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; } const STOP = new Set(['app', 'apps', 'game', 'games', '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']); 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 App content > Sensitive app 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 App content > 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 App content > 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. " collects location data to enable 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 (" collects to enable , ."), 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: , 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 (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(); 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); }