Skip to content

Commit df2f68f

Browse files
authored
Merge pull request #19 from Resgrid/develop
RIC-T40 Build fix
2 parents 862b006 + cc16330 commit df2f68f

4 files changed

Lines changed: 246 additions & 1 deletion

File tree

app.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
237237
'./plugins/withNotificationSounds.js',
238238
'./plugins/withMediaButtonModule.js',
239239
'./plugins/withInCallAudioModule.js',
240-
['app-icon-badge', appIconBadgeConfig],
240+
['./plugins/with-app-icon-badge.js', appIconBadgeConfig],
241241
],
242242
extra: {
243243
...ClientEnv,
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { AppIconBadgeConfig } from 'app-icon-badge/types';
2+
3+
const mockExecFileSync = jest.fn();
4+
const mockExistsSync = jest.fn(() => true);
5+
const mockStatSync = jest.fn(() => ({ size: 100 }));
6+
7+
jest.mock('child_process', () => ({
8+
execFileSync: mockExecFileSync,
9+
}));
10+
11+
jest.mock('fs', () => ({
12+
existsSync: mockExistsSync,
13+
statSync: mockStatSync,
14+
}));
15+
16+
interface TestExpoConfig {
17+
_internal?: {
18+
projectRoot?: string;
19+
};
20+
android?: {
21+
adaptiveIcon?: {
22+
foregroundImage?: string;
23+
};
24+
};
25+
icon?: string;
26+
ios?: {
27+
icon?: string;
28+
};
29+
}
30+
31+
const withAppIconBadge = jest.requireActual('../with-app-icon-badge.js') as (config: TestExpoConfig, options?: AppIconBadgeConfig) => TestExpoConfig;
32+
33+
describe('withAppIconBadge', () => {
34+
beforeEach(() => {
35+
jest.clearAllMocks();
36+
});
37+
38+
it('generates all configured icons before rewriting their config paths', () => {
39+
const config: TestExpoConfig = {
40+
_internal: { projectRoot: '/project' },
41+
icon: './assets/icon.png',
42+
ios: { icon: './assets/ios-icon.png' },
43+
android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } },
44+
};
45+
const options: AppIconBadgeConfig = {
46+
enabled: true,
47+
badges: [{ type: 'banner', text: 'development' }],
48+
};
49+
50+
const result = withAppIconBadge(config, options);
51+
52+
expect(mockExecFileSync).toHaveBeenCalledTimes(1);
53+
const generatorArguments = mockExecFileSync.mock.calls[0]?.[1] as string[];
54+
const payload = JSON.parse(generatorArguments[1] ?? '{}') as {
55+
jobs: { isAdaptiveIcon: boolean; outputPath: string; sourcePath: string }[];
56+
};
57+
expect(payload.jobs).toEqual([
58+
{
59+
sourcePath: '/project/assets/icon.png',
60+
outputPath: '/project/.expo/app-icon-badge/icon.png',
61+
isAdaptiveIcon: false,
62+
},
63+
{
64+
sourcePath: '/project/assets/ios-icon.png',
65+
outputPath: '/project/.expo/app-icon-badge/ios-icon.png',
66+
isAdaptiveIcon: false,
67+
},
68+
{
69+
sourcePath: '/project/assets/adaptive-icon.png',
70+
outputPath: '/project/.expo/app-icon-badge/foregroundImage.png',
71+
isAdaptiveIcon: true,
72+
},
73+
]);
74+
expect(result.icon).toBe('.expo/app-icon-badge/icon.png');
75+
expect(result.ios?.icon).toBe('.expo/app-icon-badge/ios-icon.png');
76+
expect(result.android?.adaptiveIcon?.foregroundImage).toBe('.expo/app-icon-badge/foregroundImage.png');
77+
});
78+
79+
it('leaves icon paths untouched when badges are disabled', () => {
80+
const config: TestExpoConfig = {
81+
_internal: { projectRoot: '/project' },
82+
icon: './assets/icon.png',
83+
android: { adaptiveIcon: { foregroundImage: './assets/adaptive-icon.png' } },
84+
};
85+
86+
const result = withAppIconBadge(config, { enabled: false, badges: [] });
87+
88+
expect(mockExecFileSync).not.toHaveBeenCalled();
89+
expect(result.icon).toBe('./assets/icon.png');
90+
expect(result.android?.adaptiveIcon?.foregroundImage).toBe('./assets/adaptive-icon.png');
91+
});
92+
});

