Skip to content

Commit

Permalink
[fcmv1] Manage FCM V1 Google Service Account Key in CLI
Browse files Browse the repository at this point in the history
# Why
NOTE: DO NOT LAND THIS UNTIL GRAPHQL CHANGES LAND IN WWW

We're adding support for FCM V1 credentials, as Google is shutting down
the FCM Legacy API for sending Android notifications in June.

# How
Add new prompts and refactor existing prompts to match this schematic:

# Test Plan
Verified all functionality e2e:
  • Loading branch information
christopherwalter committed Jan 23, 2024
1 parent b5f2f52 commit eb9152a
Show file tree
Hide file tree
Showing 18 changed files with 585 additions and 48 deletions.
4 changes: 1 addition & 3 deletions packages/eas-cli/graphql-codegen.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@ documents:
- 'src/credentials/ios/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/credentials/android/api/graphql/**/!(*.d).{ts,tsx}'
- 'src/commands/**/*.ts'
- 'src/branch/**/*.ts'
- 'src/channel/**/*.ts'
generates:
src/graphql/generated.ts:
plugins:
Expand All @@ -16,7 +14,7 @@ generates:
dedupeOperationSuffix: true
hooks:
afterOneFileWrite:
- 'node ./scripts/annotate-graphql-codegen.js'
- ./annotate-graphql-codegen.sh
./graphql.schema.json:
plugins:
- 'introspection'
154 changes: 154 additions & 0 deletions packages/eas-cli/graphql.schema.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
CommonAndroidAppCredentialsFragment,
GoogleServiceAccountKeyFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { CredentialsContext } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';

