1import { 2 AndroidConfig, 3 ConfigPlugin, 4 withAndroidColors, 5 withDangerousMod, 6} from '@expo/config-plugins'; 7import { ResourceXML } from '@expo/config-plugins/build/android/Resources'; 8import { ExpoConfig } from '@expo/config-types'; 9import { compositeImagesAsync, generateImageAsync } from '@expo/image-utils'; 10import fs from 'fs-extra'; 11import path from 'path'; 12 13import { withAndroidManifestIcons } from './withAndroidManifestIcons'; 14 15const { Colors } = AndroidConfig; 16 17type DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi'; 18type dpiMap = Record<DPIString, { folderName: string; scale: number }>; 19 20export const dpiValues: dpiMap = { 21 mdpi: { folderName: 'mipmap-mdpi', scale: 1 }, 22 hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 }, 23 xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 }, 24 xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 }, 25 xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 }, 26}; 27const BASELINE_PIXEL_SIZE = 108; 28export const ANDROID_RES_PATH = 'android/app/src/main/res/'; 29const MIPMAP_ANYDPI_V26 = 'mipmap-anydpi-v26'; 30const ICON_BACKGROUND = 'iconBackground'; 31const IC_LAUNCHER_PNG = 'ic_launcher.png'; 32const IC_LAUNCHER_ROUND_PNG = 'ic_launcher_round.png'; 33const IC_LAUNCHER_BACKGROUND_PNG = 'ic_launcher_background.png'; 34const IC_LAUNCHER_FOREGROUND_PNG = 'ic_launcher_foreground.png'; 35const IC_LAUNCHER_MONOCHROME_PNG = 'ic_launcher_monochrome.png'; 36const IC_LAUNCHER_XML = 'ic_launcher.xml'; 37const IC_LAUNCHER_ROUND_XML = 'ic_launcher_round.xml'; 38 39export const withAndroidIcons: ConfigPlugin = (config) => { 40 const { foregroundImage, backgroundColor, backgroundImage, monochromeImage } = 41 getAdaptiveIcon(config); 42 const icon = foregroundImage ?? getIcon(config); 43 44 if (!icon) { 45 return config; 46 } 47 48 config = withAndroidManifestIcons(config); 49 // Apply colors.xml changes 50 config = withAndroidAdaptiveIconColors(config, backgroundColor); 51 return withDangerousMod(config, [ 52 'android', 53 async (config) => { 54 await setIconAsync(config.modRequest.projectRoot, { 55 icon, 56 backgroundColor, 57 backgroundImage, 58 monochromeImage, 59 isAdaptive: !!config.android?.adaptiveIcon, 60 }); 61 return config; 62 }, 63 ]); 64}; 65 66export function setRoundIconManifest( 67 config: Pick<ExpoConfig, 'android'>, 68 manifest: AndroidConfig.Manifest.AndroidManifest 69): AndroidConfig.Manifest.AndroidManifest { 70 const isAdaptive = !!config.android?.adaptiveIcon; 71 const application = AndroidConfig.Manifest.getMainApplicationOrThrow(manifest); 72 73 if (isAdaptive) { 74 application.$['android:roundIcon'] = '@mipmap/ic_launcher_round'; 75 } else { 76 delete application.$['android:roundIcon']; 77 } 78 return manifest; 79} 80 81const withAndroidAdaptiveIconColors: ConfigPlugin<string | null> = (config, backgroundColor) => { 82 return withAndroidColors(config, (config) => { 83 config.modResults = setBackgroundColor(backgroundColor ?? '#FFFFFF', config.modResults); 84 return config; 85 }); 86}; 87 88export function getIcon(config: ExpoConfig) { 89 return config.android?.icon || config.icon || null; 90} 91 92export function getAdaptiveIcon(config: ExpoConfig) { 93 return { 94 foregroundImage: config.android?.adaptiveIcon?.foregroundImage ?? null, 95 backgroundColor: config.android?.adaptiveIcon?.backgroundColor ?? null, 96 backgroundImage: config.android?.adaptiveIcon?.backgroundImage ?? null, 97 monochromeImage: config.android?.adaptiveIcon?.monochromeImage ?? null, 98 }; 99} 100 101/** 102 * Resizes the user-provided icon to create a set of legacy icon files in 103 * their respective "mipmap" directories for <= Android 7, and creates a set of adaptive 104 * icon files for > Android 7 from the adaptive icon files (if provided). 105 */ 106export async function setIconAsync( 107 projectRoot: string, 108 { 109 icon, 110 backgroundColor, 111 backgroundImage, 112 monochromeImage, 113 isAdaptive, 114 }: { 115 icon: string | null; 116 backgroundColor: string | null; 117 backgroundImage: string | null; 118 monochromeImage: string | null; 119 isAdaptive: boolean; 120 } 121) { 122 if (!icon) { 123 return null; 124 } 125 126 await configureLegacyIconAsync(projectRoot, icon, backgroundImage, backgroundColor); 127 if (isAdaptive) { 128 await generateRoundIconAsync(projectRoot, icon, backgroundImage, backgroundColor); 129 } else { 130 await deleteIconNamedAsync(projectRoot, IC_LAUNCHER_ROUND_PNG); 131 } 132 await configureAdaptiveIconAsync(projectRoot, icon, backgroundImage, monochromeImage, isAdaptive); 133 134 return true; 135} 136 137/** 138 * Configures legacy icon files to be used on Android 7 and earlier. If adaptive icon configuration 139 * was provided, we create a pseudo-adaptive icon by layering the provided files (or background 140 * color if no backgroundImage is provided. If no backgroundImage and no backgroundColor are provided, 141 * the background is set to transparent.) 142 */ 143async function configureLegacyIconAsync( 144 projectRoot: string, 145 icon: string, 146 backgroundImage: string | null, 147 backgroundColor: string | null 148) { 149 return generateMultiLayerImageAsync(projectRoot, { 150 icon, 151 backgroundImage, 152 backgroundColor, 153 outputImageFileName: IC_LAUNCHER_PNG, 154 imageCacheFolder: 'android-standard-square', 155 backgroundImageCacheFolder: 'android-standard-square-background', 156 }); 157} 158 159async function generateRoundIconAsync( 160 projectRoot: string, 161 icon: string, 162 backgroundImage: string | null, 163 backgroundColor: string | null 164) { 165 return generateMultiLayerImageAsync(projectRoot, { 166 icon, 167 borderRadiusRatio: 0.5, 168 outputImageFileName: IC_LAUNCHER_ROUND_PNG, 169 backgroundImage, 170 backgroundColor, 171 imageCacheFolder: 'android-standard-circle', 172 backgroundImageCacheFolder: 'android-standard-round-background', 173 }); 174} 175 176/** 177 * Configures adaptive icon files to be used on Android 8 and up. A foreground image must be provided, 178 * and will have a transparent background unless: 179 * - A backgroundImage is provided, or 180 * - A backgroundColor was specified 181 */ 182export async function configureAdaptiveIconAsync( 183 projectRoot: string, 184 foregroundImage: string, 185 backgroundImage: string | null, 186 monochromeImage: string | null, 187 isAdaptive: boolean 188) { 189 if (monochromeImage) { 190 await generateMonochromeImageAsync(projectRoot, { 191 icon: monochromeImage, 192 imageCacheFolder: 'android-adaptive-monochrome', 193 outputImageFileName: IC_LAUNCHER_MONOCHROME_PNG, 194 }); 195 } 196 await generateMultiLayerImageAsync(projectRoot, { 197 backgroundColor: 'transparent', 198 backgroundImage, 199 backgroundImageCacheFolder: 'android-adaptive-background', 200 outputImageFileName: IC_LAUNCHER_FOREGROUND_PNG, 201 icon: foregroundImage, 202 imageCacheFolder: 'android-adaptive-foreground', 203 backgroundImageFileName: IC_LAUNCHER_BACKGROUND_PNG, 204 }); 205 206 // create ic_launcher.xml and ic_launcher_round.xml 207 const icLauncherXmlString = createAdaptiveIconXmlString(backgroundImage, monochromeImage); 208 await createAdaptiveIconXmlFiles( 209 projectRoot, 210 icLauncherXmlString, 211 // If the user only defined icon and not android.adaptiveIcon, then skip enabling the layering system 212 // this will scale the image down and present it uncropped. 213 isAdaptive 214 ); 215} 216 217function setBackgroundColor(backgroundColor: string | null, colors: ResourceXML) { 218 return Colors.assignColorValue(colors, { 219 value: backgroundColor, 220 name: ICON_BACKGROUND, 221 }); 222} 223 224export const createAdaptiveIconXmlString = ( 225 backgroundImage: string | null, 226 monochromeImage: string | null 227) => { 228 const background = backgroundImage ? `@mipmap/ic_launcher_background` : `@color/iconBackground`; 229 230 const iconElements: string[] = [ 231 `<background android:drawable="${background}"/>`, 232 '<foreground android:drawable="@mipmap/ic_launcher_foreground"/>', 233 ]; 234 235 if (monochromeImage) { 236 iconElements.push('<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>'); 237 } 238 239 return `<?xml version="1.0" encoding="utf-8"?> 240<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> 241 ${iconElements.join('\n ')} 242</adaptive-icon>`; 243}; 244 245async function createAdaptiveIconXmlFiles( 246 projectRoot: string, 247 icLauncherXmlString: string, 248 add: boolean 249) { 250 const anyDpiV26Directory = path.resolve(projectRoot, ANDROID_RES_PATH, MIPMAP_ANYDPI_V26); 251 await fs.ensureDir(anyDpiV26Directory); 252 const launcherPath = path.resolve(anyDpiV26Directory, IC_LAUNCHER_XML); 253 const launcherRoundPath = path.resolve(anyDpiV26Directory, IC_LAUNCHER_ROUND_XML); 254 if (add) { 255 await Promise.all([ 256 fs.writeFile(launcherPath, icLauncherXmlString), 257 fs.writeFile(launcherRoundPath, icLauncherXmlString), 258 ]); 259 } else { 260 // Remove the xml if the icon switches from adaptive to standard. 261 await Promise.all( 262 [launcherPath, launcherRoundPath].map(async (path) => { 263 if (fs.existsSync(path)) { 264 return fs.remove(path); 265 } 266 }) 267 ); 268 } 269} 270 271async function generateMultiLayerImageAsync( 272 projectRoot: string, 273 { 274 icon, 275 backgroundColor, 276 backgroundImage, 277 imageCacheFolder, 278 backgroundImageCacheFolder, 279 borderRadiusRatio, 280 outputImageFileName, 281 backgroundImageFileName, 282 }: { 283 icon: string; 284 backgroundImage: string | null; 285 backgroundColor: string | null; 286 imageCacheFolder: string; 287 backgroundImageCacheFolder: string; 288 backgroundImageFileName?: string; 289 borderRadiusRatio?: number; 290 outputImageFileName: string; 291 } 292) { 293 await iterateDpiValues(projectRoot, async ({ dpiFolder, scale }) => { 294 let iconLayer = await generateIconAsync(projectRoot, { 295 cacheType: imageCacheFolder, 296 src: icon, 297 scale, 298 // backgroundImage overrides backgroundColor 299 backgroundColor: backgroundImage ? 'transparent' : backgroundColor ?? 'transparent', 300 borderRadiusRatio, 301 }); 302 303 if (backgroundImage) { 304 const backgroundLayer = await generateIconAsync(projectRoot, { 305 cacheType: backgroundImageCacheFolder, 306 src: backgroundImage, 307 scale, 308 backgroundColor: 'transparent', 309 borderRadiusRatio, 310 }); 311 312 if (backgroundImageFileName) { 313 await fs.writeFile(path.resolve(dpiFolder, backgroundImageFileName), backgroundLayer); 314 } else { 315 iconLayer = await compositeImagesAsync({ 316 foreground: iconLayer, 317 background: backgroundLayer, 318 }); 319 } 320 } else if (backgroundImageFileName) { 321 // Remove any instances of ic_launcher_background.png that are there from previous icons 322 await deleteIconNamedAsync(projectRoot, backgroundImageFileName); 323 } 324 325 await fs.ensureDir(dpiFolder); 326 await fs.writeFile(path.resolve(dpiFolder, outputImageFileName), iconLayer); 327 }); 328} 329 330async function generateMonochromeImageAsync( 331 projectRoot: string, 332 { 333 icon, 334 imageCacheFolder, 335 outputImageFileName, 336 }: { icon: string; imageCacheFolder: string; outputImageFileName: string } 337) { 338 await iterateDpiValues(projectRoot, async ({ dpiFolder, scale }) => { 339 const monochromeIcon = await generateIconAsync(projectRoot, { 340 cacheType: imageCacheFolder, 341 src: icon, 342 scale, 343 backgroundColor: 'transparent', 344 }); 345 await fs.ensureDir(dpiFolder); 346 await fs.writeFile(path.resolve(dpiFolder, outputImageFileName), monochromeIcon); 347 }); 348} 349 350function iterateDpiValues( 351 projectRoot: string, 352 callback: (value: { dpiFolder: string; folderName: string; scale: number }) => Promise<void> 353) { 354 return Promise.all( 355 Object.values(dpiValues).map((value) => 356 callback({ 357 dpiFolder: path.resolve(projectRoot, ANDROID_RES_PATH, value.folderName), 358 ...value, 359 }) 360 ) 361 ); 362} 363 364async function deleteIconNamedAsync(projectRoot: string, name: string) { 365 return iterateDpiValues(projectRoot, ({ dpiFolder }) => { 366 return fs.remove(path.resolve(dpiFolder, name)); 367 }); 368} 369 370async function generateIconAsync( 371 projectRoot: string, 372 { 373 cacheType, 374 src, 375 scale, 376 backgroundColor, 377 borderRadiusRatio, 378 }: { 379 cacheType: string; 380 src: string; 381 scale: number; 382 backgroundColor: string; 383 borderRadiusRatio?: number; 384 } 385) { 386 const iconSizePx = BASELINE_PIXEL_SIZE * scale; 387 388 return ( 389 await generateImageAsync( 390 { projectRoot, cacheType }, 391 { 392 src, 393 width: iconSizePx, 394 height: iconSizePx, 395 resizeMode: 'cover', 396 backgroundColor, 397 borderRadius: borderRadiusRatio ? iconSizePx * borderRadiusRatio : undefined, 398 } 399 ) 400 ).source; 401} 402