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 Feb 7, 2024
1 parent 0b71812 commit b096105
Show file tree
Hide file tree
Showing 19 changed files with 427 additions and 55 deletions.
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
Expand Up @@ -6,7 +6,7 @@ import Log from '../../../log';
import { CredentialsContext } from '../../context';
import { AppLookupParams } from '../api/GraphqlClient';

export class AssignGoogleServiceAccountKey {
export class AssignGoogleServiceAccountKeyForSubmissions {
constructor(private app: AppLookupParams) {}

public async runAsync(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class CreateGoogleServiceAccountKey {

Log.log(
`${chalk.bold(
'A Google Service Account JSON key is required to upload your app to Google Play Store'
'A Google Service Account JSON key is required for uploading your app to Google Play Store, and for sending 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',
Expand Down
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))
);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import nullthrows from 'nullthrows';

import { AssignGoogleServiceAccountKey } from './AssignGoogleServiceAccountKey';
import { AssignGoogleServiceAccountKeyForSubmissions } from './AssignGoogleServiceAccountKeyForSubmissions';
import { CreateGoogleServiceAccountKey } from './CreateGoogleServiceAccountKey';
import { UseExistingGoogleServiceAccountKey } from './UseExistingGoogleServiceAccountKey';
import {
Expand All @@ -13,7 +13,7 @@ import { CredentialsContext } from '../../context';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { AppLookupParams } from '../api/GraphqlClient';

export class SetUpGoogleServiceAccountKey {
export class SetUpGoogleServiceAccountKeyForSubmissions {
constructor(private app: AppLookupParams) {}

public async runAsync(ctx: CredentialsContext): Promise<CommonAndroidAppCredentialsFragment> {
Expand Down Expand Up @@ -46,7 +46,10 @@ export class SetUpGoogleServiceAccountKey {
} else {
googleServiceAccountKey = await this.createOrUseExistingKeyAsync(ctx);
}
return await new AssignGoogleServiceAccountKey(this.app).runAsync(ctx, googleServiceAccountKey);
return await new AssignGoogleServiceAccountKeyForSubmissions(this.app).runAsync(
ctx,
googleServiceAccountKey
);
}

private async isGoogleServiceAccountKeySetupAsync(ctx: CredentialsContext): Promise<boolean> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { testGoogleServiceAccountKeyFragment } from '../../../__tests__/fixtures-android';
import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants';
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { AssignGoogleServiceAccountKey } from '../AssignGoogleServiceAccountKey';
import { AssignGoogleServiceAccountKeyForSubmissions } from '../AssignGoogleServiceAccountKey';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';

jest.mock('../../../../graphql/queries/AppQuery');

describe(AssignGoogleServiceAccountKey, () => {
describe(AssignGoogleServiceAccountKeyForSubmissions, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
Expand All @@ -16,7 +16,9 @@ describe(AssignGoogleServiceAccountKey, () => {
nonInteractive: false,
});
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
const assignGoogleServiceAccountKeyAction = new AssignGoogleServiceAccountKey(appLookupParams);
const assignGoogleServiceAccountKeyAction = new AssignGoogleServiceAccountKeyForSubmissions(
appLookupParams
);
await assignGoogleServiceAccountKeyAction.runAsync(ctx, testGoogleServiceAccountKeyFragment);

// expect app credentials to be fetched/created, then updated
Expand All @@ -30,7 +32,9 @@ describe(AssignGoogleServiceAccountKey, () => {
nonInteractive: true,
});
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
const assignGoogleServiceAccountKeyAction = new AssignGoogleServiceAccountKey(appLookupParams);
const assignGoogleServiceAccountKeyAction = new AssignGoogleServiceAccountKeyForSubmissions(
appLookupParams
);

// dont fail if users are running in non-interactive mode
await expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { testAppQueryByIdResponse } from '../../../__tests__/fixtures-constants'
import { createCtxMock } from '../../../__tests__/fixtures-context';
import { MissingCredentialsNonInteractiveError } from '../../../errors';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { SetUpGoogleServiceAccountKey } from '../SetUpGoogleServiceAccountKey';
import { SetUpGoogleServiceAccountKeyForSubmissions } from '../SetUpGoogleServiceAccountKeyForSubmissions';

jest.mock('../../../../prompts');
jest.mock('fs');
Expand All @@ -24,7 +24,7 @@ beforeEach(() => {
vol.reset();
});

describe(SetUpGoogleServiceAccountKey, () => {
describe(SetUpGoogleServiceAccountKeyForSubmissions, () => {
beforeEach(() => {
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
});
Expand All @@ -39,7 +39,9 @@ describe(SetUpGoogleServiceAccountKey, () => {
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
const setupGoogleServiceAccountKeyAction = new SetUpGoogleServiceAccountKey(appLookupParams);
const setupGoogleServiceAccountKeyAction = new SetUpGoogleServiceAccountKeyForSubmissions(
appLookupParams
);
await setupGoogleServiceAccountKeyAction.runAsync(ctx);

expect(ctx.android.createGoogleServiceAccountKeyAsync).not.toHaveBeenCalled();
Expand All @@ -62,7 +64,9 @@ describe(SetUpGoogleServiceAccountKey, () => {
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
const setupGoogleServiceAccountKeyAction = new SetUpGoogleServiceAccountKey(appLookupParams);
const setupGoogleServiceAccountKeyAction = new SetUpGoogleServiceAccountKeyForSubmissions(
appLookupParams
);
await setupGoogleServiceAccountKeyAction.runAsync(ctx);

expect(ctx.android.createGoogleServiceAccountKeyAsync).toHaveBeenCalledTimes(1);
Expand All @@ -73,7 +77,9 @@ describe(SetUpGoogleServiceAccountKey, () => {
nonInteractive: true,
});
const appLookupParams = await getAppLookupParamsFromContextAsync(ctx);
const setupGoogleServiceAccountKeyAction = new SetUpGoogleServiceAccountKey(appLookupParams);
const setupGoogleServiceAccountKeyAction = new SetUpGoogleServiceAccountKeyForSubmissions(
appLookupParams
);
await expect(setupGoogleServiceAccountKeyAction.runAsync(ctx)).rejects.toThrowError(
MissingCredentialsNonInteractiveError
);
Expand Down
10 changes: 10 additions & 0 deletions packages/eas-cli/src/credentials/android/api/GraphqlClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,11 @@ export async function updateAndroidAppCredentialsAsync(
{
androidFcmId,
googleServiceAccountKeyForSubmissionsId,
googleServiceAccountKeyForFcmV1Id,
}: {
androidFcmId?: string;
googleServiceAccountKeyForSubmissionsId?: string;
googleServiceAccountKeyForFcmV1Id?: string;
}
): Promise<CommonAndroidAppCredentialsFragment> {
let updatedAppCredentials = appCredentials;
Expand All @@ -127,6 +129,14 @@ export async function updateAndroidAppCredentialsAsync(
googleServiceAccountKeyForSubmissionsId
);
}
if (googleServiceAccountKeyForFcmV1Id) {
updatedAppCredentials =
await AndroidAppCredentialsMutation.setGoogleServiceAccountKeyForFcmV1Async(
graphqlClient,
appCredentials.id,
googleServiceAccountKeyForFcmV1Id
);
}
return updatedAppCredentials;
}

Expand Down
Loading

0 comments on commit b096105

Please sign in to comment.