1import {
2  AndroidConfig,
3  ConfigPlugin,
4  withDangerousMod,
5  withAndroidManifest,
6  XML,
7} from '@expo/config-plugins';
8import { ExpoConfig } from '@expo/config-types';
9import { generateImageAsync } from '@expo/image-utils';
10import { writeFileSync, unlinkSync, copyFileSync, existsSync, mkdirSync } from 'fs';
11import { basename, resolve } from 'path';
12
13import { NotificationsPluginProps } from './withNotifications';
14
15const { buildResourceItem, readResourcesXMLAsync } = AndroidConfig.Resources;
16const { writeXMLAsync } = XML;
17const { Colors } = AndroidConfig;
18
19type DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi';
20type dpiMap = Record<DPIString, { folderName: string; scale: number }>;
21
22export const ANDROID_RES_PATH = 'android/app/src/main/res/';
23export const dpiValues: dpiMap = {
24  mdpi: { folderName: 'mipmap-mdpi', scale: 1 },
25  hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 },
26  xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 },
27  xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 },
28  xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 },
29};
30const {
31  addMetaDataItemToMainApplication,
32  getMainApplicationOrThrow,
33  removeMetaDataItemFromMainApplication,
34} = AndroidConfig.Manifest;
35const BASELINE_PIXEL_SIZE = 24;
36const ERROR_MSG_PREFIX = 'An error occurred while configuring Android notifications. ';
37export const META_DATA_NOTIFICATION_ICON = 'expo.modules.notifications.default_notification_icon';
38export const META_DATA_NOTIFICATION_ICON_COLOR =
39  'expo.modules.notifications.default_notification_color';
40export const NOTIFICATION_ICON = 'notification_icon';
41export const NOTIFICATION_ICON_RESOURCE = `@drawable/${NOTIFICATION_ICON}`;
42export const NOTIFICATION_ICON_COLOR = 'notification_icon_color';
43export const NOTIFICATION_ICON_COLOR_RESOURCE = `@color/${NOTIFICATION_ICON_COLOR}`;
44
45export const withNotificationIcons: ConfigPlugin<{ icon: string | null }> = (config, { icon }) => {
46  // If no icon provided in the config plugin props, fallback to value from app.json
47  icon = icon || getNotificationIcon(config);
48  return withDangerousMod(config, [
49    'android',
50    async config => {
51      await setNotificationIconAsync(config.modRequest.projectRoot, icon);
52      return config;
53    },
54  ]);
55};
56
57export const withNotificationIconColor: ConfigPlugin<{ color: string | null }> = (
58  config,
59  { color }
60) => {
61  // If no color provided in the config plugin props, fallback to value from app.json
62  color = color || getNotificationColor(config);
63  return withDangerousMod(config, [
64    'android',
65    async config => {
66      await setNotificationIconColorAsync(config.modRequest.projectRoot, color);
67      return config;
68    },
69  ]);
70};
71
72export const withNotificationManifest: ConfigPlugin<{
73  icon: string | null;
74  color: string | null;
75}> = (config, { icon, color }) => {
76  // If no icon or color provided in the config plugin props, fallback to value from app.json
77  icon = icon || getNotificationIcon(config);
78  color = color || getNotificationColor(config);
79  return withAndroidManifest(config, config => {
80    config.modResults = setNotificationConfig({ icon, color }, config.modResults);
81    return config;
82  });
83};
84
85export const withNotificationSounds: ConfigPlugin<{ sounds: string[] }> = (config, { sounds }) => {
86  return withDangerousMod(config, [
87    'android',
88    config => {
89      setNotificationSounds(config.modRequest.projectRoot, sounds);
90      return config;
91    },
92  ]);
93};
94
95export function getNotificationIcon(config: ExpoConfig) {
96  return config.notification?.icon || null;
97}
98
99export function getNotificationColor(config: ExpoConfig) {
100  return config.notification?.color || null;
101}
102
103/**
104 * Applies notification icon configuration for expo-notifications
105 */
106export async function setNotificationIconAsync(projectRoot: string, icon: string | null) {
107  if (icon) {
108    await writeNotificationIconImageFilesAsync(icon, projectRoot);
109  } else {
110    removeNotificationIconImageFiles(projectRoot);
111  }
112}
113
114function setNotificationConfig(
115  props: { icon: string | null; color: string | null },
116  manifest: AndroidConfig.Manifest.AndroidManifest
117) {
118  const mainApplication = getMainApplicationOrThrow(manifest);
119  if (props.icon) {
120    addMetaDataItemToMainApplication(
121      mainApplication,
122      META_DATA_NOTIFICATION_ICON,
123      NOTIFICATION_ICON_RESOURCE,
124      'resource'
125    );
126  } else {
127    removeMetaDataItemFromMainApplication(mainApplication, META_DATA_NOTIFICATION_ICON);
128  }
129  if (props.color) {
130    addMetaDataItemToMainApplication(
131      mainApplication,
132      META_DATA_NOTIFICATION_ICON_COLOR,
133      NOTIFICATION_ICON_COLOR_RESOURCE,
134      'resource'
135    );
136  } else {
137    removeMetaDataItemFromMainApplication(mainApplication, META_DATA_NOTIFICATION_ICON_COLOR);
138  }
139  return manifest;
140}
141
142export async function setNotificationIconColorAsync(projectRoot: string, color: string | null) {
143  const colorsXmlPath = await Colors.getProjectColorsXMLPathAsync(projectRoot);
144  let colorsJson = await readResourcesXMLAsync({ path: colorsXmlPath });
145  if (color) {
146    const colorItemToAdd = buildResourceItem({ name: NOTIFICATION_ICON_COLOR, value: color });
147    colorsJson = Colors.setColorItem(colorItemToAdd, colorsJson);
148  } else {
149    colorsJson = Colors.removeColorItem(NOTIFICATION_ICON_COLOR, colorsJson);
150  }
151  await writeXMLAsync({ path: colorsXmlPath, xml: colorsJson });
152}
153
154async function writeNotificationIconImageFilesAsync(icon: string, projectRoot: string) {
155  await Promise.all(
156    Object.values(dpiValues).map(async ({ folderName, scale }) => {
157      const drawableFolderName = folderName.replace('mipmap', 'drawable');
158      const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName);
159      if (!existsSync(dpiFolderPath)) {
160        mkdirSync(dpiFolderPath, { recursive: true });
161      }
162      const iconSizePx = BASELINE_PIXEL_SIZE * scale;
163
164      try {
165        const resizedIcon = (
166          await generateImageAsync(
167            { projectRoot, cacheType: 'android-notification' },
168            {
169              src: icon,
170              width: iconSizePx,
171              height: iconSizePx,
172              resizeMode: 'cover',
173              backgroundColor: 'transparent',
174            }
175          )
176        ).source;
177        writeFileSync(resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'), resizedIcon);
178      } catch (e) {
179        throw new Error(
180          ERROR_MSG_PREFIX + 'Encountered an issue resizing Android notification icon: ' + e
181        );
182      }
183    })
184  );
185}
186
187function removeNotificationIconImageFiles(projectRoot: string) {
188  Object.values(dpiValues).forEach(async ({ folderName }) => {
189    const drawableFolderName = folderName.replace('mipmap', 'drawable');
190    const dpiFolderPath = resolve(projectRoot, ANDROID_RES_PATH, drawableFolderName);
191    unlinkSync(resolve(dpiFolderPath, NOTIFICATION_ICON + '.png'));
192  });
193}
194
195/**
196 * Save sound files to `<project-root>/android/app/src/main/res/raw`
197 */
198export function setNotificationSounds(projectRoot: string, sounds: string[]) {
199  if (!Array.isArray(sounds)) {
200    throw new Error(
201      ERROR_MSG_PREFIX +
202        `Must provide an array of sound files in your app config, found ${typeof sounds}.`
203    );
204  }
205  for (const soundFileRelativePath of sounds) {
206    writeNotificationSoundFile(soundFileRelativePath, projectRoot);
207  }
208}
209
210/**
211 * Copies the input file to the `<project-root>/android/app/src/main/res/raw` directory if
212 * there isn't already an existing file under that name.
213 */
214function writeNotificationSoundFile(soundFileRelativePath: string, projectRoot: string) {
215  const rawResourcesPath = resolve(projectRoot, ANDROID_RES_PATH, 'raw');
216  const inputFilename = basename(soundFileRelativePath);
217
218  if (inputFilename) {
219    try {
220      const sourceFilepath = resolve(projectRoot, soundFileRelativePath);
221      const destinationFilepath = resolve(rawResourcesPath, inputFilename);
222      if (!existsSync(rawResourcesPath)) {
223        mkdirSync(rawResourcesPath, { recursive: true });
224      }
225      copyFileSync(sourceFilepath, destinationFilepath);
226    } catch (e) {
227      throw new Error(
228        ERROR_MSG_PREFIX + 'Encountered an issue copying Android notification sounds: ' + e
229      );
230    }
231  }
232}
233
234export const withNotificationsAndroid: ConfigPlugin<NotificationsPluginProps> = (
235  config,
236  { icon = null, color = null, sounds = [] }
237) => {
238  config = withNotificationIconColor(config, { color });
239  config = withNotificationIcons(config, { icon });
240  config = withNotificationManifest(config, { icon, color });
241  config = withNotificationSounds(config, { sounds });
242  return config;
243};
244