diff --git a/app.config.ts b/app.config.ts index bef5f34..5439fb6 100644 --- a/app.config.ts +++ b/app.config.ts @@ -242,7 +242,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ }, ], './plugins/withInCallAudioModule.js', - ['app-icon-badge', appIconBadgeConfig], + ['./plugins/with-app-icon-badge.js', appIconBadgeConfig], ], extra: { ...ClientEnv, diff --git a/plugins/generate-app-icon-badges.js b/plugins/generate-app-icon-badges.js new file mode 100644 index 0000000..349ad2b --- /dev/null +++ b/plugins/generate-app-icon-badges.js @@ -0,0 +1,62 @@ +const { mkdir, rm, stat } = require('node:fs/promises'); +const path = require('node:path'); + +const Jimp = require('jimp'); +const { addBadge } = require('app-icon-badge'); + +const MAX_IMAGE_READ_ATTEMPTS = 200; +const IMAGE_READ_RETRY_DELAY_MS = 25; + +const delay = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const waitForReadableImage = async (destinationPath) => { + let previousSize = -1; + + for (let attempt = 0; attempt < MAX_IMAGE_READ_ATTEMPTS; attempt += 1) { + try { + const file = await stat(destinationPath); + if (file.size > 0 && file.size === previousSize) { + const image = await Jimp.read(destinationPath); + if (image.bitmap.width > 0 && image.bitmap.height > 0) { + return; + } + } + previousSize = file.size; + } catch { + previousSize = -1; + } + + await delay(IMAGE_READ_RETRY_DELAY_MS); + } + + throw new Error(`Generated app icon is not a readable image: ${destinationPath}`); +}; + +const generateBadge = async ({ badges, destinationPath, isAdaptiveIcon, sourcePath }) => { + await mkdir(path.dirname(destinationPath), { recursive: true }); + await rm(destinationPath, { force: true }); + await addBadge({ + badges, + dstPath: destinationPath, + icon: sourcePath, + isAdaptiveIcon, + }); + await waitForReadableImage(destinationPath); +}; + +const main = async () => { + const payload = JSON.parse(process.argv[2] ?? '{}'); + if (!Array.isArray(payload.tasks)) { + throw new Error('Expected an app icon badge task list'); + } + + for (const task of payload.tasks) { + await generateBadge(task); + } +}; + +main().catch((error) => { + const message = error instanceof Error ? (error.stack ?? error.message) : String(error); + process.stderr.write(`${path.basename(__filename)}: ${message}\n`); + process.exitCode = 1; +}); diff --git a/plugins/with-app-icon-badge.js b/plugins/with-app-icon-badge.js new file mode 100644 index 0000000..097fd1e --- /dev/null +++ b/plugins/with-app-icon-badge.js @@ -0,0 +1,64 @@ +const { execFileSync } = require('node:child_process'); +const path = require('node:path'); + +const OUTPUT_DIRECTORY = '.expo/app-icon-badge'; +const GENERATOR_SCRIPT = path.join(__dirname, 'generate-app-icon-badges.js'); + +const resolveProjectPath = (projectRoot, filePath) => path.resolve(projectRoot, filePath); + +const createBadgeTask = ({ projectRoot, sourcePath, destinationPath, badges, isAdaptiveIcon = false }) => ({ + badges, + destinationPath: resolveProjectPath(projectRoot, destinationPath), + isAdaptiveIcon, + sourcePath: resolveProjectPath(projectRoot, sourcePath), +}); + +const generateBadgedIcons = (projectRoot, tasks) => { + // The upstream config plugin starts image writes without awaiting them. A child + // process lets Expo block until every generated icon has been fully validated. + execFileSync(process.execPath, [GENERATOR_SCRIPT, JSON.stringify({ tasks })], { + cwd: projectRoot, + stdio: 'inherit', + }); +}; + +const withAppIconBadge = (config, options = {}) => { + const { badges = [], enabled = true } = options; + if (!enabled) { + return config; + } + + const projectRoot = config._internal?.projectRoot ?? process.cwd(); + const tasks = []; + const iconSource = config.icon; + const iosIconSource = config.ios?.icon; + const adaptiveIconSource = config.android?.adaptiveIcon?.foregroundImage; + + if (typeof iconSource === 'string') { + const destinationPath = `${OUTPUT_DIRECTORY}/icon.png`; + tasks.push(createBadgeTask({ projectRoot, sourcePath: iconSource, destinationPath, badges })); + config.icon = destinationPath; + } + + if (typeof iosIconSource === 'string') { + const destinationPath = iosIconSource === iconSource ? `${OUTPUT_DIRECTORY}/icon.png` : `${OUTPUT_DIRECTORY}/ios-icon.png`; + if (iosIconSource !== iconSource) { + tasks.push(createBadgeTask({ projectRoot, sourcePath: iosIconSource, destinationPath, badges })); + } + config.ios.icon = destinationPath; + } + + if (typeof adaptiveIconSource === 'string') { + const destinationPath = `${OUTPUT_DIRECTORY}/foreground-image.png`; + tasks.push(createBadgeTask({ projectRoot, sourcePath: adaptiveIconSource, destinationPath, badges, isAdaptiveIcon: true })); + config.android.adaptiveIcon.foregroundImage = destinationPath; + } + + if (tasks.length > 0) { + generateBadgedIcons(projectRoot, tasks); + } + + return config; +}; + +module.exports = withAppIconBadge; diff --git a/src/lib/check-in-timer-utils.ts b/src/lib/check-in-timer-utils.ts index 66243eb..9d4e6db 100644 --- a/src/lib/check-in-timer-utils.ts +++ b/src/lib/check-in-timer-utils.ts @@ -1,6 +1,14 @@ import { CHECK_IN_TARGET_TYPE, type CheckInEligibilityContext, isCheckInTargetEligible } from '@/lib/check-in-eligibility'; -export type CheckInTimerStatus = 'critical' | 'ok' | 'overdue' | 'unknown' | 'warning'; +const STATUS_COLORS = { + critical: '#EF4444', + ok: '#22C55E', + overdue: '#F59E0B', + unknown: '#808080', + warning: '#F59E0B', +} as const; + +export type CheckInTimerStatus = keyof typeof STATUS_COLORS; export type CheckInTimerBadgeVariant = 'critical' | 'warning'; interface CheckInTimerTargetStatus { @@ -15,14 +23,6 @@ export interface CheckInTimerBadge { variant: CheckInTimerBadgeVariant; } -const STATUS_COLORS: Record = { - critical: '#EF4444', - ok: '#22C55E', - overdue: '#F59E0B', - unknown: '#808080', - warning: '#F59E0B', -}; - const STATUS_SEVERITY: Record = { critical: 0, overdue: 1,