react-native-intune 0.1.2 → 1.0.0-rc.1

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.
@@ -0,0 +1,329 @@
1
+ /**
2
+ * The text edits `setup` makes to iOS project files, as pure functions.
3
+ *
4
+ * Text, not a parse-and-serialise round trip, for plists and project.pbxproj alike.
5
+ * `plutil` would have been simpler and it drops comments: React Native's template keeps
6
+ * "Do not change NSAllowsArbitraryLoads to true, or you will risk app rejection!" inside
7
+ * Info.plist, and a setup that silently deletes the template's warnings has made a change
8
+ * nobody asked for. Every function here inserts or replaces the smallest span it can and
9
+ * leaves the rest of the file byte for byte.
10
+ *
11
+ * Each returns the new text, or null when the file does not have the shape the edit
12
+ * needs. Null becomes "do this by hand" — the same answer as a check without an apply,
13
+ * which beats an edit landing somewhere it has no effect.
14
+ */
15
+
16
+ import crypto from 'node:crypto';
17
+
18
+ // ---------------------------------------------------------------- plist
19
+
20
+ /** The top-level dict's closing tag — the last `</dict>` before `</plist>`. */
21
+ function topLevelEnd(xml) {
22
+ const plist = xml.lastIndexOf('</plist>');
23
+ const at = plist > -1 ? xml.lastIndexOf('</dict>', plist) : -1;
24
+ return at > -1 ? at : null;
25
+ }
26
+
27
+ /** Adds `<key>…</key>` and its value at the end of the top-level dict. */
28
+ export function plistAddKey(xml, key, valueXml) {
29
+ const at = topLevelEnd(xml);
30
+ if (at === null) {
31
+ return null;
32
+ }
33
+ const entry = `\t<key>${key}</key>\n${valueXml.replace(/^/gm, '\t')}\n`;
34
+ return `${xml.slice(0, at)}${entry}${xml.slice(at)}`;
35
+ }
36
+
37
+ /** The span of the `<array>` that follows a top-level `<key>`, or null. */
38
+ function arrayAfterKey(xml, key) {
39
+ const m = new RegExp(`<key>${key}</key>\\s*<array>`).exec(xml);
40
+ if (!m) {
41
+ return null;
42
+ }
43
+ const start = m.index + m[0].length;
44
+ const end = xml.indexOf('</array>', start);
45
+ return end > -1 ? { start, end } : null;
46
+ }
47
+
48
+ /** The indentation the existing items of an array use, or a sensible default. */
49
+ function itemIndent(xml, span, fallback) {
50
+ return xml.slice(span.start, span.end).match(/\n([ \t]*)</)?.[1] ?? fallback;
51
+ }
52
+
53
+ /**
54
+ * Makes `strings` present in the array under `key`, creating the key if it is absent.
55
+ * Existing entries are left where they are; missing ones are appended.
56
+ */
57
+ export function plistEnsureStrings(xml, key, strings) {
58
+ const span = arrayAfterKey(xml, key);
59
+ if (!span) {
60
+ if (new RegExp(`<key>${key}</key>`).test(xml)) {
61
+ return null; // present, but not an array — not ours to reshape
62
+ }
63
+ return plistAddKey(
64
+ xml,
65
+ key,
66
+ `<array>\n${strings.map((s) => `\t<string>${s}</string>`).join('\n')}\n</array>`
67
+ );
68
+ }
69
+ const body = xml.slice(span.start, span.end);
70
+ const missing = strings.filter(
71
+ (s) => !body.includes(`<string>${s}</string>`)
72
+ );
73
+ if (missing.length === 0) {
74
+ return xml;
75
+ }
76
+ const indent = itemIndent(xml, span, '\t\t');
77
+ const closing = body.match(/\n([ \t]*)$/)?.[0] ?? '\n\t';
78
+ const trimmed = body.replace(/\s*$/, '');
79
+ const added = missing.map((s) => `\n${indent}<string>${s}</string>`).join('');
80
+ return `${xml.slice(0, span.start)}${trimmed}${added}${closing}${xml.slice(span.end)}`;
81
+ }
82
+
83
+ /**
84
+ * Adds a URL type carrying `scheme` to CFBundleURLTypes, creating the key if needed.
85
+ * A scheme already present in any URL type is left alone.
86
+ */
87
+ export function plistEnsureUrlScheme(xml, scheme) {
88
+ if (xml.includes(`<string>${scheme}</string>`)) {
89
+ return xml;
90
+ }
91
+ const type = `<dict>\n\t<key>CFBundleURLSchemes</key>\n\t<array>\n\t\t<string>${scheme}</string>\n\t</array>\n</dict>`;
92
+ const span = arrayAfterKey(xml, 'CFBundleURLTypes');
93
+ if (!span) {
94
+ if (/<key>CFBundleURLTypes<\/key>/.test(xml)) {
95
+ return null;
96
+ }
97
+ return plistAddKey(
98
+ xml,
99
+ 'CFBundleURLTypes',
100
+ `<array>\n${type.replace(/^/gm, '\t')}\n</array>`
101
+ );
102
+ }
103
+ // The URL types array holds dicts, and `arrayAfterKey` stops at the first </array> —
104
+ // the inner CFBundleURLSchemes one. Insert right after the opening tag instead, which
105
+ // needs no knowledge of what follows.
106
+ const indent = itemIndent(
107
+ xml,
108
+ { start: span.start, end: span.start + 200 },
109
+ '\t\t'
110
+ );
111
+ const entry = `\n${type.replace(/^/gm, indent)}`;
112
+ return `${xml.slice(0, span.start)}${entry}${xml.slice(span.start)}`;
113
+ }
114
+
115
+ /** Removes `<key>K</key>` and the one value that follows it, wherever it appears. */
116
+ export function plistRemoveKeys(xml, keys) {
117
+ let next = xml;
118
+ for (const key of keys) {
119
+ next = next.replace(
120
+ new RegExp(
121
+ `\\n?[ \\t]*<key>${key}</key>\\s*<(string|integer|true|false)\\b[^>]*?(?:/>|>[\\s\\S]*?</\\1>)`,
122
+ 'g'
123
+ ),
124
+ ''
125
+ );
126
+ }
127
+ return next;
128
+ }
129
+
130
+ // ---------------------------------------------------------------- entitlements
131
+
132
+ /**
133
+ * The keychain groups in the required order — the app's own first, which is where iOS
134
+ * writes items that name no group — followed by any the app already had.
135
+ */
136
+ export function entitlementsWithKeychainGroups(xml, groups) {
137
+ const fresh = `<?xml version="1.0" encoding="UTF-8"?>
138
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
139
+ <plist version="1.0">
140
+ <dict>
141
+ </dict>
142
+ </plist>
143
+ `;
144
+ const source = xml || fresh;
145
+ const span = arrayAfterKey(source, 'keychain-access-groups');
146
+ const existing = span
147
+ ? [
148
+ ...source
149
+ .slice(span.start, span.end)
150
+ .matchAll(/<string>([^<]*)<\/string>/g),
151
+ ].map((m) => m[1])
152
+ : [];
153
+ const ordered = [...groups, ...existing.filter((g) => !groups.includes(g))];
154
+ const array = `<array>\n${ordered.map((g) => `\t<string>${g}</string>`).join('\n')}\n</array>`;
155
+ if (!span) {
156
+ return plistAddKey(source, 'keychain-access-groups', array);
157
+ }
158
+ const keyAt = source.lastIndexOf(
159
+ '<key>keychain-access-groups</key>',
160
+ span.start
161
+ );
162
+ const lineStart = source.lastIndexOf('\n', keyAt) + 1;
163
+ const indent = source.slice(lineStart, keyAt);
164
+ const arrayEnd = span.end + '</array>'.length;
165
+ const replacement = `<key>keychain-access-groups</key>\n${array.replace(/^/gm, indent).trimStart()}`;
166
+ return `${source.slice(0, keyAt)}${replacement}${source.slice(arrayEnd)}`;
167
+ }
168
+
169
+ // ---------------------------------------------------------------- project.pbxproj
170
+
171
+ const object = (isa) =>
172
+ new RegExp(
173
+ `(\\w{24})\\s*(?:/\\*[^*]*\\*/)?\\s*=\\s*\\{\\s*isa\\s*=\\s*${isa};([\\s\\S]*?)\\n\\t\\t\\};`,
174
+ 'g'
175
+ );
176
+
177
+ /** The XCBuildConfiguration ids of every application target, and those targets' ids. */
178
+ function applicationTargets(pbxproj) {
179
+ const lists = new Map();
180
+ for (const [, id, body] of pbxproj.matchAll(object('XCConfigurationList'))) {
181
+ const ids = body.match(/buildConfigurations\s*=\s*\(([^)]*)\)/)?.[1] ?? '';
182
+ lists.set(
183
+ id,
184
+ [...ids.matchAll(/\w{24}/g)].map((m) => m[0])
185
+ );
186
+ }
187
+ const targets = [];
188
+ for (const [, id, body] of pbxproj.matchAll(object('PBXNativeTarget'))) {
189
+ if (
190
+ !/productType\s*=\s*"com\.apple\.product-type\.application"/.test(body)
191
+ ) {
192
+ continue;
193
+ }
194
+ const list = body.match(/buildConfigurationList\s*=\s*(\w{24})/)?.[1];
195
+ targets.push({ id, configs: lists.get(list) ?? [] });
196
+ }
197
+ return targets;
198
+ }
199
+
200
+ /** Applies `change` to the buildSettings body of each configuration id. */
201
+ function editBuildSettings(pbxproj, ids, change) {
202
+ let next = pbxproj;
203
+ for (const id of ids) {
204
+ const re = new RegExp(
205
+ `(${id}\\s*(?:/\\*[^*]*\\*/)?\\s*=\\s*\\{[\\s\\S]*?buildSettings\\s*=\\s*\\{)([\\s\\S]*?)(\\n\\t\\t\\t\\};)`
206
+ );
207
+ next = next.replace(
208
+ re,
209
+ (all, open, body, close) => `${open}${change(body)}${close}`
210
+ );
211
+ }
212
+ return next;
213
+ }
214
+
215
+ /** Sets `key = value;` in a buildSettings body, replacing it or adding it. */
216
+ function setBuildSetting(body, key, value) {
217
+ const line = new RegExp(`(\\n\\t\\t\\t\\t${key}\\s*=\\s*)[^;]*;`);
218
+ if (line.test(body)) {
219
+ return body.replace(line, `$1${value};`);
220
+ }
221
+ return `${body}\n\t\t\t\t${key} = ${value};`;
222
+ }
223
+
224
+ /**
225
+ * Raises IPHONEOS_DEPLOYMENT_TARGET on every configuration of every application target,
226
+ * setting it on the target rather than relying on the project level it would otherwise
227
+ * inherit — which is what Expo's prebuild does, and what doctor reads.
228
+ */
229
+ export function pbxprojSetDeploymentTarget(pbxproj, version, atLeast) {
230
+ const targets = applicationTargets(pbxproj);
231
+ if (targets.length === 0) {
232
+ return null;
233
+ }
234
+ const ids = targets.flatMap((t) => t.configs);
235
+ return editBuildSettings(pbxproj, ids, (body) => {
236
+ const current = body.match(
237
+ /IPHONEOS_DEPLOYMENT_TARGET\s*=\s*"?([\d.]+)"?;/
238
+ )?.[1];
239
+ return current && atLeast(current)
240
+ ? body
241
+ : setBuildSetting(body, 'IPHONEOS_DEPLOYMENT_TARGET', version);
242
+ });
243
+ }
244
+
245
+ /** Points every application target's configurations at an entitlements file. */
246
+ export function pbxprojSetEntitlements(pbxproj, relativePath) {
247
+ const targets = applicationTargets(pbxproj);
248
+ if (targets.length === 0) {
249
+ return null;
250
+ }
251
+ return editBuildSettings(
252
+ pbxproj,
253
+ targets.flatMap((t) => t.configs),
254
+ (body) =>
255
+ /CODE_SIGN_ENTITLEMENTS\s*=/.test(body)
256
+ ? body
257
+ : setBuildSetting(body, 'CODE_SIGN_ENTITLEMENTS', relativePath)
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Adds a Run Script phase that runs Microsoft's IntuneMAMConfigurator, first in every
263
+ * application target — before Compile Sources, so the plist and entitlements it writes
264
+ * are in place for the build that follows.
265
+ */
266
+ export function pbxprojAddConfiguratorPhase(
267
+ pbxproj,
268
+ script,
269
+ id = newObjectId(pbxproj)
270
+ ) {
271
+ if (/IntuneMAMConfigurator/.test(pbxproj)) {
272
+ return pbxproj;
273
+ }
274
+ const targets = applicationTargets(pbxproj);
275
+ if (targets.length === 0) {
276
+ return null;
277
+ }
278
+ const escaped = script
279
+ .replace(/\\/g, '\\\\')
280
+ .replace(/"/g, '\\"')
281
+ .replace(/\n/g, '\\n');
282
+ const phase =
283
+ `\t\t${id} /* Run IntuneMAMConfigurator */ = {\n` +
284
+ '\t\t\tisa = PBXShellScriptBuildPhase;\n' +
285
+ '\t\t\talwaysOutOfDate = 1;\n' +
286
+ '\t\t\tbuildActionMask = 2147483647;\n' +
287
+ '\t\t\tfiles = (\n\t\t\t);\n' +
288
+ '\t\t\tinputPaths = (\n\t\t\t);\n' +
289
+ '\t\t\tname = "Run IntuneMAMConfigurator";\n' +
290
+ '\t\t\toutputPaths = (\n\t\t\t);\n' +
291
+ '\t\t\trunOnlyForDeploymentPostprocessing = 0;\n' +
292
+ '\t\t\tshellPath = /bin/sh;\n' +
293
+ `\t\t\tshellScript = "${escaped}";\n` +
294
+ '\t\t};\n';
295
+
296
+ let next = pbxproj;
297
+ const section = '/* Begin PBXShellScriptBuildPhase section */\n';
298
+ if (next.includes(section)) {
299
+ next = next.replace(section, `${section}${phase}`);
300
+ } else {
301
+ const anchor = '/* Begin PBXSourcesBuildPhase section */';
302
+ if (!next.includes(anchor)) {
303
+ return null;
304
+ }
305
+ next = next.replace(
306
+ anchor,
307
+ `${section}${phase}/* End PBXShellScriptBuildPhase section */\n\n${anchor}`
308
+ );
309
+ }
310
+ for (const t of targets) {
311
+ next = next.replace(
312
+ new RegExp(
313
+ `(${t.id}\\s*(?:/\\*[^*]*\\*/)?\\s*=\\s*\\{[\\s\\S]*?buildPhases\\s*=\\s*\\(\\n)`
314
+ ),
315
+ `$1\t\t\t\t${id} /* Run IntuneMAMConfigurator */,\n`
316
+ );
317
+ }
318
+ return next;
319
+ }
320
+
321
+ /** A 24-hex-digit object id, the form Xcode uses, not already in the file. */
322
+ function newObjectId(pbxproj) {
323
+ for (;;) {
324
+ const id = crypto.randomBytes(12).toString('hex').toUpperCase();
325
+ if (!pbxproj.includes(id)) {
326
+ return id;
327
+ }
328
+ }
329
+ }
@@ -178,11 +178,24 @@ function walk(dir, onFile, depth = 0) {
178
178
  }
179
179
  }
180
180
 
181
+ /**
182
+ * Edits `setup` has decided on but not yet written, by path.
183
+ *
184
+ * Several checks change the same file — three edit Info.plist, three project.pbxproj —
185
+ * and each computes its edit from what it reads. Reading through this means the second
186
+ * edit starts from the first one's result instead of from the original file, which it
187
+ * would otherwise silently overwrite.
188
+ */
189
+ export const pending = new Map();
190
+
181
191
  /** Reads a file, or returns an empty string. Callers treat absent and empty alike. */
182
192
  export function read(file) {
183
193
  if (!file) {
184
194
  return '';
185
195
  }
196
+ if (pending.has(file)) {
197
+ return pending.get(file);
198
+ }
186
199
  try {
187
200
  return fs.readFileSync(file, 'utf8');
188
201
  } catch {
package/scripts/setup.mjs CHANGED
@@ -20,7 +20,8 @@
20
20
 
21
21
  import fs from 'node:fs';
22
22
  import readline from 'node:readline';
23
- import { findProject, rel } from './lib/project.mjs';
23
+ import path from 'node:path';
24
+ import { findProject, pending, rel } from './lib/project.mjs';
24
25
  import { gitIsClean, runChecks, summarise } from './doctor.mjs';
25
26
 
26
27
  const args = process.argv.slice(2);
@@ -37,7 +38,8 @@ async function main() {
37
38
  const project = findProject();
38
39
  const s = summarise(runChecks(project));
39
40
  report(project, s);
40
- process.exit(s.blocking.length > 0 ? 1 : 0);
41
+ process.exitCode = s.blocking.length > 0 ? 1 : 0;
42
+ return;
41
43
  }
42
44
 
43
45
  const project = findProject();
@@ -51,6 +53,26 @@ async function main() {
51
53
  );
52
54
  }
53
55
 
56
+ // Under Expo, android/ and ios/ are generated by prebuild and regenerated by the next
57
+ // one, so everything written here would vanish without a trace. The config plugin is
58
+ // the same edits, applied where they survive.
59
+ const pkg = (() => {
60
+ try {
61
+ return JSON.parse(fs.readFileSync(path.join(project.root, 'package.json'), 'utf8'));
62
+ } catch {
63
+ return {};
64
+ }
65
+ })();
66
+ if ((pkg.dependencies?.expo || pkg.devDependencies?.expo) && !flags.force) {
67
+ fail(
68
+ `This is an Expo project. Its android/ and ios/ directories are generated by\n` +
69
+ `\`expo prebuild\`, so the next prebuild would overwrite anything written here.\n\n` +
70
+ `Add the config plugin to app.json instead and run prebuild:\n\n` +
71
+ ` "plugins": [["react-native-intune", { "androidSignatureHash": "…" }]]\n\n` +
72
+ `doctor still works here, for checking what prebuild produced.`
73
+ );
74
+ }
75
+
54
76
  if (!project.android && !project.ios) {
55
77
  fail(
56
78
  `No native project found under ${project.root}.\n` +
@@ -75,16 +97,37 @@ async function main() {
75
97
  const results = runChecks(project);
76
98
  const s = summarise(results);
77
99
 
78
- const edits = [];
100
+ // Applied one after another through `pending`, so each edit reads the ones before it.
101
+ // One check may change several files — creating the entitlements file and pointing the
102
+ // Xcode target at it — and several checks may change the same one.
103
+ const originals = new Map();
104
+ const reasons = new Map();
79
105
  for (const r of s.failing) {
80
106
  if (typeof r.check.apply !== 'function') {
81
107
  continue;
82
108
  }
83
- const edit = r.check.apply(project);
84
- if (edit) {
85
- edits.push({ ...edit, check: r.check, before: fs.readFileSync(edit.file, 'utf8') });
109
+ const result = r.check.apply(project);
110
+ for (const edit of [result].flat().filter(Boolean)) {
111
+ if (!originals.has(edit.file)) {
112
+ originals.set(
113
+ edit.file,
114
+ fs.existsSync(edit.file) ? fs.readFileSync(edit.file, 'utf8') : null
115
+ );
116
+ }
117
+ pending.set(edit.file, edit.contents);
118
+ reasons.set(edit.file, [...(reasons.get(edit.file) ?? []), r.check.title]);
86
119
  }
87
120
  }
121
+ const edits = [...originals]
122
+ .map(([file, before]) => ({
123
+ file,
124
+ before: before ?? '',
125
+ created: before === null,
126
+ contents: pending.get(file),
127
+ titles: [...new Set(reasons.get(file))],
128
+ }))
129
+ .filter((e) => e.created || e.contents !== e.before);
130
+ pending.clear();
88
131
 
89
132
  if (edits.length === 0) {
90
133
  process.stdout.write(
@@ -95,16 +138,20 @@ async function main() {
95
138
  } else {
96
139
  process.stdout.write(`\nThese files will change:\n\n`);
97
140
  for (const e of edits) {
98
- process.stdout.write(` ${rel(project, e.file)} — ${e.check.title}\n`);
141
+ process.stdout.write(
142
+ ` ${rel(project, e.file)}${e.created ? ' (new)' : ''} — ${e.titles.join('; ')}\n`
143
+ );
99
144
  process.stdout.write(diff(e.before, e.contents));
100
145
  }
101
146
 
102
147
  if (!flags.yes && !(await confirm())) {
103
148
  process.stdout.write(`\nNothing written.\n\n`);
104
- process.exit(1);
149
+ process.exitCode = 1;
150
+ return;
105
151
  }
106
152
 
107
153
  for (const e of edits) {
154
+ fs.mkdirSync(path.dirname(e.file), { recursive: true });
108
155
  fs.writeFileSync(e.file, e.contents);
109
156
  process.stdout.write(` wrote ${rel(project, e.file)}\n`);
110
157
  }
@@ -115,7 +162,10 @@ async function main() {
115
162
  // own apply is a bug worth surfacing here instead of at the next build.
116
163
  const after = summarise(runChecks(findProject()));
117
164
  report(project, after, { afterApply: true });
118
- process.exit(after.blocking.length > 0 ? 1 : 0);
165
+ // An exit code, not process.exit(): on macOS a write to a pipe is asynchronous, and
166
+ // exiting straight after the diff cut it off — every file after the project.pbxproj
167
+ // diff, and every "wrote" line, silently missing whenever setup's output was piped.
168
+ process.exitCode = after.blocking.length > 0 ? 1 : 0;
119
169
  }
120
170
 
121
171
  function report(project, s, { afterApply = false } = {}) {
package/sdk-lock.json CHANGED
@@ -1,17 +1,18 @@
1
1
  {
2
2
  "ios": {
3
3
  "repo": "microsoftconnect/ms-intune-app-sdk-ios",
4
- "tag": "21.8.0",
5
- "fetchedAt": "2026-09-03T10:39:33.572Z",
4
+ "tag": "21.9.0",
5
+ "fetchedAt": "2026-09-30T10:58:03.813Z",
6
6
  "files": {
7
- "ios/IntuneMAMSwift.xcframework": "f38f0f130bd2adf0dd936acb26958f6c6ebc0b2d5a4bf886d4c8c7d4ae0870ab",
8
- "ios/IntuneMAMSwiftStub.xcframework": "a03e563feec36f719bfca34b061e590e1c54a03650a3855a8298988e49b9ea9c",
9
- "ios/IntuneMAMTelemetry.xcframework": "d4582c8ffa014611cb1c10b5717cf13cdbeeb50e34b67624dc884e194636b3de",
10
- "ios/IntuneMAMResources.bundle": "51d6f666efb80eddb6d35467c9d6491b746405a72c8ebe6cde5de5c68e7181f3",
11
- "ios/IntuneMAMConfigurator.dmg": "4fc4fbcd6211e62fa355ef1976970e230d806855703f75788727d5dbcb454b61",
7
+ "ios/IntuneMAMSwift.xcframework": "6100e987caed8d4286056d04c003b71ad7ea15d3523495ab3cf7a3aa7d39565d",
8
+ "ios/IntuneMAMSwiftStub.xcframework": "b6906db29374ffbd4e8b708975572bc0cb3bb02199e36c61f5b359573175deb8",
9
+ "ios/IntuneMAMTelemetry.xcframework": "e218e261dbc28a70a390bc249905884a97790560ab4bd0e57633fc548cdef1d9",
10
+ "ios/IntuneMAMResources.bundle": "3d71e06a0261c4904b8104ef7405bdf22114ed60ccf09b6ab4094cd56199b7dd",
11
+ "ios/IntuneMAMConfigurator.dmg": "753a563e336e40884d2f30cfaad80663f6bd63a81c5bc53c5a232c49bec6ab07",
12
+ "ios/IntuneMAMConfigurator": "b8c747c49b114ad6171820edcf7562bcd43831c1da4dbdfcb6993c81c5136c72",
12
13
  "ios/REDIST.txt": "31c5b0e0c3ee48b60c6f12e56d5a8b6d47754f127750f27627800d2b44b79e56",
13
14
  "ios/Microsoft License Terms Intune App SDK for iOS.pdf": "5b12b9a6ee3671a138b67884ff69c5bda17b6cb7bec0646f108cf072930c7394",
14
- "ios/ThirdPartyNotices.txt": "aa0bbc665cfd7b7549b05c3bd747faa5d383c1ea4a297fb4dc33184180e3663f"
15
+ "ios/ThirdPartyNotices.txt": "f99b09285acd2944c0dde5773f68cfa1179e8a743e0e6ba913d1f688a79d439f"
15
16
  }
16
17
  },
17
18
  "android": {