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 = Updates.setUpdatesConfig(
63      '/app',
64      config,
65      androidManifestJson,
66      'user',
67      '0.11.0'
68    );
69    const mainApplication = getMainApplication(androidManifestJson)!;
70
71    if (!mainApplication['meta-data']) {
72      throw new Error('No meta-data found in AndroidManifest.xml');
73    }
74
75    const updateUrl = mainApplication['meta-data'].filter(
76      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_UPDATE_URL'
77    );
78    expect(updateUrl).toHaveLength(0);
79
80    const sdkVersion = mainApplication['meta-data'].filter(
81      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_SDK_VERSION'
82    );
83    expect(sdkVersion).toHaveLength(1);
84    expect(sdkVersion[0].$['android:value']).toMatch('37.0.0');
85
86    const enabled = mainApplication['meta-data'].filter(
87      (e) => e.$['android:name'] === 'expo.modules.updates.ENABLED'
88    );
89    expect(enabled).toHaveLength(1);
90    expect(enabled[0].$['android:value']).toMatch('false');
91
92    const checkOnLaunch = mainApplication['meta-data'].filter(
93      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH'
94    );
95    expect(checkOnLaunch).toHaveLength(1);
96    expect(checkOnLaunch[0].$['android:value']).toMatch('ERROR_RECOVERY_ONLY');
97
98    const timeout = mainApplication['meta-data'].filter(
99      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS'
100    );
101    expect(timeout).toHaveLength(1);
102    expect(timeout[0].$['android:value']).toMatch('2000');
103
104    const codeSigningCertificate = mainApplication['meta-data'].filter(
105      (e) => e.$['android:name'] === 'expo.modules.updates.CODE_SIGNING_CERTIFICATE'
106    );
107    expect(codeSigningCertificate).toHaveLength(1);
108    expect(codeSigningCertificate[0].$['android:value']).toMatch(
109      fsReal.readFileSync(sampleCodeSigningCertificatePath, 'utf-8')
110    );
111
112    const codeSigningMetadata = mainApplication['meta-data'].filter(
113      (e) => e.$['android:name'] === 'expo.modules.updates.CODE_SIGNING_METADATA'
114    );
115    expect(codeSigningMetadata).toHaveLength(1);
116    expect(codeSigningMetadata[0].$['android:value']).toMatch(
117      '{"alg":"rsa-v1_5-sha256","keyid":"test"}'
118    );
119
120    const requestHeaders = mainApplication['meta-data'].filter(
121      (e) =>
122        e.$['android:name'] === 'expo.modules.updates.UPDATES_CONFIGURATION_REQUEST_HEADERS_KEY'
123    );
124    expect(requestHeaders).toHaveLength(1);
125    expect(requestHeaders[0].$['android:value']).toMatch(
126      '{"expo-channel-name":"test","testheader":"test"}'
127    );
128
129    // For this config, runtime version should not be defined, so check that it does not appear in the manifest
130    const runtimeVersion = mainApplication['meta-data']?.filter(
131      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_RUNTIME_VERSION'
132    );
133    expect(runtimeVersion).toHaveLength(0);
134  });
135
136  it('set correct values in AndroidManifest.xml for useClassicUpdates', async () => {
137    vol.fromJSON({
138      '/app/hello': fsReal.readFileSync(sampleCodeSigningCertificatePath, 'utf-8'),
139    });
140
141    let androidManifestJson = await getFixtureManifestAsync();
142    const config: ExpoConfig = {
143      name: 'foo',
144      sdkVersion: '37.0.0',
145      slug: 'my-app',
146      owner: 'owner',
147      updates: {
148        useClassicUpdates: true,
149      },
150    };
151    androidManifestJson = Updates.setUpdatesConfig(
152      '/app',
153      config,
154      androidManifestJson,
155      'user',
156      '0.11.0'
157    );
158    const mainApplication = getMainApplication(androidManifestJson)!;
159
160    if (!mainApplication['meta-data']) {
161      throw new Error('No meta-data found in AndroidManifest.xml');
162    }
163
164    const updateUrl = mainApplication['meta-data'].filter(
165      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_UPDATE_URL'
166    );
167    expect(updateUrl).toHaveLength(1);
168    expect(updateUrl[0].$['android:value']).toMatch('https://exp.host/@owner/my-app');
169
170    const sdkVersion = mainApplication['meta-data'].filter(
171      (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_SDK_VERSION'
172    );
173    expect(sdkVersion).toHaveLength(1);
174    expect(sdkVersion[0].$['android:value']).toMatch('37.0.0');
175
176    const enabled = mainApplication['meta-data'].filter(
177      (e) => e.$['android:name'] === 'expo.modules.updates.ENABLED'
178    );
179    expect(enabled).toHaveLength(1);
180    expect(enabled[0].$['android:value']).toMatch('true');
181  });
182
183  describe(Updates.ensureBuildGradleContainsConfigurationScript, () => {
184    it('adds create-manifest-android.gradle line to build.gradle', async () => {
185      vol.fromJSON(
186        {
187          'android/app/build.gradle': fsReal.readFileSync(
188            path.join(__dirname, 'fixtures/build-without-create-manifest-android.gradle'),
189            'utf-8'
190          ),
191          'node_modules/expo-updates/scripts/create-manifest-android.gradle': 'whatever',
192        },
193        '/app'
194      );
195
196      const contents = rnFixture['android/app/build.gradle'];
197      const newContents = Updates.ensureBuildGradleContainsConfigurationScript('/app', contents);
198      expect(newContents).toMatchSnapshot();
199    });
200
201    it('fixes the path to create-manifest-android.gradle in case of a monorepo', async () => {
202      // Pseudo node module resolution since actually mocking it could prove challenging.
203      // In a yarn workspace, resolve-from would be able to locate a module in any node_module folder if properly linked.
204      const resolveFrom = require('resolve-from');
205      resolveFrom.silent = (p, a) => {
206        return silent(path.join(p, '..'), a);
207      };
208
209      vol.fromJSON(
210        {
211          'workspace/android/app/build.gradle': fsReal.readFileSync(
212            path.join(
213              __dirname,
214              'fixtures/build-with-incorrect-create-manifest-android-path.gradle'
215            ),
216            'utf-8'
217          ),
218          'node_modules/expo-updates/scripts/create-manifest-android.gradle': 'whatever',
219        },
220        '/app'
221      );
222
223      const contents = await fs.promises.readFile(
224        '/app/workspace/android/app/build.gradle',
225        'utf-8'
226      );
227      const newContents = Updates.ensureBuildGradleContainsConfigurationScript(
228        '/app/workspace',
229        contents
230      );
231      expect(newContents).toMatchSnapshot();
232    });
233  });
234
235  describe('Runtime version tests', () => {
236    const sampleStringsXML = `
237<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
238<resources>
239</resources>`;
240
241    beforeAll(async () => {
242      const directoryJSON = {
243        './android/app/src/main/res/values/strings.xml': sampleStringsXML,
244      };
245      vol.fromJSON(directoryJSON, '/app');
246    });
247
248    it('Correct metadata written to Android manifest with appVersion policy', async () => {
249      vol.fromJSON({
250        '/app/hello': fsReal.readFileSync(sampleCodeSigningCertificatePath, 'utf-8'),
251      });
252
253      let androidManifestJson = await getFixtureManifestAsync();
254      const config: ExpoConfig = {
255        name: 'foo',
256        version: '37.0.0',
257        slug: 'my-app',
258        owner: 'owner',
259        runtimeVersion: {
260          policy: 'appVersion',
261        },
262      };
263      androidManifestJson = Updates.setUpdatesConfig(
264        '/app',
265        config,
266        androidManifestJson,
267        'user',
268        '0.11.0'
269      );
270      const mainApplication = getMainApplication(androidManifestJson);
271
272      const runtimeVersion = mainApplication!['meta-data']?.filter(
273        (e) => e.$['android:name'] === 'expo.modules.updates.EXPO_RUNTIME_VERSION'
274      );
275      expect(runtimeVersion).toHaveLength(1);
276      expect(runtimeVersion && runtimeVersion[0].$['android:value']).toMatch(
277        '@string/expo_runtime_version'
278      );
279    });
280
281    it('Write and clear runtime version in strings resource', async () => {
282      const stringsPath = '/app/android/app/src/main/res/values/strings.xml';
283      const stringsJSON = await readResourcesXMLAsync({ path: stringsPath });
284      const config = {
285        runtimeVersion: '1.10',
286      };
287      Updates.applyRuntimeVersionFromConfig(config, stringsJSON);
288      expect(format(stringsJSON)).toEqual(
289        '<resources>\n  <string name="expo_runtime_version">1.10</string>\n</resources>'
290      );
291
292      const config2 = {
293        sdkVersion: '1.10',
294      };
295      Updates.applyRuntimeVersionFromConfig(config2, stringsJSON);
296      expect(format(stringsJSON)).toEqual('<resources/>');
297    });
298
299    afterAll(async () => {
300      vol.reset();
301    });
302  });
303});
304