1import assert from 'assert';
2import chalk from 'chalk';
3
4import { env } from './env';
5import { memoize } from './fn';
6import { learnMore } from './link';
7import { isUrlAvailableAsync } from './url';
8import { fetchAsync } from '../api/rest/client';
9import { Log } from '../log';
10
11const debug = require('debug')('expo:utils:validateApplicationId') as typeof console.log;
12
13const IOS_BUNDLE_ID_REGEX = /^[a-zA-Z0-9-.]+$/;
14const ANDROID_PACKAGE_REGEX = /^(?!.*\bnative\b)[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z][a-zA-Z0-9_]*)+$/;
15
16/** Validate an iOS bundle identifier. */
17export function validateBundleId(value: string): boolean {
18  return IOS_BUNDLE_ID_REGEX.test(value);
19}
20
21/** Validate an Android package name. */
22export function validatePackage(value: string): boolean {
23  return validatePackageWithWarning(value) === true;
24}
25
26/** Validate an Android package name and return the reason if invalid. */
27export function validatePackageWithWarning(value: string): true | string {
28  const parts = value.split('.');
29  for (const segment of parts) {
30    if (RESERVED_ANDROID_PACKAGE_NAME_SEGMENTS.includes(segment)) {
31      return `"${segment}" is a reserved Java keyword.`;
32    }
33  }
34  if (parts.length < 2) {
35    return `Package name must contain more than one segment, separated by ".", e.g. com.${value}`;
36  }
37  if (!ANDROID_PACKAGE_REGEX.test(value)) {
38    return 'Invalid characters in Android package name. Only alphanumeric characters, "." and "_" are allowed, and each "." must be followed by a letter or number.';
39  }
40
41  return true;
42}
43
44// https://en.wikipedia.org/wiki/List_of_Java_keywords
45// Running the following in the console and pruning the "Reserved Identifiers" section:
46// [...document.querySelectorAll('dl > dt > code')].map(node => node.innerText)
47const RESERVED_ANDROID_PACKAGE_NAME_SEGMENTS = [
48  // List of Java keywords
49  '_',
50  'abstract',
51  'assert',
52  'boolean',
53  'break',
54  'byte',
55  'case',
56  'catch',
57  'char',
58  'class',
59  'const',
60  'continue',
61  'default',
62  'do',
63  'double',
64  'else',
65  'enum',
66  'extends',
67  'final',
68  'finally',
69  'float',
70  'for',
71  'goto',
72  'if',
73  'implements',
74  'import',
75  'instanceof',
76  'int',
77  'interface',
78  'long',
79  'native',
80  'new',
81  'package',
82  'private',
83  'protected',
84  'public',
85  'return',
86  'short',
87  'static',
88  'super',
89  'switch',
90  'synchronized',
91  'this',
92  'throw',
93  'throws',
94  'transient',
95  'try',
96  'void',
97  'volatile',
98  'while',
99  // Reserved words for literal values
100  'true',
101  'false',
102  'null',
103  // Unused
104  'const',
105  'goto',
106  'strictfp',
107];
108
109export function assertValidBundleId(value: string) {
110  assert.match(
111    value,
112    IOS_BUNDLE_ID_REGEX,
113    `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.`
114  );
115}
116
117export function assertValidPackage(value: string) {
118  assert.match(
119    value,
120    ANDROID_PACKAGE_REGEX,
121    `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.`
122  );
123}
124
125/** @private */
126export async function getBundleIdWarningInternalAsync(bundleId: string): Promise<string | null> {
127  if (env.EXPO_OFFLINE) {
128    Log.warn('Skipping Apple bundle identifier reservation validation in offline-mode.');
129    return null;
130  }
131
132  if (!(await isUrlAvailableAsync('itunes.apple.com'))) {
133    debug(
134      `Couldn't connect to iTunes Store to check bundle ID ${bundleId}. itunes.apple.com may be down.`
135    );
136    // If no network, simply skip the warnings since they'll just lead to more confusion.
137    return null;
138  }
139
140  const url = `http://itunes.apple.com/lookup?bundleId=${bundleId}`;
141  try {
142    debug(`Checking iOS bundle ID '${bundleId}' at: ${url}`);
143    const response = await fetchAsync(url);
144    const json = await response.json();
145    if (json.resultCount > 0) {
146      const firstApp = json.results[0];
147      return formatInUseWarning(firstApp.trackName, firstApp.sellerName, bundleId);
148    }
149  } catch (error: any) {
150    debug(`Error checking bundle ID ${bundleId}: ${error.message}`);
151    // Error fetching itunes data.
152  }
153  return null;
154}
155
156/** Returns a warning message if an iOS bundle identifier is potentially already in use. */
157export const getBundleIdWarningAsync = memoize(getBundleIdWarningInternalAsync);
158
159/** @private */
160export async function getPackageNameWarningInternalAsync(
161  packageName: string
162): Promise<string | null> {
163  if (env.EXPO_OFFLINE) {
164    Log.warn('Skipping Android package name reservation validation in offline-mode.');
165    return null;
166  }
167
168  if (!(await isUrlAvailableAsync('play.google.com'))) {
169    debug(
170      `Couldn't connect to Play Store to check package name ${packageName}. play.google.com may be down.`
171    );
172    // If no network, simply skip the warnings since they'll just lead to more confusion.
173    return null;
174  }
175
176  const url = `https://play.google.com/store/apps/details?id=${packageName}`;
177  try {
178    debug(`Checking Android package name '${packageName}' at: ${url}`);
179    const response = await fetchAsync(url);
180    // If the page exists, then warn the user.
181    if (response.status === 200) {
182      // There is no JSON API for the Play Store so we can't concisely
183      // locate the app name and developer to match the iOS warning.
184      return `⚠️  The package ${chalk.bold(packageName)} is already in use. ${chalk.dim(
185        learnMore(url)
186      )}`;
187    }
188  } catch (error: any) {
189    // Error fetching play store data or the page doesn't exist.
190    debug(`Error checking package name ${packageName}: ${error.message}`);
191  }
192  return null;
193}
194
195function formatInUseWarning(appName: string, author: string, id: string): string {
196  return `⚠️  The app ${chalk.bold(appName)} by ${chalk.italic(
197    author
198  )} is already using ${chalk.bold(id)}`;
199}
200
201/** Returns a warning message if an Android package name is potentially already in use. */
202export const getPackageNameWarningAsync = memoize(getPackageNameWarningInternalAsync);
203