1import assert from 'assert'; 2import fs from 'fs'; 3import path from 'path'; 4 5import * as XML from '../utils/XML'; 6 7export type StringBoolean = 'true' | 'false'; 8 9type ManifestMetaDataAttributes = AndroidManifestAttributes & { 10 'android:value'?: string; 11 'android:resource'?: string; 12}; 13 14type AndroidManifestAttributes = { 15 'android:name': string | 'android.intent.action.VIEW'; 16 'tools:node'?: string | 'remove'; 17}; 18 19type ManifestAction = { 20 $: AndroidManifestAttributes; 21}; 22 23type ManifestCategory = { 24 $: AndroidManifestAttributes; 25}; 26 27type ManifestData = { 28 $: { 29 [key: string]: string | undefined; 30 'android:host'?: string; 31 'android:pathPrefix'?: string; 32 'android:scheme'?: string; 33 }; 34}; 35 36type ManifestReceiver = { 37 $: AndroidManifestAttributes & { 38 'android:exported'?: StringBoolean; 39 'android:enabled'?: StringBoolean; 40 }; 41 'intent-filter'?: ManifestIntentFilter[]; 42}; 43 44export type ManifestIntentFilter = { 45 $?: { 46 'android:autoVerify'?: StringBoolean; 47 'data-generated'?: StringBoolean; 48 }; 49 action?: ManifestAction[]; 50 data?: ManifestData[]; 51 category?: ManifestCategory[]; 52}; 53 54export type ManifestMetaData = { 55 $: ManifestMetaDataAttributes; 56}; 57 58type ManifestServiceAttributes = AndroidManifestAttributes & { 59 'android:enabled'?: StringBoolean; 60 'android:exported'?: StringBoolean; 61 'android:permission'?: string; 62 // ... 63}; 64 65type ManifestService = { 66 $: ManifestServiceAttributes; 67 'intent-filter'?: ManifestIntentFilter[]; 68}; 69 70type ManifestApplicationAttributes = { 71 'android:name': string | '.MainApplication'; 72 'android:icon'?: string; 73 'android:roundIcon'?: string; 74 'android:label'?: string; 75 'android:allowBackup'?: StringBoolean; 76 'android:largeHeap'?: StringBoolean; 77 'android:requestLegacyExternalStorage'?: StringBoolean; 78 'android:usesCleartextTraffic'?: StringBoolean; 79 [key: string]: string | undefined; 80}; 81 82export type ManifestActivity = { 83 $: ManifestApplicationAttributes & { 84 'android:exported'?: StringBoolean; 85 'android:launchMode'?: string; 86 'android:theme'?: string; 87 'android:windowSoftInputMode'?: 88 | string 89 | 'stateUnspecified' 90 | 'stateUnchanged' 91 | 'stateHidden' 92 | 'stateAlwaysHidden' 93 | 'stateVisible' 94 | 'stateAlwaysVisible' 95 | 'adjustUnspecified' 96 | 'adjustResize' 97 | 'adjustPan'; 98 [key: string]: string | undefined; 99 }; 100 'intent-filter'?: ManifestIntentFilter[]; 101 // ... 102}; 103 104export type ManifestUsesLibrary = { 105 $: AndroidManifestAttributes & { 106 'android:required'?: StringBoolean; 107 }; 108}; 109 110export type ManifestApplication = { 111 $: ManifestApplicationAttributes; 112 activity?: ManifestActivity[]; 113 service?: ManifestService[]; 114 receiver?: ManifestReceiver[]; 115 'meta-data'?: ManifestMetaData[]; 116 'uses-library'?: ManifestUsesLibrary[]; 117 // ... 118}; 119 120type ManifestPermission = { 121 $: AndroidManifestAttributes & { 122 'android:protectionLevel'?: string | 'signature'; 123 }; 124}; 125 126export type ManifestUsesPermission = { 127 $: AndroidManifestAttributes; 128}; 129 130type ManifestUsesFeature = { 131 $: AndroidManifestAttributes & { 132 'android:glEsVersion'?: string; 133 'android:required': StringBoolean; 134 }; 135}; 136 137export type AndroidManifest = { 138 manifest: { 139 // Probably more, but this is currently all we'd need for most cases in Expo. 140 $: { 141 'xmlns:android': string; 142 'xmlns:tools'?: string; 143 package?: string; 144 [key: string]: string | undefined; 145 }; 146 permission?: ManifestPermission[]; 147 'uses-permission'?: ManifestUsesPermission[]; 148 'uses-permission-sdk-23'?: ManifestUsesPermission[]; 149 'uses-feature'?: ManifestUsesFeature[]; 150 application?: ManifestApplication[]; 151 }; 152}; 153 154export async function writeAndroidManifestAsync( 155 manifestPath: string, 156 androidManifest: AndroidManifest 157): Promise<void> { 158 const manifestXml = XML.format(androidManifest); 159 await fs.promises.mkdir(path.dirname(manifestPath), { recursive: true }); 160 await fs.promises.writeFile(manifestPath, manifestXml); 161} 162 163export async function readAndroidManifestAsync(manifestPath: string): Promise<AndroidManifest> { 164 const xml = await XML.readXMLAsync({ path: manifestPath }); 165 if (!isManifest(xml)) { 166 throw new Error('Invalid manifest found at: ' + manifestPath); 167 } 168 return xml; 169} 170 171function isManifest(xml: XML.XMLObject): xml is AndroidManifest { 172 // TODO: Maybe more validation 173 return !!xml.manifest; 174} 175 176/** Returns the `manifest.application` tag ending in `.MainApplication` */ 177export function getMainApplication(androidManifest: AndroidManifest): ManifestApplication | null { 178 return ( 179 androidManifest?.manifest?.application?.filter( 180 (e) => e?.$?.['android:name'].endsWith('.MainApplication') 181 )[0] ?? null 182 ); 183} 184 185export function getMainApplicationOrThrow(androidManifest: AndroidManifest): ManifestApplication { 186 const mainApplication = getMainApplication(androidManifest); 187 assert(mainApplication, 'AndroidManifest.xml is missing the required MainApplication element'); 188 return mainApplication; 189} 190 191export function getMainActivityOrThrow(androidManifest: AndroidManifest): ManifestActivity { 192 const mainActivity = getMainActivity(androidManifest); 193 assert(mainActivity, 'AndroidManifest.xml is missing the required MainActivity element'); 194 return mainActivity; 195} 196 197export function getRunnableActivity(androidManifest: AndroidManifest): ManifestActivity | null { 198 // Get enabled activities 199 const enabledActivities = androidManifest?.manifest?.application?.[0]?.activity?.filter?.( 200 (e: any) => e.$['android:enabled'] !== 'false' && e.$['android:enabled'] !== false 201 ); 202 203 if (!enabledActivities) { 204 return null; 205 } 206 207 // Get the activity that has a runnable intent-filter 208 for (const activity of enabledActivities) { 209 if (Array.isArray(activity['intent-filter'])) { 210 for (const intentFilter of activity['intent-filter']) { 211 if ( 212 intentFilter.action?.find( 213 (action) => action.$['android:name'] === 'android.intent.action.MAIN' 214 ) && 215 intentFilter.category?.find( 216 (category) => category.$['android:name'] === 'android.intent.category.LAUNCHER' 217 ) 218 ) { 219 return activity; 220 } 221 } 222 } 223 } 224 225 return null; 226} 227 228export function getMainActivity(androidManifest: AndroidManifest): ManifestActivity | null { 229 const mainActivity = androidManifest?.manifest?.application?.[0]?.activity?.filter?.( 230 (e: any) => e.$['android:name'] === '.MainActivity' 231 ); 232 return mainActivity?.[0] ?? null; 233} 234 235export function addMetaDataItemToMainApplication( 236 mainApplication: ManifestApplication, 237 itemName: string, 238 itemValue: string, 239 itemType: 'resource' | 'value' = 'value' 240): ManifestApplication { 241 let existingMetaDataItem; 242 const newItem = { 243 $: prefixAndroidKeys({ name: itemName, [itemType]: itemValue }), 244 } as ManifestMetaData; 245 if (mainApplication['meta-data']) { 246 existingMetaDataItem = mainApplication['meta-data'].filter( 247 (e: any) => e.$['android:name'] === itemName 248 ); 249 if (existingMetaDataItem.length) { 250 existingMetaDataItem[0].$[`android:${itemType}` as keyof ManifestMetaDataAttributes] = 251 itemValue; 252 } else { 253 mainApplication['meta-data'].push(newItem); 254 } 255 } else { 256 mainApplication['meta-data'] = [newItem]; 257 } 258 return mainApplication; 259} 260 261export function removeMetaDataItemFromMainApplication(mainApplication: any, itemName: string) { 262 const index = findMetaDataItem(mainApplication, itemName); 263 if (mainApplication?.['meta-data'] && index > -1) { 264 mainApplication['meta-data'].splice(index, 1); 265 } 266 return mainApplication; 267} 268 269function findApplicationSubItem( 270 mainApplication: ManifestApplication, 271 category: keyof ManifestApplication, 272 itemName: string 273): number { 274 const parent = mainApplication[category]; 275 if (Array.isArray(parent)) { 276 const index = parent.findIndex((e: any) => e.$['android:name'] === itemName); 277 278 return index; 279 } 280 return -1; 281} 282 283export function findMetaDataItem(mainApplication: any, itemName: string): number { 284 return findApplicationSubItem(mainApplication, 'meta-data', itemName); 285} 286 287export function findUsesLibraryItem(mainApplication: any, itemName: string): number { 288 return findApplicationSubItem(mainApplication, 'uses-library', itemName); 289} 290 291export function getMainApplicationMetaDataValue( 292 androidManifest: AndroidManifest, 293 name: string 294): string | null { 295 const mainApplication = getMainApplication(androidManifest); 296 297 if (mainApplication?.hasOwnProperty('meta-data')) { 298 const item = mainApplication?.['meta-data']?.find((e: any) => e.$['android:name'] === name); 299 return item?.$['android:value'] ?? null; 300 } 301 302 return null; 303} 304 305export function addUsesLibraryItemToMainApplication( 306 mainApplication: ManifestApplication, 307 item: { name: string; required?: boolean } 308): ManifestApplication { 309 let existingMetaDataItem; 310 const newItem = { 311 $: prefixAndroidKeys(item), 312 } as ManifestUsesLibrary; 313 314 if (mainApplication['uses-library']) { 315 existingMetaDataItem = mainApplication['uses-library'].filter( 316 (e) => e.$['android:name'] === item.name 317 ); 318 if (existingMetaDataItem.length) { 319 existingMetaDataItem[0].$ = newItem.$; 320 } else { 321 mainApplication['uses-library'].push(newItem); 322 } 323 } else { 324 mainApplication['uses-library'] = [newItem]; 325 } 326 return mainApplication; 327} 328 329export function removeUsesLibraryItemFromMainApplication( 330 mainApplication: ManifestApplication, 331 itemName: string 332) { 333 const index = findUsesLibraryItem(mainApplication, itemName); 334 if (mainApplication?.['uses-library'] && index > -1) { 335 mainApplication['uses-library'].splice(index, 1); 336 } 337 return mainApplication; 338} 339 340export function prefixAndroidKeys<T extends Record<string, any> = Record<string, string>>( 341 head: T 342): Record<string, any> { 343 // prefix all keys with `android:` 344 return Object.entries(head).reduce( 345 (prev, [key, curr]) => ({ ...prev, [`android:${key}`]: curr }), 346 {} as T 347 ); 348} 349 350/** 351 * Ensure the `tools:*` namespace is available in the manifest. 352 * 353 * @param manifest AndroidManifest.xml 354 * @returns manifest with the `tools:*` namespace available 355 */ 356export function ensureToolsAvailable(manifest: AndroidManifest) { 357 return ensureManifestHasNamespace(manifest, { 358 namespace: 'xmlns:tools', 359 url: 'http://schemas.android.com/tools', 360 }); 361} 362 363/** 364 * Ensure a particular namespace is available in the manifest. 365 * 366 * @param manifest `AndroidManifest.xml` 367 * @returns manifest with the provided namespace available 368 */ 369function ensureManifestHasNamespace( 370 manifest: AndroidManifest, 371 { namespace, url }: { namespace: string; url: string } 372) { 373 if (manifest?.manifest?.$?.[namespace]) { 374 return manifest; 375 } 376 manifest.manifest.$[namespace] = url; 377 return manifest; 378} 379