Skip to content

Commit

Permalink
feat(icon): basic implementation of adaptive icon generation for Android
Browse files Browse the repository at this point in the history
release-npm
  • Loading branch information
tobua committed Dec 16, 2022
1 parent 799cab9 commit bd33fe2
Show file tree
Hide file tree
Showing 7 changed files with 264 additions and 26 deletions.
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<img align="right" src="https://github.com/tobua/icon-numic-plugin/raw/main/logo.png" width="20%" alt="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

Expand Down Expand Up @@ -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.

110 changes: 110 additions & 0 deletions adaptive-icon.ts
Original file line number Diff line number Diff line change
@@ -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: `<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>`,
},
{
path: 'android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml',
contents: `<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>`,
},
]

// 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)
}
}
37 changes: 25 additions & 12 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => [
Expand Down Expand Up @@ -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 []
}
Expand All @@ -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')
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
)
}
}
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
}
},
"dependencies": {
"sharp": "^0.30.7"
"sharp": "^0.31.2",
"svg2vectordrawable": "^2.9.1"
},
"peerDependencies": {
"numic": ">= 0.3"
Expand All @@ -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": {
Expand Down
82 changes: 82 additions & 0 deletions test/adaptive.test.ts
Original file line number Diff line number Diff line change
@@ -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('<vector')
expect(drawableBackgroundContents).toContain('<?xml')

expect(drawableForegroundContents).toContain('<vector')
expect(drawableForegroundContents).toContain('<?xml')
})

test('Detects file type from name.', () => {
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')
})
15 changes: 15 additions & 0 deletions test/background.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
18 changes: 9 additions & 9 deletions test/logo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,9 +149,9 @@ test('iOS background transparency can be configured.', async () => {
)

expect(existsSync(someIOSIcon)).toBe(true)
let pixels = await new Promise<number[]>((done) =>
getPixels(someIOSIcon, (_, pixels: any) => done(pixels.data))
)
let pixels = await new Promise<number[]>((done) => {
getPixels(someIOSIcon, (_, currentPixels: any) => done(currentPixels.data))
})

expect(pixels[0]).toBe(255) // Red
expect(pixels[1]).toBe(255) // Green
Expand All @@ -164,9 +164,9 @@ test('iOS background transparency can be configured.', async () => {
},
})

pixels = await new Promise<number[]>((done) =>
getPixels(someIOSIcon, (_, pixels: any) => done(pixels.data))
)
pixels = await new Promise<number[]>((done) => {
getPixels(someIOSIcon, (_, currentPixels: any) => done(currentPixels.data))
})

expect(pixels[0]).toBe(0) // Red
expect(pixels[1]).toBe(0) // Green
Expand All @@ -179,9 +179,9 @@ test('iOS background transparency can be configured.', async () => {
},
})

pixels = await new Promise<number[]>((done) =>
getPixels(someIOSIcon, (_, pixels: any) => done(pixels.data))
)
pixels = await new Promise<number[]>((done) => {
getPixels(someIOSIcon, (_, currentPixels: any) => done(currentPixels.data))
})

expect(pixels[0]).toBe(255) // Red
expect(pixels[1]).toBe(255) // Green
Expand Down

0 comments on commit bd33fe2

Please sign in to comment.