export class AssignGoogleServiceAccountKeyForFcmV1 {
constructor(private app: AppLookupParams) {}

public async runAsync(
ctx: CredentialsContext,
googleServiceAccountKey: GoogleServiceAccountKeyFragment
): Promise<CommonAndroidAppCredentialsFragment> {
const appCredentials =
await ctx.android.createOrGetExistingAndroidAppCredentialsWithBuildCredentialsAsync(
ctx.graphqlClient,
this.app
);
const updatedAppCredentials = await ctx.android.updateAndroidAppCredentialsAsync(
ctx.graphqlClient,
appCredentials,
{
googleServiceAccountKeyForFcmV1Id: googleServiceAccountKey.id,
}
);
Log.succeed(
`Google Service Account Key assigned to ${this.app.androidApplicationIdentifier} for FCM V1`
);
return updatedAppCredentials;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import chalk from 'chalk';
import fs from 'fs-extra';

import { AccountFragment, GoogleServiceAccountKeyFragment } from '../../../graphql/generated';
import Log, { learnMore } from '../../../log';
import { promptAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
import { GoogleServiceAccountKey } from '../credentials';
import {
detectGoogleServiceAccountKeyPathAsync,
readAndValidateServiceAccountKey,
} from '../utils/googleServiceAccountKey';

export class CreateGoogleServiceAccountKeyForFcmV1 {
constructor(private account: AccountFragment) {}

public async runAsync(ctx: CredentialsContext): Promise<GoogleServiceAccountKeyFragment> {
if (ctx.nonInteractive) {
throw new Error(`New FCM V1 Service Account Key cannot be created in non-interactive mode.`);
}
const jsonKeyObject = await this.provideAsync(ctx);
const gsaKeyFragment = await ctx.android.createGoogleServiceAccountKeyAsync(
ctx.graphqlClient,
this.account,
jsonKeyObject
);
Log.succeed('Uploaded FCM V1 Service Account Key.');
return gsaKeyFragment;
}

private async provideAsync(ctx: CredentialsContext): Promise<GoogleServiceAccountKey> {
try {
const keyJsonPath = await this.provideKeyJsonPathAsync(ctx);
return readAndValidateServiceAccountKey(keyJsonPath);
} catch (e) {
Log.error(e);
return await this.provideAsync(ctx);
}
}

private async provideKeyJsonPathAsync(ctx: CredentialsContext): Promise<string> {
const detectedPath = await detectGoogleServiceAccountKeyPathAsync(ctx.projectDir);
if (detectedPath) {
return detectedPath;
}

Log.log(
`${chalk.bold(
'A Google Service Account JSON key is required to send Android notifications via FCM V1'
)}.\n` +
`If you're not sure what this is or how to create one, ${learnMore(
'https://expo.fyi/creating-google-service-account-for-fcm-v1',
{ learnMoreMessage: 'learn more' }
)}`
);
const { filePath } = await promptAsync({
name: 'filePath',
message: 'Path to Google Service Account file:',
initial: 'api-0000000000000000000-111111-aaaaaabbbbbb.json',
type: 'text',
// eslint-disable-next-line async-protect/async-suffix
validate: async (filePath: string) => {
try {
const stats = await fs.stat(filePath);
if (stats.isFile()) {
return true;
}
return 'Input is not a file.';
} catch {
return 'File does not exist.';
}
},
});
return filePath;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import nullthrows from 'nullthrows';

import { AssignGoogleServiceAccountKeyForFcmV1 } from './AssignGoogleServiceAccountKeyForFcmV1';
import { CreateGoogleServiceAccountKeyForFcmV1 } from './CreateGoogleServiceAccountKeyForFcmV1';
import { UseExistingGoogleServiceAccountKey } from './UseExistingGoogleServiceAccountKey';
import {
CommonAndroidAppCredentialsFragment,
GoogleServiceAccountKeyFragment,
} from '../../../graphql/generated';
import Log from '../../../log';
import { promptAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { AppLookupParams } from '../api/GraphqlClient';

export class SetUpGoogleServiceAccountKeyForFcmV1 {
constructor(private app: AppLookupParams) {}

public async runAsync(ctx: CredentialsContext): Promise<CommonAndroidAppCredentialsFragment> {
const isKeySetup = await this.isGoogleServiceAccountKeySetupAsync(ctx);
if (isKeySetup) {
Log.succeed('Google Service Account Key for FCM V1 already set up.');
return nullthrows(
await ctx.android.getAndroidAppCredentialsWithCommonFieldsAsync(
ctx.graphqlClient,
this.app
),
'androidAppCredentials cannot be null if google service account key is already set up'
);
}
if (ctx.nonInteractive) {
throw new MissingCredentialsNonInteractiveError(
'Google Service Account Keys cannot be set up in --non-interactive mode.'
);
}

const keysForAccount = await ctx.android.getGoogleServiceAccountKeysForAccountAsync(
ctx.graphqlClient,
this.app.account
);
let googleServiceAccountKey = null;
if (keysForAccount.length === 0) {
googleServiceAccountKey = await new CreateGoogleServiceAccountKeyForFcmV1(
this.app.account
).runAsync(ctx);
} else {
googleServiceAccountKey = await this.createOrUseExistingKeyAsync(ctx);
}
return await new AssignGoogleServiceAccountKeyForFcmV1(this.app).runAsync(
ctx,
googleServiceAccountKey
);
}

private async isGoogleServiceAccountKeySetupAsync(ctx: CredentialsContext): Promise<boolean> {
const appCredentials = await ctx.android.getAndroidAppCredentialsWithCommonFieldsAsync(
ctx.graphqlClient,
this.app
);
return !!appCredentials?.googleServiceAccountKeyForFcmV1;
}

private async createOrUseExistingKeyAsync(
ctx: CredentialsContext
): Promise<GoogleServiceAccountKeyFragment> {
const { action } = await promptAsync({
type: 'select',
name: 'action',
message: 'Select the Google Service Account Key to use for FCM V1:',
choices: [
{
title: '[Choose an existing key]',
value: 'CHOOSE_EXISTING',
},
{ title: '[Upload a new service account key]', value: 'GENERATE' },
],
});

if (action === 'GENERATE') {
return await new CreateGoogleServiceAccountKeyForFcmV1(this.app.account).runAsync(ctx);
}
return (
(await new UseExistingGoogleServiceAccountKey(this.app.account).runAsync(ctx)) ??
(await this.createOrUseExistingKeyAsync(ctx))
);
}
}
Loading

0 comments on commit eb9152a

Please sign in to comment.