diff --git a/app.config.ts b/app.config.ts index 481e71e..d3edb08 100644 --- a/app.config.ts +++ b/app.config.ts @@ -237,7 +237,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ './plugins/withNotificationSounds.js', './plugins/withMediaButtonModule.js', './plugins/withInCallAudioModule.js', - ['app-icon-badge', appIconBadgeConfig], + ['./plugins/with-app-icon-badge.js', appIconBadgeConfig], ], extra: { ...ClientEnv, diff --git a/plugins/__tests__/with-app-icon-badge.test.ts b/plugins/__tests__/with-app-icon-badge.test.ts new file mode 100644 index 0000000..0ae6ed1 --- /dev/null +++ b/plugins/__tests__/with-app-icon-badge.test.ts @@ -0,0 +1,92 @@ +import type { AppIconBadgeConfig } from 'app-icon-badge/types'; + +const mockExecFileSync = jest.fn(); +const mockExistsSync = jest.fn(() => true); +const mockStatSync = jest.fn(() => ({ size: 100 })); + +jest.mock('child_process', () => ({ + execFileSync: mockExecFileSync, +})); + +jest.mock('fs', () => ({ + existsSync: mockExistsSync, + statSync: mockStatSync, +})); + +interface TestExpoConfig { + _internal?: { + projectRoot?: string; + }; + android?: { + adaptiveIcon?: { + foregroundImage?: string; + }; + }; + icon?: string; + ios?: { + icon?: string; + }; +} + +const withAppIconBadge = jest.requireActual('../with-app-icon-badge.js') as (config: TestExpoConfig, options?: AppIconBadgeConfig) => TestExpoConfig; + +describe('withAppIconBadge', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('generates all configured icons before rewriting their config paths', () => { + const config: TestExpoConfig = { + _internal: { projectRoot: '/project' }, + icon: './assets/icon.png', + ios: { icon: './assets/ios-icon.png' }, + android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } }, + }; + const options: AppIconBadgeConfig = { + enabled: true, + badges: [{ type: 'banner', text: 'development' }], + }; + + const result = withAppIconBadge(config, options); + + expect(mockExecFileSync).toHaveBeenCalledTimes(1); + const generatorArguments = mockExecFileSync.mock.calls[0]?.[1] as string[]; + const payload = JSON.parse(generatorArguments[1] ?? '{}') as { + jobs: { isAdaptiveIcon: boolean; outputPath: string; sourcePath: string }[]; + }; + expect(payload.jobs).toEqual([ + { + sourcePath: '/project/assets/icon.png', + outputPath: '/project/.expo/app-icon-badge/icon.png', + isAdaptiveIcon: false, + }, + { + sourcePath: '/project/assets/ios-icon.png', + outputPath: '/project/.expo/app-icon-badge/ios-icon.png', + isAdaptiveIcon: false, + }, + { + sourcePath: '/project/assets/adaptive-icon.png', + outputPath: '/project/.expo/app-icon-badge/foregroundImage.png', + isAdaptiveIcon: true, + }, + ]); + expect(result.icon).toBe('.expo/app-icon-badge/icon.png'); + expect(result.ios?.icon).toBe('.expo/app-icon-badge/ios-icon.png'); + expect(result.android?.adaptiveIcon?.foregroundImage).toBe('.expo/app-icon-badge/foregroundImage.png'); + }); + + it('leaves icon paths untouched when badges are disabled', () => { + const config: TestExpoConfig = { + _internal: { projectRoot: '/project' }, + icon: './assets/icon.png', + android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } }, + }; + + const result = withAppIconBadge(config, { enabled: false, badges: [] }); + + expect(mockExecFileSync).not.toHaveBeenCalled(); + expect(result.icon).toBe('./assets/icon.png'); + expect(result.android?.adaptiveIcon?.foregroundImage).toBe('./assets/adaptive-icon.png'); + }); +}); diff --git a/plugins/with-app-icon-badge.js b/plugins/with-app-icon-badge.js new file mode 100644 index 0000000..3f690fa --- /dev/null +++ b/plugins/with-app-icon-badge.js @@ -0,0 +1,77 @@ +const { execFileSync } = require('child_process'); +const { existsSync, statSync } = require('fs'); +const { resolve } = require('path'); + +const GENERATED_DIRECTORY = '.expo/app-icon-badge'; +const GENERATED_APP_ICON = `${GENERATED_DIRECTORY}/icon.png`; +const GENERATED_IOS_ICON = `${GENERATED_DIRECTORY}/ios-icon.png`; +const GENERATED_ADAPTIVE_ICON = `${GENERATED_DIRECTORY}/foregroundImage.png`; + +function assertGeneratedImage(projectRoot, imagePath) { + const absolutePath = resolve(projectRoot, imagePath); + if (!existsSync(absolutePath) || statSync(absolutePath).size === 0) { + throw new Error(`App icon badge generator did not create a valid image at ${imagePath}`); + } +} + +function withAppIconBadge(config, options = {}) { + if (options.enabled === false) { + return config; + } + + const projectRoot = config._internal?.projectRoot ?? process.cwd(); + const jobs = []; + + if (config.icon) { + jobs.push({ + sourcePath: resolve(projectRoot, config.icon), + outputPath: resolve(projectRoot, GENERATED_APP_ICON), + isAdaptiveIcon: false, + }); + } + + if (config.ios?.icon) { + jobs.push({ + sourcePath: resolve(projectRoot, config.ios.icon), + outputPath: resolve(projectRoot, GENERATED_IOS_ICON), + isAdaptiveIcon: false, + }); + } + + if (config.android?.adaptiveIcon?.foregroundImage) { + jobs.push({ + sourcePath: resolve(projectRoot, config.android.adaptiveIcon.foregroundImage), + outputPath: resolve(projectRoot, GENERATED_ADAPTIVE_ICON), + isAdaptiveIcon: true, + }); + } + + if (jobs.length === 0) { + return config; + } + + const generatorPath = resolve(__dirname, '../scripts/generate-app-icon-badges.js'); + execFileSync(process.execPath, [generatorPath, JSON.stringify({ badges: options.badges ?? [], jobs })], { + cwd: projectRoot, + stdio: 'inherit', + }); + + if (config.icon) { + assertGeneratedImage(projectRoot, GENERATED_APP_ICON); + config.icon = GENERATED_APP_ICON; + } + + if (config.ios?.icon) { + assertGeneratedImage(projectRoot, GENERATED_IOS_ICON); + config.ios.icon = GENERATED_IOS_ICON; + } + + if (config.android?.adaptiveIcon?.foregroundImage) { + assertGeneratedImage(projectRoot, GENERATED_ADAPTIVE_ICON); + config.android.adaptiveIcon.foregroundImage = GENERATED_ADAPTIVE_ICON; + } + + return config; +} + +module.exports = withAppIconBadge; diff --git a/scripts/generate-app-icon-badges.js b/scripts/generate-app-icon-badges.js new file mode 100644 index 0000000..4375d21 --- /dev/null +++ b/scripts/generate-app-icon-badges.js @@ -0,0 +1,76 @@ +const { copyFileSync, mkdirSync, readFileSync, unlinkSync } = require('fs'); +const { dirname } = require('path'); + +const { addBadge } = require('app-icon-badge'); +const Jimp = require('jimp'); + +const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); +const IMAGE_WRITE_TIMEOUT_MS = 30000; +const IMAGE_WRITE_POLL_INTERVAL_MS = 25; + +function delay(duration) { + return new Promise((resolve) => { + setTimeout(resolve, duration); + }); +} + +async function waitForValidPng(imagePath) { + const startedAt = Date.now(); + + while (Date.now() - startedAt < IMAGE_WRITE_TIMEOUT_MS) { + try { + const imageBuffer = readFileSync(imagePath); + if (imageBuffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) { + const image = await Jimp.read(imagePath); + if (image.bitmap.width > 0 && image.bitmap.height > 0) { + return; + } + } + } catch { + // app-icon-badge does not await Jimp.writeAsync, so the file may not exist yet. + } + + await delay(IMAGE_WRITE_POLL_INTERVAL_MS); + } + + throw new Error(`Timed out waiting for app icon badge image ${imagePath}`); +} + +async function generateBadgeImage(job, badges) { + const temporaryPath = `${job.outputPath}.${process.pid}.tmp.png`; + mkdirSync(dirname(job.outputPath), { recursive: true }); + + try { + await addBadge({ + icon: job.sourcePath, + dstPath: temporaryPath, + badges, + isAdaptiveIcon: job.isAdaptiveIcon, + }); + await waitForValidPng(temporaryPath); + copyFileSync(temporaryPath, job.outputPath); + await waitForValidPng(job.outputPath); + } finally { + try { + unlinkSync(temporaryPath); + } catch { + // The temporary image is absent when generation fails before Jimp starts writing. + } + } +} + +async function main() { + const payload = JSON.parse(process.argv[2] ?? '{}'); + if (!Array.isArray(payload.jobs) || !Array.isArray(payload.badges)) { + throw new Error('App icon badge generator requires jobs and badges arrays'); + } + + for (const job of payload.jobs) { + await generateBadgeImage(job, payload.badges); + } +} + +main().catch((error) => { + console.error('Failed to generate app icon badges:', error); + process.exitCode = 1; +});