Skip to content
Draft
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9144472
docs: design for Android onboarding app-existence check via Play Deve…
WcaleNieWolny Jun 5, 2026
161ecea
docs: Android verify spec — Trapeze auto-rename (Path A) + Play Conso…
WcaleNieWolny Jun 5, 2026
0205945
docs: correct overstated apps:search claim — androidpublisher first-u…
WcaleNieWolny Jun 5, 2026
98699fe
docs: confirm no programmatic create/first-upload for public Play app…
WcaleNieWolny Jun 5, 2026
db0593c
docs: correct Path B — app-record creation is the only UI-only step (…
WcaleNieWolny Jun 5, 2026
7b34b38
docs: resolve draft-upload question — builder already defaults releas…
WcaleNieWolny Jun 5, 2026
29a3384
docs: consolidate Android verify spec into clean v1 (resolved facts +…
WcaleNieWolny Jun 5, 2026
0bf7480
docs: clarify Path A rename is explicit opt-in, never automatic (no s…
WcaleNieWolny Jun 6, 2026
6f95e78
docs: record Android verify decisions + add phased implementation plan
WcaleNieWolny Jun 6, 2026
f95aad8
feat(cli): Android app-verify pure modules - reporting apps:search + …
WcaleNieWolny Jun 6, 2026
b38162f
feat(cli): verify Android app vs Play apps on package-select (scope +…
WcaleNieWolny Jun 6, 2026
2266b36
feat(cli): Path A Trapeze rename + Path B create-app + telemetry for …
WcaleNieWolny Jun 6, 2026
95887b9
fix(cli): address Android app-verify review findings
WcaleNieWolny Jun 6, 2026
32ce60a
feat(cli): explain the Play app-list scope on the pre-OAuth screens
WcaleNieWolny Jun 6, 2026
640c902
feat(cli): warn on the OAuth success page when the optional Play app-…
WcaleNieWolny Jun 6, 2026
0a875c6
fix(cli): drop useless escape in success-page template (oxlint)
WcaleNieWolny Jun 6, 2026
1d8c091
fix(cli): make Android app-verify a hard gate — never advance with a …
WcaleNieWolny Jun 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
102 changes: 102 additions & 0 deletions cli/src/build/onboarding/android/android-rename.ts
Original file line number Diff line number Diff line change
@@ -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 <appId>`. */
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 <appId>')
process.exit(1)
}

const project = new MobileProject('.', { android: { path: 'android' } })

@jinhongliang991013 jinhongliang991013 Jun 13, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Honor the configured Android platform path

androidDir is resolved from capacitor.config and may be a non-default path such as apps/mobile/platforms/android-native, but this generated script always edits ./android. With only the custom directory present, the advertised automatic rename always fails. More dangerously, if the repository also contains a stale/default ./android, Trapeze commits the package/namespace change to that wrong native project; doRename then verifies the configured directory and reports failure only after the unrelated tree has been mutated. Pass the resolved androidDir into the generated script (or its MobileProject config) and cover a non-default path in the rename test.

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)
}
89 changes: 89 additions & 0 deletions cli/src/build/onboarding/android/app-verification-android.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 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] ?? ''
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' }
}
16 changes: 15 additions & 1 deletion cli/src/build/onboarding/android/oauth-google.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>
}
Expand Down Expand Up @@ -562,7 +570,13 @@ 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)
//
// 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 missing = findMissingScopes(tokens.scope, requiredScopes)
if (missing.length > 0) {
finishResponse(scopeMissingHtml(missing), 400)
throw new MissingScopesError(missing, tokens.scope)
Expand Down
121 changes: 121 additions & 0 deletions cli/src/build/onboarding/android/reporting-api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// 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 `packageName` /
* `displayName` — Google omits fields rather than nulling them, and a malformed
* page must never throw into the wizard (the whole feature degrades gracefully).
*
* Mirrors iOS `parseAppsResponse`: maps every entry without dropping partial
* ones, so the caller sees exactly what the API returned.
*/
export function parseAppsSearchResponse(json: any): PlayApp[] {
return (json?.apps || []).map((app: any): PlayApp => ({
packageName: app?.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<PlayApp[]> {
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
}
10 changes: 10 additions & 0 deletions cli/src/build/onboarding/android/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -234,6 +240,8 @@ export const ANDROID_STEP_PROGRESS: Record<AndroidOnboardingStep, number> = {
'gcp-project-create-name': 60,

'android-package-select': 65,
'android-app-verify-rename': 66,
'android-app-verify-create-app': 66,

'gcp-setup-running': 70,

Expand Down Expand Up @@ -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':
Expand Down
Loading
Loading