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