@devicecloud.dev/dcd 5.2.0-beta.3 → 5.2.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/cloud.d.ts +4 -8
- package/dist/commands/cloud.js +10 -9
- package/dist/commands/upload.d.ts +4 -0
- package/dist/commands/upload.js +3 -0
- package/dist/config/environments.d.ts +17 -0
- package/dist/config/environments.js +2 -0
- package/dist/config/flags/binary.flags.d.ts +4 -0
- package/dist/config/flags/binary.flags.js +4 -0
- package/dist/config/flags/environment.flags.d.ts +0 -8
- package/dist/config/flags/environment.flags.js +0 -8
- package/dist/constants.d.ts +4 -8
- package/dist/gateways/api-gateway.d.ts +43 -1
- package/dist/gateways/api-gateway.js +98 -3
- package/dist/mcp/tools/run-cloud-test.d.ts +1 -1
- package/dist/mcp/tools/run-cloud-test.js +8 -1
- package/dist/methods.d.ts +6 -0
- package/dist/methods.js +103 -13
- package/dist/services/test-submission.service.d.ts +7 -2
- package/dist/services/test-submission.service.js +40 -7
- package/dist/types/domain/device.types.d.ts +1 -0
- package/dist/types/domain/device.types.js +1 -0
- package/dist/types/generated/schema.types.d.ts +3531 -873
- package/dist/types.d.ts +7 -0
- package/dist/utils/envelope.d.ts +73 -0
- package/dist/utils/envelope.js +220 -0
- package/package.json +4 -5
package/dist/methods.js
CHANGED
|
@@ -11,6 +11,7 @@ import { inferEnvFromApiUrl } from './config/environments.js';
|
|
|
11
11
|
import { ApiError, ApiGateway } from './gateways/api-gateway.js';
|
|
12
12
|
import { SupabaseGateway } from './gateways/supabase-gateway.js';
|
|
13
13
|
import { MetadataExtractorService } from './services/metadata-extractor.service.js';
|
|
14
|
+
import { encryptFileToPath, generateDek, isEncryptionEnabled, resolveKekPublicKey, wrapDek, } from './utils/envelope.js';
|
|
14
15
|
import { colors, formatId } from './utils/styling.js';
|
|
15
16
|
const mimeTypeLookupByExtension = {
|
|
16
17
|
apk: 'application/vnd.android.package-archive',
|
|
@@ -68,7 +69,7 @@ export const compressFilesFromRelativePath = async (basePath, files, commonRoot)
|
|
|
68
69
|
return zipToBuffer(zipfile);
|
|
69
70
|
};
|
|
70
71
|
export const verifyAppZip = async (zipPath) => {
|
|
71
|
-
// eslint-disable-next-line
|
|
72
|
+
// eslint-disable-next-line new-cap
|
|
72
73
|
const zip = await new StreamZip.async({
|
|
73
74
|
file: zipPath,
|
|
74
75
|
storeEntries: true,
|
|
@@ -94,6 +95,7 @@ export const verifyAppZip = async (zipPath) => {
|
|
|
94
95
|
};
|
|
95
96
|
export const uploadBinary = async (config) => {
|
|
96
97
|
const { filePath, apiUrl, auth, ignoreShaCheck = false, log = true, debug = false } = config;
|
|
98
|
+
const encrypt = isEncryptionEnabled(config.encrypt);
|
|
97
99
|
if (log) {
|
|
98
100
|
ux.action.start(colors.bold('Checking and uploading binary'), colors.dim('Initializing'), {
|
|
99
101
|
stdout: true,
|
|
@@ -107,14 +109,20 @@ export const uploadBinary = async (config) => {
|
|
|
107
109
|
}
|
|
108
110
|
const startTime = Date.now();
|
|
109
111
|
let source;
|
|
112
|
+
let encCleanupDir;
|
|
113
|
+
let enc;
|
|
110
114
|
try {
|
|
111
115
|
// Prepare file for upload
|
|
112
116
|
source = await prepareFileForUpload(filePath, debug, startTime);
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
117
|
+
// Hash the PLAINTEXT first, before any encryption (dcd#1168). Encryption
|
|
118
|
+
// uses a fresh random DEK per upload, so the ciphertext hash differs every
|
|
119
|
+
// time and cannot dedup — the plaintext hash is the only stable key. Doing it
|
|
120
|
+
// in this order also means a dedup hit skips the encryption work entirely,
|
|
121
|
+
// not just the upload.
|
|
122
|
+
const shaPlain = await calculateFileHash(source, debug, log);
|
|
123
|
+
// Check for an existing upload before spending anything on encryption.
|
|
124
|
+
if (!ignoreShaCheck && shaPlain) {
|
|
125
|
+
const { exists, binaryId } = await checkExistingUpload(apiUrl, auth, encrypt ? { encrypted: true, shaPlain } : { sha: shaPlain }, debug);
|
|
118
126
|
if (exists && binaryId) {
|
|
119
127
|
if (log) {
|
|
120
128
|
ux.info(colors.dim('SHA hash matches existing binary with ID: ') + formatId(binaryId) + colors.dim(', skipping upload. Force upload with --ignore-sha-check'));
|
|
@@ -123,8 +131,33 @@ export const uploadBinary = async (config) => {
|
|
|
123
131
|
return binaryId;
|
|
124
132
|
}
|
|
125
133
|
}
|
|
134
|
+
// Encrypt after the dedup check, so the SHA sent at finalise, and both
|
|
135
|
+
// uploaders, operate on ciphertext (binaries.sha = ciphertext hash, #1138).
|
|
136
|
+
if (encrypt) {
|
|
137
|
+
const encrypted = await encryptUploadSource(source, apiUrl, debug);
|
|
138
|
+
enc = encrypted.enc;
|
|
139
|
+
encCleanupDir = encrypted.cleanupDir;
|
|
140
|
+
if (log) {
|
|
141
|
+
ux.info(colors.dim(`Encrypting binary before upload (KEK v${enc.kek})`));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// Re-hash once encrypted: what lands in storage is the ciphertext, and every
|
|
145
|
+
// downstream verifySha hashes the bytes it actually holds (no DEK required).
|
|
146
|
+
const sha = encrypt ? await calculateFileHash(source, debug, false) : shaPlain;
|
|
126
147
|
// Perform the upload
|
|
127
|
-
const uploadId = await performUpload({
|
|
148
|
+
const uploadId = await performUpload({
|
|
149
|
+
auth,
|
|
150
|
+
apiUrl,
|
|
151
|
+
debug,
|
|
152
|
+
enc,
|
|
153
|
+
filePath,
|
|
154
|
+
sha,
|
|
155
|
+
// Only encrypted uploads record a plaintext hash; it is what makes the
|
|
156
|
+
// next encrypted run of this binary dedupable.
|
|
157
|
+
shaPlain: encrypt ? shaPlain : undefined,
|
|
158
|
+
source,
|
|
159
|
+
startTime,
|
|
160
|
+
});
|
|
128
161
|
if (log) {
|
|
129
162
|
ux.action.stop(colors.success('\n✓ Binary uploaded with ID: ') + formatId(uploadId));
|
|
130
163
|
}
|
|
@@ -150,8 +183,41 @@ export const uploadBinary = async (config) => {
|
|
|
150
183
|
if (source?.cleanupDir) {
|
|
151
184
|
await rm(source.cleanupDir, { recursive: true, force: true }).catch(() => { });
|
|
152
185
|
}
|
|
186
|
+
if (encCleanupDir) {
|
|
187
|
+
await rm(encCleanupDir, { recursive: true, force: true }).catch(() => { });
|
|
188
|
+
}
|
|
153
189
|
}
|
|
154
190
|
};
|
|
191
|
+
/**
|
|
192
|
+
* Encrypt the prepared upload source in place (dcd#1138): generate a per-upload
|
|
193
|
+
* DEK, stream-encrypt `source.diskPath` into a temp ciphertext file, wrap the
|
|
194
|
+
* DEK with the environment's pinned KEK public key, and repoint `source` at the
|
|
195
|
+
* ciphertext so the SHA, dedup check, and both uploaders operate on ciphertext.
|
|
196
|
+
* Returns the envelope metadata (for `binaries.metadata.enc`) plus the temp dir
|
|
197
|
+
* to clean up. Throws if no KEK is available for the environment.
|
|
198
|
+
*/
|
|
199
|
+
async function encryptUploadSource(source, apiUrl, debug) {
|
|
200
|
+
const kek = resolveKekPublicKey(apiUrl);
|
|
201
|
+
if (!kek) {
|
|
202
|
+
throw new Error('Binary encryption was requested but no KEK public key is configured for this environment. ' +
|
|
203
|
+
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.');
|
|
204
|
+
}
|
|
205
|
+
const dek = generateDek();
|
|
206
|
+
const enc = wrapDek(dek, kek);
|
|
207
|
+
const cleanupDir = await mkdtemp(path.join(os.tmpdir(), 'dcd-enc-'));
|
|
208
|
+
const cipherPath = path.join(cleanupDir, 'binary.enc');
|
|
209
|
+
if (debug) {
|
|
210
|
+
console.log(`[DEBUG] Encrypting binary with KEK v${kek.version} -> ${cipherPath}`);
|
|
211
|
+
}
|
|
212
|
+
await encryptFileToPath(source.diskPath, cipherPath, dek, kek.version);
|
|
213
|
+
const { size } = await stat(cipherPath);
|
|
214
|
+
source.diskPath = cipherPath;
|
|
215
|
+
source.size = size;
|
|
216
|
+
if (debug) {
|
|
217
|
+
console.log(`[DEBUG] Ciphertext size: ${(size / 1024 / 1024).toFixed(2)} MB`);
|
|
218
|
+
}
|
|
219
|
+
return { enc, cleanupDir };
|
|
220
|
+
}
|
|
155
221
|
/**
|
|
156
222
|
* Prepares a file for upload: .app directories are zipped to a temp file on
|
|
157
223
|
* disk; everything else is described in place. Nothing is read into memory.
|
|
@@ -256,21 +322,34 @@ async function calculateFileHash(source, debug, log) {
|
|
|
256
322
|
}
|
|
257
323
|
}
|
|
258
324
|
/**
|
|
259
|
-
* Checks
|
|
325
|
+
* Checks whether a matching binary has already been uploaded.
|
|
326
|
+
*
|
|
327
|
+
* `lookup` is `{ sha }` for a plaintext upload, or `{ shaPlain, encrypted: true }`
|
|
328
|
+
* for an encrypted one (dcd#1168) — see {@link ApiGateway.checkForExistingUpload}.
|
|
329
|
+
*
|
|
330
|
+
* When asking as an encrypting client, a hit is only honoured if the server
|
|
331
|
+
* confirms the matched binary is itself encrypted. Any binary is a *plausible*
|
|
332
|
+
* match on plaintext hash, including a previously-uploaded plaintext copy of the
|
|
333
|
+
* same app, and reusing that would hand back an unencrypted binary while the user
|
|
334
|
+
* had asked for encryption. The server applies the same predicate; this is the
|
|
335
|
+
* client refusing to depend on that, so an older or misbehaving deployment
|
|
336
|
+
* degrades into a redundant upload rather than a silent loss of encryption.
|
|
337
|
+
*
|
|
260
338
|
* @param apiUrl API base URL
|
|
261
339
|
* @param auth AuthContext carrying request headers
|
|
262
|
-
* @param
|
|
340
|
+
* @param lookup Dedup key — plaintext hash for encrypted uploads, else the sha
|
|
263
341
|
* @param debug Whether debug logging is enabled
|
|
264
342
|
* @returns Promise resolving to object with exists flag and optional binaryId
|
|
265
343
|
*/
|
|
266
|
-
async function checkExistingUpload(apiUrl, auth,
|
|
344
|
+
async function checkExistingUpload(apiUrl, auth, lookup, debug) {
|
|
267
345
|
try {
|
|
268
346
|
if (debug) {
|
|
269
347
|
console.log('[DEBUG] Checking for existing upload with matching SHA...');
|
|
348
|
+
console.log(`[DEBUG] Lookup: ${JSON.stringify(lookup)}`);
|
|
270
349
|
console.log(`[DEBUG] Target endpoint: ${apiUrl}/uploads/checkForExistingUpload`);
|
|
271
350
|
}
|
|
272
351
|
const shaCheckStartTime = Date.now();
|
|
273
|
-
const { appBinaryId, exists } = await ApiGateway.checkForExistingUpload(apiUrl, auth,
|
|
352
|
+
const { appBinaryId, encrypted, exists } = await ApiGateway.checkForExistingUpload(apiUrl, auth, lookup);
|
|
274
353
|
if (debug) {
|
|
275
354
|
console.log(`[DEBUG] SHA check completed in ${Date.now() - shaCheckStartTime}ms`);
|
|
276
355
|
console.log(`[DEBUG] Existing binary found: ${exists}`);
|
|
@@ -278,6 +357,12 @@ async function checkExistingUpload(apiUrl, auth, sha, debug) {
|
|
|
278
357
|
console.log(`[DEBUG] Existing binary ID: ${appBinaryId}`);
|
|
279
358
|
}
|
|
280
359
|
}
|
|
360
|
+
if (exists && lookup.encrypted && encrypted !== true) {
|
|
361
|
+
if (debug) {
|
|
362
|
+
console.log('[DEBUG] Ignoring dedup hit: encryption was requested but the matched binary is not encrypted');
|
|
363
|
+
}
|
|
364
|
+
return { exists: false };
|
|
365
|
+
}
|
|
281
366
|
return { binaryId: appBinaryId, exists };
|
|
282
367
|
}
|
|
283
368
|
catch (error) {
|
|
@@ -504,11 +589,15 @@ function validateUploadResults(supabaseSuccess, backblazeSuccess, lastError, b2,
|
|
|
504
589
|
* @returns Promise resolving to upload ID
|
|
505
590
|
*/
|
|
506
591
|
async function performUpload(config) {
|
|
507
|
-
const { filePath, apiUrl, auth, source, sha, debug, startTime } = config;
|
|
592
|
+
const { filePath, apiUrl, auth, enc, source, sha, shaPlain, debug, startTime } = config;
|
|
508
593
|
// Request upload URL and paths
|
|
509
594
|
const { id, tempPath, finalPath, b2 } = await requestUploadPaths(apiUrl, auth, filePath, source.size, debug);
|
|
510
|
-
// Extract app metadata
|
|
595
|
+
// Extract app metadata from the original (plaintext) file. Attach the
|
|
596
|
+
// envelope so it lands on binaries.metadata.enc (#1138).
|
|
511
597
|
const metadata = await extractBinaryMetadata(filePath, debug);
|
|
598
|
+
if (enc) {
|
|
599
|
+
metadata.enc = enc;
|
|
600
|
+
}
|
|
512
601
|
const env = inferEnvFromApiUrl(apiUrl);
|
|
513
602
|
// Upload to Backblaze first (primary)
|
|
514
603
|
const backblazeResult = await handleBackblazeUpload({
|
|
@@ -554,6 +643,7 @@ async function performUpload(config) {
|
|
|
554
643
|
path: tempPath,
|
|
555
644
|
// sha is undefined when hash calculation failed — omit it explicitly
|
|
556
645
|
...(sha ? { sha } : {}),
|
|
646
|
+
...(shaPlain ? { shaPlain } : {}),
|
|
557
647
|
supabaseSuccess: supabaseResult.success,
|
|
558
648
|
});
|
|
559
649
|
if (debug) {
|
|
@@ -4,6 +4,7 @@ export interface TestSubmissionConfig {
|
|
|
4
4
|
androidApiLevel?: string;
|
|
5
5
|
androidDevice?: string;
|
|
6
6
|
androidNoSnapshot?: boolean;
|
|
7
|
+
apiUrl?: string;
|
|
7
8
|
appBinaryId: string;
|
|
8
9
|
cliVersion: string;
|
|
9
10
|
commonRoot: string;
|
|
@@ -12,6 +13,12 @@ export interface TestSubmissionConfig {
|
|
|
12
13
|
deviceLocale?: string;
|
|
13
14
|
deviceMatrix?: DeviceMatrixConfig[];
|
|
14
15
|
disableAnimations?: boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Encrypt the flow zip and env vars before upload (#1151/#1152), each with its
|
|
18
|
+
* own per-upload DEK wrapped under the environment KEK. Requires `apiUrl` to
|
|
19
|
+
* resolve the pinned KEK public key.
|
|
20
|
+
*/
|
|
21
|
+
encrypt?: boolean;
|
|
15
22
|
env?: string[];
|
|
16
23
|
executionPlan: IExecutionPlan;
|
|
17
24
|
flowFile: string;
|
|
@@ -22,8 +29,6 @@ export interface TestSubmissionConfig {
|
|
|
22
29
|
maestroChromeOnboarding?: boolean;
|
|
23
30
|
maestroVersion: string;
|
|
24
31
|
metadata?: string[];
|
|
25
|
-
mitmHost?: string;
|
|
26
|
-
mitmPath?: string;
|
|
27
32
|
name?: string;
|
|
28
33
|
orientation?: string;
|
|
29
34
|
raw?: unknown;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { compressFilesFromRelativePath } from '../methods.js';
|
|
4
|
+
import { encryptEnv, encryptFlowBuffer, resolveKekPublicKey, } from '../utils/envelope.js';
|
|
4
5
|
import { toPortableRelativePath } from '../utils/paths.js';
|
|
5
6
|
const mimeTypeLookupByExtension = {
|
|
6
7
|
zip: 'application/zip',
|
|
@@ -19,7 +20,7 @@ export class TestSubmissionService {
|
|
|
19
20
|
* @returns The flow zip buffer, its SHA-256, and the string-encoded fields
|
|
20
21
|
*/
|
|
21
22
|
async buildTestPayload(config) {
|
|
22
|
-
const { appBinaryId, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation,
|
|
23
|
+
const { apiUrl, appBinaryId, encrypt = false, flowFile, executionPlan, commonRoot, cliVersion, env = [], metadata = [], googlePlay = false, androidApiLevel, androidDevice, androidNoSnapshot, iOSVersion, iOSDevice, name, runnerType, maestroVersion, deviceLocale, orientation, retry, continueOnFailure = true, report, showCrosshairs, maestroChromeOnboarding, raw, disableAnimations, deviceMatrix, debug = false, logger, } = config;
|
|
23
24
|
const { allExcludeTags, allIncludeTags, flowMetadata, flowOverrides, flowsToRun: testFileNames, referencedFiles, sequence, workspaceConfig, } = executionPlan;
|
|
24
25
|
const { flows: sequentialFlows = [] } = sequence ?? {};
|
|
25
26
|
const envObject = this.parseKeyValuePairs(env);
|
|
@@ -42,7 +43,7 @@ export class TestSubmissionService {
|
|
|
42
43
|
}
|
|
43
44
|
}
|
|
44
45
|
this.logDebug(debug, logger, `[DEBUG] Compressing files from path: ${flowFile}`);
|
|
45
|
-
const
|
|
46
|
+
const plaintextZip = await compressFilesFromRelativePath(flowFile?.endsWith('.yaml') || flowFile?.endsWith('.yml')
|
|
46
47
|
? path.dirname(flowFile)
|
|
47
48
|
: flowFile, [
|
|
48
49
|
...new Set([
|
|
@@ -51,8 +52,37 @@ export class TestSubmissionService {
|
|
|
51
52
|
...sequentialFlows,
|
|
52
53
|
]),
|
|
53
54
|
], commonRoot);
|
|
54
|
-
this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${
|
|
55
|
-
//
|
|
55
|
+
this.logDebug(debug, logger, `[DEBUG] Compressed file size: ${plaintextZip.length} bytes`);
|
|
56
|
+
// Client-side envelope encryption (#1151 flow zip, #1152 env vars). Each
|
|
57
|
+
// gets its own per-upload DEK wrapped under the environment KEK; the flow
|
|
58
|
+
// zip becomes a DCDE container and `sha` is the CIPHERTEXT hash (uploads.sha
|
|
59
|
+
// stays a ciphertext hash, mirroring binaries.sha). `enc` rides `fields`
|
|
60
|
+
// as a JSON string like every other field, so both the JSON submitFlowTest
|
|
61
|
+
// body and the legacy multipart form carry it identically.
|
|
62
|
+
let buffer = plaintextZip;
|
|
63
|
+
let envObjectToSend = envObject;
|
|
64
|
+
let flowEnc;
|
|
65
|
+
if (encrypt) {
|
|
66
|
+
if (!apiUrl) {
|
|
67
|
+
throw new Error('Encryption requires apiUrl to resolve the KEK public key');
|
|
68
|
+
}
|
|
69
|
+
const kek = resolveKekPublicKey(apiUrl);
|
|
70
|
+
if (!kek) {
|
|
71
|
+
throw new Error('Encryption was requested but no KEK public key is configured for this environment. ' +
|
|
72
|
+
'Set DCD_BINARY_KEK_PUBLIC=<version>:<base64> or pin one in src/config/environments.ts.');
|
|
73
|
+
}
|
|
74
|
+
const flow = encryptFlowBuffer(plaintextZip, kek);
|
|
75
|
+
buffer = flow.ciphertext;
|
|
76
|
+
flowEnc = flow.enc;
|
|
77
|
+
this.logDebug(debug, logger, `[DEBUG] Encrypting flow zip before upload (KEK v${kek.version}); ciphertext ${buffer.length} bytes`);
|
|
78
|
+
// Only encrypt when there are env vars; an empty map has no secret to
|
|
79
|
+
// protect and stays a plaintext `{}` (no `enc` marker, passes through).
|
|
80
|
+
if (Object.keys(envObject).length > 0) {
|
|
81
|
+
envObjectToSend = { enc: encryptEnv(envObject, kek) };
|
|
82
|
+
this.logDebug(debug, logger, `[DEBUG] Encrypting ${Object.keys(envObject).length} env var(s)`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// SHA-256 of what actually gets uploaded (ciphertext when encrypted).
|
|
56
86
|
const sha = createHash('sha256').update(buffer).digest('hex');
|
|
57
87
|
this.logDebug(debug, logger, `[DEBUG] Flow ZIP SHA-256: ${sha}`);
|
|
58
88
|
// String-encoded fields, in the same order and with the same encoding as
|
|
@@ -64,7 +94,12 @@ export class TestSubmissionService {
|
|
|
64
94
|
fields.flowMetadata = JSON.stringify(this.normalizePathMap(flowMetadata, commonRoot));
|
|
65
95
|
fields.testFileOverrides = JSON.stringify(this.normalizePathMap(flowOverrides, commonRoot));
|
|
66
96
|
fields.sequentialFlows = JSON.stringify(this.normalizePaths(sequentialFlows, commonRoot));
|
|
67
|
-
fields.env = JSON.stringify(
|
|
97
|
+
fields.env = JSON.stringify(envObjectToSend);
|
|
98
|
+
// Flow-zip envelope for uploads.metadata.enc (#1151). JSON string so both
|
|
99
|
+
// submission paths carry it like every other field; the API parses it.
|
|
100
|
+
if (flowEnc) {
|
|
101
|
+
fields.enc = JSON.stringify(flowEnc);
|
|
102
|
+
}
|
|
68
103
|
// Note: googlePlay is now included in configPayload below instead of as a separate field
|
|
69
104
|
// to work around a FormData parsing issue in the API
|
|
70
105
|
// Explicit device matrix (one upload, N cells). Only sent when present, so
|
|
@@ -94,8 +129,6 @@ export class TestSubmissionService {
|
|
|
94
129
|
deviceLocale,
|
|
95
130
|
googlePlay,
|
|
96
131
|
maestroVersion,
|
|
97
|
-
mitmHost,
|
|
98
|
-
mitmPath,
|
|
99
132
|
orientation,
|
|
100
133
|
raw: JSON.stringify(raw),
|
|
101
134
|
report,
|
|
@@ -35,6 +35,7 @@ export var EAndroidApiLevels;
|
|
|
35
35
|
EAndroidApiLevels["thirtyFive"] = "35";
|
|
36
36
|
EAndroidApiLevels["thirtyFour"] = "34";
|
|
37
37
|
EAndroidApiLevels["thirtyOne"] = "31";
|
|
38
|
+
EAndroidApiLevels["thirtySeven"] = "37";
|
|
38
39
|
EAndroidApiLevels["thirtySix"] = "36";
|
|
39
40
|
EAndroidApiLevels["thirtyThree"] = "33";
|
|
40
41
|
EAndroidApiLevels["thirtyTwo"] = "32";
|