From bd33fe22085899f9bb87051fe7b0ee41984382ea Mon Sep 17 00:00:00 2001 From: Matthias Giger Date: Fri, 16 Dec 2022 18:15:43 +0100 Subject: [PATCH] feat(icon): basic implementation of adaptive icon generation for Android release-npm --- README.md | 21 +++++++- adaptive-icon.ts | 110 ++++++++++++++++++++++++++++++++++++++++++ index.ts | 37 +++++++++----- package.json | 7 +-- test/adaptive.test.ts | 82 +++++++++++++++++++++++++++++++ test/background.svg | 15 ++++++ test/logo.test.ts | 18 +++---- 7 files changed, 264 insertions(+), 26 deletions(-) create mode 100644 adaptive-icon.ts create mode 100644 test/adaptive.test.ts create mode 100644 test/background.svg diff --git a/README.md b/README.md index 5dd3d91..a707017 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Icon Numic Plugin Logo -Numic plugin for React Native to automatically generate iOS and Android app icons from a single file. Commit only one 1024x1024 file of your app icon but get all sizes automatically. +Numic plugin for React Native to automatically generate iOS and Android app icons from a single file. Commit only one 1024x1024 file of your app icon but get all sizes automatically. Also supports the generation of adaptive icons for Android. ## Installation @@ -30,8 +30,25 @@ The icon can be configured in `package.json` under the `numic` property. This wi "icon-numic-plugin": { "icon": "image/my-icon.png", // Convert transparent icons to a black background for iOS, default white. - "iOSBackground": "#000000" + "iOSBackground": "#000000", + // Generate Android adaptive icons from SVG images. + "androidForeground": "image/my-adaptive-foreground.svg", + "androidBackground": "image/my-adaptive-background.svg", + // Pass native Android vector drawables in XML format. + "androidForeground": "image/my-adaptive-foreground.xml", + "androidBackground": "image/my-adaptive-background.xml", + // Instead of "androidBackground" it's possible to just set a solid color. + "androidBackgroundColor": "red", } } } ``` + +## Adaptive Icons for Android + +Adaptive icons use vector graphics and are composed of a foreground icon and a background image. Due to using vector graphics only one image is required. Using this plugin will also generate all the required configuration files as well as the default legacy icons for older devices. + +For web developers the easiest way to generate the vector drawables used on Android for adaptive icons is to convert from an SVG. + +When using the default Andriod XML format to create the images it's best to do this in Android Studio. Opening the `/android` folder there will allow a direct preview of the graphics. + diff --git a/adaptive-icon.ts b/adaptive-icon.ts new file mode 100644 index 0000000..9d19350 --- /dev/null +++ b/adaptive-icon.ts @@ -0,0 +1,110 @@ +import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs' +import { dirname, join, extname } from 'path' +import svg2vectordrawable from 'svg2vectordrawable' +import { Log, Options } from './index' + +const androidXMLFiles = () => [ + { + path: 'android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml', + contents: ` + + + + `, + }, + { + path: 'android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml', + contents: ` + + + + `, + }, +] + +// Options see https://www.npmjs.com/package/svg2vectordrawable +const convertSVG = async (svgContents: string) => { + return svg2vectordrawable(svgContents, { xmlTag: true }) +} + +export const getFileType = (fileName?: string) => extname(fileName ?? '').replace('.', '') + +const writeResFile = (nativePath: string, path: string, contents: string) => { + const destinationFile = join(nativePath, 'android/app/src/main/res', path) + const directory = dirname(destinationFile) + if (!existsSync(directory)) { + mkdirSync(directory, { recursive: true }) + } + writeFileSync(destinationFile, contents) +} + +export const generateAndroidAdaptiveIcons = async ( + nativePath: string, + options: Options, + log: Log +) => { + const xmlFiles = androidXMLFiles() + + if ( + !options.androidForeground || + (!options.androidBackground && !options.androidBackgroundColor) + ) { + log('Not creating adaptive icons for Android') + return + } + + const foregroundType = getFileType(options.androidForeground) + const backgroundType = !options.androidBackgroundColor && getFileType(options.androidBackground) + + if (foregroundType !== 'svg' && foregroundType !== 'xml') { + log('"androidForeground" invalid, .svg or .xml file required') + } + + if (!options.androidBackgroundColor && backgroundType !== 'svg' && backgroundType !== 'xml') { + log('"androidBackground" invalid, .svg or .xml file required') + } + + // Create importing files. + xmlFiles.forEach((file) => { + const destinationFile = join(nativePath, file.path) + const directory = dirname(destinationFile) + if (!existsSync(directory)) { + mkdirSync(directory, { recursive: true }) + } + writeFileSync(destinationFile, file.contents) + }) + + if (foregroundType === 'svg') { + const foregroundSVGContents = readFileSync( + join(process.cwd(), options.androidForeground), + 'utf-8' + ) + const foregroundVector = await convertSVG(foregroundSVGContents) + writeResFile(nativePath, 'drawable-v24/ic_launcher_foreground.xml', foregroundVector) + } + + if (foregroundType === 'xml') { + const foregroundXMLContents = readFileSync( + join(process.cwd(), options.androidForeground), + 'utf-8' + ) + writeResFile(nativePath, 'drawable-v24/ic_launcher_foreground.xml', foregroundXMLContents) + } + + if (!options.androidBackgroundColor && backgroundType === 'svg') { + const backgroundSVGContents = readFileSync( + join(process.cwd(), options.androidBackground as string), + 'utf-8' + ) + const backgroundVector = await convertSVG(backgroundSVGContents) + writeResFile(nativePath, 'drawable/ic_launcher_background.xml', backgroundVector) + } + + if (!options.androidBackgroundColor && backgroundType === 'xml') { + const backgroundXMLContents = readFileSync( + join(process.cwd(), options.androidBackground as string), + 'utf-8' + ) + writeResFile(nativePath, 'drawable/ic_launcher_background.xml', backgroundXMLContents) + } +} diff --git a/index.ts b/index.ts index f34a00f..108f339 100644 --- a/index.ts +++ b/index.ts @@ -2,15 +2,24 @@ import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'fs' import { join, dirname } from 'path' // Alternative in Rust: https://github.com/silvia-odwyer/photon import sharp from 'sharp' +import { generateAndroidAdaptiveIcons } from './adaptive-icon' import { contentsWithLinks } from './ios' -// https://github.com/aeirola/react-native-svg-app-icon +export interface Options { + iOSBackground?: string + icon?: string + androidForeground?: string + androidBackground?: string + androidBackgroundColor?: string +} + +export type Log = (message: string, type?: 'warning' | 'error') => void type Input = { projectPath?: string nativePath?: string - log?: (message: string, type?: string) => void - options?: { iOSBackground?: string; icon?: string } + log?: Log + options?: Options } const iconSourcePaths = (projectPath: string) => [ @@ -59,7 +68,7 @@ const getAndroidFolders = () => [ { path: 'android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png', size: 192, round: true }, ] -const getIOSFolders = (iosImageDirectory: string) => { +const getIOSFolders = (iosImageDirectory?: string) => { if (!iosImageDirectory) { return [] } @@ -77,13 +86,13 @@ const getIOSFolders = (iosImageDirectory: string) => { ] } -const getSizes = ({ nativePath, log }: Input) => { +const getSizes = (nativePath: string, log: Log) => { const iosDirectories = readdirSync(join(nativePath, 'ios'), { withFileTypes: true }) .filter((dirent) => dirent.isDirectory()) .filter((dirent) => existsSync(join(nativePath, 'ios', dirent.name, 'Images.xcassets'))) .map((dirent) => dirent.name) const iosImageDirectory = - iosDirectories.length > 0 ? join('ios', iosDirectories[0], 'Images.xcassets') : null + iosDirectories.length > 0 ? join('ios', iosDirectories[0], 'Images.xcassets') : undefined if (!iosImageDirectory) { log('iOS project directory with "Images.xcassets" not found', 'warning') @@ -104,7 +113,7 @@ export default async ({ options = {}, }: Input) => { const inputFile = getInput(projectPath, options) - const sizes = getSizes({ nativePath, projectPath, log, options }) + const sizes = getSizes(nativePath, log) const androidPromises = sizes.android.map((icon) => { const destinationFile = join(nativePath, icon.path) @@ -117,6 +126,8 @@ export default async ({ await Promise.all(androidPromises) + await generateAndroidAdaptiveIcons(nativePath, options, log) + const iosPromises = sizes.ios.map((icon) => { const destinationFile = join(nativePath, icon.path) const directory = dirname(destinationFile) @@ -132,9 +143,11 @@ export default async ({ await Promise.all(iosPromises) - // Link ios icons in Contents.json. - writeFileSync( - join(nativePath, sizes.iosDirectory, 'AppIcon.appiconset/Contents.json'), - JSON.stringify(contentsWithLinks, null, 2) - ) + if (sizes.iosDirectory) { + // Link ios icons in Contents.json. + writeFileSync( + join(nativePath, sizes.iosDirectory, 'AppIcon.appiconset/Contents.json'), + JSON.stringify(contentsWithLinks, null, 2) + ) + } } diff --git a/package.json b/package.json index d5d0b99..e4ee7c1 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ } }, "dependencies": { - "sharp": "^0.30.7" + "sharp": "^0.31.2", + "svg2vectordrawable": "^2.9.1" }, "peerDependencies": { "numic": ">= 0.3" @@ -48,11 +49,11 @@ ], "devDependencies": { "@types/get-pixels": "^3.3.2", - "@types/sharp": "^0.30.4", + "@types/sharp": "^0.31.0", "get-pixels": "^3.3.3", "jest-fixture": "^3.0.1", "padua": "^0.6.1", - "vitest": "^0.21.0" + "vitest": "^0.25.8" }, "prettier": "padua/configuration/.prettierrc.json", "eslintConfig": { diff --git a/test/adaptive.test.ts b/test/adaptive.test.ts new file mode 100644 index 0000000..dfb5125 --- /dev/null +++ b/test/adaptive.test.ts @@ -0,0 +1,82 @@ +import { cpSync, existsSync, mkdirSync } from 'fs' +import { join } from 'path' +import { expect, test, beforeEach, afterEach, vi } from 'vitest' +import { prepare, environment, packageJson, listFilesMatching, readFile, file } from 'jest-fixture' +import plugin from '../index' +import { getFileType } from '../adaptive-icon' + +const initialCwd = process.cwd() + +// @ts-ignore +global.jest = { spyOn: vi.spyOn } +// @ts-ignore +global.beforeEach = beforeEach +// @ts-ignore +global.afterEach = afterEach + +environment('adaptive') + +test('Creates proper description XML files when adaptive icon input is supplied.', async () => { + prepare([packageJson('adaptive'), file('ios/test.xml', '')]) + + // Regular logo, still required. + cpSync(join(initialCwd, 'test/logo.png'), join(process.cwd(), 'logo.png')) + mkdirSync(join(process.cwd(), 'ios/numic/Images.xcassets'), { recursive: true }) + + const backgroundPath = join(process.cwd(), 'image/my-background.svg') + const foregroundPath = join(process.cwd(), 'image/my-foreground.svg') + + cpSync(join(initialCwd, 'test/background.svg'), backgroundPath) + cpSync(join(initialCwd, 'test/logo.svg'), foregroundPath) + + expect(existsSync(backgroundPath)).toBe(true) + expect(existsSync(foregroundPath)).toBe(true) + + await plugin({ + options: { + androidBackground: 'image/my-background.svg', + androidForeground: 'image/my-foreground.svg', + }, + }) + + const iosPngImages = listFilesMatching('ios/**/*.png') + const androidPngImages = listFilesMatching('android/**/*.png') + + // Regular icons still generated. + expect(iosPngImages.length + androidPngImages.length).toBe(19) + + const androidXMLFiles = listFilesMatching('android/app/src/main/res/**/*.xml') + + expect( + androidXMLFiles.includes('android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml') + ).toBe(true) + expect( + androidXMLFiles.includes('android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml') + ).toBe(true) + + const adaptiveLauncherIconContents = readFile( + 'android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml' + ) + + expect(adaptiveLauncherIconContents).toContain('adaptive-icon') + + const drawableBackgroundContents = readFile( + 'android/app/src/main/res/drawable/ic_launcher_background.xml' + ) + const drawableForegroundContents = readFile( + 'android/app/src/main/res/drawable-v24/ic_launcher_foreground.xml' + ) + + expect(drawableBackgroundContents).toContain(' { + expect(getFileType('image/my-image.svg')).toBe('svg') + expect(getFileType('another/path/somevectordrawable.xml')).toBe('xml') + expect(getFileType('/Absolute/path/somevectordrawable.xml')).toBe('xml') + expect(getFileType('./relative/path/some-svg.svg')).toBe('svg') +}) diff --git a/test/background.svg b/test/background.svg new file mode 100644 index 0000000..e7dfbec --- /dev/null +++ b/test/background.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/test/logo.test.ts b/test/logo.test.ts index bd3a79a..4e524a3 100644 --- a/test/logo.test.ts +++ b/test/logo.test.ts @@ -149,9 +149,9 @@ test('iOS background transparency can be configured.', async () => { ) expect(existsSync(someIOSIcon)).toBe(true) - let pixels = await new Promise((done) => - getPixels(someIOSIcon, (_, pixels: any) => done(pixels.data)) - ) + let pixels = await new Promise((done) => { + getPixels(someIOSIcon, (_, currentPixels: any) => done(currentPixels.data)) + }) expect(pixels[0]).toBe(255) // Red expect(pixels[1]).toBe(255) // Green @@ -164,9 +164,9 @@ test('iOS background transparency can be configured.', async () => { }, }) - pixels = await new Promise((done) => - getPixels(someIOSIcon, (_, pixels: any) => done(pixels.data)) - ) + pixels = await new Promise((done) => { + getPixels(someIOSIcon, (_, currentPixels: any) => done(currentPixels.data)) + }) expect(pixels[0]).toBe(0) // Red expect(pixels[1]).toBe(0) // Green @@ -179,9 +179,9 @@ test('iOS background transparency can be configured.', async () => { }, }) - pixels = await new Promise((done) => - getPixels(someIOSIcon, (_, pixels: any) => done(pixels.data)) - ) + pixels = await new Promise((done) => { + getPixels(someIOSIcon, (_, currentPixels: any) => done(currentPixels.data)) + }) expect(pixels[0]).toBe(255) // Red expect(pixels[1]).toBe(255) // Green