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