18d307f52SEvan Baconimport assert from 'assert';
28d307f52SEvan Baconimport chalk from 'chalk';
38d307f52SEvan Bacon
4e32ccf9fSEvan Baconimport { env } from './env';
5e32ccf9fSEvan Baconimport { memoize } from './fn';
68d307f52SEvan Baconimport { learnMore } from './link';
78d307f52SEvan Baconimport { isUrlAvailableAsync } from './url';
88a424bebSJames Ideimport { fetchAsync } from '../api/rest/client';
98a424bebSJames Ideimport { Log } from '../log';
108d307f52SEvan Bacon
11474a7a4bSEvan Baconconst debug = require('debug')('expo:utils:validateApplicationId') as typeof console.log;
12474a7a4bSEvan Bacon
138d307f52SEvan Baconconst IOS_BUNDLE_ID_REGEX = /^[a-zA-Z0-9-.]+$/;
14*f4cccd85SAlan Hughesconst ANDROID_PACKAGE_REGEX = /^(?!.*\bnative\b)[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
158d307f52SEvan Bacon
168d307f52SEvan Bacon/** Validate an iOS bundle identifier. */
178d307f52SEvan Baconexport function validateBundleId(value: string): boolean {
188d307f52SEvan Bacon  return IOS_BUNDLE_ID_REGEX.test(value);
198d307f52SEvan Bacon}
208d307f52SEvan Bacon
218d307f52SEvan Bacon/** Validate an Android package name. */
228d307f52SEvan Baconexport function validatePackage(value: string): boolean {
2351740f69SEvan Bacon  return validatePackageWithWarning(value) === true;
248d307f52SEvan Bacon}
258d307f52SEvan Bacon
2651740f69SEvan Bacon/** Validate an Android package name and return the reason if invalid. */
2751740f69SEvan Baconexport function validatePackageWithWarning(value: string): true | string {
2851740f69SEvan Bacon  const parts = value.split('.');
2951740f69SEvan Bacon  for (const segment of parts) {
3051740f69SEvan Bacon    if (RESERVED_ANDROID_PACKAGE_NAME_SEGMENTS.includes(segment)) {
3151740f69SEvan Bacon      return `"${segment}" is a reserved Java keyword.`;
3251740f69SEvan Bacon    }
3351740f69SEvan Bacon  }
3451740f69SEvan Bacon  if (parts.length < 2) {
3551740f69SEvan Bacon    return `Package name must contain more than one segment, separated by ".", e.g. com.${value}`;
3651740f69SEvan Bacon  }
3751740f69SEvan Bacon  if (!ANDROID_PACKAGE_REGEX.test(value)) {
3851740f69SEvan Bacon    return 'Invalid characters in Android package name. Only alphanumeric characters, "." and "_" are allowed, and each "." must be followed by a letter or number.';
3951740f69SEvan Bacon  }
4051740f69SEvan Bacon
4151740f69SEvan Bacon  return true;
4251740f69SEvan Bacon}
4351740f69SEvan Bacon
4451740f69SEvan Bacon// https://en.wikipedia.org/wiki/List_of_Java_keywords
4551740f69SEvan Bacon// Running the following in the console and pruning the "Reserved Identifiers" section:
4651740f69SEvan Bacon// [...document.querySelectorAll('dl > dt > code')].map(node => node.innerText)
4751740f69SEvan Baconconst RESERVED_ANDROID_PACKAGE_NAME_SEGMENTS = [
4851740f69SEvan Bacon  // List of Java keywords
4951740f69SEvan Bacon  '_',
5051740f69SEvan Bacon  'abstract',
5151740f69SEvan Bacon  'assert',
5251740f69SEvan Bacon  'boolean',
5351740f69SEvan Bacon  'break',
5451740f69SEvan Bacon  'byte',
5551740f69SEvan Bacon  'case',
5651740f69SEvan Bacon  'catch',
5751740f69SEvan Bacon  'char',
5851740f69SEvan Bacon  'class',
5951740f69SEvan Bacon  'const',
6051740f69SEvan Bacon  'continue',
6151740f69SEvan Bacon  'default',
6251740f69SEvan Bacon  'do',
6351740f69SEvan Bacon  'double',
6451740f69SEvan Bacon  'else',
6551740f69SEvan Bacon  'enum',
6651740f69SEvan Bacon  'extends',
6751740f69SEvan Bacon  'final',
6851740f69SEvan Bacon  'finally',
6951740f69SEvan Bacon  'float',
7051740f69SEvan Bacon  'for',
7151740f69SEvan Bacon  'goto',
7251740f69SEvan Bacon  'if',
7351740f69SEvan Bacon  'implements',
7451740f69SEvan Bacon  'import',
7551740f69SEvan Bacon  'instanceof',
7651740f69SEvan Bacon  'int',
7751740f69SEvan Bacon  'interface',
7851740f69SEvan Bacon  'long',
7951740f69SEvan Bacon  'native',
8051740f69SEvan Bacon  'new',
8151740f69SEvan Bacon  'package',
8251740f69SEvan Bacon  'private',
8351740f69SEvan Bacon  'protected',
8451740f69SEvan Bacon  'public',
8551740f69SEvan Bacon  'return',
8651740f69SEvan Bacon  'short',
8751740f69SEvan Bacon  'static',
8851740f69SEvan Bacon  'super',
8951740f69SEvan Bacon  'switch',
9051740f69SEvan Bacon  'synchronized',
9151740f69SEvan Bacon  'this',
9251740f69SEvan Bacon  'throw',
9351740f69SEvan Bacon  'throws',
9451740f69SEvan Bacon  'transient',
9551740f69SEvan Bacon  'try',
9651740f69SEvan Bacon  'void',
9751740f69SEvan Bacon  'volatile',
9851740f69SEvan Bacon  'while',
9951740f69SEvan Bacon  // Reserved words for literal values
10051740f69SEvan Bacon  'true',
10151740f69SEvan Bacon  'false',
10251740f69SEvan Bacon  'null',
10351740f69SEvan Bacon  // Unused
10451740f69SEvan Bacon  'const',
10551740f69SEvan Bacon  'goto',
10651740f69SEvan Bacon  'strictfp',
10751740f69SEvan Bacon];
10851740f69SEvan Bacon
1098d307f52SEvan Baconexport function assertValidBundleId(value: string) {
1108d307f52SEvan Bacon  assert.match(
1118d307f52SEvan Bacon    value,
1128d307f52SEvan Bacon    IOS_BUNDLE_ID_REGEX,
1138d307f52SEvan Bacon    `The ios.bundleIdentifier defined in your Expo config is not formatted properly. Only alphanumeric characters, '.', '-', and '_' are allowed, and each '.' must be followed by a letter.`
1148d307f52SEvan Bacon  );
1158d307f52SEvan Bacon}
1168d307f52SEvan Bacon
1178d307f52SEvan Baconexport function assertValidPackage(value: string) {
1188d307f52SEvan Bacon  assert.match(
1198d307f52SEvan Bacon    value,
1208d307f52SEvan Bacon    ANDROID_PACKAGE_REGEX,
121*f4cccd85SAlan Hughes    `Invalid format of Android package name. Only alphanumeric characters, '.' and '_' are allowed, and each '.' must be followed by a letter. The Java keyword 'native' is not allowed.`
1228d307f52SEvan Bacon  );
1238d307f52SEvan Bacon}
1248d307f52SEvan Bacon
125e32ccf9fSEvan Bacon/** @private */
126e32ccf9fSEvan Baconexport async function getBundleIdWarningInternalAsync(bundleId: string): Promise<string | null> {
127e32ccf9fSEvan Bacon  if (env.EXPO_OFFLINE) {
128e32ccf9fSEvan Bacon    Log.warn('Skipping Apple bundle identifier reservation validation in offline-mode.');
129e32ccf9fSEvan Bacon    return null;
1308d307f52SEvan Bacon  }
1318d307f52SEvan Bacon
1328d307f52SEvan Bacon  if (!(await isUrlAvailableAsync('itunes.apple.com'))) {
133474a7a4bSEvan Bacon    debug(
134474a7a4bSEvan Bacon      `Couldn't connect to iTunes Store to check bundle ID ${bundleId}. itunes.apple.com may be down.`
135474a7a4bSEvan Bacon    );
1368d307f52SEvan Bacon    // If no network, simply skip the warnings since they'll just lead to more confusion.
1378d307f52SEvan Bacon    return null;
1388d307f52SEvan Bacon  }
1398d307f52SEvan Bacon
1408d307f52SEvan Bacon  const url = `http://itunes.apple.com/lookup?bundleId=${bundleId}`;
1418d307f52SEvan Bacon  try {
142474a7a4bSEvan Bacon    debug(`Checking iOS bundle ID '${bundleId}' at: ${url}`);
1438c8eefe0SEvan Bacon    const response = await fetchAsync(url);
1448d307f52SEvan Bacon    const json = await response.json();
1458d307f52SEvan Bacon    if (json.resultCount > 0) {
1468d307f52SEvan Bacon      const firstApp = json.results[0];
147e32ccf9fSEvan Bacon      return formatInUseWarning(firstApp.trackName, firstApp.sellerName, bundleId);
1488d307f52SEvan Bacon    }
149474a7a4bSEvan Bacon  } catch (error: any) {
150474a7a4bSEvan Bacon    debug(`Error checking bundle ID ${bundleId}: ${error.message}`);
1518d307f52SEvan Bacon    // Error fetching itunes data.
1528d307f52SEvan Bacon  }
1538d307f52SEvan Bacon  return null;
1548d307f52SEvan Bacon}
1558d307f52SEvan Bacon
156e32ccf9fSEvan Bacon/** Returns a warning message if an iOS bundle identifier is potentially already in use. */
157e32ccf9fSEvan Baconexport const getBundleIdWarningAsync = memoize(getBundleIdWarningInternalAsync);
158e32ccf9fSEvan Bacon
159e32ccf9fSEvan Bacon/** @private */
160e32ccf9fSEvan Baconexport async function getPackageNameWarningInternalAsync(
161e32ccf9fSEvan Bacon  packageName: string
162e32ccf9fSEvan Bacon): Promise<string | null> {
163e32ccf9fSEvan Bacon  if (env.EXPO_OFFLINE) {
164e32ccf9fSEvan Bacon    Log.warn('Skipping Android package name reservation validation in offline-mode.');
165e32ccf9fSEvan Bacon    return null;
1668d307f52SEvan Bacon  }
1678d307f52SEvan Bacon
1688d307f52SEvan Bacon  if (!(await isUrlAvailableAsync('play.google.com'))) {
169474a7a4bSEvan Bacon    debug(
170474a7a4bSEvan Bacon      `Couldn't connect to Play Store to check package name ${packageName}. play.google.com may be down.`
171474a7a4bSEvan Bacon    );
1728d307f52SEvan Bacon    // If no network, simply skip the warnings since they'll just lead to more confusion.
1738d307f52SEvan Bacon    return null;
1748d307f52SEvan Bacon  }
1758d307f52SEvan Bacon
1768d307f52SEvan Bacon  const url = `https://play.google.com/store/apps/details?id=${packageName}`;
1778d307f52SEvan Bacon  try {
178474a7a4bSEvan Bacon    debug(`Checking Android package name '${packageName}' at: ${url}`);
1798c8eefe0SEvan Bacon    const response = await fetchAsync(url);
1808d307f52SEvan Bacon    // If the page exists, then warn the user.
1818d307f52SEvan Bacon    if (response.status === 200) {
1828d307f52SEvan Bacon      // There is no JSON API for the Play Store so we can't concisely
1838d307f52SEvan Bacon      // locate the app name and developer to match the iOS warning.
184e32ccf9fSEvan Bacon      return `⚠️  The package ${chalk.bold(packageName)} is already in use. ${chalk.dim(
1858d307f52SEvan Bacon        learnMore(url)
1868d307f52SEvan Bacon      )}`;
1878d307f52SEvan Bacon    }
188474a7a4bSEvan Bacon  } catch (error: any) {
1898d307f52SEvan Bacon    // Error fetching play store data or the page doesn't exist.
190e32ccf9fSEvan Bacon    debug(`Error checking package name ${packageName}: ${error.message}`);
1918d307f52SEvan Bacon  }
1928d307f52SEvan Bacon  return null;
1938d307f52SEvan Bacon}
1948d307f52SEvan Bacon
1958d307f52SEvan Baconfunction formatInUseWarning(appName: string, author: string, id: string): string {
1968d307f52SEvan Bacon  return `⚠️  The app ${chalk.bold(appName)} by ${chalk.italic(
1978d307f52SEvan Bacon    author
1988d307f52SEvan Bacon  )} is already using ${chalk.bold(id)}`;
1998d307f52SEvan Bacon}
200e32ccf9fSEvan Bacon
201e32ccf9fSEvan Bacon/** Returns a warning message if an Android package name is potentially already in use. */
202e32ccf9fSEvan Baconexport const getPackageNameWarningAsync = memoize(getPackageNameWarningInternalAsync);
203