diff --git a/GooglePlay.ts b/GooglePlay.ts new file mode 100644 index 0000000..16b3981 --- /dev/null +++ b/GooglePlay.ts @@ -0,0 +1,426 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as tl from 'azure-pipelines-task-lib/task'; +import * as glob from 'glob'; +import * as apkReader from 'adbkit-apkreader'; +import * as googleutil from './googleutil'; +import { androidpublisher_v3 as pub3 } from 'googleapis'; +import { JWT } from 'google-auth-library'; + +async function run() { + try { + tl.setResourcePath(path.join(__dirname, 'task.json')); + + tl.debug('Prepare task inputs.'); + + const authType: string = tl.getInput('authType', true); + let key: googleutil.ClientKey = {}; + if (authType === 'JsonFile') { + const serviceAccountKeyFile: string = tl.getPathInput('serviceAccountKey', true, true); + + const stats: tl.FsStats = tl.stats(serviceAccountKeyFile); + if (stats && stats.isFile()) { + key = require(serviceAccountKeyFile); + } else { + tl.debug(`The service account file path ${serviceAccountKeyFile} points to a directory.`); + throw new Error(tl.loc('InvalidAuthFile', serviceAccountKeyFile)); + } + } else if (authType === 'ServiceEndpoint') { + let serviceEndpoint: tl.EndpointAuthorization = tl.getEndpointAuthorization(tl.getInput('serviceEndpoint', true), false); + key.client_email = serviceEndpoint.parameters['username']; + key.private_key = serviceEndpoint.parameters['password'].replace(/\\n/g, '\n'); + } + const mainApkPattern: string = tl.getPathInput('apkFile', true); + tl.debug(`Main APK pattern: ${mainApkPattern}`); + + const mainApkFile: string = resolveGlobPath(mainApkPattern); + tl.checkPath(mainApkFile, 'apkFile'); + const reader = await apkReader.open(mainApkFile); + const manifest = await reader.readManifest(); + const mainVersionCode = manifest.versionCode; + console.log(tl.loc('FoundMainApk', mainApkFile, mainVersionCode)); + tl.debug(` Found the main APK file: ${mainApkFile} (version code ${mainVersionCode}).`); + + const apkFileList: string[] = await getAllApkPaths(mainApkFile); + if (apkFileList.length > 1) { + console.log(tl.loc('FoundMultiApks')); + console.log(apkFileList); + } + + const versionCodeFilterType: string = tl.getInput('versionCodeFilterType', false) ; + let versionCodeFilter: string | number[] = null; + if (versionCodeFilterType === 'list') { + versionCodeFilter = getVersionCodeListInput(); + } else if (versionCodeFilterType === 'expression') { + versionCodeFilter = tl.getInput('replaceExpression', true); + } + + const track: string = tl.getInput('track', true); + const userFractionSupplied: boolean = tl.getBoolInput('rolloutToUserFraction'); + const userFraction: number = Number(userFractionSupplied ? tl.getInput('userFraction', false) : 1.0); + + const updatePrioritySupplied: boolean = tl.getBoolInput('changeUpdatePriority'); + const updatePriority: number = Number(updatePrioritySupplied ? tl.getInput('updatePriority', false) : 0); + + const shouldAttachMetadata: boolean = tl.getBoolInput('shouldAttachMetadata', false); + const updateStoreListing: boolean = tl.getBoolInput('updateStoreListing', false); + const shouldUploadApks: boolean = tl.getBoolInput('shouldUploadApks', false); + + const shouldPickObbFile: boolean = tl.getBoolInput('shouldPickObbFile', false); + const shouldPickObbFileForAdditonalApks: boolean = tl.getBoolInput('shouldPickObbFileForAdditonalApks', false); + + let changelogFile: string = null; + let languageCode: string = null; + let metadataRootPath: string = null; + + if (shouldAttachMetadata) { + metadataRootPath = tl.getPathInput('metadataRootPath', true, true); + } else { + changelogFile = tl.getInput('changelogFile', false); + languageCode = tl.getInput('languageCode', false) || 'en-US'; + } + + const globalParams: googleutil.GlobalParams = { auth: null, params: {} }; + const apkVersionCodes: number[] = []; + + // The submission process is composed + // of a transaction with the following steps: + // ----------------------------------------- + // #1) Extract the package name from the specified APK file + // #2) Get an OAuth token by authenticating the service account + // #3) Create a new editing transaction + // #4) Upload the new APK(s) + // #5) Specify the track that should be used for the new APK (e.g. alpha, beta) + // #6) Specify the new change log + // #7) Commit the edit transaction + + tl.debug(`Getting a package name from ${mainApkFile}`); + const packageName: string = manifest.package; + googleutil.updateGlobalParams(globalParams, 'packageName', packageName); + + tl.debug('Initializing JWT.'); + const jwtClient: JWT = googleutil.getJWT(key); + globalParams.auth = jwtClient; + + tl.debug('Initializing Google Play publisher API.'); + const edits: pub3.Resource$Edits = googleutil.publisher.edits; + + tl.debug('Authorize JWT.'); + await jwtClient.authorize(); + + console.log(tl.loc('GetNewEditAfterAuth')); + tl.debug('Creating a new edit transaction in Google Play.'); + const edit = await googleutil.getNewEdit(edits, globalParams, packageName); + googleutil.updateGlobalParams(globalParams, 'editId', edit.id); + + let requireTrackUpdate = false; + + if (updateStoreListing) { + tl.debug('Selected store listing update -> skip APK reading'); + } else if (shouldUploadApks) { + tl.debug(`Uploading ${apkFileList.length} APK(s).`); + requireTrackUpdate = true; + + for (const apkFile of apkFileList) { + tl.debug(`Uploading APK ${apkFile}`); + const apk: googleutil.Apk = await googleutil.addApk(edits, packageName, apkFile); + tl.debug(`Uploaded ${apkFile} with the version code ${apk.versionCode}`); + if ((shouldPickObbForApk(apkFile, mainApkFile, shouldPickObbFile, shouldPickObbFileForAdditonalApks)) && (getObbFile(apkFile, packageName, apk.versionCode) !== null)) { + const obb: googleutil.ObbResponse = await googleutil.addObb(edits, packageName, getObbFile(apkFile, packageName, apk.versionCode), apk.versionCode, 'main'); + if (obb.expansionFile.fileSize !== 0) { + console.log(`Uploaded Obb file with version code ${apk.versionCode} and size ${obb.expansionFile.fileSize}`); + } + } + apkVersionCodes.push(apk.versionCode); + } + + if (apkVersionCodes.length > 0 && tl.getBoolInput('shouldUploadMappingFile', false)) { + const mappingFilePattern = tl.getPathInput('mappingFilePath', false); + tl.debug(`Mapping file pattern: ${mappingFilePattern}`); + + const mappingFilePath = resolveGlobPath(mappingFilePattern); + tl.checkPath(mappingFilePath, 'mappingFilePath'); + console.log(tl.loc('FoundDeobfuscationFile', mappingFilePath)); + tl.debug(`Uploading mapping file ${mappingFilePath}`); + await googleutil.uploadDeobfuscation(edits, mappingFilePath, packageName, apkVersionCodes[0]); + tl.debug(`Uploaded ${mappingFilePath} for APK ${mainApkFile}`); + } + } else { + tl.debug(`Getting APK version codes of ${apkFileList.length} APK(s).`); + + for (let apkFile of apkFileList) { + tl.debug(`Getting version code of APK ${apkFile}`); + const reader = await apkReader.open(apkFile); + const manifest = await reader.readManifest(); + const apkVersionCode: number = manifest.versionCode; + tl.debug(`Got APK ${apkFile} version code: ${apkVersionCode}`); + apkVersionCodes.push(apkVersionCode); + } + } + + let releaseNotes: googleutil.ReleaseNotes[]; + if (shouldAttachMetadata) { + console.log(tl.loc('AttachingMetadataToRelease')); + tl.debug(`Uploading metadata from ${metadataRootPath}`); + releaseNotes = await addMetadata(edits, apkVersionCodes, metadataRootPath); + if (updateStoreListing) { + tl.debug('Selected store listing update -> skip update track'); + } + requireTrackUpdate = !updateStoreListing; + } else if (changelogFile) { + tl.debug(`Uploading the common change log ${changelogFile} to all versions`); + const commonNotes = await getCommonReleaseNotes(languageCode, changelogFile); + releaseNotes = commonNotes && [commonNotes]; + requireTrackUpdate = true; + } + + if (requireTrackUpdate) { + console.log(tl.loc('UpdateTrack')); + tl.debug(`Updating the track ${track}.`); + const updatedTrack: googleutil.Track = await updateTrack(edits, packageName, track, apkVersionCodes, versionCodeFilterType, versionCodeFilter, userFraction, updatePriority, releaseNotes); + tl.debug('Updated track info: ' + JSON.stringify(updatedTrack)); + } + + tl.debug('Committing the edit transaction in Google Play.'); + await edits.commit(); + + if (updateStoreListing) { + console.log(tl.loc('StoreListUpdateSucceed')); + } else { + console.log(tl.loc('AptPublishSucceed')); + console.log(tl.loc('TrackInfo', track)); + } + + tl.setResult(tl.TaskResult.Succeeded, tl.loc('Success')); + } catch (e) { + if (e) { + tl.debug('Exception thrown releasing to Google Play: ' + e); + } else { + tl.debug('Unknown error, no response given from Google Play'); + } + tl.setResult(tl.TaskResult.Failed, e); + } +} + +/** + * Update a given release track with the given information + * Assumes authorized + * @param {string} packageName unique android package name (com.android.etc) + * @param {string} track one of the values {"internal", "alpha", "beta", "production"} + * @param {number[]} apkVersionCodes version code of uploaded modules. + * @param {string} versionCodeListType type of version code replacement filter, i.e. 'all', 'list', or 'expression' + * @param {string | string[]} versionCodeFilter version code filter, i.e. either a list of version code or a regular expression string. + * @param {double} userFraction the fraction of users to get update + * @param {priority} updatePriority - In-app update priority value of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. + * @param {googleutil.ReleaseNotes[]} releaseNotes optional release notes to be attached as part of the update + * @returns {Promise} track A promise that will return result from updating a track + * { track: string, versionCodes: [integer], userFraction: double } + */ +async function updateTrack( + edits: pub3.Resource$Edits, + packageName: string, + track: string, + apkVersionCodes: number[], + versionCodeListType: string, + versionCodeFilter: string | number[], + userFraction: number, + updatePriority: number, + releaseNotes?: googleutil.ReleaseNotes[]): Promise { + + let newTrackVersionCodes: number[] = []; + let res: googleutil.Track; + + if (versionCodeListType === 'all') { + newTrackVersionCodes = apkVersionCodes; + } else { + try { + res = await googleutil.getTrack(edits, packageName, track); + } catch (e) { + tl.debug(`Failed to download track ${track} information.`); + tl.debug(e); + throw new Error(tl.loc('CannotDownloadTrack', track, e)); + } + + const oldTrackVersionCodes: number[] = res.releases[0].versionCodes; + tl.debug('Current version codes: ' + JSON.stringify(oldTrackVersionCodes)); + + if (typeof(versionCodeFilter) === 'string') { + tl.debug(`Removing version codes matching the regular expression: ^${versionCodeFilter as string}$`); + const versionCodesToRemove: RegExp = new RegExp(`^${versionCodeFilter as string}$`); + + oldTrackVersionCodes.forEach((versionCode) => { + if (!versionCode.toString().match(versionCodesToRemove)) { + newTrackVersionCodes.push(versionCode); + } + }); + } else { + const versionCodesToRemove: number[] = versionCodeFilter as number[]; + tl.debug('Removing version codes: ' + JSON.stringify(versionCodesToRemove)); + + oldTrackVersionCodes.forEach((versionCode) => { + if (versionCodesToRemove.indexOf(versionCode) === -1) { + newTrackVersionCodes.push(versionCode); + } + }); + } + + tl.debug('Version codes to keep: ' + JSON.stringify(newTrackVersionCodes)); + apkVersionCodes.forEach((versionCode) => { + if (newTrackVersionCodes.indexOf(versionCode) === -1) { + newTrackVersionCodes.push(versionCode); + } + }); + } + + tl.debug(`New ${track} track version codes: ` + JSON.stringify(newTrackVersionCodes)); + try { + res = await googleutil.updateTrack(edits, packageName, track, newTrackVersionCodes, userFraction, updatePriority, releaseNotes); + } catch (e) { + tl.debug(`Failed to update track ${track}.`); + tl.debug(e); + throw new Error(tl.loc('CannotUpdateTrack', track, e)); + } + return res; +} + +/** + * Get the appropriate file from the provided pattern + * @param {string} path The minimatch pattern of glob to be resolved to file path + * @returns {string} path path of the file resolved by glob + */ +function resolveGlobPath(path: string): string { + if (path) { + // VSTS tries to be smart when passing in paths with spaces in them by quoting the whole path. Unfortunately, this actually breaks everything, so remove them here. + path = path.replace(/\"/g, ''); + + const filesList: string[] = glob.sync(path); + if (filesList.length > 0) { + path = filesList[0]; + } + } + + return path; +} + +/** + * Get the appropriate files from the provided pattern + * @param {string} path The minimatch pattern of glob to be resolved to file path + * @returns {string[]} paths of the files resolved by glob + */ +function resolveGlobPaths(path: string): string[] { + if (path) { + // Convert the path pattern to a rooted one. We do this to mimic for string inputs the behaviour of filePath inputs provided by Build Agent. + path = tl.resolve(tl.getVariable('System.DefaultWorkingDirectory'), path); + + let filesList: string[] = glob.sync(path); + if (filesList.length === 0) { + filesList.push(path); + } + tl.debug(`Additional APK paths: ${JSON.stringify(filesList)}`); + + return filesList; + } + + return []; +} + +/** + * Get obb file. Returns any file with .obb extension if present in parent directory else returns + * from apk directory with pattern: main...obb + * @param {string} apkPath apk file path + * @param {string} packageName package name of the apk + * @param {string} versionCode version code of the apk + * @returns {string} ObbPathFile of the obb file if present else null + */ +function getObbFile(apkPath: string, packageName: string, versionCode: number): string { + const currentDirectory: string = path.dirname(apkPath); + const parentDirectory: string = path.dirname(currentDirectory); + + const fileNamesInParentDirectory: string[] = fs.readdirSync(parentDirectory); + const obbPathFileInParent: string | undefined = fileNamesInParentDirectory.find(file => path.extname(file) === '.obb'); + + if (obbPathFileInParent) { + tl.debug(`Found Obb file for upload in parent directory: ${obbPathFileInParent}`); + return path.join(parentDirectory, obbPathFileInParent); + } + + const fileNamesInApkDirectory: string[] = fs.readdirSync(currentDirectory); + const expectedMainObbFile: string = `main.${versionCode}.${packageName}.obb`; + const obbPathFileInCurrent: string | undefined = fileNamesInApkDirectory.find(file => file.toString() === expectedMainObbFile); + + if (obbPathFileInCurrent) { + tl.debug(`Found Obb file for upload in current directory: ${obbPathFileInCurrent}`); + return path.join(currentDirectory, obbPathFileInCurrent); + } else { + tl.debug(`No Obb found for ${apkPath}, skipping upload`); + } + + return obbPathFileInCurrent; +} + +/** + * Get unique APK file paths from main and additional APK file inputs. + * @returns {string[]} paths of the files + */ +async function getAllApkPaths(mainApkFile: string): Promise { + const apkFileList: { [key: string]: number } = {}; + + apkFileList[mainApkFile] = 0; + + const additionalApks: string[] = tl.getDelimitedInput('additionalApks', '\n'); + for (const additionalApk of additionalApks) { + tl.debug(`Additional APK pattern: ${additionalApk}`); + const apkPaths: string[] = resolveGlobPaths(additionalApk); + + for (const apkPath of apkPaths) { + apkFileList[apkPath] = 0; + tl.debug(`Checking additional APK ${apkPath} version...`); + const reader = await apkReader.open(apkPath); + const manifest = await reader.readManifest(); + tl.debug(` Found the additional APK file: ${apkPath} (version code ${manifest.versionCode}).`); + } + } + + return Object.keys(apkFileList); +} + +function getVersionCodeListInput(): number[] { + const versionCodeFilterInput: string[] = tl.getDelimitedInput('replaceList', ',', false); + const versionCodeFilter: number[] = []; + const incorrectCodes: string[] = []; + + for (const versionCode of versionCodeFilterInput) { + const versionCodeNumber: number = parseInt(versionCode.trim(), 10); + + if (versionCodeNumber && (versionCodeNumber > 0)) { + versionCodeFilter.push(versionCodeNumber); + } else { + incorrectCodes.push(versionCode.trim()); + } + } + + if (incorrectCodes.length > 0) { + throw new Error(tl.loc('IncorrectVersionCodeFilter', JSON.stringify(incorrectCodes))); + } else { + return versionCodeFilter; + } +} + +function shouldPickObbForApk(apk: string, mainApk: string, shouldPickObbFile: boolean, shouldPickObbFileForAdditonalApks: boolean): boolean { + + if ((apk === mainApk) && shouldPickObbFile) { + return true; + } else if ((apk !== mainApk) && shouldPickObbFileForAdditonalApks) { + return true; + } + return false; +} + +// Future features: +// ---------------- +// 1) Adding testers +// 2) Adding new images +// 3) Adding expansion files +// 4) Updating contact info + +run(); diff --git a/googleutil.ts b/googleutil.ts new file mode 100644 index 0000000..ce041f5 --- /dev/null +++ b/googleutil.ts @@ -0,0 +1,312 @@ +// common code shared by all tasks +import * as fs from 'fs'; +import * as tl from 'azure-pipelines-task-lib/task'; +import { google } from 'googleapis'; +import { JWT } from 'google-auth-library'; + +export const publisher = google.androidpublisher('v3'); + +export interface ClientKey { + client_email?: string; + private_key?: string; +} + +export interface Apk { + versionCode: number; + binary: { + sha1: string; + }; +} + +export interface Obb { + referencesVersion: number; + fileSize: number; +} + +export interface ObbResponse { + expansionFile: Obb; +} + +export interface AndroidRelease { + name?: string; + userFraction?: number; + releaseNotes?: ReleaseNotes[]; + versionCodes?: [number]; + status?: string; + inAppUpdatePriority?: number; +} + +export interface AndroidMedia { + body: fs.ReadStream; + mimeType: string; +} + +export interface AndroidResource { + track?: string; + releases?: AndroidRelease[]; +} + +export interface AndroidListingResource { + language?: string; + title?: string; + fullDescription?: string; + shortDescription?: string; + video?: string; +} + +export interface Edit { + id: string; + expiryTimeSeconds: string; +} + +export interface ObbRequest { + packageName?: string; + media?: AndroidMedia; + apkVersionCode?: number; + expansionFileType?: string; +} + +export interface PackageParams { + packageName?: string; + editId?: any; + track?: string; + resource?: AndroidResource; // 'resource' goes into the 'body' of the http request + media?: AndroidMedia; + apkVersionCode?: number; + language?: string; + imageType?: string; + uploadType?: string; +} + +export interface PackageListingParams { + packageName?: string; + editId?: any; + track?: string; + resource?: AndroidListingResource; // 'resource' goes into the 'body' of the http request + media?: AndroidMedia; + apkVersionCode?: number; + language?: string; + imageType?: string; + uploadType?: string; +} + +export interface ReleaseNotes { + language?: string; + text?: string; +} + +export interface Release { + name?: string; + versionCodes?: number[]; + userFraction?: number; + releaseNotes: ReleaseNotes[]; + status?: string; +} + +export interface Track { + track: string; + releases: Release[]; +} + +export interface GlobalParams { + auth?: any; + params?: PackageParams; +} + +export function getJWT(key: ClientKey): JWT { + const GOOGLE_PLAY_SCOPES: string[] = ['https://www.googleapis.com/auth/androidpublisher']; + return new JWT(key.client_email, null, key.private_key, GOOGLE_PLAY_SCOPES, null); +} + +/** + * Uses the provided JWT client to request a new edit from the Play store and attach the edit id to all requests made this session + * Assumes authorized + * @param {string} packageName - unique android package name (com.android.etc) + * @return {Promise} edit - A promise that will return result from inserting a new edit + * { id: string, expiryTimeSeconds: string } + */ +export async function getNewEdit(edits: any, globalParams: GlobalParams, packageName: string): Promise { + tl.debug('Creating a new edit'); + const requestParameters: PackageParams = { + packageName: packageName + }; + + tl.debug('Additional Parameters: ' + JSON.stringify(requestParameters)); + const res = await edits.insert(requestParameters); + return res.data; +} + +/** + * Gets information for the specified app and track + * Assumes authorized + * @param {string} packageName - unique android package name (com.android.etc) + * @param {string} track - one of the values {"internal", "alpha", "beta", "production"} + * @returns {Promise} track - A promise that will return result from updating a track + * { track: string, versionCodes: [integer], userFraction: double } + */ +export async function getTrack(edits: any, packageName: string, track: string): Promise { + tl.debug('Getting Track information'); + const requestParameters: PackageParams = { + packageName: packageName, + track: track + }; + + tl.debug('Additional Parameters: ' + JSON.stringify(requestParameters)); + const getTrack = await edits.tracks.get(requestParameters); + return getTrack.data; +} + +/** + * Update a given release track with the given information + * Assumes authorized + * @param {string} packageName - unique android package name (com.android.etc) + * @param {string} track - one of the values {"internal", "alpha", "beta", "production"} + * @param {integer or [integers]} versionCode - version code returned from an apk call. will take either a number or a [number] + * @param {double} userFraction - for rollouting out a release to a track, it's the fraction of users to get update 1.0 is all users + * @param {number} updatePriority - In-app update priority value of the release. All newly added APKs in the release will be considered at this priority. Can take values in the range [0, 5], with 5 the highest priority. Defaults to 0. + * @param {releaseNotes} releaseNotes - optional release notes to be attached as part of the update + * @returns {Promise} track - A promise that will return result from updating a track + * { track: string, versionCodes: [integer], userFraction: double } + */ +export async function updateTrack(edits: any, packageName: string, track: string, versionCode: any, userFraction: number, updatePriority: number, releaseNotes?: ReleaseNotes[]): Promise { + const release: AndroidRelease = { + versionCodes: (typeof versionCode === 'number' ? [versionCode] : versionCode), + inAppUpdatePriority: updatePriority + }; + + if (releaseNotes && releaseNotes.length > 0) { + tl.debug('Attaching release notes to the update'); + release.releaseNotes = releaseNotes; + } + + if (userFraction < 1.0) { + release.userFraction = userFraction; + release.status = 'inProgress'; + } else { + tl.debug('User fraction is more than 100% marking rollout "completed"'); + release.status = 'completed'; + } + + const requestParameters: PackageParams = { + packageName: packageName, + track: track, + resource: { + track: track, + releases: [release] + } + }; + + tl.debug('Additional Parameters: ' + JSON.stringify(requestParameters)); + + tl.debug('Updating track'); + const updatedTrack = await edits.tracks.update(requestParameters); + + return updatedTrack.data; +} + +/** + * Update the universal parameters attached to every request + * @param {string} paramName - Name of parameter to add/update + * @param {any} value - value to assign to paramName. Any value is admissible. + * @returns {void} void + */ +export function updateGlobalParams(globalParams: GlobalParams, paramName: string, value: any): void { + tl.debug('Updating Global Parameters'); + tl.debug('SETTING ' + paramName + ' TO ' + JSON.stringify(value)); + globalParams.params[paramName] = value; + google.options(globalParams); + tl.debug('Global Params set to ' + JSON.stringify(globalParams)); +} + +/** + * Adds an apk to an existing edit + * Assumes authorized + * @param {string} packageName unique android package name (com.android.etc) + * @param {string} apkFile path to apk file + * @returns {Promise} apk A promise that will return result from uploading an apk + * { versionCode: integer, binary: { sha1: string } } + */ +export async function addApk(edits: any, packageName: string, apkFile: string): Promise { + let requestParameters: PackageParams = { + packageName: packageName, + media: { + body: fs.createReadStream(apkFile), + mimeType: 'application/vnd.android.package-archive' + } + }; + + try { + tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); + const res: Apk = (await edits.apks.upload(requestParameters)).data; + tl.debug('returned: ' + JSON.stringify(res)); + return res; + } catch (e) { + tl.debug(`Failed to upload the APK ${apkFile}`); + tl.debug(e); + throw new Error(tl.loc('CannotUploadApk', apkFile, e)); + } +} + +/** + * Adds an obb for an apk to an existing edit + * Assumes authorized + * @param {string} packageName unique android package name (com.android.etc) + * @param {string} obbFile path to obb file + * @param {string} obbVersionCode version code of the corresponding apk + * @param {string} obbFileType type of obb to be uploaded (main/patch) + * @returns {Promise} ObbResponse A promise that will return result from uploading an obb + * { expansionFile: { referencesVersion: number, fileSize: number } } + */ +export async function addObb(edits: any, packageName: string, obbFile: string, obbVersionCode: number, obbFileType: string): Promise { + const requestParameters: ObbRequest = { + packageName: packageName, + media: { + body: fs.createReadStream(obbFile), + mimeType: 'application/octet-stream' + }, + apkVersionCode: obbVersionCode, + expansionFileType: obbFileType + }; + + try { + tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); + const res: ObbResponse = ( await edits.expansionfiles.upload(requestParameters)).data; + tl.debug('returned: ' + JSON.stringify(res)); + return res; + } catch (e) { + tl.debug(`Failed to upload the Obb ${obbFile}`); + tl.debug(e); + throw new Error(e); + } +} + +/** + * Uploads a deobfuscation file (mapping.txt) for a given package + * Assumes authorized + * @param {string} mappingFilePath the path to the file to upload + * @param {string} packageName unique android package name (com.android.etc) + * @param apkVersionCode version code of uploaded APK + * @returns {Promise} deobfuscationFiles A promise that will return result from uploading a deobfuscation file + * { deobfuscationFile: { symbolType: string } } + */ +export async function uploadDeobfuscation(edits: any, mappingFilePath: string, packageName: string, apkVersionCode: number): Promise { + const requestParameters = { + deobfuscationFileType: 'proguard', + packageName: packageName, + apkVersionCode: apkVersionCode, + media: { + body: fs.createReadStream(mappingFilePath), + mimeType: '' + } + }; + + try { + tl.debug('Request Parameters: ' + JSON.stringify(requestParameters)); + const res = (await edits.deobfuscationfiles.upload(requestParameters)).data; + tl.debug('returned: ' + JSON.stringify(res)); + } catch (e) { + tl.debug(`Failed to upload deobfuscation file ${mappingFilePath}`); + tl.debug(e); + throw new Error(tl.loc('CannotUploadDeobfuscationFile', mappingFilePath, e)); + } +} diff --git a/metadataHelper.ts b/metadataHelper.ts new file mode 100644 index 0000000..57b0315 --- /dev/null +++ b/metadataHelper.ts @@ -0,0 +1,477 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as tl from 'azure-pipelines-task-lib/task'; + +import { androidpublisher_v3 as pub3 } from 'googleapis'; + +/** + * Uploads change log files if specified for all the apk version codes in the update + * @param changelogFile + * @param apkVersionCodes + * @returns nothing + */ + async function getCommonReleaseNotes(languageCode: string, changelogFile: string): Promise { + const stats: tl.FsStats = tl.stats(changelogFile); + + let releaseNotes: googleutil.ReleaseNotes = null; + if (stats && stats.isFile()) { + console.log(tl.loc('AppendChangelog', changelogFile)); + releaseNotes = { + language: languageCode, + text: getChangelog(changelogFile) + }; + + } else { + tl.debug(`The change log path ${changelogFile} either does not exist or points to a directory. Ignoring...`); + } + return releaseNotes; +} + +/** + * Reads a change log from a file + * Assumes authorized + * @param {string} changelogFile Path to changelog file. + * @returns {string} change log file content as a string. + */ +function getChangelog(changelogFile: string): string { + tl.debug(`Reading change log from ${changelogFile}`); + try { + return fs.readFileSync(changelogFile).toString(); + } catch (e) { + tl.debug(`Change log reading from ${changelogFile} failed`); + tl.debug(e); + throw new Error(tl.loc('CannotReadChangeLog', changelogFile)); + } +} + +/** + * Adds all release notes found in directory to an edit. Pulls version code from file name. Failing this, assumes the global version code inferred from apk + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory with a changesogs folder where release notes can be found. + * @returns nothing + */ +async function addAllReleaseNotes(apkVersionCodes: number[], languageCode: string, directory: string): Promise { + const changelogDir: string = path.join(directory, 'changelogs'); + + const changelogs: string[] = filterDirectoryContents(changelogDir, stat => stat.isFile()); + + if (changelogs.length === 0) { + return []; + } + + const releaseNotes: googleutil.ReleaseNotes[] = []; + for (const changelogFile of changelogs) { + const changelogName: string = path.basename(changelogFile, path.extname(changelogFile)); + const changelogVersion: number = parseInt(changelogName, 10); + if (!isNaN(changelogVersion) && (apkVersionCodes.indexOf(changelogVersion) !== -1)) { + const fullChangelogPath: string = path.join(changelogDir, changelogFile); + + console.log(tl.loc('AppendChangelog', fullChangelogPath)); + releaseNotes.push({ + language: languageCode, + text: getChangelog(fullChangelogPath) + }); + tl.debug(`Found release notes version ${changelogVersion} from ${fullChangelogPath} for language code ${languageCode}`); + } else { + tl.debug(`The name of the file ${changelogFile} is not a valid version code. Skipping it.`); + } + } + + tl.debug(`All release notes found for ${changelogDir}: ${JSON.stringify(releaseNotes)}`); + return releaseNotes; +} + +/** + * Filters the directory contents to find files or directories + * @param {string} directory the directory to search + * @param {(stats: tl.FsStats) => boolean} filter callback on every item in the directory, return true to keep the results + * @returns the filtered contents of the directory + */ +function filterDirectoryContents(directory: string, filter: (stats: tl.FsStats) => boolean): string[] { + return fs.readdirSync(directory).filter(subPath => { + try { + const fullPath: string = path.join(directory, subPath); + tl.debug(`Checking path ${fullPath}`); + return filter(tl.stats(fullPath)); + } catch (e) { + tl.debug(`Failed to stat path ${subPath}:`); + tl.debug(e); + tl.debug('Ignoring...'); + return false; + } + }); +} + +/** + * Attaches the metadata in the specified directory to the edit. Assumes the metadata structure specified by Fastlane. + * Assumes authorized + * + * Metadata Structure: + * metadata + * └ $(languageCodes) + * ├ full_description.txt + * ├ short_description.txt + * ├ title.txt + * ├ video.txt + * ├ images + * | ├ featureGraphic.png || featureGraphic.jpg || featureGraphic.jpeg + * | ├ icon.png || icon.jpg || icon.jpeg + * | ├ promoGraphic.png || promoGraphic.jpg || promoGraphic.jpeg + * | ├ tvBanner.png || tvBanner.jpg || tvBanner.jpeg + * | ├ phoneScreenshots + * | | └ *.png || *.jpg || *.jpeg + * | ├ sevenInchScreenshots + * | | └ *.png || *.jpg || *.jpeg + * | ├ tenInchScreenshots + * | | └ *.png || *.jpg || *.jpeg + * | ├ tvScreenshots + * | | └ *.png || *.jpg || *.jpeg + * | └ wearScreenshots + * | └ *.png || *.jpg || *.jpeg + * └ changelogs + * └ $(versioncodes).txt + * + * @param {string} metadataRootDirectory Path to the folder where the Fastlane metadata structure is found. eg the folders under this directory should be the language codes + * @returns nothing + */ +async function addMetadata(edits: pub3.Resource$Edits, apkVersionCodes: number[], metadataRootDirectory: string): Promise { + const metadataLanguageCodes: string[] = filterDirectoryContents(metadataRootDirectory, stat => stat.isDirectory()); + tl.debug(`Found language codes: ${metadataLanguageCodes}`); + + let allReleaseNotes: googleutil.ReleaseNotes[] = []; + for (const languageCode of metadataLanguageCodes) { + const metadataDirectory: string = path.join(metadataRootDirectory, languageCode); + + tl.debug(`Uploading metadata from ${metadataDirectory} for language code ${languageCode} and version codes ${apkVersionCodes}`); + const releaseNotesForLanguage = await uploadMetadataWithLanguageCode(edits, apkVersionCodes, languageCode, metadataDirectory); + allReleaseNotes = allReleaseNotes.concat(releaseNotesForLanguage); + } + + tl.debug(`Collected ${allReleaseNotes.length} release notes`); + return allReleaseNotes; +} + +/** + * Updates the details for a language with new information + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory where updated listing details can be found. + * @returns nothing + */ +async function uploadMetadataWithLanguageCode(edits: pub3.Resource$Edits, apkVersionCodes: number[], languageCode: string, directory: string): Promise { + console.log(tl.loc('UploadingMetadataForLanguage', directory, languageCode)); + + tl.debug(`Adding localized store listing for language code ${languageCode} from ${directory}`); + await addLanguageListing(edits, languageCode, directory); + + tl.debug(`Uploading change logs for language code ${languageCode} from ${directory}`); + const releaseNotes: googleutil.ReleaseNotes[] = await addAllReleaseNotes(apkVersionCodes, languageCode, directory); + + tl.debug(`Uploading images for language code ${languageCode} from ${directory}`); + await attachImages(edits, languageCode, directory); + + return releaseNotes; +} + +/** + * Updates the details for a language with new information + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory where updated listing details can be found. + * @returns nothing + */ +async function addLanguageListing(edits: pub3.Resource$Edits, languageCode: string, directory: string) { + const listingResource: googleutil.AndroidListingResource = createListingResource(languageCode, directory); + + const isPatch:boolean = (!listingResource.fullDescription) || + (!listingResource.shortDescription) || + (!listingResource.title); + + const isEmpty:boolean = (!listingResource.fullDescription) && + (!listingResource.shortDescription) && + (!listingResource.video) && + (!listingResource.title); + + const listingRequestParameters: googleutil.PackageListingParams = { + language: languageCode, + resource: listingResource + }; + + try { + + if (isEmpty) { + tl.debug(`Skip localized ${languageCode} store listing.`); + } else if (isPatch) { + tl.debug(`Patching an existing localized ${languageCode} store listing.`); + tl.debug('Request Parameters: ' + JSON.stringify(listingRequestParameters)); + await edits.listings.patch(listingRequestParameters); + tl.debug(`Successfully patched the localized ${languageCode} store listing.`); + } else { + // The patch method fails if the listing for the language does not exist already, + // while update actually updates or creates. + tl.debug(`Updating a localized ${languageCode} store listing.`); + tl.debug('Request Parameters: ' + JSON.stringify(listingRequestParameters)); + await edits.listings.update(listingRequestParameters); + tl.debug(`Successfully updated the localized ${languageCode} store listing.`); + } + } catch (e) { + tl.debug(`Failed to create the localized ${languageCode} store listing.`); + tl.debug(e); + throw new Error(tl.loc('CannotCreateListing', languageCode, e)); + } +} + +/** + * Helper method for creating the resource for the edits.listings.update method. + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory where updated listing details can be found. + * @returns {AndroidListingResource} resource A crafted resource for the edits.listings.update method. + * { languageCode: string, fullDescription: string, shortDescription: string, title: string, video: string } + */ +function createListingResource(languageCode: string, directory: string): googleutil.AndroidListingResource { + tl.debug(`Constructing resource to update listing with language code ${languageCode} from ${directory}`); + + const resourceParts = { + fullDescription: 'full_description.txt', + shortDescription: 'short_description.txt', + title: 'title.txt', + video: 'video.txt' + }; + + const resource: googleutil.AndroidListingResource = { + language: languageCode + }; + + for (const i in resourceParts) { + if (resourceParts.hasOwnProperty(i)) { + const file: string = path.join(directory, resourceParts[i]); + try { + const fileContents: Buffer = fs.readFileSync(file); + resource[i] = fileContents.toString(); + } catch (e) { + tl.debug(`Failed to read metadata file ${file}:`); + tl.debug(e); + tl.debug('Ignoring...'); + } + } + } + + tl.debug(`Finished constructing listing resource ${JSON.stringify(resource)}`); + return resource; +} + +/** + * Upload images to the app listing. + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} directory Directory where updated listing details can be found. + * @returns nothing + */ +async function attachImages(edits: pub3.Resource$Edits, languageCode: string, directory: string) { + const imageList: { [key: string]: string[] } = getImageList(directory); + tl.debug(`Found ${languageCode} images: ${JSON.stringify(imageList)}`); + + let cnt: number = 0; + for (const imageType of Object.keys(imageList)) { + const images: string[] = imageList[imageType]; + tl.debug(`Uploading images of type ${imageType}: ${JSON.stringify(images)}`); + + if (images.length > 0) { + await removeOldImages(edits, languageCode, imageType); + } + + for (const image of images) { + tl.debug(`Uploading image of type ${imageType} from ${image}`); + await uploadImage(edits, languageCode, imageType, image); + cnt++; + } + } + + tl.debug(`${cnt} image(s) uploaded.`); +} + +/** + * Remove existing images from the app listing. + * See the user Story 955465 and https://github.com/Microsoft/google-play-vsts-extension/issues/34. + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} imageType type of images. + * @returns nothing + */ +async function removeOldImages(edits: pub3.Resource$Edits, languageCode: string, imageType: string) { + try { + let imageRequest: googleutil.PackageParams = { + language: languageCode, + imageType: imageType + }; + + tl.debug(`Removing old images of type ${imageType} for language ${languageCode}.`); + tl.debug('Request Parameters: ' + JSON.stringify(imageRequest)); + await edits.images.deleteall(imageRequest); + tl.debug(`Successfully removed old images of type ${imageType} for language ${languageCode}.`); + } catch (e) { + tl.debug(`Failed to remove old images of type ${imageType} for language ${languageCode}.`); + tl.debug(e); + } +} + +/** + * Get all the images in the metadata directory that need to be uploaded. + * Assumes all files are in a folder labeled "images" at the root of directory + * directory + * └ images + * ├ featureGraphic.png || featureGraphic.jpg || featureGraphic.jpeg + * ├ icon.png || icon.jpg || icon.jpeg + * ├ promoGraphic.png || promoGraphic.jpg || promoGraphic.jpeg + * ├ tvBanner.png || tvBanner.jpg || tvBanner.jpeg + * ├ phoneScreenshots + * | └ *.png || *.jpg || *.jpeg + * ├ sevenInchScreenshots + * | └ *.png || *.jpg || *.jpeg + * ├ tenInchScreenshots + * | └ *.png || *.jpg || *.jpeg + * ├ tvScreenshots + * | └ *.png || *.jpg || *.jpeg + * └ wearScreenshots + * └ *.png || *.jpg || *.jpeg + * @param {string} directory Directory where the "images" folder is found matching the structure specified above + * @returns {Object} imageList Map of image types to lists of images matching that type. + * { [imageType]: string[] } + */ +function getImageList(directory: string): { [key: string]: string[] } { + const imageTypes: string[] = ['featureGraphic', 'icon', 'promoGraphic', 'tvBanner', 'phoneScreenshots', 'sevenInchScreenshots', 'tenInchScreenshots', 'tvScreenshots', 'wearScreenshots']; + const acceptedExtensions: string[] = ['.png', '.jpg', '.jpeg']; + + const imageDirectory: string = path.join(directory, 'images'); + const imageList: { [key: string]: string[] } = {}; + + for (const imageType of imageTypes) { + let shouldAttemptUpload: boolean = false; + + imageList[imageType] = []; + + tl.debug(`Attempting to get images of type ${imageType}`); + switch (imageType) { + case 'featureGraphic': + case 'icon': + case 'promoGraphic': + case 'tvBanner': + for (let acceptedExtension of acceptedExtensions) { + let fullPathToFileToCheck: string = path.join(imageDirectory, imageType + acceptedExtension); + try { + let imageStat: tl.FsStats = tl.stats(fullPathToFileToCheck); + if (imageStat) { + shouldAttemptUpload = imageStat.isFile(); + if (shouldAttemptUpload) { + console.log(tl.loc('FoundImageAtPath', imageType, fullPathToFileToCheck)); + imageList[imageType].push(fullPathToFileToCheck); + break; + } + } + } catch (e) { + tl.debug(`File ${fullPathToFileToCheck} doesn't exist. Skipping...`); + } + } + + if (!shouldAttemptUpload) { + console.log(tl.loc('ImageTypeNotFound', imageType)); + } + break; + case 'phoneScreenshots': + case 'sevenInchScreenshots': + case 'tenInchScreenshots': + case 'tvScreenshots': + case 'wearScreenshots': + try { + let fullPathToDirToCheck: string = path.join(imageDirectory, imageType); + let imageStat: fs.Stats = fs.statSync(fullPathToDirToCheck); + if (imageStat) { + tl.debug(`Found something for type ${imageType}`); + shouldAttemptUpload = imageStat.isDirectory(); + if (!shouldAttemptUpload) { + console.log(tl.loc('StatNotDirectory', imageType)); + } else { + imageList[imageType] = fs.readdirSync(fullPathToDirToCheck) + .filter(function (image) { + try { + return fs.statSync(path.join(fullPathToDirToCheck, image)).isFile(); + } catch (e) { + tl.debug(e); + tl.debug(`Failed to stat path ${image}. Ignoring...`); + } + + return false; + }) + .map(function (image) { + return path.join(fullPathToDirToCheck, image); + }); + } + } + } catch (e) { + tl.debug(e); + console.log(tl.loc('ImageDirNotFound', imageType)); + } + break; + default: + tl.debug(`Image type ${imageType} is an unknown type and was ignored`); + continue; + } + } + + tl.debug(`Finished enumerating images: ${JSON.stringify(imageList)}`); + return imageList; +} + +/** + * Attempts to upload the specified image to the edit + * Assumes authorized + * @param {string} languageCode Language code (a BCP-47 language tag) of the localized listing to update + * @param {string} imageType One of the following values: "featureGraphic", "icon", "promoGraphic", "tvBanner", "phoneScreenshots", "sevenInchScreenshots", "tenInchScreenshots", "tvScreenshots", "wearScreenshots" + * @param {string} imagePath Path to image to attempt upload with + * @returns nothing + */ +async function uploadImage(edits: pub3.Resource$Edits, languageCode: string, imageType: string, imagePath: string) { + const imageRequest: googleutil.PackageParams = { + language: languageCode, + imageType: imageType + }; + + imageRequest.uploadType = 'media'; + imageRequest.media = { + body: fs.createReadStream(imagePath), + mimeType: helperResolveImageMimeType(imagePath) + }; + + try { + tl.debug(`Uploading image ${imagePath} of type ${imageType}.`); + tl.debug('Request Parameters: ' + JSON.stringify(imageRequest)); + await edits.images.upload(imageRequest); + tl.debug(`Successfully uploaded image ${imagePath} of type ${imageType}.`); + } catch (e) { + tl.debug(`Failed to upload image ${imagePath} of type ${imageType}.`); + tl.debug(e); + throw new Error(tl.loc('UploadImageFail')); + } +} + +/** + * Attempts to resolve the image mime type of the given path. + * Not compelete. DO NOT REUSE. + * @param {string} imagePath Path to attempt to resolve image mime for. + * @returns {string} mimeType Google Play accepted image mime type that imagePath most closely maps to. + */ +function helperResolveImageMimeType(imagePath: string): string { + const extension: string = imagePath.split('.').pop(); + + switch (extension) { + case 'png': + return 'image/png'; + case 'jpg': + case 'jpeg': + return 'image/jpeg'; + default: + tl.debug(`Could not resolve image mime type for ${imagePath}. Defaulting to jpeg.`); + return 'image/jpeg'; + } +} diff --git a/task.json b/task.json new file mode 100644 index 0000000..6f2bc29 --- /dev/null +++ b/task.json @@ -0,0 +1,310 @@ +{ + "id": "8cf7cac0-620b-11e5-b4cf-8565e60f4d27", + "name": "GooglePlayRelease", + "friendlyName": "Google Play - Release", + "description": "Release an app to the Google Play Store", + "author": "Microsoft Corporation", + "category": "Deploy", + "visibility": [ + "Build", + "Release" + ], + "demands": [ + "npm" + ], + "version": { + "Major": "3", + "Minor": "193", + "Patch": "0" + }, + "minimumAgentVersion": "2.182.1", + "groups": [ + { + "name": "advanced", + "displayName": "Advanced Options", + "isExpanded": false + } + ], + "instanceNameFormat": "Release $(apkFile) to $(track)", + "inputs": [ + { + "name": "authType", + "type": "pickList", + "label": "Authentication method", + "defaultValue": "ServiceEndpoint", + "helpMarkDown": "", + "options": { + "JsonFile": "JSON Auth File", + "ServiceEndpoint": "Service connection" + } + }, + { + "name": "serviceEndpoint", + "aliases": [ + "serviceConnection" + ], + "type": "connectedService:google-play", + "label": "Service connection", + "defaultValue": "", + "required": true, + "helpMarkDown": "Google Play service connection that is configured with your account credentials.", + "visibleRule": "authType = ServiceEndpoint" + }, + { + "name": "serviceAccountKey", + "type": "filePath", + "label": "JSON key path", + "defaultValue": "", + "required": true, + "helpMarkDown": "The JSON file provided by Google Play that includes the service account's identity you wish to publish your APK under.", + "visibleRule": "authType = JsonFile" + }, + { + "name": "apkFile", + "type": "filePath", + "label": "APK path", + "defaultValue": "", + "required": true, + "helpMarkDown": "Path to the APK file you want to publish to the specified track. Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match the first APK file, in any directory." + }, + { + "name": "shouldPickObbFile", + "type": "boolean", + "label": "Upload Obb", + "defaultValue": false, + "required": false, + "helpMarkDown": "Select this option to pick obb file for the apk. If present in the parent directory, it will pick the first file with .obb extension, else it will pick from apk directory with expected format as main...obb" + }, + { + "name": "track", + "type": "pickList", + "label": "Track", + "defaultValue": "internal", + "required": true, + "helpMarkDown": "Track you want to publish the APK to.", + "options": { + "internal": "Internal test", + "alpha": "Alpha", + "beta": "Beta", + "production": "Production" + }, + "properties": { + "EditableOptions": "True" + } + }, + { + "name": "changeUpdatePriority", + "type": "boolean", + "label": "Set in-app update priority", + "defaultValue": false, + "required": false, + "helpMarkDown": "Change the in-app update priority value." + }, + { + "name": "updatePriority", + "type": "pickList", + "label": "In-app Update Priority", + "defaultValue": "0", + "required": false, + "options": { + "0": "0", + "1": "1", + "2": "2", + "3": "3", + "4": "4", + "5": "5" + }, + "helpMarkDown": "Set a custom in-app update priority value to help keep your app up-to-date on your users’ devices. To determine priority, Google Play uses an integer value between 0 and 5, with 0 being the default, and 5 being the highest priority. Priority can only be set when rolling out a new release, and cannot be changed later.", + "visibleRule": "changeUpdatePriority = true" + }, + { + "name": "rolloutToUserFraction", + "type": "boolean", + "label": "Roll out release", + "defaultValue": false, + "required": false, + "helpMarkDown": "Roll out the release to a percentage of users." + }, + { + "name": "userFraction", + "type": "string", + "label": "Rollout fraction", + "defaultValue": "1.0", + "required": false, + "helpMarkDown": "The percentage of users the specified APK will be released to for the specified 'Track'. It can be increased later with the 'Google Play - Increase Rollout' task.", + "visibleRule": "rolloutToUserFraction = true" + }, + { + "name": "shouldAttachMetadata", + "type": "boolean", + "label": "Update metadata", + "defaultValue": false, + "required": false, + "helpMarkDown": "Select this option to update the metadata on your app release." + }, + { + "name": "changeLogFile", + "type": "filePath", + "label": "Release notes (file)", + "defaultValue": "", + "required": false, + "helpMarkDown": "Path to the file specifying the release notes (change log) for the APK you are publishing.", + "visibleRule": "shouldAttachMetadata = false" + }, + { + "name": "languageCode", + "type": "string", + "label": "Language code", + "defaultValue": "en-US", + "required": false, + "helpMarkDown": "An IETF language tag identifying the language of the release notes as specified in the BCP-47 document. Default value is _en-US_", + "visibleRule": "shouldAttachMetadata = false" + }, + { + "name": "metadataRootPath", + "type": "filePath", + "label": "Metadata root directory", + "defaultValue": "", + "required": true, + "helpMarkDown": "The path to the metadata folder with the fastlane metadata structure.", + "visibleRule": "shouldAttachMetadata = true" + }, + { + "name": "updateStoreListing", + "type": "boolean", + "label": "Update only store listing", + "defaultValue": false, + "required": false, + "helpMarkDown": " By default, the task will update the specified track and selected APK file(s) will be assigned to the related track. By selecting this option you can update only store listing." + }, + { + "name": "shouldUploadApks", + "type": "boolean", + "label": "Update APK(s)", + "defaultValue": true, + "required": false, + "visibleRule": "updateStoreListing = false", + "helpMarkDown": "By default, the task will update the specified binary APK file(s) on your app release. By unselecting this option you can update metadata keeping the APKs untouched." + }, + { + "name": "shouldUploadMappingFile", + "type": "boolean", + "label": "Upload deobfuscation file (mapping.txt)", + "defaultValue": false, + "required": false, + "helpMarkDown": "Select this option to attach your proguard mapping.txt file to the primary APK." + }, + { + "name": "mappingFilePath", + "type": "filePath", + "label": "Deobfuscation path", + "defaultValue": "", + "required": false, + "helpMarkDown": "The path to the proguard mapping.txt file to upload.", + "visibleRule": "shouldUploadApks = true && shouldUploadMappingFile = true" + }, + { + "name": "additionalApks", + "type": "multiLine", + "label": "Additional APK path(s)", + "defaultValue": "", + "groupName": "advanced", + "required": false, + "helpMarkDown": "Paths to additional APK files you want to publish to the specified track (e.g. an x86 build). Wildcards can be used. For example, _\\*\\*/\\*.apk_ to match all APK files, in any directory." + }, + { + "name": "shouldPickObbFileForAdditonalApks", + "type": "boolean", + "label": "Upload Obbs", + "defaultValue": false, + "groupName": "advanced", + "required": false, + "helpMarkDown": "Select this option to pick obb file for the apk. If present in the parent directory, it will pick the first file with .obb extension, else it will pick from apk directory with expected format as main...obb" + }, + { + "name": "versionCodeFilterType", + "type": "pickList", + "label": "Replace version codes", + "defaultValue": "all", + "groupName": "advanced", + "required": false, + "helpMarkDown": "Specify version codes to replace in the selected track with the new APKs: all, the comma separated list, or a regular expression pattern.", + "options": { + "all": "All", + "list": "List", + "expression": "Regular expression" + } + }, + { + "name": "replaceList", + "type": "string", + "label": "Version code list", + "defaultValue": "", + "groupName": "advanced", + "required": true, + "helpMarkDown": "The comma separated list of APK version codes to be removed from the track with this deployment.", + "visibleRule": "versionCodeFilterType = list" + }, + { + "name": "replaceExpression", + "type": "string", + "label": "Version code pattern", + "defaultValue": "", + "groupName": "advanced", + "required": true, + "helpMarkDown": "The regular expression pattern to select a list of APK version codes to be removed from the track with this deployment, e.g. _.\\*12?(3|4)?5_ ", + "visibleRule": "versionCodeFilterType = expression" + } + ], + "execution": { + "Node10": { + "target": "GooglePlay.js", + "argumentFormat": "" + }, + "PowerShell": { + "target": "$(currentDirectory)\\GooglePlay.ps1", + "argumentFormat": "", + "workingDirectory": "$(currentDirectory)" + } + }, + "messages": { + "InvalidAuthFile": "%s is not a valid auth file", + "FoundMainApk": "Found main APK to upload: %s (version code %s)", + "FoundMultiApks": "Found multiple APKs to upload:", + "FoundDeobfuscationFile": "Found deobfuscation (mapping) file: %s", + "GetNewEditAfterAuth": "Authenticated with Google Play and getting new edit ", + "UploadApk": "Uploading APK file %s...", + "UpdateTrack": "Updating track information...", + "AttachingMetadataToRelease": "Attempting to attach metadata to release...", + "AptPublishSucceed": "APK successfully published!", + "TrackInfo": "Track: %s", + "Success": "Successfully publish APKs.", + "Failure": "Failed to publish APKs.", + "AddChangelog": "Adding changelog file...", + "AppendChangelog": "Appending changelog %s", + "UploadingMetadataForLanguage": "Attempting to upload metadata in %s for language code %s", + "FoundImageAtPath": "Found image for type %s at %s", + "ImageTypeNotFound": "Image for %s was not found. Skipping...", + "StatNotDirectory": "Stat returned that %s was not a directory. Is there a file that shares this name?", + "ImageDirNotFound": "Image directory for %s was not found. Skipping...", + "UploadImageFail": "Failed to upload image.", + "RequestDetails": "Request Details: %s", + "CannotCreateTransaction": "Failed to create a new edit transaction for the package %s. Failed with message: %s. See log for details.", + "CannotUploadApk": "Failed to upload the APK %s. Failed with message: %s.", + "CannotUploadDeobfuscationFile": "Failed to upload the deobfuscation file %s. Failed with message: %s.", + "CannotDownloadTrack": "Failed to download track %s information. Failed with message: %s.", + "CannotUpdateTrack": "Failed to update track %s information. Failed with message: %s.", + "CannotReadChangeLog": "Failed to read change log %s. Failed with message: %s.", + "CannotCreateListing": "Failed to create the localized %s store listing. Failed with message: %s.", + "IncorrectVersionCodeFilter": "Version code list specified contains incorrect codes: %s", + "StoreListUpdateSucceed": "Store list metadata successfully updated" + }, + "restrictions": { + "commands": { + "mode": "restricted" + }, + "settableVariables": { + "allowed": [] + } + } +}