1import { ExpoConfig } from '@expo/config-types';
2import fs from 'fs';
3import { vol } from 'memfs';
4import path from 'path';
5
6import rnFixture from '../../plugins/__tests__/fixtures/react-native-project';
7import { format } from '../../utils/XML';
8import * as XML from '../../utils/XML';
9import { AndroidManifest, getMainApplication } from '../Manifest';
10import { readResourcesXMLAsync } from '../Resources';
11import * as Updates from '../Updates';
12
13async function getFixtureManifestAsync() {
14  return (await XML.parseXMLAsync(
15    rnFixture['android/app/src/main/AndroidManifest.xml']
16  )) as AndroidManifest;
17}
18
19const fixturesPath = path.resolve(__dirname, 'fixtures');
20const sampleCodeSigningCertificatePath = path.resolve(fixturesPath, 'codeSigningCertificate.pem');
21
22jest.mock('fs');
23jest.mock('resolve-from');
24
25const { silent } = require('resolve-from');
26
27const fsReal = jest.requireActual('fs') as typeof import('fs');
28
29describe('Android Updates config', () => {
30  beforeEach(() => {
31    const resolveFrom = require('resolve-from');
32    resolveFrom.silent = silent;
33    vol.reset();
34  });
35
36  it('set correct values in AndroidManifest.xml', async () => {
37    vol.fromJSON({
38      '/app/hello': fsReal.readFileSync(sampleCodeSigningCertificatePath, 'utf-8'),
39    });
40
41    let androidManifestJson = await getFixtureManifestAsync();
42    const config: ExpoConfig = {
43      name: 'foo',
44      sdkVersion: '37.0.0',
45      slug: 'my-app',
46      owner: 'owner',
47      updates: {
48        enabled: false,
49        fallbackToCacheTimeout: 2000,
50        checkAutomatically: 'ON_ERROR_RECOVERY',
51        codeSigningCertificate: 'hello',
52        codeSigningMetadata: {
53          alg: 'rsa-v1_5-sha256',
54          keyid: 'test',
55        },
56        requestHeaders: {
57          'expo-channel-name': 'test',
58          testheader: 'test',
59        },
60      },
61    };
62    androidManifestJson = await Updates.setUpdatesConfigAsync(
63      '/app',
64      config,
65      androidManifestJson,
66      '0.11.0'
67    );
68    const mainApplication = getMainApplication(androidManifestJson)!;
69
70    if (!mainApplication['meta-data']) {
71      throw new Error('No meta-data found in AndroidManifest.xml');
72    }
73
74    const updateUrl = mainApplication['meta-data'].filter(
75      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_UPDATE_URL'
76    );
77    expect(updateUrl).toHaveLength(0);
78
79    const sdkVersion = mainApplication['meta-data'].filter(
80      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_SDK_VERSION'
81    );
82    expect(sdkVersion).toHaveLength(1);
83    expect(sdkVersion[0].$['android:value']).toMatch('37.0.0');
84
85    const enabled = mainApplication['meta-data'].filter(
86      (e) => e.$['android:name'] === 'expo.modules.updates.ENABLED'
87    );
88    expect(enabled).toHaveLength(1);
89    expect(enabled[0].$['android:value']).toMatch('false');
90
91    const checkOnLaunch = mainApplication['meta-data'].filter(
92      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH'
93    );
94    expect(checkOnLaunch).toHaveLength(1);
95    expect(checkOnLaunch[0].$['android:value']).toMatch('ERROR_RECOVERY_ONLY');
96
97    const timeout = mainApplication['meta-data'].filter(
98      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS'
99    );
100    expect(timeout).toHaveLength(1);
101    expect(timeout[0].$['android:value']).toMatch('2000');
102
103    const codeSigningCertificate = mainApplication['meta-data'].filter(
104      (e) => e.$['android:name'] === 'expo.modules.updates.CODE_SIGNING_CERTIFICATE'
105    );
106    expect(codeSigningCertificate).toHaveLength(1);
107    expect(codeSigningCertificate[0].$['android:value']).toMatch(
108      fsReal.readFileSync(sampleCodeSigningCertificatePath, 'utf-8')
109    );
110
111    const codeSigningMetadata = mainApplication['meta-data'].filter(
112      (e) => e.$['android:name'] === 'expo.modules.updates.CODE_SIGNING_METADATA'
113    );
114    expect(codeSigningMetadata).toHaveLength(1);
115    expect(codeSigningMetadata[0].$['android:value']).toMatch(
116      '{"alg":"rsa-v1_5-sha256","keyid":"test"}'
117    );
118
119    const requestHeaders = mainApplication['meta-data'].filter(
120      (e) =>
121        e.$['android:name'] === 'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY'
122    );
123    expect(requestHeaders).toHaveLength(1);
124    expect(requestHeaders[0].$['android:value']).toMatch(
125      '{"expo-channel-name":"test","testheader":"test"}'
126    );
127
128    // For this config, runtime version should not be defined, so check that it does not appear in the manifest
129    const runtimeVersion = mainApplication['meta-data']?.filter(
130      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_RUNTIME_VERSION'
131    );
132    expect(runtimeVersion).toHaveLength(0);
133  });
134
135  describe(Updates.ensureBuildGradleContainsConfigurationScript, () => {
136    it('adds create-manifest-android.gradle line to build.gradle', async () => {
137      vol.fromJSON(
138        {
139          'android/app/build.gradle': fsReal.readFileSync(
140            path.join(__dirname, 'fixtures/build-without-create-manifest-android.gradle'),
141            'utf-8'
142          ),
143          'node_modules/expo-updates/scripts/create-manifest-android.gradle': 'whatever',
144        },
145        '/app'
146      );
147
148      const contents = rnFixture['android/app/build.gradle'];
149      const newContents = Updates.ensureBuildGradleContainsConfigurationScript('/app', contents);
150      expect(newContents).toMatchSnapshot();
151    });
152
153    it('fixes the path to create-manifest-android.gradle in case of a monorepo', async () => {
154      // Pseudo node module resolution since actually mocking it could prove challenging.
155      // In a yarn workspace, resolve-from would be able to locate a module in any node_module folder if properly linked.
156      const resolveFrom = require('resolve-from');
157      resolveFrom.silent = (p, a) => {
158        return silent(path.join(p, '..'), a);
159      };
160
161      vol.fromJSON(
162        {
163          'workspace/android/app/build.gradle': fsReal.readFileSync(
164            path.join(
165              __dirname,
166              'fixtures/build-with-incorrect-create-manifest-android-path.gradle'
167            ),
168            'utf-8'
169          ),
170          'node_modules/expo-updates/scripts/create-manifest-android.gradle': 'whatever',
171        },
172        '/app'
173      );
174
175      const contents = await fs.promises.readFile(
176        '/app/workspace/android/app/build.gradle',
177        'utf-8'
178      );
179      const newContents = Updates.ensureBuildGradleContainsConfigurationScript(
180        '/app/workspace',
181        contents
182      );
183      expect(newContents).toMatchSnapshot();
184    });
185  });
186
187  describe('Runtime version tests', () => {
188    const sampleStringsXML = `
189<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
190<resources>
191</resources>`;
192
193    beforeAll(async () => {
194      const directoryJSON = {
195        './android/app/src/main/res/values/strings.xml': sampleStringsXML,
196      };
197      vol.fromJSON(directoryJSON, '/app');
198    });
199
200    it('Correct metadata written to Android manifest with appVersion policy', async () => {
201      vol.fromJSON({
202        '/app/hello': fsReal.readFileSync(sampleCodeSigningCertificatePath, 'utf-8'),
203      });
204
205      let androidManifestJson = await getFixtureManifestAsync();
206      const config: ExpoConfig = {
207        name: 'foo',
208        version: '37.0.0',
209        slug: 'my-app',
210        owner: 'owner',
211        runtimeVersion: {
212          policy: 'appVersion',
213        },
214      };
215      androidManifestJson = await Updates.setUpdatesConfigAsync(
216        '/app',
217        config,
218        androidManifestJson,
219        '0.11.0'
220      );
221      const mainApplication = getMainApplication(androidManifestJson);
222
223      const runtimeVersion = mainApplication!['meta-data']?.filter(
224        (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_RUNTIME_VERSION'
225      );
226      expect(runtimeVersion).toHaveLength(1);
227      expect(runtimeVersion && runtimeVersion[0].$['android:value']).toMatch(
228        '@string/expo_runtime_version'
229      );
230    });
231
232    it('Write and clear runtime version in strings resource', async () => {
233      const stringsPath = '/app/android/app/src/main/res/values/strings.xml';
234      const stringsJSON = await readResourcesXMLAsync({ path: stringsPath });
235      const config = {
236        runtimeVersion: '1.10',
237        modRequest: {
238          projectRoot: '/',
239        },
240      };
241      await Updates.applyRuntimeVersionFromConfigAsync(config, stringsJSON);
242      expect(format(stringsJSON)).toEqual(
243        '<resources>\n  <string name="expo_runtime_version">1.10</string>\n</resources>'
244      );
245
246      const config2 = {
247        sdkVersion: '1.10',
248        modRequest: {
249          projectRoot: '/',
250        },
251      };
252      await Updates.applyRuntimeVersionFromConfigAsync(config2, stringsJSON);
253      expect(format(stringsJSON)).toEqual('<resources/>');
254    });
255
256    afterAll(async () => {
257      vol.reset();
258    });
259  });
260});
261