diff --git a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserOnlineCordovaExport.js b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserOnlineCordovaExport.js index 31308bcf44e6..0697b01b47e2 100644 --- a/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserOnlineCordovaExport.js +++ b/newIDE/app/src/ExportAndShare/BrowserExporters/BrowserOnlineCordovaExport.js @@ -3,6 +3,7 @@ import * as React from 'react'; import assignIn from 'lodash/assignIn'; import { type Build, + type BuildSigningOptions, buildCordovaAndroid, getBuildFileUploadOptions, } from '../../Utils/GDevelopServices/Build'; @@ -65,6 +66,7 @@ export const browserOnlineCordovaExportPipeline: ExportPipeline< getInitialExportState: () => ({ targets: ['androidApk'], keystore: 'new', + buildSigningOptions: null, signingDialogOpen: false, }), @@ -189,12 +191,17 @@ export const browserOnlineCordovaExportPipeline: ExportPipeline< if (!firebaseUser) return Promise.reject(new Error('User is not authenticated')); + const signing: BuildSigningOptions = + exportState.keystore === 'custom' && exportState.buildSigningOptions + ? exportState.buildSigningOptions + : { keystore: exportState.keystore }; + return buildCordovaAndroid( getAuthorizationHeader, firebaseUser.uid, uploadBucketKey, exportState.targets, - exportState.keystore, + signing, gameId, options, payWithCredits diff --git a/newIDE/app/src/ExportAndShare/GenericExporters/OnlineCordovaExport.js b/newIDE/app/src/ExportAndShare/GenericExporters/OnlineCordovaExport.js index 824c151fcae6..e31607fd1ae8 100644 --- a/newIDE/app/src/ExportAndShare/GenericExporters/OnlineCordovaExport.js +++ b/newIDE/app/src/ExportAndShare/GenericExporters/OnlineCordovaExport.js @@ -3,7 +3,10 @@ import { Trans } from '@lingui/macro'; import * as React from 'react'; import Text from '../../UI/Text'; import { Column, Line } from '../../UI/Grid'; -import { type TargetName } from '../../Utils/GDevelopServices/Build'; +import { + type TargetName, + type BuildSigningOptions, +} from '../../Utils/GDevelopServices/Build'; import FormControlLabel from '@material-ui/core/FormControlLabel'; import Radio from '@material-ui/core/Radio'; import RadioGroup from '@material-ui/core/RadioGroup'; @@ -14,10 +17,15 @@ import { type HeaderProps, type ExportFlowProps } from '../ExportPipeline.flow'; import BuildStepsProgress from '../Builds/BuildStepsProgress'; import RaisedButton from '../../UI/RaisedButton'; import { ColumnStackLayout } from '../../UI/Layout'; +import { AndroidSigningCredentialsSelector } from '../SigningCredentials/AndroidSigningCredentialsSelector'; +import TextField from '../../UI/TextField'; +import { t } from '@lingui/macro'; +import AlertMessage from '../../UI/AlertMessage'; export type ExportState = {| targets: Array, - keystore: 'old' | 'new', + keystore: 'old' | 'new' | 'custom', + buildSigningOptions: BuildSigningOptions | null, signingDialogOpen: boolean, |}; @@ -26,6 +34,7 @@ export const SetupExportHeader = ({ updateExportState, isExporting, build, + authenticatedUser, }: HeaderProps): null | React.Node => { // Build is finished, hide options. if (!!build && build.status === 'complete') return null; @@ -79,7 +88,9 @@ export const SetupExportHeader = ({ })); }} disabled={ - exportState.targets[0] !== 'androidAppBundle' || isExporting + (exportState.targets[0] === 'androidApk' && + exportState.keystore !== 'custom') || + isExporting } /> @@ -153,10 +164,65 @@ export const SetupExportHeader = ({ } - label={Custom upload key (not available yet)} - disabled + label={Custom upload key} /> + {exportState.keystore === 'custom' && ( + + { + updateExportState(prevExportState => ({ + ...prevExportState, + buildSigningOptions, + })); + }} + disabled={isExporting} + /> + {exportState.targets[0] === 'androidApk' && ( + + + + If Google Play asks you to prove package name ownership, + paste the content of your{' '} + adi-registration.properties file from Google Play + Console here. + + + Package name verification token (optional) + } + hintText={t`Paste the content of adi-registration.properties`} + value={ + exportState.buildSigningOptions && + exportState.buildSigningOptions.verificationTokenAsBase64 + ? atob( + exportState.buildSigningOptions + .verificationTokenAsBase64 + ) + : '' + } + onChange={(e, value) => { + updateExportState(prevExportState => ({ + ...prevExportState, + buildSigningOptions: { + ...prevExportState.buildSigningOptions, + verificationTokenAsBase64: value + ? btoa(value) + : undefined, + }, + })); + }} + multiline + rows={4} + fullWidth + /> + + )} + + )} )} diff --git a/newIDE/app/src/ExportAndShare/LocalExporters/LocalOnlineCordovaExport.js b/newIDE/app/src/ExportAndShare/LocalExporters/LocalOnlineCordovaExport.js index e785f7f05c5c..e7ecc199cb83 100644 --- a/newIDE/app/src/ExportAndShare/LocalExporters/LocalOnlineCordovaExport.js +++ b/newIDE/app/src/ExportAndShare/LocalExporters/LocalOnlineCordovaExport.js @@ -4,6 +4,7 @@ import * as React from 'react'; import assignIn from 'lodash/assignIn'; import { type Build, + type BuildSigningOptions, buildCordovaAndroid, getBuildFileUploadOptions, } from '../../Utils/GDevelopServices/Build'; @@ -63,6 +64,7 @@ export const localOnlineCordovaExportPipeline: ExportPipeline< getInitialExportState: () => ({ targets: ['androidApk'], keystore: 'new', + buildSigningOptions: null, signingDialogOpen: false, }), @@ -201,12 +203,17 @@ export const localOnlineCordovaExportPipeline: ExportPipeline< if (!firebaseUser) return Promise.reject(new Error('User is not authenticated')); + const signing: BuildSigningOptions = + exportState.keystore === 'custom' && exportState.buildSigningOptions + ? exportState.buildSigningOptions + : { keystore: exportState.keystore }; + return buildCordovaAndroid( getAuthorizationHeader, firebaseUser.uid, uploadBucketKey, exportState.targets, - exportState.keystore, + signing, gameId, options, payWithCredits diff --git a/newIDE/app/src/ExportAndShare/SigningCredentials/AndroidSigningCredentialsDialog/index.js b/newIDE/app/src/ExportAndShare/SigningCredentials/AndroidSigningCredentialsDialog/index.js new file mode 100644 index 000000000000..5476bda1b39d --- /dev/null +++ b/newIDE/app/src/ExportAndShare/SigningCredentials/AndroidSigningCredentialsDialog/index.js @@ -0,0 +1,472 @@ +// @flow + +import * as React from 'react'; +import { type I18n as I18nType } from '@lingui/core'; +import { t, Trans } from '@lingui/macro'; +import { type AuthenticatedUser } from '../../../Profile/AuthenticatedUserContext'; +import { + type SigningCredential, + filterAndroidKeystoreSigningCredentials, + signingCredentialApi, +} from '../../../Utils/GDevelopServices/Build'; +import Dialog from '../../../UI/Dialog'; +import FlatButton from '../../../UI/FlatButton'; +import RaisedButton from '../../../UI/RaisedButton'; +import { Tabs } from '../../../UI/Tabs'; +import { Line } from '../../../UI/Grid'; +import { ColumnStackLayout } from '../../../UI/Layout'; +import Text from '../../../UI/Text'; +import TextField from '../../../UI/TextField'; +import PlaceholderError from '../../../UI/PlaceholderError'; +import PlaceholderLoader from '../../../UI/PlaceholderLoader'; +import Card from '../../../UI/Card'; +import IconButton from '../../../UI/IconButton'; +import ThreeDotsMenu from '../../../UI/CustomSvgIcons/ThreeDotsMenu'; +import ElementWithMenu from '../../../UI/Menu/ElementWithMenu'; +import AlertMessage from '../../../UI/AlertMessage'; +import HelpButton from '../../../UI/HelpButton'; +import useAlertDialog from '../../../UI/Alert/useAlertDialog'; +import CircledInfo from '../../../UI/CustomSvgIcons/CircledInfo'; +import Add from '../../../UI/CustomSvgIcons/Add'; + +const GENERATE_TAB = 'generate'; +const UPLOAD_TAB = 'upload'; + +type Props = {| + authenticatedUser: AuthenticatedUser, + signingCredentials: Array | null, + error: Error | null, + onRefreshSigningCredentials: () => Promise, + onClose: () => void, +|}; + +export const AndroidSigningCredentialsDialog = ({ + authenticatedUser, + signingCredentials, + error, + onRefreshSigningCredentials, + onClose, +}: Props): React.Node => { + const { showConfirmation } = useAlertDialog(); + const androidKeystores = filterAndroidKeystoreSigningCredentials( + signingCredentials + ); + + const [currentTab, setCurrentTab] = React.useState(GENERATE_TAB); + + // Generate tab state + const [generateName, setGenerateName] = React.useState(''); + const [generateAlias, setGenerateAlias] = React.useState('mykey'); + const [generateCommonName, setGenerateCommonName] = React.useState(''); + const [generateOrg, setGenerateOrg] = React.useState(''); + const [generateCountry, setGenerateCountry] = React.useState(''); + const [isGenerating, setIsGenerating] = React.useState(false); + const [generatedKeystoreBase64, setGeneratedKeystoreBase64] = React.useState< + string | null + >(null); + const [generatedPassword, setGeneratedPassword] = React.useState< + string | null + >(null); + const [generateError, setGenerateError] = React.useState(null); + + // Upload tab state + const [uploadName, setUploadName] = React.useState(''); + const [uploadAlias, setUploadAlias] = React.useState(''); + const [uploadStorePassword, setUploadStorePassword] = React.useState(''); + const [uploadKeyPassword, setUploadKeyPassword] = React.useState(''); + const [uploadKeystoreBase64, setUploadKeystoreBase64] = React.useState< + string | null + >(null); + const [uploadFileName, setUploadFileName] = React.useState(''); + const [isUploading, setIsUploading] = React.useState(false); + const [uploadError, setUploadError] = React.useState(null); + + const fileInputRef = React.useRef(null); + + const userId = authenticatedUser.profile + ? authenticatedUser.profile.id + : null; + + const handleFileChange = (e: SyntheticInputEvent) => { + const file = e.target.files && e.target.files[0]; + if (!file) return; + setUploadFileName(file.name); + const reader = new FileReader(); + reader.onload = evt => { + // $FlowFixMe[incompatible-type] - FileReader event target has result + const target = (evt.target: any); + if (target && typeof target.result === 'string') { + // result is a data URL like "data:...;base64,XXXX" + const base64 = target.result.split(',')[1]; + setUploadKeystoreBase64(base64 || null); + } + }; + reader.readAsDataURL(file); + }; + + const handleGenerate = async () => { + if (!userId) return; + setIsGenerating(true); + setGenerateError(null); + try { + const result = await signingCredentialApi.createAndroidKeystore( + authenticatedUser.getAuthorizationHeader, + userId, + { + name: generateName || 'My keystore', + keystoreAlias: generateAlias || 'mykey', + commonName: generateCommonName || undefined, + organizationName: generateOrg || undefined, + countryName: generateCountry || undefined, + } + ); + setGeneratedKeystoreBase64(result.keystoreAsBase64); + setGeneratedPassword(result.keystorePassword); + await onRefreshSigningCredentials(); + } catch (err) { + setGenerateError(err); + } finally { + setIsGenerating(false); + } + }; + + const handleUpload = async () => { + if (!userId || !uploadKeystoreBase64) return; + setIsUploading(true); + setUploadError(null); + try { + await signingCredentialApi.uploadAndroidKeystore( + authenticatedUser.getAuthorizationHeader, + userId, + { + name: uploadName || uploadFileName || 'My keystore', + keystoreAsBase64: uploadKeystoreBase64, + keystoreAlias: uploadAlias, + keystoreStorePassword: uploadStorePassword, + keystoreKeyPassword: uploadKeyPassword || uploadStorePassword, + } + ); + setUploadKeystoreBase64(null); + setUploadFileName(''); + setUploadName(''); + setUploadAlias(''); + setUploadStorePassword(''); + setUploadKeyPassword(''); + await onRefreshSigningCredentials(); + } catch (err) { + setUploadError(err); + } finally { + setIsUploading(false); + } + }; + + const handleDownloadBackup = () => { + if (!generatedKeystoreBase64) return; + const byteCharacters = atob(generatedKeystoreBase64); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: 'application/octet-stream' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${generateName || 'keystore'}.p12`; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + Android upload keystores} + actions={[ + Close} + primary + onClick={onClose} + />, + ]} + secondaryActions={[ + , + ]} + open + onRequestClose={onClose} + maxWidth="sm" + > + + {/* Existing keystores list */} + {error ? ( + + An error happened while loading the keystores. + + ) : !androidKeystores ? ( + + ) : androidKeystores.length > 0 ? ( + + + Your keystores + + {androidKeystores.map(keystore => ( + + {keystore.name} + + } + cardCornerAction={ + + + + } + buildMenuTemplate={(i18n: I18nType) => [ + { + label: i18n._(t`Remove this keystore`), + click: async () => { + const answer = await showConfirmation({ + level: 'warning', + title: t`Remove this keystore?`, + message: t`If you have already published an app signed with this keystore, removing it will prevent future updates unless you re-upload it. Make sure you have a backup.`, + confirmButtonLabel: t`Remove keystore`, + }); + if (!answer) return; + if (!userId) return; + try { + await signingCredentialApi.deleteSigningCredential( + authenticatedUser.getAuthorizationHeader, + userId, + { + type: 'android-keystore', + keystoreId: keystore.keystoreId, + } + ); + onRefreshSigningCredentials(); + } catch (err) { + console.error('Unable to delete the keystore', err); + } + }, + }, + ]} + /> + } + > + + + + ID: {keystore.keystoreId} + + + + ))} + + ) : null} + + {/* Add new keystore section */} + + Add a keystore + + Generate a new keystore, + }, + { + value: UPLOAD_TAB, + label: Upload existing keystore, + }, + ]} + /> + + {currentTab === GENERATE_TAB && ( + + + + GDevelop will generate a new upload keystore for you. Download + and keep the backup file safe — you will need it if you ever + need to re-sign your app outside of GDevelop. + + + Keystore name} + value={generateName} + onChange={(e, value) => setGenerateName(value)} + hintText={t`e.g. My Game`} + fullWidth + /> + Key alias} + value={generateAlias} + onChange={(e, value) => setGenerateAlias(value)} + hintText={t`e.g. mykey`} + fullWidth + /> + Common name (optional)} + value={generateCommonName} + onChange={(e, value) => setGenerateCommonName(value)} + hintText={t`e.g. Your Name or Company`} + fullWidth + /> + Organization (optional)} + value={generateOrg} + onChange={(e, value) => setGenerateOrg(value)} + fullWidth + /> + Country code (optional)} + value={generateCountry} + onChange={(e, value) => setGenerateCountry(value)} + hintText={t`e.g. US`} + maxLength={2} + fullWidth + /> + {generateError && ( + + + An error occurred while generating the keystore. Please try + again. + + + )} + {generatedKeystoreBase64 && generatedPassword ? ( + + + + Keystore generated successfully! Download the backup file + and note the password below. Store them securely. + + + + Password: {generatedPassword} + + Download backup (.p12)} + onClick={handleDownloadBackup} + /> + + ) : ( + + Generating... + ) : ( + Generate keystore + ) + } + onClick={handleGenerate} + disabled={isGenerating || !generateAlias} + /> + + )} + + )} + + {currentTab === UPLOAD_TAB && ( + + + + Upload your existing Android keystore (.jks or .p12). You'll + need the alias and passwords you chose when creating it. + + + Keystore name} + value={uploadName} + onChange={(e, value) => setUploadName(value)} + hintText={t`e.g. My Game keystore`} + fullWidth + /> + + {/* $FlowFixMe[incompatible-type] */} + + File: {uploadFileName} + ) : ( + Choose keystore file (.jks / .p12) + ) + } + icon={} + onClick={() => { + if (fileInputRef.current) fileInputRef.current.click(); + }} + /> + + Key alias} + value={uploadAlias} + onChange={(e, value) => setUploadAlias(value)} + fullWidth + /> + Store password} + value={uploadStorePassword} + onChange={(e, value) => setUploadStorePassword(value)} + type="password" + fullWidth + /> + + Key password (leave empty if same as store password) + + } + value={uploadKeyPassword} + onChange={(e, value) => setUploadKeyPassword(value)} + type="password" + fullWidth + /> + {uploadError && ( + + + An error occurred while uploading the keystore. Please check + your file and credentials and try again. + + + )} + + Uploading... + ) : ( + Upload keystore + ) + } + onClick={handleUpload} + disabled={ + isUploading || + !uploadKeystoreBase64 || + !uploadAlias || + !uploadStorePassword + } + /> + + + )} + + + ); +}; diff --git a/newIDE/app/src/ExportAndShare/SigningCredentials/AndroidSigningCredentialsSelector.js b/newIDE/app/src/ExportAndShare/SigningCredentials/AndroidSigningCredentialsSelector.js new file mode 100644 index 000000000000..e9fd76a81688 --- /dev/null +++ b/newIDE/app/src/ExportAndShare/SigningCredentials/AndroidSigningCredentialsSelector.js @@ -0,0 +1,162 @@ +// @flow + +import * as React from 'react'; +import { Trans, t } from '@lingui/macro'; +import { type AuthenticatedUser } from '../../Profile/AuthenticatedUserContext'; +import { + type BuildSigningOptions, + type AndroidKeystoreSigningCredential, + filterAndroidKeystoreSigningCredentials, +} from '../../Utils/GDevelopServices/Build'; +import SelectField from '../../UI/SelectField'; +import SelectOption from '../../UI/SelectOption'; +import { LineStackLayout } from '../../UI/Layout'; +import FlatButton from '../../UI/FlatButton'; +import RaisedButton from '../../UI/RaisedButton'; +import { useGetUserSigningCredentials } from './SigningCredentialsDialog'; +import { AndroidSigningCredentialsDialog } from './AndroidSigningCredentialsDialog'; + +const styles = { + button: { flexShrink: 0 }, +}; + +const getDefaultOrValidKeystoreId = ( + buildSigningOptions: BuildSigningOptions | null, + keystores: Array | null +): string | null => { + if (!keystores || keystores.length === 0) return null; + + if ( + buildSigningOptions && + buildSigningOptions.androidKeystoreId && + keystores.find(k => k.keystoreId === buildSigningOptions.androidKeystoreId) + ) { + return buildSigningOptions.androidKeystoreId || null; + } + + return keystores[0].keystoreId; +}; + +type Props = {| + authenticatedUser: AuthenticatedUser, + buildSigningOptions: BuildSigningOptions | null, + onSelectBuildSigningOptions: (BuildSigningOptions | null) => void, + disabled?: boolean, +|}; + +export const AndroidSigningCredentialsSelector = ({ + authenticatedUser, + buildSigningOptions, + onSelectBuildSigningOptions, + disabled, +}: Props): React.Node => { + const { + signingCredentials, + error, + onRefreshSigningCredentials, + } = useGetUserSigningCredentials(authenticatedUser); + + const androidKeystores = filterAndroidKeystoreSigningCredentials( + signingCredentials + ); + + const [isDialogOpen, setIsDialogOpen] = React.useState(false); + + React.useEffect( + () => { + const validKeystoreId = getDefaultOrValidKeystoreId( + buildSigningOptions, + androidKeystores + ); + if ( + validKeystoreId && + validKeystoreId !== + (buildSigningOptions && buildSigningOptions.androidKeystoreId) + ) { + onSelectBuildSigningOptions({ + ...buildSigningOptions, + keystore: 'custom', + androidKeystoreId: validKeystoreId, + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [signingCredentials] + ); + + return ( + + Upload key} + value={ + buildSigningOptions && buildSigningOptions.androidKeystoreId + ? buildSigningOptions.androidKeystoreId + : '' + } + onChange={(e, i, value: string) => { + onSelectBuildSigningOptions({ + ...buildSigningOptions, + keystore: 'custom', + androidKeystoreId: value, + }); + }} + translatableHintText={ + androidKeystores + ? androidKeystores.length > 0 + ? t`Choose a keystore` + : t`Add a keystore first` + : t`Loading...` + } + disabled={disabled} + > + {androidKeystores ? ( + androidKeystores.map(keystore => ( + + )) + ) : ( + + )} + + {androidKeystores && androidKeystores.length > 0 ? ( + Add or edit} + onClick={() => setIsDialogOpen(true)} + disabled={disabled} + /> + ) : ( + Add new} + onClick={() => setIsDialogOpen(true)} + disabled={disabled} + /> + )} + {isDialogOpen && ( + { + setIsDialogOpen(false); + onRefreshSigningCredentials(); + }} + /> + )} + + ); +}; diff --git a/newIDE/app/src/Utils/GDevelopServices/Build.js b/newIDE/app/src/Utils/GDevelopServices/Build.js index 273db0e2b311..a4ee8375efa7 100644 --- a/newIDE/app/src/Utils/GDevelopServices/Build.js +++ b/newIDE/app/src/Utils/GDevelopServices/Build.js @@ -67,6 +67,8 @@ export type BuildSigningOptions = {| certificateSerial?: string, mobileProvisionUuid?: string, authKeyApiKey?: string, + androidKeystoreId?: string, + verificationTokenAsBase64?: string, |}; export type AppleCertificateSigningCredential = { @@ -89,9 +91,17 @@ export type AppleAuthKeySigningCredential = { hasAuthKeyReady: boolean, }; +export type AndroidKeystoreSigningCredential = { + type: 'android-keystore', + name: string, + keystoreId: string, + hasKeystoreReady: boolean, +}; + export type SigningCredential = | AppleCertificateSigningCredential - | AppleAuthKeySigningCredential; + | AppleAuthKeySigningCredential + | AndroidKeystoreSigningCredential; // $FlowFixMe[cannot-resolve-name] export const client: Axios = axios.create({ @@ -120,6 +130,17 @@ export const filterAppleAuthKeySigningCredentials = ( : null; }; +export const filterAndroidKeystoreSigningCredentials = ( + signingCredentials: Array | null +): Array | null => { + return signingCredentials + ? // $FlowFixMe[incompatible-type] - we're sure this should refine the type. + signingCredentials.filter( + signingCredential => signingCredential.type === 'android-keystore' + ) + : null; +}; + export const getBuildExtensionlessFilename = ({ gameName, gameVersion, @@ -261,7 +282,7 @@ export const buildCordovaAndroid = ( userId: string, key: string, targets: Array, - keystore: 'old' | 'new', + signing: BuildSigningOptions, gameId: string, options: {| gameName: string, @@ -274,9 +295,7 @@ export const buildCordovaAndroid = ( client.post( `/build`, JSON.stringify({ - signing: { - keystore, - }, + signing, }), { params: { @@ -629,6 +648,7 @@ export const signingCredentialApi = { appleApiKey?: string, certificateSerial?: string, mobileProvisionUuid?: string, + keystoreId?: string, |} ): Promise => { const authorizationHeader = await getAuthorizationHeader(); @@ -643,4 +663,65 @@ export const signingCredentialApi = { }, }); }, + uploadAndroidKeystore: async ( + getAuthorizationHeader: () => Promise, + userId: string, + options: {| + name: string, + keystoreAsBase64: string, + keystoreAlias: string, + keystoreStorePassword: string, + keystoreKeyPassword: string, + |} + ): Promise<{| keystoreId: string, name: string |}> => { + const authorizationHeader = await getAuthorizationHeader(); + + const response = await client.post( + `/signing-credential/action/upload-android-keystore`, + { ...options }, + { + params: { userId }, + headers: { Authorization: authorizationHeader }, + } + ); + + return ensureIsObject({ + data: response.data, + endpointName: + '/signing-credential/action/upload-android-keystore of Build API', + }); + }, + createAndroidKeystore: async ( + getAuthorizationHeader: () => Promise, + userId: string, + options: {| + name: string, + keystoreAlias: string, + commonName?: string, + organizationName?: string, + countryName?: string, + |} + ): Promise<{| + keystoreId: string, + name: string, + keystoreAsBase64: string, + keystorePassword: string, + |}> => { + const authorizationHeader = await getAuthorizationHeader(); + + const response = await client.post( + `/signing-credential/action/create-android-keystore`, + { ...options }, + { + params: { userId }, + headers: { Authorization: authorizationHeader }, + } + ); + + return ensureIsObject({ + data: response.data, + endpointName: + '/signing-credential/action/create-android-keystore of Build API', + }); + }, };