UNPKG

@howincodes/expo-dynamic-app-icon

Version:
697 lines (618 loc) 24.2 kB
import type { ExpoConfig } from "@expo/config"; import { ConfigPlugin, withDangerousMod, withXcodeProject, withAndroidManifest, AndroidConfig, ExportedConfigWithProps, } from "@expo/config-plugins"; import { generateImageAsync } from "@expo/image-utils"; import * as fs from "fs"; import * as path from "path"; const moduleRoot = path.join(__dirname, "..", ".."); const { getMainApplicationOrThrow, getMainActivityOrThrow } = AndroidConfig.Manifest; const ANDROID_FOLDER_PATH = ["app", "src", "main", "res"]; const ANDROID_FOLDER_NAMES = [ "mipmap-hdpi", "mipmap-mdpi", "mipmap-xhdpi", "mipmap-xxhdpi", "mipmap-xxxhdpi", ]; const ANDROID_SIZES = [162, 108, 216, 324, 432]; /** The default icon folder name to export to */ const IOS_ASSETS_FOLDER_NAME = "Images.xcassets"; /** * The default icon dimensions to export. * * @see https://developer.apple.com/design/human-interface-guidelines/app-icons#iOS-iPadOS-app-icon-sizes */ const IOS_ICON_DIMENSIONS: IconDimensions[] = [ // iPhone, iPad, MacOS { scale: 1, size: 1024 }, ]; type IconDimensions = { /** The scale of the icon itself, affets file name and width/height when omitted. */ scale: number; /** Both width and height of the icon, affects file name only. */ size: number; /** The width, in pixels, of the icon. Generated from `size` + `scale` when omitted */ width?: number; /** The height, in pixels, of the icon. Generated from `size` + `scale` when omitted */ height?: number; /** Special target of the icon dimension, if any */ target?: null | "ipad"; }; type IconVariant = "light" | "dark" | "tinted"; interface AssetImage { filename?: string; idiom: "universal"; platform: "ios"; size: string; appearances?: { appearance: "luminosity"; value: IconVariant }[]; } type AndroidAdaptiveIconConfig = { foregroundImage: string; backgroundColor: string; }; type IconSet = Record<string, IconSetProps>; type IosIconSet = string | { light: string; dark?: string; tinted?: string }; type IconSetProps = { ios?: IosIconSet; android?: string | AndroidAdaptiveIconConfig; }; type Props = { icons: IconSet; dimensions: Required<IconDimensions>[]; }; const withDynamicIcon: ConfigPlugin<string[] | IconSet | void> = ( config, props = {} ) => { const icons = resolveIcons(props); const dimensions = resolveIconDimensions(config); config = withGenerateTypes(config, { icons }); // for ios config = withIconXcodeProject(config, { icons, dimensions }); config = withIconImages(config, { icons, dimensions }); // for android config = withIconAndroidManifest(config, { icons, dimensions }); config = withIconAndroidImages(config, { icons, dimensions }); return config; }; // ============================================================================= // TypeScript // ============================================================================= function withGenerateTypes(config: ExpoConfig, props: { icons: IconSet }) { const names = Object.keys(props.icons); const union = names.map((name) => `"${name}"`).join(" | ") || "string"; const unionType = `IconName: ${union}`; const buildDir = path.join(moduleRoot, "build"); const buildFile = path.join(buildDir, "types.d.ts"); // Create build directory if it doesn't exist if (!fs.existsSync(buildDir)) { fs.mkdirSync(buildDir, { recursive: true }); } if (fs.existsSync(buildFile)) { const buildFileContent = fs.readFileSync(buildFile, "utf8"); const updatedContent = buildFileContent.replace(/IconName:\s.*/, unionType); fs.writeFileSync(buildFile, updatedContent); } else { // Create the types file if it doesn't exist const content = `export interface DynamicAppIconRegistry {\n ${unionType};\n}\n`; fs.writeFileSync(buildFile, content); } return config; } // ============================================================================= // Android // ============================================================================= const getSafeResourceName = (name: string) => { return name.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase(); }; function validateImagePath(projectRoot: string, imagePath: string, iconKey: string, platform: string): string { const resolvedPath = path.resolve(projectRoot, imagePath); if (!fs.existsSync(resolvedPath)) { throw new Error( `[expo-dynamic-app-icon] Icon "${iconKey}" references image "${imagePath}" for ${platform} which does not exist. Resolved path: ${resolvedPath}` ); } return resolvedPath; } const withIconAndroidManifest: ConfigPlugin<Props> = (config, { icons }) => { const appName = config.name; return withAndroidManifest(config, (config) => { const mainApplication: any = getMainApplicationOrThrow(config.modResults); const mainActivity = getMainActivityOrThrow(config.modResults); const iconNamePrefix = `${config.android!.package}.MainActivity`; const iconNames = Object.keys(icons); function addIconActivityAlias(config: any[]): any[] { return [ ...config, ...iconNames.map((iconKey) => { const iconProps = icons[iconKey]; const androidConfig = iconProps.android; const safeIconKey = getSafeResourceName(iconKey); let iconResourceName: string; let roundIconResourceName: string; if (typeof androidConfig === "object" && androidConfig !== null) { // Adaptive icon iconResourceName = `@mipmap/ic_launcher_adaptive_${safeIconKey}`; roundIconResourceName = `@mipmap/ic_launcher_adaptive_${safeIconKey}`; } else { // Legacy icon (or if androidConfig is a string) iconResourceName = `@mipmap/${safeIconKey}`; roundIconResourceName = `@mipmap/${safeIconKey}_round`; } return { $: { "android:name": `${iconNamePrefix}${iconKey}`, "android:enabled": "false", "android:exported": "true", "android:icon": iconResourceName, "android:label": mainActivity.$["android:label"] || mainApplication.$["android:label"] || appName || "", "android:targetActivity": ".MainActivity", "android:roundIcon": roundIconResourceName, }, "intent-filter": [ ...(mainActivity["intent-filter"] || [ { action: [ { $: { "android:name": "android.intent.action.MAIN" } }, ], category: [ { $: { "android:name": "android.intent.category.LAUNCHER" }, }, ], }, ]), ], }; }), ]; } function removeIconActivityAlias(currentActivityAliases: any[]): any[] { return currentActivityAliases.filter( (activityAlias) => !(activityAlias.$["android:name"] as string).startsWith( iconNamePrefix ) ); } let activityAliases = mainApplication["activity-alias"] || []; activityAliases = removeIconActivityAlias(activityAliases); activityAliases = addIconActivityAlias(activityAliases); mainApplication["activity-alias"] = activityAliases; return config; // Return the modified config object itself }); }; const withIconAndroidImages: ConfigPlugin<Props> = (config, { icons }) => { return withDangerousMod(config, [ "android", async (config) => { const androidResPath = path.join( config.modRequest.platformProjectRoot, ...ANDROID_FOLDER_PATH ); const drawableDirPath = path.join(androidResPath, "drawable"); const mipmapAnyDpiV26DirPath = path.join( androidResPath, "mipmap-anydpi-v26" ); // Ensure directories exist await fs.promises.mkdir(drawableDirPath, { recursive: true }); await fs.promises.mkdir(mipmapAnyDpiV26DirPath, { recursive: true }); const removeIconRes = async () => { // Clean up legacy mipmap-*dpi folders for (const folderName of ANDROID_FOLDER_NAMES) { const folderPath = path.join(androidResPath, folderName); const files = await fs.promises.readdir(folderPath).catch(() => []); for (const file of files) { // Avoid deleting main ic_launcher files, only those generated by this plugin (conventionally named) // This logic might need refinement to be more precise about what this plugin generated previously. if ( !file.startsWith("ic_launcher.") && !file.startsWith("ic_launcher_round.") ) { const isPluginGenerated = Object.keys(icons).some( (iconKey) => file.startsWith(`${getSafeResourceName(iconKey)}.png`) || file.startsWith(`${getSafeResourceName(iconKey)}_round.png`) ); if (isPluginGenerated) { await fs.promises .rm(path.join(folderPath, file), { force: true }) .catch(() => null); } } } } // Clean up adaptive icon files from drawable and mipmap-anydpi-v26 // This assumes a naming convention for plugin-generated adaptive icons. const drawableFiles = await fs.promises .readdir(drawableDirPath) .catch(() => []); for (const file of drawableFiles) { if ( Object.keys(icons).some((iconKey) => file.startsWith( `ic_launcher_adaptive_${getSafeResourceName(iconKey)}_` ) ) ) { await fs.promises .rm(path.join(drawableDirPath, file), { force: true }) .catch(() => null); } } const mipmapAnyDpiFiles = await fs.promises .readdir(mipmapAnyDpiV26DirPath) .catch(() => []); for (const file of mipmapAnyDpiFiles) { if ( Object.keys(icons).some((iconKey) => file.startsWith( `ic_launcher_adaptive_${getSafeResourceName(iconKey)}.xml` ) ) ) { await fs.promises .rm(path.join(mipmapAnyDpiV26DirPath, file), { force: true }) .catch(() => null); } } }; const addIconRes = async () => { for (const [iconConfigName, iconProps] of Object.entries(icons) as [string, IconSetProps][]) { const androidConfig = iconProps.android; if (typeof androidConfig === "object" && androidConfig !== null) { // Handle Adaptive Icons const safeIconKey = getSafeResourceName(iconConfigName); const foregroundBaseName = `ic_launcher_adaptive_${safeIconKey}_fg`; const backgroundBaseName = `ic_launcher_adaptive_${safeIconKey}_bg`; const adaptiveIconBaseName = `ic_launcher_adaptive_${safeIconKey}`; // Foreground Image validateImagePath(config.modRequest.projectRoot, androidConfig.foregroundImage, iconConfigName, "android"); const foregroundImageSrc = path.resolve( config.modRequest.projectRoot, androidConfig.foregroundImage ); const foregroundImageDest = path.join( drawableDirPath, `${foregroundBaseName}.png` ); const { source: foregroundSource } = await generateImageAsync( { projectRoot: config.modRequest.projectRoot, cacheType: `expo-dynamic-app-icon-fg-${safeIconKey}`, }, { src: foregroundImageSrc, removeTransparency: false, backgroundColor: "transparent", // Ensure transparency is kept width: 432, // Standard dimension for foreground assets height: 432, resizeMode: "contain", // Preserve aspect ratio within the bounds } ); await fs.promises.writeFile(foregroundImageDest, foregroundSource); // Background Color Drawable if (!/^#[0-9A-Fa-f]{3,8}$/.test(androidConfig.backgroundColor)) { throw new Error( `[expo-dynamic-app-icon] Icon "${iconConfigName}" has invalid backgroundColor "${androidConfig.backgroundColor}". Must be a hex color (e.g., "#FFFFFF" or "#FF000000").` ); } const backgroundColorXml = `<shape xmlns:android="http://schemas.android.com/apk/res/android">\n <solid android:color="${androidConfig.backgroundColor}" />\n</shape>`; const backgroundDrawablePath = path.join( drawableDirPath, `${backgroundBaseName}.xml` ); await fs.promises.writeFile( backgroundDrawablePath, backgroundColorXml ); // Adaptive Icon XML const adaptiveIconXml = `<?xml version="1.0" encoding="utf-8"?>\n<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">\n <background android:drawable="@drawable/${backgroundBaseName}" />\n <foreground android:drawable="@drawable/${foregroundBaseName}" />\n</adaptive-icon>`; const adaptiveIconXmlPath = path.join( mipmapAnyDpiV26DirPath, `${adaptiveIconBaseName}.xml` ); await fs.promises.writeFile(adaptiveIconXmlPath, adaptiveIconXml); } else if (typeof androidConfig === "string") { // Handle Legacy Icons (existing logic) validateImagePath(config.modRequest.projectRoot, androidConfig, iconConfigName, "android"); for (let i = 0; ANDROID_FOLDER_NAMES.length > i; i += 1) { const size = ANDROID_SIZES[i]; const outputPath = path.join( androidResPath, ANDROID_FOLDER_NAMES[i] ); const safeIconKey = getSafeResourceName(iconConfigName); // Use the same safe name // Square ones const fileNameSquare = `${safeIconKey}.png`; const { source: sourceSquare } = await generateImageAsync( { projectRoot: config.modRequest.projectRoot, cacheType: `expo-dynamic-app-icon-${safeIconKey}-${size}`, }, { name: fileNameSquare, src: path.resolve(config.modRequest.projectRoot, androidConfig), removeTransparency: true, backgroundColor: "#ffffff", resizeMode: "cover", width: size, height: size, } ); await fs.promises.writeFile( path.join(outputPath, fileNameSquare), sourceSquare ); // Round ones const fileNameRound = `${safeIconKey}_round.png`; const { source: sourceRound } = await generateImageAsync( { projectRoot: config.modRequest.projectRoot, cacheType: `expo-dynamic-app-icon-round-${safeIconKey}-${size}`, }, { name: fileNameRound, src: path.resolve(config.modRequest.projectRoot, androidConfig), removeTransparency: true, backgroundColor: "#ffffff", resizeMode: "cover", width: size, height: size, borderRadius: size / 2, } ); await fs.promises.writeFile( path.join(outputPath, fileNameRound), sourceRound ); } } } }; await removeIconRes(); await addIconRes(); return config; }, ]); }; // ============================================================================= // iOS // ============================================================================= const withIconXcodeProject: ConfigPlugin<Props> = ( config, { icons, dimensions } ) => { return withXcodeProject(config, async (config: any) => { const project = config.modResults; // Remove old settings const configurations = project.hash.project.objects["XCBuildConfiguration"]; for (const id of Object.keys(configurations)) { const configuration = project.hash.project.objects["XCBuildConfiguration"][id]; if (typeof configuration !== "object") continue; const buildSettings = configuration.buildSettings; delete buildSettings["ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES"]; delete buildSettings["ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS"]; delete buildSettings["ASSETCATALOG_COMPILER_APPICON_NAME"]; project.hash.project.objects["XCBuildConfiguration"][id].buildSettings = buildSettings; } // Add new settings for (const id of Object.keys(configurations)) { const configuration = project.hash.project.objects["XCBuildConfiguration"][id]; if (typeof configuration !== "object") continue; const buildSettings = configuration.buildSettings; // Include all AppIcon assets buildSettings["ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS"] = "YES"; // Include all alternate AppIcon names const names: string[] = []; await iterateIconsAndDimensionsAsync( { icons, dimensions }, async (key) => { const iconName = getIconName(key); names.push(iconName); } ); buildSettings["ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES"] = JSON.stringify(names.join(" ")); // Include default icon buildSettings["ASSETCATALOG_COMPILER_APPICON_NAME"] = "AppIcon"; project.hash.project.objects["XCBuildConfiguration"][id].buildSettings = buildSettings; } return config; }); }; const withIconImages: ConfigPlugin<Props> = (config, { icons, dimensions }: any) => { return withDangerousMod(config, [ "ios", async (config: any) => { const iosRoot = path.join( config.modRequest.platformProjectRoot, config.modRequest.projectName! ); await iterateIconsAndDimensionsAsync( { icons, dimensions }, async (key, { icon, dimension }) => { if (!icon.ios) return; // Clean the old AppIcon-*.appiconset const iconsetPath = path.join( IOS_ASSETS_FOLDER_NAME, `${getIconName(key)}.appiconset` ); const outputIconsetPath = path.join(iosRoot, iconsetPath); await fs.promises .rm(outputIconsetPath, { recursive: true, force: true, }) .catch(() => null); await fs.promises.mkdir(outputIconsetPath, { recursive: true }); // Generate the Contents.json file const contents = generateIconsetContents(icon.ios, key, dimension); const outputContentsPath = path.join( outputIconsetPath, "Contents.json" ); await fs.promises.writeFile( outputContentsPath, JSON.stringify(contents, null, 2) ); const images = typeof icon.ios === "string" ? { light: icon.ios } : icon.ios; // Generate the assets for each variant for (const [variant, icon] of Object.entries(images)) { validateImagePath(config.modRequest.projectRoot, icon, `${key}/${variant}`, "ios"); const iconFileName = getIconAssetFileName( key, variant as IconVariant, dimension ); const isTransparent = variant === "dark"; const { source } = await generateImageAsync( { projectRoot: config.modRequest.projectRoot, cacheType: `expo-dynamic-app-icon-${key}-${variant}-${dimension.width}-${dimension.height}`, }, { name: iconFileName, src: icon, removeTransparency: !isTransparent, backgroundColor: isTransparent ? "transparent" : "#ffffff", resizeMode: "cover", width: dimension.width, height: dimension.height, } ); const outputAssetPath = path.join(outputIconsetPath, iconFileName); await fs.promises.writeFile(outputAssetPath, source); } } ); return config; }, ]); }; /** Resolve and sanitize the icon set from config plugin props. */ function resolveIcons(props: string[] | IconSet | void): Props["icons"] { let icons: Props["icons"] = {}; if (Array.isArray(props)) { icons = props.reduce( (prev, curr, i) => ({ ...prev, [i]: { image: curr } }), {} ); } else if (props) { icons = props; } return icons; } /** Resolve the required icon dimension/target based on the app config. */ function resolveIconDimensions(config: ExpoConfig): Required<IconDimensions>[] { const targets: NonNullable<IconDimensions["target"]>[] = []; if (config.ios?.supportsTablet) { targets.push("ipad"); } return IOS_ICON_DIMENSIONS.filter( ({ target }) => !target || targets.includes(target) ).map((dimension) => ({ ...dimension, target: dimension.target ?? null, width: dimension.width ?? dimension.size * dimension.scale, height: dimension.height ?? dimension.size * dimension.scale, })); } /** Get the icon name, used to refer to the icon from within the plist */ function getIconName(name: string) { return `AppIcon-${name}`; } /** Get the icon asset file name */ function getIconAssetFileName( key: string, variant: IconVariant, dimension: Required<IconDimensions> ) { const name = `${getIconName(key)}-${variant}`; const size = `${dimension.size}x${dimension.size}@${dimension.scale}x`; return `${name}-${size}.png`; } /** Generate the Contents.json for an icon set */ function generateIconsetContents( iconset: IosIconSet, key: string, dimension: Required<IconDimensions> ) { const lightFileName = getIconAssetFileName(key, "light", dimension); const images: AssetImage[] = [ { filename: lightFileName, idiom: "universal", platform: "ios", size: `${dimension.size}x${dimension.size}`, }, ]; if (typeof iconset === "object" && iconset.dark) { const darkFileName = getIconAssetFileName(key, "dark", dimension); images.push({ filename: darkFileName, idiom: "universal", platform: "ios", size: `${dimension.size}x${dimension.size}`, appearances: [ { appearance: "luminosity", value: "dark", }, ], }); } if (typeof iconset === "object" && iconset.tinted) { const tintedFileName = getIconAssetFileName(key, "tinted", dimension); images.push({ filename: tintedFileName, idiom: "universal", platform: "ios", size: `${dimension.size}x${dimension.size}`, appearances: [ { appearance: "luminosity", value: "tinted", }, ], }); } return { images, info: { version: 1, author: "expo", }, }; } /** Iterate all combinations of icons and dimensions to export */ async function iterateIconsAndDimensionsAsync( { icons, dimensions }: Props, callback: ( iconKey: string, iconAndDimension: { icon: Props["icons"][string]; dimension: Props["dimensions"][0]; } ) => Promise<void> ) { for (const [iconKey, icon] of Object.entries(icons)) { for (const dimension of dimensions) { await callback(iconKey, { icon, dimension }); } } } export default withDynamicIcon;