Console pages as absolute deep links: cli/lib/console-links.ts builds the URL of every App Store Connect / Play Console page from apple.appId, play.developerId and play.appId (app list / developer chooser when unknown); every plain 'App Store Connect > ...' / 'Play Console > ...' direction in cli/ and templates/ now carries it, validate / scan / declarations / release plan JSON carry url, the desktop shows an Open button; tests/console-links.test.ts guards it; AGENTS rule 11, pitfall 71
This commit is contained in:
parent
f898d642d0
commit
e500e88e40
50 changed files with 381 additions and 92 deletions
|
|
@ -52,6 +52,7 @@
|
|||
// - 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';
|
||||
|
|
@ -164,7 +165,7 @@ export function playRules(inp: ScanInput, x: PlayCtx): void {
|
|||
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).' });
|
||||
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`,
|
||||
|
|
@ -193,12 +194,12 @@ export function playRules(inp: ScanInput, x: PlayCtx): void {
|
|||
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.' });
|
||||
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 App content > Full-screen intent, ask the user with ACTION_MANAGE_APP_USE_FULL_SCREEN_INTENT and handle a denial. Never for ads.' });
|
||||
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`,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
// error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id).
|
||||
// The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages,
|
||||
// testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources.
|
||||
import { consoleLink, consoleUrl } from '../console-links';
|
||||
import type { FixAction } from '../fix-actions';
|
||||
import { templateBlockers } from '../template-answers';
|
||||
import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules';
|
||||
|
|
@ -57,6 +58,8 @@ export interface ScanFinding {
|
|||
severity: Severity;
|
||||
title: string;
|
||||
evidence: Hit[];
|
||||
/** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */
|
||||
url?: string;
|
||||
fix: string;
|
||||
skill: string;
|
||||
/** Policy page. */
|
||||
|
|
@ -222,7 +225,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
}
|
||||
if (play && ra?.signInRequired && !inp.demoAccount)
|
||||
add({ rule: 'play-app-access', guideline: 'Play: App access', store: 'play', severity: 'high', title: 'Sign-in is required but there are no reviewer credentials for Play App access', evidence: ev,
|
||||
fix: 'Play Console > App content > App access: add the demo account and instructions (keep the credentials in .storebound/credentials.json only).', skill: 'review-access', ref: `${PLAY}9859455` });
|
||||
fix: `${consoleLink('play.app-access')}: add the demo account and instructions (keep the credentials in .storebound/credentials.json only).`, url: consoleUrl('play.app-access'), skill: 'review-access', ref: `${PLAY}9859455` });
|
||||
}
|
||||
if (creates.length) {
|
||||
if (apple && !deletionCode.length && deletionClaim.length)
|
||||
|
|
@ -264,7 +267,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
const termsUrl = c?.app.termsUrl;
|
||||
if (!(termsUrl && all.includes(termsUrl)) && !/terms of use|terms of service|\beula\b|license agreement/i.test(all))
|
||||
add({ rule: 'subscription-info', guideline: '3.1.2(c)', store: 'apple', severity: 'high', title: 'App Store description has no Terms of Use (EULA) link', evidence: desc.slice(0, 1),
|
||||
fix: 'Add a line "Terms of Use: <app.termsUrl>" (and the privacy policy) to the description of every locale, or set a custom EULA in App Store Connect > App Information.', skill: 'store-subscriptions' });
|
||||
fix: 'Add a line "Terms of Use: <app.termsUrl>" (and the privacy policy) to the description of every locale, or set a custom EULA in ' + consoleLink('asc.app-info') + '.', url: consoleUrl('asc.app-info'), skill: 'store-subscriptions' });
|
||||
if (!/renew|cancel/i.test(all))
|
||||
add({ rule: 'subscription-info', guideline: '3.1.2(c)', store: 'apple', severity: 'medium', title: 'App Store description does not say the subscription renews automatically or how to cancel', evidence: desc.slice(0, 1),
|
||||
fix: 'Describe what the subscription includes, the length and price per period, that it renews automatically unless cancelled at least 24 hours before the end of the period, and where to manage it.', skill: 'store-subscriptions' });
|
||||
|
|
@ -469,7 +472,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
|
||||
else if (!a.targetSdk && a.targetSdkFrom)
|
||||
add({ rule: 'play-target-sdk', guideline: 'Play: Target API level', store: 'play', severity: 'low', title: `targetSdk comes from ${a.targetSdkFrom}: check that the built AAB targets ${need} or higher`, evidence: a.manifests.slice(0, 1).map(f => ({ file: f, line: 1, text: 'AndroidManifest.xml' })),
|
||||
fix: `Check with \`aapt2 dump badging app.aab\` or Play Console > App bundle explorer; Play refuses uploads below ${need}.`, skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
|
||||
fix: `Check with \`aapt2 dump badging app.aab\` or ${consoleLink('play.bundle-explorer')}; Play refuses uploads below ${need}.`, skill: 'play-first-release', ref: 'https://developer.android.com/google/play/requirements/target-sdk' });
|
||||
const perms = new Map(a.permissions.map(x => [x.name.replace(/^android\.permission\./, ''), x] as const));
|
||||
const fgs = perms.get('FOREGROUND_SERVICE');
|
||||
if (fgs && (!a.targetSdk || a.targetSdk.value >= 34)) {
|
||||
|
|
@ -477,7 +480,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
const untyped = a.services.filter(s => !s.fgsType);
|
||||
if (untyped.length && !typed.length)
|
||||
add({ rule: 'play-fgs', guideline: 'Play: Foreground service types', store: 'play', severity: 'high', title: 'FOREGROUND_SERVICE is requested but no <service> declares android:foregroundServiceType (required when targeting Android 14+)', evidence: [{ ...fgs.at, text: 'FOREGROUND_SERVICE' }, ...untyped.slice(0, 2).map(s => ({ ...s.at, text: `service ${s.name}` }))],
|
||||
fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill App content > Foreground service permissions (description, user impact, video).', skill: 'play-first-release', ref: `${PLAY}13392821` });
|
||||
fix: 'Give each foreground service a type and its FOREGROUND_SERVICE_<TYPE> permission, then fill ' + consoleLink('play.foreground-service') + ' (description, user impact, video).', url: consoleUrl('play.foreground-service'), skill: 'play-first-release', ref: `${PLAY}13392821` });
|
||||
for (const s of typed) {
|
||||
const types = s.fgsType!.split('|').map(x => x.trim()).filter(Boolean);
|
||||
const missing = types.filter(t => FGS_PERMISSION[t] && !perms.has(`FOREGROUND_SERVICE_${FGS_PERMISSION[t]}`));
|
||||
|
|
@ -487,7 +490,7 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
|
|||
}
|
||||
if (typed.length)
|
||||
add({ rule: 'play-fgs', guideline: 'Play: Foreground service types', store: 'play', severity: 'medium', title: `Foreground service types need a Play Console declaration: ${[...new Set(typed.flatMap(s => s.fgsType!.split('|')))].join(', ')}`, evidence: typed.slice(0, 3).map(s => ({ ...s.at, text: `${s.name}: ${s.fgsType}` })),
|
||||
fix: 'App content > Foreground service permissions: describe each use, the user impact if it is deferred, and add a short video; shortService needs none.', skill: 'play-first-release', ref: `${PLAY}13392821` });
|
||||
fix: consoleLink('play.foreground-service') + ': describe each use, the user impact if it is deferred, and add a short video; shortService needs none.', skill: 'play-first-release', ref: `${PLAY}13392821` });
|
||||
}
|
||||
const exact = perms.get('USE_EXACT_ALARM');
|
||||
if (exact)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue