diff --git a/cli/package.json b/cli/package.json index 1c69be4710..ba7f9afd5c 100644 --- a/cli/package.json +++ b/cli/package.json @@ -110,7 +110,10 @@ "test:app-verification": "bun test/test-app-verification.mjs", "test:pbxproj-parser": "bun test/test-pbxproj-parser.mjs", "test:manifest-path-encoding": "bun test/test-manifest-path-encoding.mjs", - "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit", + "test:android-reporting-api": "bun test/test-android-reporting-api.mjs", + "test:android-app-verification": "bun test/test-android-app-verification.mjs", + "test:android-rename": "bun test/test-android-rename.mjs", + "test": "bun run build && bun run test:version-detection:setup && bun run test:bundle && bun run test:functional && bun run test:semver && bun run test:version-edge-cases && bun run test:regex && bun run test:upload && bun run test:fail-on-incompatible && bun run test:credentials && bun run test:credentials-validation && bun run test:android-service-account-validation && bun run test:build-zip-filter && bun run test:checksum && bun run test:build-needed && bun run test:ci-prompts && bun run test:ci-secrets && bun run test:android-onboarding-progress && bun run test:onboarding-telemetry && bun run test:v2-event-migration && bun run test:analytics && bun run test:analytics-error-category && bun run test:analytics-org-resolver && bun run test:supabase-perf && bun run test:mcp-analytics && bun run test:app-created-source && bun run test:doctor-analytics && bun run test:posthog-exception && bun run test:build-platform-selection && bun run test:onboarding-recovery && bun run test:onboarding-progress && bun run test:onboarding-run-targets && bun run test:run-device-command && bun run test:init-app-conflict && bun run test:init-guardrails && bun run test:prompt-preferences && bun run test:esm-sdk && bun run test:mcp && bun run test:version-detection && bun run test:platform-paths && bun run test:payload-split && bun run test:manifest-path-encoding && bun run test:macos-signing && bun run test:apple-api-import-helpers && bun run test:bundle-id-detector && bun run test:apple-api-app-list && bun run test:app-verification && bun run test:pbxproj-parser && bun run test:ai-log-capture && bun run test:ai-analyze-flow && bun run test:ai-render-markdown && bun run test:ai-onboarding-mode && bun run test:ai-fit && bun run test:platform-layout && bun run test:frame-fit && bun run test:onboarding-min-size && bun run test:min-size-gate && bun run test:shell-size-gate && bun run test:build-log-sanitize && bun run test:build-output-viewport && bun run test:diff-viewer-viewport && bun run test:build-complete-exit && bun run test:android-reporting-api && bun run test:android-app-verification && bun run test:android-rename", "test:build-platform-selection": "bun test/test-build-platform-selection.mjs", "test:ai-log-capture": "bun test/test-ai-log-capture.mjs", "test:ai-analyze-flow": "bun test/test-ai-analyze-flow.mjs", diff --git a/cli/src/build/onboarding/android/android-rename.ts b/cli/src/build/onboarding/android/android-rename.ts new file mode 100644 index 0000000000..4a11066d25 --- /dev/null +++ b/cli/src/build/onboarding/android/android-rename.ts @@ -0,0 +1,102 @@ +// src/build/onboarding/android/android-rename.ts +// +// Pure helpers for the Path A "Rename my Android project for me" convenience +// (explicit opt-in only). All I/O — mkdtemp, npm install, spawning node / +// `cap sync`, and `pgrep` — lives in the app.tsx orchestration; everything +// here is pure so it can be unit-tested without touching the filesystem or +// spawning processes. + +/** + * Pinned `@trapezedev/project` version installed on demand into the temp rename + * workspace. Trapeze is NOT bundled with the CLI (keeps it lean) — it's only + * installed when the user explicitly opts into the rename. Pinned so the rename + * behavior is reproducible. + */ +export const TRAPEZE_PROJECT_VERSION = '7.1.4' + +export interface RenameWorkspaceFiles { + /** Contents of the temp `package.json` (`type: module` + pinned Trapeze). */ + packageJson: string + /** Contents of the `rename.mjs` script run as `node rename.mjs `. */ + renameMjs: string +} + +/** + * Build the two files written into the temp rename workspace. + * + * The `rename.mjs` script runs the proven 3-call Trapeze sequence — ALWAYS all + * three setters (`setPackageName` + `setApplicationId` + `setNamespace`). + * Skipping `namespace` is not an option: AGP 8 requires it, and a package move + * with a stale namespace breaks `R`/`BuildConfig` imports and fails the build. + * The `appId` is read from `process.argv[2]` so the caller passes the target + * package as a CLI argument rather than templating it into the script. + * + * `pkg` is accepted for symmetry / future validation but is intentionally NOT + * interpolated into the script — the appId always flows in via argv at runtime, + * which avoids any string-injection of an attacker-controlled package into the + * generated JS. + */ +export function buildRenameWorkspaceFiles(pkg: string): RenameWorkspaceFiles { + void pkg + const packageJson = `${JSON.stringify( + { + name: 'capgo-android-rename', + private: true, + type: 'module', + devDependencies: { + '@trapezedev/project': TRAPEZE_PROJECT_VERSION, + }, + }, + null, + 2, + )}\n` + + const renameMjs = `import { MobileProject } from '@trapezedev/project' + +const appId = process.argv[2] +if (!appId) { + console.error('Usage: node rename.mjs ') + process.exit(1) +} + +const project = new MobileProject('.', { android: { path: 'android' } }) +await project.load() +await project.android?.setPackageName(appId) +const gradle = await project.android?.getGradleFile('app/build.gradle') +await gradle?.setApplicationId(appId) +await gradle?.setNamespace(appId) +await project.commit() +console.log(\`Renamed Android project to \${appId}\`) +` + + return { packageJson, renameMjs } +} + +/** Whether Android Studio is holding the project's native files open. */ +export type AndroidStudioState = 'running' | 'not-running' | 'unknown' + +/** + * Pure predicate for the close-Android-Studio gate. Editing native files while + * Studio holds them open risks a half-written project / Studio clobbering the + * change. + * + * Only macOS can be determined here: `pgrep -f "Android Studio"` is the source + * of `pgrepOutput`, so a non-empty (trimmed) result means it's running and an + * empty one means it's closed. On any other platform we can't reliably probe, + * so the caller falls back to a one-time confirm → `unknown`. + */ +export function isAndroidStudioRunning(platform: string, pgrepOutput: string): AndroidStudioState { + if (platform !== 'darwin') + return 'unknown' + return pgrepOutput.trim().length > 0 ? 'running' : 'not-running' +} + +/** + * Verify the rename landed: re-read Gradle ids (via `findAndroidApplicationIds`, + * which returns `string[]`) and confirm the list now contains the target + * package. Returns `false` so the caller surfaces output + a manual fallback + * instead of claiming a false success. + */ +export function verifyRenamed(gradleIds: string[], target: string): boolean { + return gradleIds.includes(target) +} diff --git a/cli/src/build/onboarding/android/app-verification-android.ts b/cli/src/build/onboarding/android/app-verification-android.ts new file mode 100644 index 0000000000..aecc9ea5d9 --- /dev/null +++ b/cli/src/build/onboarding/android/app-verification-android.ts @@ -0,0 +1,95 @@ +// src/build/onboarding/android/app-verification-android.ts +// +// Pure decision logic for the Android "verify Play Store app" onboarding step. +// +// A thin wrapper around the shared iOS `classifyAppVerification` classifier so +// the exact-match / wrong-build-id / no-app decision stays in one place across +// platforms. The Android-specific wrinkle is *multiple* Gradle applicationIds +// (build flavors): when more than one id is present without a single clean +// match we force the picker (`multi-gradle`) rather than guessing. + +import type { AscAppLike } from '../app-verification.js' +import { classifyAppVerification } from '../app-verification.js' + +/** Minimal shape of a Play Store app needed for reconciliation. */ +export interface PlayAppLike { + packageName: string + displayName: string +} + +/** + * Reconcile outcome for the Android package-select step. + * + * - `exact-match` — a Play app's `packageName` == a single clean Gradle id. + * Auto-confirm, no picker. Carries the matched package. + * - `wrong-build-id` — apps exist but the build id matches none → Path A picker. + * - `no-app` — no Play apps at all → Path B (create the app). + * - `multi-gradle` — several Gradle flavors and no clean single match → force + * the picker so the user disambiguates. + */ +export type AndroidReconcileResult + = | { kind: 'exact-match', packageName: string } + | { kind: 'wrong-build-id' } + | { kind: 'no-app' } + | { kind: 'multi-gradle' } + +export interface ReconcileAndroidAppInput { + /** Every distinct `applicationId` found in the project's Gradle files (≥0). */ + gradleIds: string[] + /** Apps that actually exist in the user's Play Console. */ + apps: PlayAppLike[] +} + +/** + * Pure reconciliation of the Android app-existence invariant. + * + * The common Capacitor case is a single Gradle id, which defers entirely to the + * shared iOS classifier (`packageName` ↦ `bundleId`). Android has no + * Developer-portal registration split, so both iOS `no-app-*` results collapse + * to a single `no-app` route. + * + * Multiple Gradle ids are handled here: exactly one matching a Play app is + * still a clean single match (`exact-match`); zero apps is `no-app`; anything + * else forces the picker (`multi-gradle`). + */ +export function reconcileAndroidApp(input: ReconcileAndroidAppInput): AndroidReconcileResult { + const { gradleIds, apps } = input + + // Map Play apps onto the shared classifier's shape so the decision stays + // shared with iOS. + const ascApps: AscAppLike[] = apps.map(a => ({ bundleId: a.packageName, name: a.displayName })) + + // Single Gradle id (or none): defer to the shared iOS classifier. + if (gradleIds.length <= 1) { + const releaseBundleId = gradleIds[0] ?? '' + // Defensive: with no usable Gradle id there is nothing to match — never + // let an empty string "exact-match" (e.g. a malformed Play row that lost + // its packageName). Apps exist → the enriched picker (wrong-build-id); + // none → Path B (no-app). + if (!releaseBundleId) + return apps.length > 0 ? { kind: 'wrong-build-id' } : { kind: 'no-app' } + const { result } = classifyAppVerification({ + releaseBundleId, + apps: ascApps, + registeredBundleIds: [], + }) + if (result === 'exact-match') + return { kind: 'exact-match', packageName: releaseBundleId } + if (result === 'wrong-build-id') + return { kind: 'wrong-build-id' } + // no-app-identifier-exists / no-app-unregistered → Android has no portal + // registration split, so both collapse to a single no-app route. + return { kind: 'no-app' } + } + + // Multiple Gradle flavors. Exactly one matching a Play app is still a clean + // single match → auto-confirm; otherwise force the picker. + const matchedIds = gradleIds.filter(id => ascApps.some(a => a.bundleId === id)) + if (matchedIds.length === 1) + return { kind: 'exact-match', packageName: matchedIds[0] } + + if (apps.length === 0) + return { kind: 'no-app' } + + return { kind: 'multi-gradle' } +} diff --git a/cli/src/build/onboarding/android/oauth-google.ts b/cli/src/build/onboarding/android/oauth-google.ts index d0d1754c7e..e76de8329f 100644 --- a/cli/src/build/onboarding/android/oauth-google.ts +++ b/cli/src/build/onboarding/android/oauth-google.ts @@ -46,6 +46,14 @@ export interface GoogleOAuthConfig { */ clientSecret?: string scopes: readonly string[] + /** + * Subset of `scopes` whose absence must FAIL sign-in. Any scope present in + * `scopes` but NOT listed here is *optional*: the user may deselect it on the + * consent screen and sign-in still succeeds (the feature relying on it is + * expected to degrade gracefully). Defaults to all of `scopes` when omitted, + * preserving the original "every requested scope is required" behavior. + */ + requiredScopes?: readonly string[] /** Extra params to include on the auth URL (e.g. `login_hint`, `prompt`). */ extraAuthParams?: Record } @@ -273,11 +281,26 @@ function escapeHtml(s: string): string { return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', '\'': ''' }[c] as string)) } -function successHtml(): string { +/** + * Human blurbs for OPTIONAL scopes a user may decline on the consent screen — + * shown on the success page so they understand the implication right where the + * decision just happened (the consent wording, e.g. "see metrics and data", is + * Google's and reads scarier / vaguer than what the CLI actually does). + */ +const OPTIONAL_SCOPE_BLURBS: Record = { + 'https://www.googleapis.com/auth/playdeveloperreporting': + 'Play app list (Google words it "see metrics and data"): without it the CLI can\'t check that the app you\'re building exists in your Play Console, so it will trust the package from your Gradle config as-is.', +} + +function successHtml(skippedOptionalScopes: readonly string[] = []): string { + const warning = skippedOptionalScopes.length > 0 + ? `
Heads-up: you skipped ${skippedOptionalScopes.length === 1 ? 'an optional permission' : 'some optional permissions'} — that's fine, onboarding continues without it.
    ${skippedOptionalScopes.map(s => `
  • ${escapeHtml(OPTIONAL_SCOPE_BLURBS[s] ?? s)}
  • `).join('')}
Want it after all? Re-run onboarding and leave it checked on the consent screen.
` + : '' return `Capgo — signed in - +

✅ You can close this tab

Capgo CLI received your Google sign-in. Head back to your terminal to continue.

+${warning} ` } @@ -332,6 +355,30 @@ export function findMissingScopes(grantedScope: string, requestedScopes: readonl return requestedScopes.filter(s => !granted.has(s)) } +export interface MissingScopeSplit { + /** Required scopes the user didn't grant — sign-in must fail with a retry. */ + missingRequired: string[] + /** Optional scopes the user declined — proceed, but tell them what they lose. */ + skippedOptional: string[] +} + +/** + * Split the requested-but-not-granted scopes into the required ones (block + * sign-in → `MissingScopesError`) and the optional ones (sign-in proceeds; the + * success page + caller warn about the degraded behavior). + */ +export function splitMissingScopes( + grantedScope: string, + scopes: readonly string[], + requiredScopes: readonly string[], +): MissingScopeSplit { + const missingAll = findMissingScopes(grantedScope, scopes) + return { + missingRequired: missingAll.filter(s => requiredScopes.includes(s)), + skippedOptional: missingAll.filter(s => !requiredScopes.includes(s)), + } +} + export interface LoopbackCallbackResult { /** Authorization code Google returned in the query string. */ code: string @@ -562,13 +609,24 @@ export async function runOAuthFlow( // the user gets a clear "please grant all permissions" message in BOTH // the browser tab and the CLI, instead of failing several API calls // later with confusing 403s. - const missing = findMissingScopes(tokens.scope, config.scopes) - if (missing.length > 0) { - finishResponse(scopeMissingHtml(missing), 400) - throw new MissingScopesError(missing, tokens.scope) + // + // Only the REQUIRED scopes block sign-in. Optional scopes (in `scopes` but + // not in `requiredScopes`) may be declined — the caller is responsible for + // degrading gracefully when the granted token lacks them. Defaults to all + // requested scopes when `requiredScopes` is omitted. + const requiredScopes = config.requiredScopes ?? config.scopes + const { missingRequired, skippedOptional } = splitMissingScopes(tokens.scope, config.scopes, requiredScopes) + if (missingRequired.length > 0) { + finishResponse(scopeMissingHtml(missingRequired), 400) + throw new MissingScopesError(missingRequired, tokens.scope) } - finishResponse(successHtml()) + // Optional scopes declined → still a success, but say so in BOTH places: + // the browser tab (where the decline just happened) and the CLI status + // stream — so the later "verification skipped" warning isn't a surprise. + if (skippedOptional.length > 0) + options.onStatus?.('Note: you skipped the optional Play app-list permission — app-existence verification will be skipped.') + finishResponse(successHtml(skippedOptional)) return tokens } finally { diff --git a/cli/src/build/onboarding/android/reporting-api.ts b/cli/src/build/onboarding/android/reporting-api.ts new file mode 100644 index 0000000000..6ddb77350f --- /dev/null +++ b/cli/src/build/onboarding/android/reporting-api.ts @@ -0,0 +1,126 @@ +// src/build/onboarding/android/reporting-api.ts +// +// Play Developer Reporting API wrapper — the subset onboarding needs: list the +// Play Store apps accessible to the signed-in user so we can reconcile them +// against the project's Gradle `applicationId`. +// +// `apps:search` is a *separate* API from androidpublisher (single scope +// `…/auth/playdeveloperreporting`) and works with a user OAuth token. Mirrors +// the iOS `apple-api.ts` listApps helper: the pure parser is unit-tested and +// the fetch is thin and accepts an injectable `fetchImpl` so tests never touch +// the network. + +const REPORTING_BASE_URL = 'https://playdeveloperreporting.googleapis.com/v1beta1' + +/** + * A Play Store app record as returned by `apps:search`. Used by the Android + * app-verification step to check whether an app exists whose `packageName` + * matches the project's Gradle `applicationId`. + */ +export interface PlayApp { + packageName: string + displayName: string +} + +/** + * Parse an `apps:search` response into {@link PlayApp} records. Tolerant of a + * missing `apps` array and of individual apps missing `displayName` — Google + * omits fields rather than nulling them, and a malformed page must never throw + * into the wizard (the whole feature degrades gracefully). + * + * Entries missing `packageName` are DROPPED: the package is the join key for + * reconciliation, and letting an empty one through could spuriously + * "exact-match" a project whose Gradle parse found no applicationId. The API + * documents `packageName` as always present, so this only guards malformed + * pages. + */ +export function parseAppsSearchResponse(json: any): PlayApp[] { + return (json?.apps || []).flatMap((app: any): PlayApp[] => { + const packageName = typeof app?.packageName === 'string' ? app.packageName : '' + if (!packageName) + return [] + return [{ packageName, displayName: app?.displayName || '' }] + }) +} + +/** + * Carries the HTTP status alongside the message so callers can distinguish a + * 403 (scope not granted / Reporting API disabled → graceful degrade to the + * plain Gradle picker) from other failures. + */ +export class ReportingApiHttpError extends Error { + readonly status: number + constructor(status: number, message: string) { + super(message) + this.name = 'ReportingApiHttpError' + this.status = status + } +} + +/** Injectable fetch — defaults to the global `fetch`, overridable in tests. */ +export type FetchImpl = typeof fetch + +export interface ListPlayAppsOptions { + /** + * Override the global `fetch`. Tests inject a stub returning a Response-like + * object; production omits this and uses `globalThis.fetch`. + */ + fetchImpl?: FetchImpl +} + +// `apps:search` returns at most `pageSize` apps per page plus a `nextPageToken` +// when more exist. We follow it up to MAX_LIST_PAGES — a hard cap so a +// malformed/looping token can never spin forever. 1000 × 10 = 10000 apps is +// far more than any real developer account has. Mirrors apple-api.ts' +// MAX_LIST_PAGES. +const MAX_LIST_PAGES = 10 +const PAGE_SIZE = 1000 + +/** + * List every Play Store app accessible to the signed-in user, following + * pagination. Authenticates with the supplied user OAuth access token (must + * carry the `playdeveloperreporting` scope). + * + * `GET …/v1beta1/apps:search?pageSize=1000`, following `nextPageToken` up to + * {@link MAX_LIST_PAGES}. Throws {@link ReportingApiHttpError} on a non-OK + * response so callers can branch on `.status` (e.g. 403 → degrade). + */ +export async function listPlayApps( + accessToken: string, + opts: ListPlayAppsOptions = {}, +): Promise { + const fetchImpl = opts.fetchImpl ?? globalThis.fetch + const apps: PlayApp[] = [] + let pageToken: string | null = null + for (let page = 0; page < MAX_LIST_PAGES; page++) { + const url = new URL(`${REPORTING_BASE_URL}/apps:search`) + url.searchParams.set('pageSize', String(PAGE_SIZE)) + if (pageToken) + url.searchParams.set('pageToken', pageToken) + + const res = await fetchImpl(url.toString(), { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + const body: any = await res.json().catch(() => null) + + if (!res.ok) { + const message: string = body?.error?.message || `HTTP ${res.status}` + throw new ReportingApiHttpError( + res.status, + `Play Developer Reporting API error (${res.status}): ${message}`, + ) + } + + apps.push(...parseAppsSearchResponse(body)) + + const next: string | undefined = body?.nextPageToken + if (!next) + break + pageToken = next + } + return apps +} diff --git a/cli/src/build/onboarding/android/types.ts b/cli/src/build/onboarding/android/types.ts index b9a590392e..4944bf3c66 100644 --- a/cli/src/build/onboarding/android/types.ts +++ b/cli/src/build/onboarding/android/types.ts @@ -42,6 +42,12 @@ export type AndroidOnboardingStep | 'gcp-project-create-name' // Phase 4.5 — Pick the Android package name to grant SA access to | 'android-package-select' + // Phase 4.5 — App-existence verification (generate path): reconcile the Gradle + // applicationId against the user's real Play Store apps. Path A = the chosen + // Play app's package differs from the build id (offer a project rename); Path B + // = no Play app exists yet (open Play Console to create it). + | 'android-app-verify-rename' + | 'android-app-verify-create-app' // Phase 5 — Automated provisioning (create project if needed, enable API, SA, key, invite) | 'gcp-setup-running' // Phase 6 — Save + build @@ -234,6 +240,8 @@ export const ANDROID_STEP_PROGRESS: Record = { 'gcp-project-create-name': 60, 'android-package-select': 65, + 'android-app-verify-rename': 66, + 'android-app-verify-create-app': 66, 'gcp-setup-running': 70, @@ -310,6 +318,8 @@ export function getAndroidPhaseLabel(step: AndroidOnboardingStep): string { case 'gcp-projects-select': case 'gcp-project-create-name': case 'android-package-select': + case 'android-app-verify-rename': + case 'android-app-verify-create-app': case 'gcp-setup-running': return 'Step 3 of 4 · Google Cloud Project' case 'saving-credentials': diff --git a/cli/src/build/onboarding/android/ui/app.tsx b/cli/src/build/onboarding/android/ui/app.tsx index 18abf8a250..ce5b8669dc 100644 --- a/cli/src/build/onboarding/android/ui/app.tsx +++ b/cli/src/build/onboarding/android/ui/app.tsx @@ -15,12 +15,13 @@ import type { } from '../types.js' import type { OnboardingResult } from '../../types.js' import { handleCustomMsg } from '../../../qr.js' +import { spawn } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' -import { copyFile, readFile } from 'node:fs/promises' -import { homedir } from 'node:os' +import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { homedir, tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' import process from 'node:process' -import { ProgressBar, Select } from '@inkjs/ui' +import { Alert, ProgressBar, Select } from '@inkjs/ui' import type { DOMElement } from 'ink' import { Box, measureElement, Newline, Text, useApp, useInput, useStdout } from 'ink' // src/build/onboarding/android/ui/app.tsx @@ -111,6 +112,12 @@ import { WelcomeStep, } from '../../ui/steps/android-shared.js' import { findAndroidApplicationIds } from '../gradle-parser.js' +import { listPlayApps, ReportingApiHttpError } from '../reporting-api.js' +import type { PlayApp } from '../reporting-api.js' +import { reconcileAndroidApp } from '../app-verification-android.js' +import type { AndroidReconcileResult } from '../app-verification-android.js' +import { buildRenameWorkspaceFiles, isAndroidStudioRunning, verifyRenamed } from '../android-rename.js' +import { trackEvent } from '../../../../analytics/track.js' import { validateServiceAccountJson } from '../service-account-validation.js' import { diffLines } from '../../diff-utils.js' import type { DiffLine } from '../../diff-utils.js' @@ -179,8 +186,41 @@ const RELEASE_ALIAS_DEFAULT = 'release' const OAUTH_SCOPES_FOR_ONBOARDING = [ ...GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER, 'https://www.googleapis.com/auth/cloud-platform', + // OPTIONAL — used by the post-sign-in app-existence verification step to list + // the user's real Play Store apps (apps:search). A user who declines it on the + // consent screen STILL completes sign-in: it is deliberately excluded from + // OAUTH_REQUIRED_SCOPES below so its absence never throws MissingScopesError, + // and the verification degrades to the plain Gradle picker. + 'https://www.googleapis.com/auth/playdeveloperreporting', ] as const +/** + * The subset of {@link OAUTH_SCOPES_FOR_ONBOARDING} whose absence must fail + * sign-in. Excludes the optional `playdeveloperreporting` scope so a user who + * declines app-listing can still finish onboarding (graceful degradation). + */ +const OAUTH_REQUIRED_SCOPES = [ + ...GOOGLE_OAUTH_SCOPES_ANDROIDPUBLISHER, + 'https://www.googleapis.com/auth/cloud-platform', +] as const + +/** + * Path A (Trapeze rename) orchestration phase. `menu` shows the explicit + * opt-in offer; the remaining phases drive spinners while the rename runs and + * the `failed` fallback when it can't be verified (we never claim false + * success). `await-studio-confirm` is the non-darwin one-time confirm — on + * macOS we auto-detect + poll instead. + */ +type RenamePhase + = | 'menu' + | 'preparing' + | 'await-studio' + | 'await-studio-confirm' + | 'renaming' + | 'syncing' + | 'rechecking' + | 'failed' + function cleanPath(input: string): string { let s = input.trim() if (s.length >= 2) { @@ -506,6 +546,63 @@ const AndroidOnboardingApp: FC = ({ appId, initialProgress, androidDir const [packageSelectMode, setPackageSelectMode] = useState<'choose' | 'manual'>('choose') const packageLoadedRef = useRef(false) + // ── Phase 4.5 — app-existence verification (generate path only) ────────────── + // After sign-in we list the user's real Play Store apps and reconcile them + // against the project's Gradle applicationId. Mirrors the iOS verify-app gate. + // + // The Play apps accessible to the signed-in user (apps:search). Picker source + // for the enriched wrong-build-id / multi-gradle screen + Path B "Back" gate. + const [verifyPlayApps, setVerifyPlayApps] = useState([]) + // Reconcile outcome once the apps fetch completes. null while loading / on the + // import path (no fetch) / when verification degraded. + const [verifyReconcile, setVerifyReconcile] = useState(null) + // True while the initial apps:search fetch is in flight (generate path). + const [verifyAppLoading, setVerifyAppLoading] = useState(false) + // True when verification couldn't run on the generate path (reporting scope not + // granted, apps:search 403, or any network/token error) — we warn and fall + // through to the plain Gradle picker. NEVER blocks onboarding. + const [verifyDegraded, setVerifyDegraded] = useState(false) + // True on the import (custom-SA) path: app existence isn't verified there (it + // needs Google sign-in) — show a "not verified" banner over the plain picker. + const [verifyImportSkipped, setVerifyImportSkipped] = useState(false) + // The Play app the user picked whose packageName != the build id → Path A + // (rename) target. Set when entering the rename step. + const [verifyChosenPlayApp, setVerifyChosenPlayApp] = useState(null) + // Bumped on every verify synchronously, so the - // @inkjs/ui re-fire bug can't replay it. The package-pick path - // goes through async persistAndStep, which keeps the ({ + label: `✓ ${a.displayName || a.packageName} — ${a.packageName} · in Play Console`, + value: `play:${a.packageName}`, + })), + ...detectedPackageIds.map(id => ({ + label: verifyPlayApps.some(a => a.packageName === id) + ? `📦 ${id} (from Gradle · ✓ in Play Console)` + : `📦 ${id} (from Gradle · ⚠ not on Play yet — needs creating)`, + value: `gradle:${id}`, + })), + { label: '✍️ Type a different package name', value: '__manual__' }, + { label: '➕ Create a new app in Play Console', value: '__create_new__' }, + ]} + onChange={(value) => { + // This synchronously, so the + // @inkjs/ui re-fire bug can't replay it. The package-pick path + // goes through async persistAndStep, which keeps the { + setGateActionSeq(s => s + 1) + if (value === 'continue') { + // startRename always sets the workspace ref before this + // screen renders — if it's somehow gone, fail honestly + // instead of running `node rename.mjs` against ''. + const tmpDir = renameTmpDirRef.current + if (!tmpDir) { + setRenameOutput(['Internal error: the rename workspace was lost — use the manual steps below, or go Back and retry.']) + setRenamePhase('failed') + return + } + void doRename(tmpDir, 0) + return + } + addLog('Exiting onboarding.', 'yellow') + exitOnboarding() + }} + /> + + ) + } + + if (renamePhase === 'failed') { + return ( + + + Couldn't rename your Android project automatically + + + {'Set '} + applicationId + {' + '} + namespace + {' to '} + {target} + {` in ${androidDir}/app/build.gradle, run `} + npx cap sync + {', then re-check.'} + + {renameOutput.map((line, i) => ( + {line} + ))} + + + { + setGateActionSeq(s => s + 1) + if (value === 'rename') { + void startRename() + return + } + if (value === 'recheck') { + void recheckBuildId() + return + } + if (value === 'back') { + backToPicker() + return + } + addLog('Exiting onboarding.', 'yellow') + exitOnboarding() + }} + /> + + ) + })()} + + {/* Path B — no Play app exists yet. This is a real GATE on the generate + path, NOT inform-and-proceed: the SA invite grants per-package and + Google 400s on a package that doesn't exist, so onboarding cannot + proceed until the app exists in Play Console. Create it (one manual + click — the API can't), then re-check until apps:search sees it and we + auto-advance. Re-check uses a loader + attempt counter + ask-before-reopen. */} + {step === 'android-app-verify-create-app' && (() => { + const pkgLabel = detectedPackageIds.join(', ') || 'your project' + const attemptMarker = verifyAttempt > 0 ? ` (attempt ${verifyAttempt})` : '' + + // Open Play Console's app list so the user clicks "Create app" (the only + // irreducible manual step). Asked before RE-opening on later attempts. + const openCreatePlayConsole = async () => { + const epoch = verifyAsyncEpochRef.current + trackVerifyEvent('Android App Verify Create App Opened', '🌐', { attempt: verifyAttempt }) + let openFailed = false + try { + await open('https://play.google.com/console') + } + catch { + openFailed = true + } + if (verifyAsyncEpochRef.current !== epoch) + return + if (openFailed) + addLog('⚠ Could not open your browser. Visit https://play.google.com/console to create the app.', 'yellow') + setVerifyAskReopen(false) + } + + // Re-list Play apps + reconcile, with a loader + attempt counting. A + // clean match advances; apps that now exist (but the build id doesn't + // match) route to the enriched picker; otherwise ask before re-opening. + const recheckCreateApp = async () => { + const epoch = verifyAsyncEpochRef.current + setVerifyRechecking(true) + const attempt = verifyAttempt + 1 + try { + const gradleIds = await findAndroidApplicationIds(androidDir) + const token = await ensureAccessToken() + const apps = await listPlayApps(token) + if (verifyAsyncEpochRef.current !== epoch) + return + setDetectedPackageIds(gradleIds) + setVerifyPlayApps(apps) + const result = reconcileAndroidApp({ gradleIds, apps }) + setVerifyReconcile(result) + trackVerifyEvent('Android App Verify Result', '🔎', { + result: result.kind, + app_count: apps.length, + gradle_id_count: gradleIds.length, + }) + if (result.kind === 'exact-match') { + const matched = apps.find(a => a.packageName === result.packageName) + addLog(`✔ Building "${matched?.displayName || result.packageName}" (${result.packageName}) — matches your Play Store app.`) + pickAndroidPackageAndAdvance(result.packageName, 'gradle') return } - const choice: AndroidPackageChoice = { - packageName: name, - source: detectedPackageIds.includes(name) ? 'gradle' : 'user-input', + if (result.kind === 'wrong-build-id' || result.kind === 'multi-gradle') { + setVerifyAttempt(attempt) + setVerifyAskReopen(false) + setStep('android-package-select') + return } - setAndroidPackageChoice(choice) - addLog(`✔ Android package — ${name}`) - const nextStep: AndroidOnboardingStep - = serviceAccountMethod === 'existing' ? 'sa-json-existing-path' : 'gcp-setup-running' - persistAndStep( - (p) => ({ - ...p, - completedSteps: { ...p.completedSteps, androidPackageChosen: choice }, - }), - nextStep, - ) - }} - /> - )} + // Still no app → count the attempt + ask before re-opening. + setVerifyAttempt(attempt) + setVerifyAskReopen(true) + } + catch (err) { + if (verifyAsyncEpochRef.current !== epoch) + return + setVerifyAttempt(attempt) + setVerifyAskReopen(true) + const why = err instanceof ReportingApiHttpError && err.status === 403 + ? 'the Play Developer Reporting permission was not granted' + : 'Play Console could not be reached' + addLog(`⚠ Couldn't re-check Play Console — ${why}. Create the app, then re-check.`, 'yellow') + } + finally { + // Deliberately NOT epoch-guarded: leaving the step mid-flight must + // still clear the spinner flag, or a later return to this screen + // would show a stale loader forever (React drops the write + // harmlessly if the component already unmounted). + setVerifyRechecking(false) + } + } + + if (verifyRechecking) { + return ( + + + Re-listing your Play Store apps to confirm the app now exists. + + ) + } + + // After a blocked re-check we ASK before re-opening the browser. + if (verifyAskReopen) { + return ( + + + {`Still no Play Store app for ${pkgLabel}${attemptMarker}`} + + {'We re-checked Play Console and didn\'t find an app yet. Newly-created apps can take a moment to appear — re-check again. Onboarding can\'t continue until the app exists, because Capgo grants your build access to that specific package.'} + + + 0 ? [{ label: '↩ Back — pick a different app', value: 'back' }] : []), + { label: '❌ Cancel onboarding', value: 'cancel' }, + ]} + onChange={(value) => { + setGateActionSeq(s => s + 1) + if (value === 'open') { + void openCreatePlayConsole() + return + } + if (value === 'recheck') { + void recheckCreateApp() + return + } + if (value === 'back') { + setVerifyAttempt(0) + setVerifyAskReopen(false) + packageLoadedRef.current = true + setStep('android-package-select') + return + } + addLog('Exiting onboarding.', 'yellow') + exitOnboarding() + }} + /> + + ) + })()} {step === 'gcp-setup-running' && ( diff --git a/cli/src/build/onboarding/ui/steps/android-sa-gcp.tsx b/cli/src/build/onboarding/ui/steps/android-sa-gcp.tsx index 7cab186139..c0153f1281 100644 --- a/cli/src/build/onboarding/ui/steps/android-sa-gcp.tsx +++ b/cli/src/build/onboarding/ui/steps/android-sa-gcp.tsx @@ -210,9 +210,9 @@ export interface GoogleSignInStepProps { // dense" tier. That tier depended on the parent measuring leftover rows and // feeding a flag back, which kept tripping the too-small guard; it's gone. const SIGN_IN_TRUST = 'Sign in with Google so Capgo can set up Play Store publishing on your account — your tokens never reach Capgo\'s servers.' -const SIGN_IN_INTRO = 'We\'ll open Google\'s consent screen. The two access requests are:' +const SIGN_IN_INTRO = 'We\'ll open Google\'s consent screen. The access requests are:' -// The two consent scopes — shared so the wording is identical in both forms. +// The consent scopes — shared so the wording is identical in both forms. function SignInBullets() { return ( <> @@ -230,6 +230,13 @@ function SignInBullets() { {' '} — to invite that service account to your Play Console with release-only permissions + + • + {' '} + Play app list + {' '} + — Google words this one "see metrics and data"; we only list your apps to check the one you're building exists (optional — declining just skips that check) + ) } @@ -282,18 +289,19 @@ export const GoogleSignInLearnMoreStep: FC = ({ Can Capgo touch other GCP projects on my account? - The scope allows it, but this CLI only calls APIs against the project you'll pick on the next screen. It creates one service account named - {' '} + The scope allows it, but the CLI only touches the project you pick: it creates one service account ( capgo-native-build - {' '} - in that one project and stops. + ) there and stops. Will Capgo upload anything to Play Store without me knowing? - No. The flow invites one service account into one app (the package you confirm) with release-only permissions. Future builds use that service account, not your OAuth tokens. + No. It invites one service account into one app with release-only permissions. Future builds use that service account, not your OAuth tokens. Can Capgo employees access my Google account? - No. The refresh token never leaves your machine. Capgo's servers only serve the OAuth client ID — they never see your tokens. When provisioning finishes, the CLI asks Google to revoke that token, so even your local copy stops working. + No. The refresh token never leaves your machine and Capgo's servers never see it. When provisioning finishes, the CLI asks Google to revoke it. + + Why does Google mention "metrics and data"? + Google's wording for the Play Reporting permission. We only list your apps' package names to confirm your app exists — declining just skips that check. What if I change my mind later? @@ -303,7 +311,7 @@ export const GoogleSignInLearnMoreStep: FC = ({ , or just delete the service account in Google Cloud. Neither needs Capgo's involvement. - Capgo passed Google's OAuth verification on 2026-05-02 for these scopes. Source code: github.com/Cap-go/capgo + Google-verified OAuth app (2026-05-02) · source: github.com/Cap-go/capgo ` that stays mounted (avoid the @inkjs/ui re-fire bug). +- **Commit:** `feat(cli): verify Android app against Play apps on package-select (generate path)` + +## Phase 5 — Path A: user-invoked Trapeze rename (explicit opt-in) + +**T5.1** `cli/src/build/onboarding/android/android-rename.ts` (pure-ish, I/O injected) +- `buildRenameWorkspaceFiles(pkg): { packageJson, renameMjs }` — the temp `package.json` (`type: module`, pinned `@trapezedev/project`) + `rename.mjs` (setPackageName + setApplicationId + setNamespace + commit; `` from argv). **Always all three calls.** +- `isAndroidStudioRunning(platform, pgrepOutput)` — pure predicate (macOS only; non-mac → returns `unknown`). +- `verifyRenamed(gradleIds, target): boolean`. +- `cli/test/test-android-rename.mjs` — workspace-file generation, predicate (running/closed/unknown), verify. +- **Commit:** `feat(cli): add Android rename workspace builder + AS-detection predicate (+ tests)` + +**T5.2** Orchestration in `app.tsx` (runs ONLY on explicit "Rename for me" selection): +1. `mkdtemp` → write files → `npm install` (spinner "Preparing the project renamer…"); on failure → manual-instructions fallback. +2. **Close-Android-Studio gate:** macOS `pgrep -f "Android Studio"` + poll ~1s until gone (Continue auto-fires); non-mac → one-time confirm. +3. Run `node /rename.mjs ` (cwd = project); capture output. +4. `verifyRenamed`; on failure → surface output + manual fallback (never claim success). +5. `npx cap sync` (spinner; non-zero surfaced, non-fatal). +6. Re-run `reconcileAndroidApp` → exact-match → advance. +- Loader + cancel/back throughout. Telemetry `Android App Verify Auto Fixed`. +- **Commit:** `feat(cli): Path A — user-invoked Trapeze rename + cap sync + re-check` + +## Phase 6 — Path B: create app (one click) + re-check + +**T6.1** `app.tsx` Path B screen: +- "Open Play Console to create this app" → opens `https://play.google.com/console` (ask before re-opening on subsequent attempts — iOS mechanics). +- "Re-check" → re-`listPlayApps` + reconcile, with loader + attempt counting. +- **Inform + allow proceed** (never hard-block): explain the one manual step is "Create app", the first build uploads as a draft automatically. +- Telemetry `Android App Verify Create App Opened`. +- **Commit:** `feat(cli): Path B — open Play Console to create app + re-check (inform, not gate)` + +## Phase 7 — Telemetry + +**T7.1** Wire the events from spec §7 (`Shown` / `Result` / `Picked` / `Auto Fixed` / `Create App Opened`), `channel: 'bundle'`, `tags.step: 'android-app-verify'` always set; mirror the iOS `trackEvent` plumbing. +- **Commit:** `feat(cli): telemetry for Android app-verify step` + +## Phase 8 — Verify & ship + +**T8.1** Full green: typecheck + lint + every new test + `bun run build`. +**T8.2** Manual E2E against **preprod** (`CAPGO_BUILDER_CONFIG_URL=`) using a Capacitor test app: +- match → auto-confirm; wrong-id → picker → rename → cap sync → proceeds; no-app → Path B → Create app → re-check. +- import path → warning banner, gradle-only. +- scope absent → degrades to gradle picker. +**T8.3** Run D2 probe; record whether Path B re-check can confirm vs trust-and-proceed; adjust Path B copy if needed. +**T8.4** Open **draft PR** (base `main`); summary + test plan; link spec + plan. +- **Commit(s):** any fixes from verification. + +## Risk / rollback + +- Feature is **purely additive** to the generate path and **degrades to today's behavior** on any failure (graceful fallback), so risk is contained. +- Trapeze rename mutates the user's project — gated behind explicit opt-in + AS-closed + post-verify; never silent. +- Prod stays inert until D1 adds the scope to prod config (CLI tolerates its absence). diff --git a/docs/superpowers/specs/2026-06-04-android-onboarding-app-listing-design.md b/docs/superpowers/specs/2026-06-04-android-onboarding-app-listing-design.md new file mode 100644 index 0000000000..6668287601 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-android-onboarding-app-listing-design.md @@ -0,0 +1,177 @@ +# Android Onboarding — App Existence Verification (Play Developer Reporting API) + +**Date:** 2026-06-04 +**Status:** Design — ready to implement (one empirical check outstanding, see §11) +**Scope:** Capgo CLI — `build init` (Android), **OAuth / "generate" service-account path only** +**Branch:** `wolny/android-onboarding-app-listing` (off `origin/main`) +**Sibling:** mirrors the iOS verify-app gate (PR #2397, `app-verification.ts` / `verify-app` step) + +--- + +## 1. Summary (TL;DR) + +After Google sign-in, list the developer's real Play Store apps via the **Play Developer Reporting API** (`apps:search`) and reconcile them against the project's Gradle `applicationId`: + +- **Match** → silently confirm the package, no question (like iOS exact-match). +- **App exists, build id is wrong** → **Path A**: show a picker of the real Play apps. **Nothing is rewritten automatically.** If the user *explicitly* picks an app and then *explicitly* opts into "Rename my project for me", we run a **Trapeze** rename (+ `cap sync`, re-check) — otherwise they pick/retype/decline. The rename is a user-invoked convenience behind confirmation, never silent. +- **No app exists** → **Path B**: open Play Console so the user clicks **"Create app"** (the *single* irreducible manual step on Android), then proceed — the first build uploads as a draft and succeeds. + +OAuth-only: the listing uses the **user's** OAuth token + the `playdeveloperreporting` scope. We never enable the Reporting API on the user's service account, so the **import (custom-SA) path** keeps today's Gradle-only flow with a "verification skipped" warning. Any verification failure degrades gracefully to the plain Gradle picker — onboarding is never blocked. + +--- + +## 2. Problem + +`android-package-select` picks the Play package purely from local Gradle (`findAndroidApplicationIds`). Nothing checks the package actually exists in the user's Play Console, so a wrong/stale/typo'd `applicationId` flows through, the SA gets invited/granted for a package Play doesn't have, and the failure only surfaces at build/publish time. This is the Android analog of the iOS verify-app gap. + +## 3. Goal / invariant + +> **A Play Store app record exists whose `packageName` == the project's build `applicationId`.** + +That's the whole invariant. If it holds, Capgo Builder's first build uploads as a draft and succeeds (§Appendix). If it doesn't, the build 404s. Everything below is just *detecting* which case we're in and guiding the user to satisfy it. + +--- + +## 4. Background facts (researched; confidence-flagged) + +| Fact | Confidence | Source | +|---|---|---| +| `apps:search` (`GET …/v1beta1/apps:search`) lists apps "accessible by the user" → `{packageName, displayName}`, paginated | HIGH | [reporting API ref](https://developers.google.com/play/developer/reporting/reference/rest/v1beta1/apps/search) | +| It's a **separate** API from `androidpublisher`; single scope `…/auth/playdeveloperreporting`; works with SA *or* user OAuth | HIGH | reporting API getting-started | +| `playdeveloperreporting` + `androidpublisher` are **non-sensitive** scopes (no Trust & Safety review) | HIGH | the project's Cloud Console Data Access page (only `cloud-platform` is sensitive there) | +| Creating a **public** Play app record is **UI-only** — no `apps.create`, no fastlane/Terraform/automation, Console login is bot-blocked | HIGH | androidpublisher ref; fastlane; gradle-play-publisher README; 2024–2026 release notes | +| The **first** AAB upload to a never-released app **works via API as a `draft`** once the app record exists | MEDIUM | [fastlane #18293](https://github.com/fastlane/fastlane/discussions/18293) ("Only releases with status draft may be created on draft app") | +| `edits.insert` 404s until the **app record exists** — so the hard gate is *app creation*, not the first upload | HIGH | gradle-play-publisher #75/#836; Codemagic docs | +| Capgo Builder already uploads with `release_status: ENV['PLAY_STORE_RELEASE_STATUS'] || 'draft'` to the `internal` track | HIGH | `capgo_builder_new/src/fastlaneTemplateAndroid.ts:442` | +| Whether `apps:search` lists a **zero-release Draft** app (created, never uploaded) | **UNVERIFIED** | needs one empirical probe (§11) | +| The Custom App Publishing API can create+upload, but only **permanently-private managed Google Play** apps — irrelevant for public apps | HIGH | custom-app-api/publish | + +Net: the only thing a human must do for a brand-new app is click **"Create app"** once. Listing existing apps is solved by `apps:search`; the first upload is handled by the builder's existing draft default. + +--- + +## 5. Design + +### 5.1 New API helper + +`cli/src/build/onboarding/android/reporting-api.ts`: +- `parseAppsSearchResponse(json): { packageName, displayName }[]` — pure, tolerant (mirrors iOS `parseAppsResponse`). +- `listPlayApps(accessToken): Promise<{ packageName, displayName }[]>` — `GET …/v1beta1/apps:search?pageSize=1000`, follow `nextPageToken` to a sane cap, `Authorization: Bearer `. Thin fetch; pure parser is unit-tested. + +### 5.2 Reuse the iOS classifier + +Reuse `app-verification.ts` (`classifyAppVerification`) from `main`: feed `releaseBundleId: gradleId`, `apps`, `registeredBundleIds: []` → `exact-match | wrong-build-id | no-app-*`. Keep the decision logic shared. + +### 5.3 Verification on `android-package-select` (generate path only) + +When entering `android-package-select` AND `serviceAccountMethod === 'generate'`: +1. Detect Gradle ids (`findAndroidApplicationIds`) **and** `listPlayApps(await ensureAccessToken())`. +2. Reconcile ("expand the Gradle list") and route: + - **Exactly one Gradle id, and it's in `apps` → auto-confirm, skip the picker.** `addLog('✓ Building "" () — matches your Play Store app.')` → continue to `gcp-setup-running`. + - **Account has apps, build id matches none → Path A picker** (real Play apps annotated `✓ in Play Console`, the Gradle ids, "type a different name", and a "Create a new app → Path B" entry). Choosing a real app whose `packageName` ≠ the build id **then offers** the Trapeze rename as one option (alongside "I'll fix build.gradle myself — re-check" and "Back"). The rename is **never run without that explicit second choice.** + - **No apps at all → Path B.** +3. The chosen/renamed package flows into `androidPackageChosen` / SA grant exactly as today. + +### 5.4 Path A — user-invoked rename via Trapeze (explicit opt-in, never automatic) + +Hand-editing an Android `applicationId` correctly spans `build.gradle`, `namespace`, manifest/package, then needs `cap sync` — "retype it" is a non-starter. So we *offer* (never force) **"Rename my Android project to `` for me"** as one menu option, powered by Trapeze. The orchestrated sequence below runs **only after the user explicitly selects that option** — there is no code path that rewrites the project without that choice. The script: + +```ts +const project = new MobileProject('.', { android: { path: 'android' } }) +await project.load() +await project.android?.setPackageName(appId) +const gradle = await project.android?.getGradleFile('app/build.gradle') +await gradle?.setApplicationId(appId) +await gradle?.setNamespace(appId) +await project.commit() +``` + +**Trapeze is NOT bundled** — installed on demand into a temp dir only when the user opts in (CLI stays lean). Orchestrated sequence: + +1. **Prepare** — `mkdtemp`; write `package.json` (`type: module`) + `rename.mjs` (script above, `` from `argv`); `npm install @trapezedev/project@` in the temp dir (spinner "Preparing the project renamer…"). Node resolves the import from `/node_modules`; run with **cwd = the user's project** so `MobileProject('.')` targets it. +2. **Close Android Studio (gate).** Editing native files while Studio holds them open risks a half-written project / Studio clobbering the change. + - macOS: `pgrep -f "Android Studio"`; if running → "Please quit Android Studio — continues automatically once closed", re-check ~1s until gone. + - Other OSes: one-time "Close Android Studio if open" confirm, then proceed. +3. **Run** `node /rename.mjs ` (cwd = project); capture output. +4. **Verify** — re-read `findAndroidApplicationIds` and confirm it now contains ``; if not, surface output + manual fallback (never claim false success). +5. **`npx cap sync`** (cwd = project; spinner) to keep Capacitor consistent; non-zero exit surfaced but non-fatal. +6. **Re-reconcile** → matches → proceed. + +Loader + feedback mirror iOS; cancel/back always available. + +### 5.5 Path B — create the app (one click), then we take over + +Offer **"Open Play Console to create this app"** → opens `https://play.google.com/console`; the user clicks **Create app**. Then re-check (`apps:search`, loader + attempt counting + ask-before-reopen, same mechanics as iOS) and proceed. + +- The Create-app click is the **only** irreducible manual step (no API/automation exists — HIGH confidence). +- **No manual first upload needed:** the builder uploads the first AAB as a `draft` to `internal` by default (`fastlaneTemplateAndroid.ts:442`), which is exactly what a never-released app accepts. +- **Inform, don't hard-gate:** the click can't be automated, and `apps:search` freshness for a just-created app is unverified (§11), so allow proceed after informing — blocking could trap a user whose new app hasn't propagated. +- Per-build nuance (not an onboarding blocker): `draft` means the user clicks "rollout" in Play Console to push each build to testers; `PLAY_STORE_RELEASE_STATUS` overrides for auto-rollout. + +### 5.6 Import (custom-SA) path — Gradle-only + warn + +When `serviceAccountMethod === 'existing'`, keep today's Gradle-only picker, plus a one-line banner: *"App existence isn't verified on the imported-service-account path (it needs Google sign-in). Proceeding with the package from build.gradle — make sure it exists in Play Console."* No `apps:search`, no OAuth. + +### 5.7 Graceful degradation (never block) + +On the generate path, if the reporting scope wasn't granted, the API is disabled (403), the token can't refresh, or the call errors/times out → **warn + fall back to the plain Gradle picker**. A verification failure must never block onboarding (the optional scope, §6, is what makes this safe). + +--- + +## 6. Scope / config changes + +- Add `https://www.googleapis.com/auth/playdeveloperreporting` to `OAUTH_SCOPES_FOR_ONBOARDING` (`app.tsx:179`) **as optional** — excluded from the required-scope check so its absence degrades gracefully. +- Backend `/private/config/builder` must include it in `scopes[]` to actually be requested (coordinate; the CLI tolerates absence). +- Consent screen already lists it (non-sensitive) — no verification submission. + +## 7. Telemetry + +`channel: 'bundle'`, `tags.step: 'android-app-verify'` always set: +- `Android App Verify Shown` — generate path, step entered. tags: `app_count`, `gradle_id_count`. +- `Android App Verify Result` — `result` ∈ `exact-match` / `wrong-build-id` / `no-app` / `multi-gradle` / `scope-missing` / `fetch-failed` / `skipped-import`. +- `Android App Verify Picked` — tags: `matches_play_app`, `source` (gradle | play-app | manual). +- `Android App Verify Auto Fixed` — Trapeze rename verified. tags: `from`, `to`, `cap_sync_ok`, `studio_wait_ms`. +- `Android App Verify Create App Opened` — Path B. tags: `attempt`. + +## 8. Error handling + +- Reporting scope missing / 403 / network → warn + Gradle-only fallback (degraded, not blocked). +- No `ad_hoc` equivalent on Android — the only fork is generate vs import. +- Never throw out of the verification path into the wizard. + +## 9. Testing + +- **Pure:** `parseAppsSearchResponse` (well-formed / empty / missing fields / pagination); reuse/extend `app-verification` decision tests for Android inputs (single-match-skip, no-match, multi-Gradle, empty list). +- **Branch:** generate-vs-import gating; scope-missing/fetch-failed → fallback (pure decision fn). +- **Trapeze (pure parts):** temp-script/`package.json` generation; post-run verification (re-read Gradle ids → contains ``); Android-Studio-detection predicate (mock `pgrep`). `npm install` / `node` / `cap sync` spawns are integration-only (mock the spawner). +- Wire `test:` script + aggregate entry, matching the onboarding test pattern. + +## 10. Out of scope (v1) + +- **Bundling Trapeze** — installed on demand only (the rename itself is in scope). +- **Enabling the Reporting API on / granting the scope to the user's service account** — OAuth-token only. +- **Verification on the import (custom-SA) path** — warn + skip. +- **Auto-creating the Play app** — the "Create app" click is UI-only (no API/bypass). +- **Hard-gating Path B** — inform + allow proceed. + +## 11. Decisions & open questions + +**Decided:** +- **Always full rename (always set `namespace`).** Path A always runs the proven 3-call script (`setPackageName` + `setApplicationId` + `setNamespace`). Skipping `namespace` is not an option — AGP 8 requires it, and a package move (`setPackageName`) with a stale `namespace` breaks `R`/`BuildConfig` imports and fails the build. No "namespace opt-in." +- **`npx cap sync` after the rename is REQUIRED** — keep it (not optional). +- **On-demand Trapeze install** — accepted (pin version + spinner + manual fallback on failure). +- **Android Studio detection** — accepted: macOS auto-detects (`pgrep`) + polls until closed; other OSes get a one-time confirm. +- **Backend coordination → test via preprod.** Add the `playdeveloperreporting` scope to preprod's `/private/config/builder` `scopes[]` and point the CLI at it via `CAPGO_BUILDER_CONFIG_URL` for end-to-end testing. The scope stays optional so prod (before the scope is added) degrades gracefully. + +**Open:** +- **Does `apps:search` list a zero-release Draft app? (UNVERIFIED — one probe / preprod test.)** Decides whether the post-"Create app" re-check can *confirm* success or must *trust-and-proceed*. Does **not** block implementation (Path B informs-and-proceeds either way). +- **Multiple Gradle flavors** — when >1 `applicationId` matches a Play app, show the picker (no auto-skip). Confirm vs. iOS-style "pick the main one". (Minor; picker is the safe default.) + +## 12. Appendix — why Path B "completes after one click" + +The Android brand-new-app path used to look like a dead end ("you can't create the app or upload via API"). Research refined this into two separate gates: + +1. **App-record creation** — genuinely UI-only. No public API, no fastlane/Terraform/Custom-App bypass (Custom App API only makes permanently-private enterprise apps). The user must click **Create app** in Play Console once. +2. **First upload** — *not* the blocker. Once the app record exists, the first AAB uploads via API as a `draft` (fastlane #18293), and Capgo Builder already defaults to `release_status: draft`. So the build does it automatically. + +Therefore the entire "no app" flow reduces to a single human action — the Create-app click — and everything after is automated.