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