-
-
Notifications
You must be signed in to change notification settings - Fork 129
feat(cli): Android onboarding - verify app exists in Play Store (apps:search + Trapeze rename) #2443
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
WcaleNieWolny
wants to merge
17
commits into
main
Choose a base branch
from
wolny/android-onboarding-app-listing
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Draft
feat(cli): Android onboarding - verify app exists in Play Store (apps:search + Trapeze rename) #2443
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 161ecea
docs: Android verify spec — Trapeze auto-rename (Path A) + Play Conso…
WcaleNieWolny 0205945
docs: correct overstated apps:search claim — androidpublisher first-u…
WcaleNieWolny 98699fe
docs: confirm no programmatic create/first-upload for public Play app…
WcaleNieWolny db0593c
docs: correct Path B — app-record creation is the only UI-only step (…
WcaleNieWolny 7b34b38
docs: resolve draft-upload question — builder already defaults releas…
WcaleNieWolny 29a3384
docs: consolidate Android verify spec into clean v1 (resolved facts +…
WcaleNieWolny 0bf7480
docs: clarify Path A rename is explicit opt-in, never automatic (no s…
WcaleNieWolny 6f95e78
docs: record Android verify decisions + add phased implementation plan
WcaleNieWolny f95aad8
feat(cli): Android app-verify pure modules - reporting apps:search + …
WcaleNieWolny b38162f
feat(cli): verify Android app vs Play apps on package-select (scope +…
WcaleNieWolny 2266b36
feat(cli): Path A Trapeze rename + Path B create-app + telemetry for …
WcaleNieWolny 95887b9
fix(cli): address Android app-verify review findings
WcaleNieWolny 32ce60a
feat(cli): explain the Play app-list scope on the pre-OAuth screens
WcaleNieWolny 640c902
feat(cli): warn on the OAuth success page when the optional Play app-…
WcaleNieWolny 0a875c6
fix(cli): drop useless escape in success-page template (oxlint)
WcaleNieWolny 1d8c091
fix(cli): make Android app-verify a hard gate — never advance with a …
WcaleNieWolny File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' } }) | ||
| 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
89
cli/src/build/onboarding/android/app-verification-android.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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' } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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
androidDiris resolved fromcapacitor.configand may be a non-default path such asapps/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;doRenamethen verifies the configured directory and reports failure only after the unrelated tree has been mutated. Pass the resolvedandroidDirinto the generated script (or itsMobileProjectconfig) and cover a non-default path in the rename test.