plugins/with-app-icon-badge.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
const { execFileSync } = require('child_process');
2+
const { existsSync, statSync } = require('fs');
3+
const { resolve } = require('path');
4+
5+
const GENERATED_DIRECTORY = '.expo/app-icon-badge';
6+
const GENERATED_APP_ICON = `${GENERATED_DIRECTORY}/icon.png`;
7+
const GENERATED_IOS_ICON = `${GENERATED_DIRECTORY}/ios-icon.png`;
8+
const GENERATED_ADAPTIVE_ICON = `${GENERATED_DIRECTORY}/foregroundImage.png`;
9+
10+
function assertGeneratedImage(projectRoot, imagePath) {
11+
const absolutePath = resolve(projectRoot, imagePath);
12+
if (!existsSync(absolutePath) || statSync(absolutePath).size === 0) {
13+
throw new Error(`App icon badge generator did not create a valid image at ${imagePath}`);
14+
}
15+
}
16+
17+
function withAppIconBadge(config, options = {}) {
18+
if (options.enabled === false) {
19+
return config;
20+
}
21+
22+
const projectRoot = config._internal?.projectRoot ?? process.cwd();
23+
const jobs = [];
24+
25+
if (config.icon) {
26+
jobs.push({
27+
sourcePath: resolve(projectRoot, config.icon),
28+
outputPath: resolve(projectRoot, GENERATED_APP_ICON),
29+
isAdaptiveIcon: false,
30+
});
31+
}
32+
33+
if (config.ios?.icon) {
34+
jobs.push({
35+
sourcePath: resolve(projectRoot, config.ios.icon),
36+
outputPath: resolve(projectRoot, GENERATED_IOS_ICON),
37+
isAdaptiveIcon: false,
38+
});
39+
}
40+
41+
if (config.android?.adaptiveIcon?.foregroundImage) {
42+
jobs.push({
43+
sourcePath: resolve(projectRoot, config.android.adaptiveIcon.foregroundImage),
44+
outputPath: resolve(projectRoot, GENERATED_ADAPTIVE_ICON),
45+
isAdaptiveIcon: true,
46+
});
47+
}
48+
49+
if (jobs.length === 0) {
50+
return config;
51+
}
52+
53+
const generatorPath = resolve(__dirname, '../scripts/generate-app-icon-badges.js');
54+
execFileSync(process.execPath, [generatorPath, JSON.stringify({ badges: options.badges ?? [], jobs })], {
55+
cwd: projectRoot,
56+
stdio: 'inherit',
57+
});
58+
59+
if (config.icon) {
60+
assertGeneratedImage(projectRoot, GENERATED_APP_ICON);
61+
config.icon = GENERATED_APP_ICON;
62+
}
63+
64+
if (config.ios?.icon) {
65+
assertGeneratedImage(projectRoot, GENERATED_IOS_ICON);
66+
config.ios.icon = GENERATED_IOS_ICON;
67+
}
68+
69+
if (config.android?.adaptiveIcon?.foregroundImage) {
70+
assertGeneratedImage(projectRoot, GENERATED_ADAPTIVE_ICON);
71+
config.android.adaptiveIcon.foregroundImage = GENERATED_ADAPTIVE_ICON;
72+
}
73+
74+
return config;
75+
}
76+
77+
module.exports = withAppIconBadge;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
const { copyFileSync, mkdirSync, readFileSync, unlinkSync } = require('fs');
2+
const { dirname } = require('path');
3+
4+
const { addBadge } = require('app-icon-badge');
5+
const Jimp = require('jimp');
6+
7+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
8+
const IMAGE_WRITE_TIMEOUT_MS = 30000;
9+
const IMAGE_WRITE_POLL_INTERVAL_MS = 25;
10+
11+
function delay(duration) {
12+
return new Promise((resolve) => {
13+
setTimeout(resolve, duration);
14+
});
15+
}
16+
17+
async function waitForValidPng(imagePath) {
18+
const startedAt = Date.now();
19+
20+
while (Date.now() - startedAt < IMAGE_WRITE_TIMEOUT_MS) {
21+
try {
22+
const imageBuffer = readFileSync(imagePath);
23+
if (imageBuffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
24+
const image = await Jimp.read(imagePath);
25+
if (image.bitmap.width > 0 && image.bitmap.height > 0) {
26+
return;
27+
}
28+
}
29+
} catch {
30+
// app-icon-badge does not await Jimp.writeAsync, so the file may not exist yet.
31+
}
32+
33+
await delay(IMAGE_WRITE_POLL_INTERVAL_MS);
34+
}
35+
36+
throw new Error(`Timed out waiting for app icon badge image ${imagePath}`);
37+
}
38+
39+
async function generateBadgeImage(job, badges) {
40+
const temporaryPath = `${job.outputPath}.${process.pid}.tmp.png`;
41+
mkdirSync(dirname(job.outputPath), { recursive: true });
42+
43+
try {
44+
await addBadge({
45+
icon: job.sourcePath,
46+
dstPath: temporaryPath,
47+
badges,
48+
isAdaptiveIcon: job.isAdaptiveIcon,
49+
});
50+
await waitForValidPng(temporaryPath);
51+
copyFileSync(temporaryPath, job.outputPath);
52+
await waitForValidPng(job.outputPath);
53+
} finally {
54+
try {
55+
unlinkSync(temporaryPath);
56+
} catch {
57+
// The temporary image is absent when generation fails before Jimp starts writing.
58+
}
59+
}
60+
}
61+
62+
async function main() {
63+
const payload = JSON.parse(process.argv[2] ?? '{}');
64+
if (!Array.isArray(payload.jobs) || !Array.isArray(payload.badges)) {
65+
throw new Error('App icon badge generator requires jobs and badges arrays');
66+
}
67+
68+
for (const job of payload.jobs) {
69+
await generateBadgeImage(job, payload.badges);
70+
}
71+
}
72+
73+
main().catch((error) => {
74+
console.error('Failed to generate app icon badges:', error);
75+
process.exitCode = 1;
76+
});

0 commit comments

Comments
 (0)