├── android
├── src
│ └── main
│ │ ├── AndroidManifest.xml
│ │ └── java
│ │ └── expo
│ │ └── modules
│ │ └── dynamicappicon
│ │ ├── ExpoDynamicAppIconView.kt
│ │ ├── ExpoDynamicAppIconPackage.kt
│ │ ├── ExpoDynamicAppIconModule.kt
│ │ └── ExpoDynamicAppIconReactActivityLifecycleListener.kt
└── build.gradle
├── app.plugin.js
├── src
├── types.ts
├── ExpoDynamicAppIconModule.ts
├── index.web.ts
└── index.ts
├── example
├── assets
│ ├── icon.png
│ ├── favicon.png
│ ├── splash.png
│ ├── adaptive-icon.png
│ ├── ios_icon_default_dark.png
│ ├── ios_icon_default_light.png
│ ├── android_icon_default_dark.png
│ └── android_icon_default_light.png
├── tsconfig.json
├── .gitignore
├── index.js
├── webpack.config.js
├── babel.config.js
├── package.json
├── metro.config.js
├── App.tsx
└── app.json
├── .eslintrc.js
├── .npmignore
├── expo-module.config.json
├── plugin
├── tsconfig.json
└── src
│ └── withDynamicIcon.ts
├── tsconfig.json
├── .gitignore
├── ios
├── ExpoDynamicAppIcon.podspec
└── ExpoDynamicAppIconModule.swift
├── package.json
└── README.md
/android/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/app.plugin.js:
--------------------------------------------------------------------------------
1 | module.exports = require("./plugin/build/withDynamicIcon");
2 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | export interface DynamicAppIconRegistry {
2 | IconName: string;
3 | }
4 |
--------------------------------------------------------------------------------
/example/assets/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mozzius/expo-dynamic-app-icon/HEAD/example/assets/icon.png
--------------------------------------------------------------------------------
/example/assets/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mozzius/expo-dynamic-app-icon/HEAD/example/assets/favicon.png
--------------------------------------------------------------------------------
/example/assets/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mozzius/expo-dynamic-app-icon/HEAD/example/assets/splash.png
--------------------------------------------------------------------------------
/example/assets/adaptive-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mozzius/expo-dynamic-app-icon/HEAD/example/assets/adaptive-icon.png
--------------------------------------------------------------------------------
/example/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo/tsconfig.base",
3 | "compilerOptions": {
4 | "strict": true
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | extends: ['universe/native', 'universe/web'],
4 | ignorePatterns: ['build'],
5 | };
6 |
--------------------------------------------------------------------------------
/example/assets/ios_icon_default_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mozzius/expo-dynamic-app-icon/HEAD/example/assets/ios_icon_default_dark.png
--------------------------------------------------------------------------------
/example/assets/ios_icon_default_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mozzius/expo-dynamic-app-icon/HEAD/example/assets/ios_icon_default_light.png
--------------------------------------------------------------------------------
/example/assets/android_icon_default_dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mozzius/expo-dynamic-app-icon/HEAD/example/assets/android_icon_default_dark.png
--------------------------------------------------------------------------------
/example/assets/android_icon_default_light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mozzius/expo-dynamic-app-icon/HEAD/example/assets/android_icon_default_light.png
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Exclude all top-level hidden directories by convention
2 | /.*/
3 |
4 | __mocks__
5 | __tests__
6 |
7 | /babel.config.js
8 | /android/src/androidTest/
9 | /android/src/test/
10 | /android/build/
11 | /example/
12 |
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | .expo/
3 | dist/
4 | npm-debug.*
5 | *.jks
6 | *.p8
7 | *.p12
8 | *.key
9 | *.mobileprovision
10 | *.orig.*
11 | web-build/
12 |
13 | # macOS
14 | .DS_Store
15 |
16 | ios/
17 | android/
18 |
--------------------------------------------------------------------------------
/expo-module.config.json:
--------------------------------------------------------------------------------
1 | {
2 | "platforms": ["ios", "android", "web"],
3 | "ios": {
4 | "modules": ["ExpoDynamicAppIconModule"]
5 | },
6 | "android": {
7 | "modules": ["expo.modules.dynamicappicon.ExpoDynamicAppIconModule"]
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/plugin/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "expo-module-scripts/tsconfig.plugin",
3 | "compilerOptions": {
4 | "outDir": "build",
5 | "rootDir": "src"
6 | },
7 | "include": ["./src"],
8 | "exclude": ["**/__mocks__/*", "**/__tests__/*"]
9 | }
10 |
--------------------------------------------------------------------------------
/src/ExpoDynamicAppIconModule.ts:
--------------------------------------------------------------------------------
1 | import { requireNativeModule } from 'expo';
2 |
3 | // It loads the native module object from the JSI or falls back to
4 | // the bridge module (from NativeModulesProxy) if the remote debugger is on.
5 | export default requireNativeModule('ExpoDynamicAppIcon');
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | // @generated by expo-module-scripts
2 | {
3 | "extends": "expo-module-scripts/tsconfig.base",
4 | "compilerOptions": {
5 | "outDir": "./build"
6 | },
7 | "include": ["./src"],
8 | "exclude": ["**/__mocks__/*", "**/__tests__/*", "**/__rsc_tests__/*"]
9 | }
10 |
--------------------------------------------------------------------------------
/example/index.js:
--------------------------------------------------------------------------------
1 | import { registerRootComponent } from 'expo';
2 |
3 | import App from './App';
4 |
5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App);
6 | // It also ensures that whether you load the app in Expo Go or in a native build,
7 | // the environment is set up appropriately
8 | registerRootComponent(App);
9 |
--------------------------------------------------------------------------------
/android/src/main/java/expo/modules/dynamicappicon/ExpoDynamicAppIconView.kt:
--------------------------------------------------------------------------------
1 | package expo.modules.dynamicappicon
2 |
3 | import android.content.Context
4 | import expo.modules.kotlin.AppContext
5 | import expo.modules.kotlin.views.ExpoView
6 |
7 | class ExpoDynamicAppIconView(context: Context, appContext: AppContext) :
8 | ExpoView(context, appContext)
9 |
--------------------------------------------------------------------------------
/src/index.web.ts:
--------------------------------------------------------------------------------
1 | import { DynamicAppIconRegistry } from "./types";
2 |
3 | export type IconName = DynamicAppIconRegistry["IconName"];
4 |
5 | export function setAppIcon(
6 | name: IconName | null
7 | ): IconName | "DEFAULT" | false {
8 | console.error("setAppIcon is not supported on web");
9 | return false;
10 | }
11 |
12 | export function getAppIcon(): IconName | "DEFAULT" {
13 | console.error("getAppIcon is not supported on web");
14 | return "";
15 | }
16 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import ExpoDynamicAppIconModule from "./ExpoDynamicAppIconModule";
2 | import { DynamicAppIconRegistry } from "./types";
3 |
4 | export type IconName = DynamicAppIconRegistry["IconName"];
5 |
6 | export function setAppIcon(
7 | name: IconName | null
8 | ): IconName | "DEFAULT" | false {
9 | return ExpoDynamicAppIconModule.setAppIcon(name);
10 | }
11 |
12 | export function getAppIcon(): IconName | "DEFAULT" {
13 | return ExpoDynamicAppIconModule.getAppIcon();
14 | }
15 |
--------------------------------------------------------------------------------
/example/webpack.config.js:
--------------------------------------------------------------------------------
1 | const createConfigAsync = require('@expo/webpack-config');
2 | const path = require('path');
3 |
4 | module.exports = async (env, argv) => {
5 | const config = await createConfigAsync(
6 | {
7 | ...env,
8 | babel: {
9 | dangerouslyAddModulePathsToTranspile: ['expo-dynamic-app-icon'],
10 | },
11 | },
12 | argv
13 | );
14 | config.resolve.modules = [
15 | path.resolve(__dirname, './node_modules'),
16 | path.resolve(__dirname, '../node_modules'),
17 | ];
18 |
19 | return config;
20 | };
21 |
--------------------------------------------------------------------------------
/android/src/main/java/expo/modules/dynamicappicon/ExpoDynamicAppIconPackage.kt:
--------------------------------------------------------------------------------
1 | package expo.modules.dynamicappicon
2 |
3 | import android.content.Context
4 | import expo.modules.core.interfaces.Package
5 | import expo.modules.core.interfaces.ReactActivityLifecycleListener
6 |
7 | class ExpoDynamicAppIconPackage : Package {
8 | override fun createReactActivityLifecycleListeners(
9 | activityContext: Context
10 | ): List {
11 | return listOf(ExpoDynamicAppIconReactActivityLifecycleListener())
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/example/babel.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | module.exports = function (api) {
3 | api.cache(true);
4 | return {
5 | presets: ['babel-preset-expo'],
6 | plugins: [
7 | [
8 | 'module-resolver',
9 | {
10 | extensions: ['.tsx', '.ts', '.js', '.json'],
11 | alias: {
12 | // For development, we want to alias the library to the source
13 | 'expo-dynamic-app-icon': path.join(__dirname, '..', 'src', 'index.ts'),
14 | },
15 | },
16 | ],
17 | ],
18 | };
19 | };
20 |
--------------------------------------------------------------------------------
/example/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "expo-dynamic-app-icon-example",
3 | "version": "1.0.0",
4 | "scripts": {
5 | "start": "expo start --dev-client",
6 | "prebuild": "expo prebuild",
7 | "android": "expo run:android",
8 | "ios": "expo run:ios",
9 | "web": "expo start --web"
10 | },
11 | "dependencies": {
12 | "@mozzius/expo-dynamic-app-icon": "link:./../",
13 | "babel-plugin-module-resolver": "^5.0.2",
14 | "expo": "^54.0.2",
15 | "expo-splash-screen": "~31.0.9",
16 | "expo-status-bar": "~3.0.8",
17 | "react": "19.1.0",
18 | "react-dom": "19.1.0",
19 | "react-native": "0.81.4",
20 | "react-native-web": "^0.21.0"
21 | },
22 | "devDependencies": {
23 | "@babel/core": "^7.26.0",
24 | "@types/react": "~19.1.10",
25 | "typescript": "~5.9.2"
26 | },
27 | "private": true
28 | }
29 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OSX
2 | #
3 | .DS_Store
4 |
5 | # VSCode
6 | .vscode/
7 | jsconfig.json
8 |
9 | # Xcode
10 | #
11 | build/
12 | *.pbxuser
13 | !default.pbxuser
14 | *.mode1v3
15 | !default.mode1v3
16 | *.mode2v3
17 | !default.mode2v3
18 | *.perspectivev3
19 | !default.perspectivev3
20 | xcuserdata
21 | *.xccheckout
22 | *.moved-aside
23 | DerivedData
24 | *.hmap
25 | *.ipa
26 | *.xcuserstate
27 | project.xcworkspace
28 |
29 | # Android/IJ
30 | #
31 | .classpath
32 | .cxx
33 | .gradle
34 | .idea
35 | .project
36 | .settings
37 | local.properties
38 | android.iml
39 |
40 | # Cocoapods
41 | #
42 | example/ios/Pods
43 |
44 | # Ruby
45 | example/vendor/
46 |
47 | # node.js
48 | #
49 | node_modules/
50 | npm-debug.log
51 | yarn-debug.log
52 | yarn-error.log
53 |
54 | # BUCK
55 | buck-out/
56 | \.buckd/
57 | android/app/libs
58 | android/keystores/debug.keystore
59 |
60 | # Expo
61 | .expo/*
62 |
63 | *.tsbuildinfo
64 |
--------------------------------------------------------------------------------
/ios/ExpoDynamicAppIcon.podspec:
--------------------------------------------------------------------------------
1 | require 'json'
2 |
3 | package = JSON.parse(File.read(File.join(__dir__, '..', 'package.json')))
4 |
5 | Pod::Spec.new do |s|
6 | s.name = 'ExpoDynamicAppIcon'
7 | s.version = package['version']
8 | s.summary = package['description']
9 | s.description = package['description']
10 | s.license = package['license']
11 | s.author = package['author']
12 | s.homepage = package['homepage']
13 | s.platform = :ios, '13.0'
14 | s.swift_version = '5.4'
15 | s.source = { git: 'https://github.com/outsung/expo-dynamic-app-icon' }
16 | s.static_framework = true
17 |
18 | s.dependency 'ExpoModulesCore'
19 |
20 | # Swift/Objective-C compatibility
21 | s.pod_target_xcconfig = {
22 | 'DEFINES_MODULE' => 'YES',
23 | 'SWIFT_COMPILATION_MODE' => 'wholemodule'
24 | }
25 |
26 | s.source_files = "**/*.{h,m,swift}"
27 | end
28 |
--------------------------------------------------------------------------------
/example/metro.config.js:
--------------------------------------------------------------------------------
1 | // Learn more https://docs.expo.io/guides/customizing-metro
2 | const { getDefaultConfig } = require('expo/metro-config');
3 | const path = require('path');
4 |
5 | const config = getDefaultConfig(__dirname);
6 |
7 | // npm v7+ will install ../node_modules/react-native because of peerDependencies.
8 | // To prevent the incompatible react-native bewtween ./node_modules/react-native and ../node_modules/react-native,
9 | // excludes the one from the parent folder when bundling.
10 | config.resolver.blockList = [
11 | ...Array.from(config.resolver.blockList ?? []),
12 | new RegExp(path.resolve('..', 'node_modules', 'react-native')),
13 | ];
14 |
15 | config.resolver.nodeModulesPaths = [
16 | path.resolve(__dirname, './node_modules'),
17 | path.resolve(__dirname, '../node_modules'),
18 | ];
19 |
20 | config.watchFolders = [path.resolve(__dirname, '..')];
21 |
22 | config.transformer.getTransformOptions = async () => ({
23 | transform: {
24 | experimentalImportSupport: false,
25 | inlineRequires: true,
26 | },
27 | });
28 |
29 | module.exports = config;
--------------------------------------------------------------------------------
/ios/ExpoDynamicAppIconModule.swift:
--------------------------------------------------------------------------------
1 | import ExpoModulesCore
2 |
3 | public class ExpoDynamicAppIconModule: Module {
4 | public func definition() -> ModuleDefinition {
5 |
6 | Name("ExpoDynamicAppIcon")
7 |
8 | Function("setAppIcon") { (name: String?) -> String in
9 | self.setAppIcon(name)
10 |
11 | // Return "DEFAULT" if name is nil or empty
12 | return name ?? "DEFAULT"
13 | }
14 |
15 | Function("getAppIcon") { () -> String in
16 | // Return the current alternate icon name or "DEFAULT" if none is set
17 | return UIApplication.shared.alternateIconName ?? "DEFAULT"
18 | }
19 | }
20 |
21 | private func setAppIcon(_ iconName: String?) {
22 | if UIApplication.shared.responds(to: #selector(getter: UIApplication.supportsAlternateIcons)) && UIApplication.shared.supportsAlternateIcons {
23 | let iconNameToUse = iconName?.isEmpty == false ? iconName : nil // If the icon name is nil or empty, reset to default
24 |
25 | // Set the alternate icon or reset to the default icon
26 | UIApplication.shared.setAlternateIconName(iconNameToUse, completionHandler: { error in
27 | if let error = error {
28 | // Handle error if necessary
29 | print("Failed to set app icon: \(error.localizedDescription)")
30 | }
31 | })
32 | }
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/example/App.tsx:
--------------------------------------------------------------------------------
1 | import { Button, Text, View } from "react-native";
2 |
3 | import {
4 | getAppIcon,
5 | IconName,
6 | setAppIcon,
7 | } from "@mozzius/expo-dynamic-app-icon";
8 | import { useState } from "react";
9 |
10 | export default function App() {
11 | const [iconName, setIconName] = useState();
12 |
13 | return (
14 |
22 |
23 |
28 |
29 |
30 |
35 |
36 |
37 |
42 |
43 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/android/build.gradle:
--------------------------------------------------------------------------------
1 | apply plugin: 'com.android.library'
2 |
3 | group = 'expo.modules.dynamicappicon'
4 | version = '0.6.0'
5 |
6 | def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
7 | apply from: expoModulesCorePlugin
8 | applyKotlinExpoModulesCorePlugin()
9 | useCoreDependencies()
10 | useExpoPublishing()
11 |
12 | // If you want to use the managed Android SDK versions from expo-modules-core, set this to true.
13 | // The Android SDK versions will be bumped from time to time in SDK releases and may introduce breaking changes in your module code.
14 | // Most of the time, you may like to manage the Android SDK versions yourself.
15 | def useManagedAndroidSdkVersions = false
16 | if (useManagedAndroidSdkVersions) {
17 | useDefaultAndroidSdkVersions()
18 | } else {
19 | buildscript {
20 | // Simple helper that allows the root project to override versions declared by this library.
21 | ext.safeExtGet = { prop, fallback ->
22 | rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
23 | }
24 | }
25 | project.android {
26 | compileSdkVersion safeExtGet("compileSdkVersion", 34)
27 | defaultConfig {
28 | minSdkVersion safeExtGet("minSdkVersion", 21)
29 | targetSdkVersion safeExtGet("targetSdkVersion", 34)
30 | }
31 | }
32 | }
33 |
34 | android {
35 | namespace "expo.modules.dynamicappicon"
36 | defaultConfig {
37 | versionCode 1
38 | versionName "0.6.0"
39 | }
40 | lintOptions {
41 | abortOnError false
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/example/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "expo-dynamic-app-icon-example",
4 | "slug": "expo-dynamic-app-icon-example",
5 | "version": "1.0.0",
6 | "orientation": "portrait",
7 | "icon": "./assets/icon.png",
8 | "userInterfaceStyle": "light",
9 | "splash": {
10 | "image": "./assets/splash.png",
11 | "resizeMode": "contain",
12 | "backgroundColor": "#ffffff"
13 | },
14 | "updates": {
15 | "fallbackToCacheTimeout": 0
16 | },
17 | "assetBundlePatterns": ["**/*"],
18 | "ios": {
19 | "supportsTablet": true,
20 | "bundleIdentifier": "expo.modules.dynamicappicon.example"
21 | },
22 | "android": {
23 | "edgeToEdgeEnabled": true,
24 | "adaptiveIcon": {
25 | "foregroundImage": "./assets/adaptive-icon.png",
26 | "backgroundColor": "#FFFFFF"
27 | },
28 | "package": "expo.modules.dynamicappicon.example"
29 | },
30 | "web": {
31 | "favicon": "./assets/favicon.png"
32 | },
33 | "plugins": [
34 | [
35 | "@mozzius/expo-dynamic-app-icon",
36 | {
37 | "light": {
38 | "ios": "./assets/ios_icon_default_light.png",
39 | "android": "./assets/android_icon_default_light.png",
40 | "prerendered": true
41 | },
42 | "dark": {
43 | "ios": "./assets/ios_icon_default_dark.png",
44 | "android": "./assets/android_icon_default_dark.png",
45 | "prerendered": true
46 | }
47 | }
48 | ]
49 | ]
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@mozzius/expo-dynamic-app-icon",
3 | "version": "1.8.0",
4 | "description": "Programmatically change the app icon in Expo.",
5 | "main": "build/index.js",
6 | "types": "build/index.d.ts",
7 | "files": [
8 | "build",
9 | "android",
10 | "ios",
11 | "plugin/build",
12 | "app.plugin.js",
13 | "expo-module.config.json"
14 | ],
15 | "scripts": {
16 | "build": "expo-module build",
17 | "build:plugin": "tsc --build ./plugin",
18 | "clean": "expo-module clean",
19 | "lint": "expo-module lint",
20 | "test": "expo-module test",
21 | "prepare": "expo-module prepare",
22 | "prepublishOnly": "expo-module prepublishOnly",
23 | "expo-module": "expo-module",
24 | "open:ios": "open -a \"Xcode\" example/ios",
25 | "open:android": "open -a \"Android Studio\" example/android"
26 | },
27 | "keywords": [
28 | "react-native",
29 | "expo",
30 | "expo-dynamic-app-icon",
31 | "ExpoDynamicAppIcon"
32 | ],
33 | "repository": "https://tangled.sh/@samuel.bsky.team/expo-dynamic-app-icon",
34 | "bugs": {
35 | "url": "https://tangled.sh/@samuel.bsky.team/expo-dynamic-app-icon/issues"
36 | },
37 | "author": "Samuel Newman (https://github.com/mozzius)",
38 | "license": "MIT",
39 | "homepage": "https://tangled.sh/@samuel.bsky.team/expo-dynamic-app-icon#readme",
40 | "dependencies": {
41 | "@expo/image-utils": "^0.8.7",
42 | "xcode": "^3.0.1"
43 | },
44 | "devDependencies": {
45 | "@types/node": "^24.3.1",
46 | "@types/react": "~19.1.10",
47 | "expo": "^54.0.2",
48 | "expo-module-scripts": "^5.0.7",
49 | "typescript": "^5.9.2"
50 | },
51 | "peerDependencies": {
52 | "expo": "^52 || ^53 || ^54",
53 | "react": "*",
54 | "react-native": "*"
55 | },
56 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
57 | }
58 |
--------------------------------------------------------------------------------
/android/src/main/java/expo/modules/dynamicappicon/ExpoDynamicAppIconModule.kt:
--------------------------------------------------------------------------------
1 | package expo.modules.dynamicappicon
2 |
3 | import android.content.Context
4 | import expo.modules.kotlin.modules.Module
5 | import expo.modules.kotlin.modules.ModuleDefinition
6 |
7 | class ExpoDynamicAppIconModule : Module() {
8 |
9 | override fun definition() = ModuleDefinition {
10 | Name("ExpoDynamicAppIcon")
11 |
12 | Function("setAppIcon") { name: String? ->
13 | try {
14 | SharedObject.packageName = context.packageName
15 | SharedObject.pm = pm
16 | SharedObject.shouldChangeIcon = true
17 |
18 | var result: String
19 |
20 | if (name == null) {
21 | // Resetting to default icon if nothing passed
22 | var currentIcon =
23 | if (!SharedObject.icon.isEmpty()) SharedObject.icon
24 | else context.packageName + ".MainActivity"
25 |
26 | SharedObject.classesToKill.add(currentIcon)
27 | SharedObject.icon = context.packageName + ".MainActivity"
28 | result = "DEFAULT"
29 | } else {
30 | var newIcon = context.packageName + ".MainActivity" + name
31 | var currentIcon =
32 | if (!SharedObject.icon.isEmpty()) SharedObject.icon
33 | else context.packageName + ".MainActivity"
34 |
35 | if (currentIcon == newIcon) {
36 | return@Function name
37 | }
38 |
39 | SharedObject.classesToKill.add(currentIcon)
40 | SharedObject.icon = newIcon
41 | result = name
42 | }
43 |
44 | // background the app to trigger icon change
45 | try {
46 | currentActivity.moveTaskToBack(true)
47 | } catch (e: Exception) {
48 | // do nothing
49 | }
50 |
51 | return@Function result
52 | } catch (e: Exception) {
53 | return@Function false
54 | }
55 | }
56 |
57 | Function("getAppIcon") {
58 | var componentClass: String = currentActivity.componentName.className
59 | var currentIcon: String =
60 | if (!SharedObject.icon.isEmpty()) SharedObject.icon else componentClass
61 | var currentIconName: String = currentIcon.split("MainActivity")[1]
62 |
63 | return@Function if (currentIconName.isEmpty()) "DEFAULT" else currentIconName
64 | }
65 | }
66 |
67 | private val context: Context
68 | get() = requireNotNull(appContext.reactContext) { "React Application Context is null" }
69 |
70 | private val currentActivity
71 | get() = requireNotNull(appContext.activityProvider?.currentActivity)
72 |
73 | private val pm
74 | get() = requireNotNull(currentActivity.packageManager)
75 | }
76 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # @mozzius/expo-dynamic-app-icon
2 |
3 | > This is a fork of [expo-dynamic-app-icon](https://github.com/outsung/expo-dynamic-app-icon) to support Expo SDK 51+.
4 | > It also includes:
5 | >
6 | > - support for resetting the icon to the default
7 | > - round icon support
8 | > - different icons for Android and iOS
9 |
10 | Programmatically change the app icon in Expo.
11 |
12 | ## Install
13 |
14 | ```
15 | npx expo install @mozzius/expo-dynamic-app-icon
16 | ```
17 |
18 | ### Set icon file
19 |
20 | add plugins in `app.json`
21 |
22 | ```typescript
23 | "plugins": [
24 | [
25 | "expo-dynamic-app-icon",
26 | {
27 | "red": { // icon name
28 | "ios": "./assets/ios_icon1.png", // icon path for ios
29 | "android": "./assets/android_icon1.png", // icon path for android
30 | "prerendered": true // for ios UIPrerenderedIcon option
31 | },
32 | "gray": {
33 | "android": "./assets/icon2.png", // android-only icon
34 | }
35 | }
36 | ]
37 | ]
38 | ```
39 |
40 | #### Optional: check AndroidManifest (for android)
41 |
42 | After running `expo prebuild`, check the modifications to your `AndroidManifest.xml`. Additional `activity-alias` are added for each icon.
43 |
44 | ```xml
45 | ...
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | ...
60 | ```
61 |
62 | ### Create new `expo-dev-client`
63 |
64 | Create a new `expo-dev-client` and begin using `expo-dynamic-app-icon`!
65 |
66 | ### Use `setAppIcon`
67 |
68 | - if error, return **false**
69 | - else, return **changed app icon name**
70 | - pass `null` to reset app icon to default
71 |
72 | > Note: this causes the app to close on Android, and a popup to appear on iOS
73 |
74 | ```typescript
75 | import { setAppIcon } from "expo-dynamic-app-icon";
76 |
77 | ...
78 |
79 | setAppIcon("red") // set icon 'assets/icon1.png'
80 | ```
81 |
82 | ### Use `getAppIcon`
83 |
84 | get current app icon name
85 |
86 | - default return is `DEFAULT`
87 |
88 | ```typescript
89 | import { getAppIcon } from "expo-dynamic-app-icon";
90 |
91 | ...
92 |
93 | getAppIcon() // get current icon name 'red'
94 | ```
95 |
96 | Buy outsung (original author) a coffee! I couldn't have done it without his work! 👇
97 |
98 |
99 |
--------------------------------------------------------------------------------
/android/src/main/java/expo/modules/dynamicappicon/ExpoDynamicAppIconReactActivityLifecycleListener.kt:
--------------------------------------------------------------------------------
1 | package expo.modules.dynamicappicon
2 |
3 | import android.app.Activity
4 | import android.content.ComponentName
5 | import android.content.Context
6 | import android.content.pm.PackageManager
7 | import android.os.Handler
8 | import android.os.Looper
9 | import android.util.Log
10 | import expo.modules.core.interfaces.ReactActivityLifecycleListener
11 |
12 | object SharedObject {
13 | var packageName: String = ""
14 | var classesToKill = ArrayList()
15 | var icon: String = ""
16 | var pm: PackageManager? = null
17 | var shouldChangeIcon: Boolean = false
18 | }
19 |
20 | class ExpoDynamicAppIconReactActivityLifecycleListener : ReactActivityLifecycleListener {
21 | private var currentActivity: Activity? = null
22 | private var isBackground = false
23 | private val handler = Handler(Looper.getMainLooper())
24 | private val backgroundCheckRunnable = Runnable {
25 | if (isBackground) {
26 | onBackground()
27 | }
28 | }
29 |
30 | override fun onPause(activity: Activity) {
31 | currentActivity = activity
32 | isBackground = true
33 | // Apply icon change immediately when app goes to background
34 | if (SharedObject.shouldChangeIcon) {
35 | applyIconChange(activity)
36 | // Force close the app after icon change to ensure clean restart
37 | handler.postDelayed(
38 | { forceCloseApp(activity) },
39 | 500
40 | ) // Small delay to ensure icon change completes
41 | }
42 | }
43 |
44 | override fun onResume(activity: Activity) {
45 | currentActivity = activity
46 | isBackground = false
47 | handler.removeCallbacks(backgroundCheckRunnable)
48 | }
49 |
50 | override fun onDestroy(activity: Activity) {
51 | handler.removeCallbacks(backgroundCheckRunnable)
52 | if (SharedObject.shouldChangeIcon) {
53 | applyIconChange(activity)
54 | }
55 | if (currentActivity === activity) {
56 | currentActivity = null
57 | }
58 | }
59 |
60 | private fun forceCloseApp(activity: Activity) {
61 | try {
62 | // Force close the app process to ensure clean restart
63 | activity.finishAffinity()
64 | android.os.Process.killProcess(android.os.Process.myPid())
65 | } catch (e: Exception) {
66 | Log.e("IconChange", "Error force closing app", e)
67 | }
68 | }
69 |
70 | private fun applyIconChange(activity: Activity) {
71 | SharedObject.icon.takeIf { it.isNotEmpty() }?.let { icon ->
72 | val pm = SharedObject.pm ?: return
73 | val newComponent = ComponentName(SharedObject.packageName, icon)
74 |
75 | if (!doesComponentExist(newComponent)) {
76 | SharedObject.shouldChangeIcon = false
77 | return
78 | }
79 |
80 | try {
81 | // Get all launcher activities and disable all except the new one
82 | val packageInfo =
83 | pm.getPackageInfo(
84 | SharedObject.packageName,
85 | PackageManager.GET_ACTIVITIES or
86 | PackageManager.GET_DISABLED_COMPONENTS
87 | )
88 |
89 | packageInfo.activities?.forEach { activityInfo ->
90 | val componentName = ComponentName(SharedObject.packageName, activityInfo.name)
91 | val state = pm.getComponentEnabledSetting(componentName)
92 |
93 | if (activityInfo.name != icon &&
94 | state != PackageManager.COMPONENT_ENABLED_STATE_DISABLED
95 | ) {
96 | pm.setComponentEnabledSetting(
97 | componentName,
98 | PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
99 | PackageManager.DONT_KILL_APP
100 | )
101 | Log.i("IconChange", "Disabled component: ${activityInfo.name}")
102 | }
103 | }
104 |
105 | // Enable the new icon
106 | pm.setComponentEnabledSetting(
107 | newComponent,
108 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
109 | PackageManager.DONT_KILL_APP
110 | )
111 | Log.i("IconChange", "Enabled new icon: $icon")
112 | } catch (e: Exception) {
113 | Log.e("IconChange", "Error during icon change", e)
114 | } finally {
115 | SharedObject.shouldChangeIcon = false
116 | }
117 |
118 | // Ensure at least one component is enabled
119 | ensureAtLeastOneComponentEnabled(activity)
120 | }
121 | }
122 |
123 | private fun ensureAtLeastOneComponentEnabled(context: Context) {
124 | val pm = SharedObject.pm ?: return
125 | val packageInfo =
126 | pm.getPackageInfo(
127 | SharedObject.packageName,
128 | PackageManager.GET_ACTIVITIES or PackageManager.GET_DISABLED_COMPONENTS
129 | )
130 |
131 | val hasEnabledComponent =
132 | packageInfo.activities?.any { activityInfo ->
133 | val componentName = ComponentName(SharedObject.packageName, activityInfo.name)
134 | pm.getComponentEnabledSetting(componentName) ==
135 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED
136 | }
137 | ?: false
138 |
139 | if (!hasEnabledComponent) {
140 | val mainActivityName = "${SharedObject.packageName}.MainActivity"
141 | val mainComponent = ComponentName(SharedObject.packageName, mainActivityName)
142 | try {
143 | pm.setComponentEnabledSetting(
144 | mainComponent,
145 | PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
146 | PackageManager.DONT_KILL_APP
147 | )
148 | Log.i("IconChange", "No active component found. Re-enabling $mainActivityName")
149 | } catch (e: Exception) {
150 | Log.e("IconChange", "Error enabling fallback MainActivity", e)
151 | }
152 | }
153 | }
154 |
155 | /** Check if a component exists in the manifest (including disabled ones). */
156 | private fun doesComponentExist(componentName: ComponentName): Boolean {
157 | return try {
158 | val packageInfo =
159 | SharedObject.pm?.getPackageInfo(
160 | SharedObject.packageName,
161 | PackageManager.GET_ACTIVITIES or PackageManager.GET_DISABLED_COMPONENTS
162 | )
163 |
164 | val activityExists =
165 | packageInfo?.activities?.any { it.name == componentName.className } == true
166 |
167 | activityExists
168 | } catch (e: Exception) {
169 | false
170 | }
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/plugin/src/withDynamicIcon.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ExportedConfig,
3 | ConfigPlugin,
4 | IOSConfig,
5 | withDangerousMod,
6 | withInfoPlist,
7 | withXcodeProject,
8 | withAndroidManifest,
9 | AndroidConfig,
10 | } from "expo/config-plugins";
11 | import { generateImageAsync } from "@expo/image-utils";
12 | import fs from "fs";
13 | import path from "path";
14 | // @ts-ignore - no types
15 | import pbxFile from "xcode/lib/pbxFile";
16 |
17 | const moduleRoot = path.join(__dirname, "..", "..");
18 |
19 | const { getMainApplicationOrThrow, getMainActivityOrThrow } =
20 | AndroidConfig.Manifest;
21 |
22 | const ANDROID_FOLDER_PATH = ["app", "src", "main", "res"];
23 | const ANDROID_FOLDER_NAMES = [
24 | "mipmap-hdpi",
25 | "mipmap-mdpi",
26 | "mipmap-xhdpi",
27 | "mipmap-xxhdpi",
28 | "mipmap-xxxhdpi",
29 | ];
30 | const ANDROID_SIZES = [162, 108, 216, 324, 432];
31 |
32 | /** The default icon folder name to export to */
33 | const IOS_FOLDER_NAME = "DynamicAppIcons";
34 | /**
35 | * The default icon dimensions to export.
36 | *
37 | * @see https://developer.apple.com/design/human-interface-guidelines/app-icons#iOS-iPadOS-app-icon-sizes
38 | */
39 | const IOS_ICON_DIMENSIONS: IconDimensions[] = [
40 | // iPhone, iPad, MacOS, ...
41 | { scale: 2, size: 60 },
42 | { scale: 3, size: 60 },
43 | // iPad only
44 | { scale: 2, size: 60, width: 152, height: 152, target: "ipad" },
45 | { scale: 3, size: 60, width: 167, height: 167, target: "ipad" },
46 | ];
47 |
48 | type IconDimensions = {
49 | /** The scale of the icon itself, affets file name and width/height when omitted. */
50 | scale: number;
51 | /** Both width and height of the icon, affects file name only. */
52 | size: number;
53 | /** The width, in pixels, of the icon. Generated from `size` + `scale` when omitted */
54 | width?: number;
55 | /** The height, in pixels, of the icon. Generated from `size` + `scale` when omitted */
56 | height?: number;
57 | /** Special target of the icon dimension, if any */
58 | target?: null | "ipad";
59 | };
60 |
61 | type IconSet = Record;
62 | type IconSetProps = { ios?: string; android?: string; prerendered?: boolean };
63 |
64 | type Props = {
65 | icons: IconSet;
66 | dimensions: Required[];
67 | };
68 |
69 | const withDynamicIcon: ConfigPlugin = (
70 | config,
71 | props = {}
72 | ) => {
73 | const icons = resolveIcons(props);
74 | const dimensions = resolveIconDimensions(config);
75 |
76 | config = withGenerateTypes(config, { icons });
77 |
78 | // for ios
79 | config = withIconXcodeProject(config, { icons, dimensions });
80 | config = withIconInfoPlist(config, { icons, dimensions });
81 | config = withIconImages(config, { icons, dimensions });
82 |
83 | // for android
84 | config = withIconAndroidManifest(config, { icons, dimensions });
85 | config = withIconAndroidImages(config, { icons, dimensions });
86 |
87 | return config;
88 | };
89 |
90 | // =============================================================================
91 | // TypeScript
92 | // =============================================================================
93 |
94 | function withGenerateTypes(config: ExportedConfig, props: { icons: IconSet }) {
95 | const names = Object.keys(props.icons);
96 | const union = names.map((name) => `"${name}"`).join(" | ") || "string";
97 |
98 | const unionType = `IconName: ${union}`;
99 |
100 | const buildFile = path.join(moduleRoot, "build", "types.d.ts");
101 | const buildFileContent = fs.readFileSync(buildFile, "utf8");
102 | const updatedContent = buildFileContent.replace(/IconName:\s.*/, unionType);
103 | fs.writeFileSync(buildFile, updatedContent);
104 |
105 | return config;
106 | }
107 |
108 | // =============================================================================
109 | // Android
110 | // =============================================================================
111 |
112 | const getSafeResourceName = (name: string) => {
113 | return name.replace(/[^a-zA-Z0-9_]/g, "_").toLowerCase();
114 | };
115 |
116 | const withIconAndroidManifest: ConfigPlugin = (config, { icons }) => {
117 | return withAndroidManifest(config, (config) => {
118 | const mainApplication: any = getMainApplicationOrThrow(config.modResults);
119 | const mainActivity = getMainActivityOrThrow(config.modResults);
120 |
121 | const iconNamePrefix = `${config.android!.package}.MainActivity`;
122 | const iconNames = Object.keys(icons);
123 |
124 | function addIconActivityAlias(config: any[]): any[] {
125 | return [
126 | ...config,
127 | ...iconNames.map((iconKey) => {
128 | const safeIconKey = getSafeResourceName(iconKey);
129 | let iconResourceName: string;
130 | let roundIconResourceName: string;
131 |
132 | iconResourceName = `@mipmap/${safeIconKey}`;
133 | roundIconResourceName = `@mipmap/${safeIconKey}_round`;
134 |
135 | return {
136 | $: {
137 | "android:name": `${iconNamePrefix}${iconKey}`,
138 | "android:enabled": "false",
139 | "android:exported": "true",
140 | "android:icon": iconResourceName,
141 | "android:targetActivity": ".MainActivity",
142 | "android:roundIcon": roundIconResourceName,
143 | },
144 | "intent-filter": [
145 | ...(mainActivity["intent-filter"] || [
146 | {
147 | action: [
148 | { $: { "android:name": "android.intent.action.MAIN" } },
149 | ],
150 | category: [
151 | {
152 | $: { "android:name": "android.intent.category.LAUNCHER" },
153 | },
154 | ],
155 | },
156 | ]),
157 | ],
158 | };
159 | }),
160 | ];
161 | }
162 |
163 | function removeIconActivityAlias(currentActivityAliases: any[]): any[] {
164 | return currentActivityAliases.filter(
165 | (activityAlias) =>
166 | !(activityAlias.$["android:name"] as string).startsWith(
167 | iconNamePrefix
168 | )
169 | );
170 | }
171 |
172 | let activityAliases = mainApplication["activity-alias"] || [];
173 | activityAliases = removeIconActivityAlias(activityAliases);
174 | activityAliases = addIconActivityAlias(activityAliases);
175 | mainApplication["activity-alias"] = activityAliases;
176 |
177 | return config;
178 | });
179 | };
180 |
181 | const withIconAndroidImages: ConfigPlugin = (config, { icons }) => {
182 | return withDangerousMod(config, [
183 | "android",
184 | async (config) => {
185 | const androidResPath = path.join(
186 | config.modRequest.platformProjectRoot,
187 | ...ANDROID_FOLDER_PATH
188 | );
189 |
190 | const drawableDirPath = path.join(androidResPath, "drawable");
191 | const mipmapAnyDpiV26DirPath = path.join(
192 | androidResPath,
193 | "mipmap-anydpi-v26"
194 | );
195 |
196 | // Ensure directories exist
197 | await fs.promises.mkdir(drawableDirPath, { recursive: true });
198 | await fs.promises.mkdir(mipmapAnyDpiV26DirPath, { recursive: true });
199 |
200 | const removeIconRes = async () => {
201 | // Clean up legacy mipmap-*dpi folders
202 | for (const folderName of ANDROID_FOLDER_NAMES) {
203 | const folderPath = path.join(androidResPath, folderName);
204 | const files = await fs.promises.readdir(folderPath).catch(() => []);
205 | for (const file of files) {
206 | if (
207 | !file.startsWith("ic_launcher.") &&
208 | !file.startsWith("ic_launcher_round.")
209 | ) {
210 | const isPluginGenerated = Object.keys(icons).some(
211 | (iconKey) =>
212 | file.startsWith(`${getSafeResourceName(iconKey)}.png`) ||
213 | file.startsWith(`${getSafeResourceName(iconKey)}_round.png`)
214 | );
215 | if (isPluginGenerated) {
216 | await fs.promises
217 | .rm(path.join(folderPath, file), { force: true })
218 | .catch(() => null);
219 | }
220 | }
221 | }
222 | }
223 | // Clean up adaptive icon files from drawable and mipmap-anydpi-v26
224 | // This assumes a naming convention for plugin-generated adaptive icons.
225 | const drawableFiles = await fs.promises
226 | .readdir(drawableDirPath)
227 | .catch(() => []);
228 | for (const file of drawableFiles) {
229 | if (
230 | Object.keys(icons).some((iconKey) =>
231 | file.startsWith(
232 | `ic_launcher_adaptive_${getSafeResourceName(iconKey)}_`
233 | )
234 | )
235 | ) {
236 | await fs.promises
237 | .rm(path.join(drawableDirPath, file), { force: true })
238 | .catch(() => null);
239 | }
240 | }
241 | const mipmapAnyDpiFiles = await fs.promises
242 | .readdir(mipmapAnyDpiV26DirPath)
243 | .catch(() => []);
244 | for (const file of mipmapAnyDpiFiles) {
245 | if (
246 | Object.keys(icons).some((iconKey) =>
247 | file.startsWith(
248 | `ic_launcher_adaptive_${getSafeResourceName(iconKey)}.xml`
249 | )
250 | )
251 | ) {
252 | await fs.promises
253 | .rm(path.join(mipmapAnyDpiV26DirPath, file), { force: true })
254 | .catch(() => null);
255 | }
256 | }
257 | };
258 | const addIconRes = async () => {
259 | for (const [iconConfigName, { android }] of Object.entries(icons)) {
260 | if (!android) continue;
261 | for (let i = 0; ANDROID_FOLDER_NAMES.length > i; i += 1) {
262 | const size = ANDROID_SIZES[i];
263 | const outputPath = path.join(
264 | androidResPath,
265 | ANDROID_FOLDER_NAMES[i]
266 | );
267 | const safeIconKey = getSafeResourceName(iconConfigName); // Use the same safe name
268 |
269 | // Square ones
270 | const fileNameSquare = `${safeIconKey}.png`;
271 | const { source: sourceSquare } = await generateImageAsync(
272 | {
273 | projectRoot: config.modRequest.projectRoot,
274 | cacheType: `expo-dynamic-app-icon-${safeIconKey}-${size}`,
275 | },
276 | {
277 | name: fileNameSquare,
278 | src: android,
279 | removeTransparency: true,
280 | backgroundColor: "#ffffff",
281 | resizeMode: "cover",
282 | width: size,
283 | height: size,
284 | }
285 | );
286 | await fs.promises.writeFile(
287 | path.join(outputPath, fileNameSquare),
288 | sourceSquare
289 | );
290 |
291 | // Round ones
292 | const fileNameRound = `${safeIconKey}_round.png`;
293 | const { source: sourceRound } = await generateImageAsync(
294 | {
295 | projectRoot: config.modRequest.projectRoot,
296 | cacheType: `expo-dynamic-app-icon-round-${safeIconKey}-${size}`,
297 | },
298 | {
299 | name: fileNameRound,
300 | src: android,
301 | removeTransparency: false,
302 | resizeMode: "cover",
303 | width: size,
304 | height: size,
305 | borderRadius: size / 2,
306 | }
307 | );
308 | await fs.promises.writeFile(
309 | path.join(outputPath, fileNameRound),
310 | sourceRound
311 | );
312 | }
313 | }
314 | };
315 |
316 | await removeIconRes();
317 | await addIconRes();
318 |
319 | return config;
320 | },
321 | ]);
322 | };
323 |
324 | // =============================================================================
325 | // iOS
326 | // =============================================================================
327 |
328 | const withIconXcodeProject: ConfigPlugin = (
329 | config,
330 | { icons, dimensions }
331 | ) => {
332 | return withXcodeProject(config, async (config) => {
333 | const groupPath = `${config.modRequest.projectName!}/${IOS_FOLDER_NAME}`;
334 | const group = IOSConfig.XcodeUtils.ensureGroupRecursively(
335 | config.modResults,
336 | groupPath
337 | );
338 | const project = config.modResults;
339 | const opt: any = {};
340 |
341 | // Unlink old assets
342 |
343 | const groupId = Object.keys(project.hash.project.objects["PBXGroup"]).find(
344 | (id) => {
345 | const _group = project.hash.project.objects["PBXGroup"][id];
346 | return _group.name === group.name;
347 | }
348 | );
349 | if (!project.hash.project.objects["PBXVariantGroup"]) {
350 | project.hash.project.objects["PBXVariantGroup"] = {};
351 | }
352 | const variantGroupId = Object.keys(
353 | project.hash.project.objects["PBXVariantGroup"]
354 | ).find((id) => {
355 | const _group = project.hash.project.objects["PBXVariantGroup"][id];
356 | return _group.name === group.name;
357 | });
358 |
359 | const children = [...(group.children || [])];
360 |
361 | for (const child of children as {
362 | comment: string;
363 | value: string;
364 | }[]) {
365 | const file = new pbxFile(path.join(group.name, child.comment), opt);
366 | file.target = opt ? opt.target : undefined;
367 |
368 | project.removeFromPbxBuildFileSection(file); // PBXBuildFile
369 | project.removeFromPbxFileReferenceSection(file); // PBXFileReference
370 | if (group) {
371 | if (groupId) {
372 | project.removeFromPbxGroup(file, groupId); //Group other than Resources (i.e. 'splash')
373 | } else if (variantGroupId) {
374 | project.removeFromPbxVariantGroup(file, variantGroupId); // PBXVariantGroup
375 | }
376 | }
377 | project.removeFromPbxResourcesBuildPhase(file); // PBXResourcesBuildPhase
378 | }
379 |
380 | // Link new assets
381 |
382 | await iterateIconsAndDimensionsAsync(
383 | { icons, dimensions },
384 | async (key, { dimension }) => {
385 | const iconFileName = getIconFileName(key, dimension);
386 |
387 | if (
388 | !group?.children.some(
389 | ({ comment }: { comment: string }) => comment === iconFileName
390 | )
391 | ) {
392 | // Only write the file if it doesn't already exist.
393 | config.modResults = IOSConfig.XcodeUtils.addResourceFileToGroup({
394 | filepath: path.join(groupPath, iconFileName),
395 | groupName: groupPath,
396 | project: config.modResults,
397 | isBuildFile: true,
398 | verbose: true,
399 | });
400 | } else {
401 | console.log("Skipping duplicate: ", iconFileName);
402 | }
403 | }
404 | );
405 |
406 | return config;
407 | });
408 | };
409 |
410 | const withIconInfoPlist: ConfigPlugin = (
411 | config,
412 | { icons, dimensions }
413 | ) => {
414 | return withInfoPlist(config, async (config) => {
415 | const altIcons: Record<
416 | string,
417 | { CFBundleIconFiles: string[]; UIPrerenderedIcon: boolean }
418 | > = {};
419 |
420 | const altIconsByTarget: Partial<
421 | Record, typeof altIcons>
422 | > = {};
423 |
424 | await iterateIconsAndDimensionsAsync(
425 | { icons, dimensions },
426 | async (key, { icon, dimension }) => {
427 | if (!icon.ios) return;
428 | const plistItem = {
429 | CFBundleIconFiles: [
430 | // Must be a file path relative to the source root (not a icon set it seems).
431 | // i.e. `Bacon-Icon-60x60` when the image is `ios/somn/appIcons/Bacon-Icon-60x60@2x.png`
432 | getIconName(key, dimension),
433 | ],
434 | UIPrerenderedIcon: !!icon.prerendered,
435 | };
436 |
437 | if (dimension.target) {
438 | altIconsByTarget[dimension.target] =
439 | altIconsByTarget[dimension.target] || {};
440 | altIconsByTarget[dimension.target]![key] = plistItem;
441 | } else {
442 | altIcons[key] = plistItem;
443 | }
444 | }
445 | );
446 |
447 | function applyToPlist(key: string, icons: typeof altIcons) {
448 | if (
449 | typeof config.modResults[key] !== "object" ||
450 | Array.isArray(config.modResults[key]) ||
451 | !config.modResults[key]
452 | ) {
453 | config.modResults[key] = {};
454 | }
455 |
456 | // @ts-ignore
457 | config.modResults[key].CFBundleAlternateIcons = icons;
458 |
459 | // @ts-ignore
460 | config.modResults[key].CFBundlePrimaryIcon = {
461 | CFBundleIconFiles: ["AppIcon"],
462 | };
463 | }
464 |
465 | // Apply for general phone support
466 | applyToPlist("CFBundleIcons", altIcons);
467 |
468 | // Apply for each target, like iPad
469 | for (const [target, icons] of Object.entries(altIconsByTarget)) {
470 | if (Object.keys(icons).length > 0) {
471 | applyToPlist(`CFBundleIcons~${target}`, icons);
472 | }
473 | }
474 |
475 | return config;
476 | });
477 | };
478 |
479 | const withIconImages: ConfigPlugin = (config, { icons, dimensions }) => {
480 | return withDangerousMod(config, [
481 | "ios",
482 | async (config) => {
483 | const iosRoot = path.join(
484 | config.modRequest.platformProjectRoot,
485 | config.modRequest.projectName!
486 | );
487 |
488 | // Delete all existing assets
489 | await fs.promises
490 | .rm(path.join(iosRoot, IOS_FOLDER_NAME), {
491 | recursive: true,
492 | force: true,
493 | })
494 | .catch(() => null);
495 |
496 | // Ensure directory exists
497 | await fs.promises.mkdir(path.join(iosRoot, IOS_FOLDER_NAME), {
498 | recursive: true,
499 | });
500 |
501 | // Generate new assets
502 | await iterateIconsAndDimensionsAsync(
503 | { icons, dimensions },
504 | async (key, { icon, dimension }) => {
505 | if (!icon.ios) return;
506 | const iconFileName = getIconFileName(key, dimension);
507 | const fileName = path.join(IOS_FOLDER_NAME, iconFileName);
508 | const outputPath = path.join(iosRoot, fileName);
509 |
510 | const { source } = await generateImageAsync(
511 | {
512 | projectRoot: config.modRequest.projectRoot,
513 | cacheType: `expo-dynamic-app-icon-${dimension.width}-${dimension.height}`,
514 | },
515 | {
516 | name: iconFileName,
517 | src: icon.ios,
518 | removeTransparency: true,
519 | backgroundColor: "#ffffff",
520 | resizeMode: "cover",
521 | width: dimension.width,
522 | height: dimension.height,
523 | }
524 | );
525 |
526 | await fs.promises.writeFile(outputPath, source);
527 | }
528 | );
529 |
530 | return config;
531 | },
532 | ]);
533 | };
534 |
535 | /** Resolve and sanitize the icon set from config plugin props. */
536 | function resolveIcons(props: string[] | IconSet | void): Props["icons"] {
537 | let icons: Props["icons"] = {};
538 |
539 | if (Array.isArray(props)) {
540 | icons = props.reduce(
541 | (prev, curr, i) => ({ ...prev, [i]: { image: curr } }),
542 | {}
543 | );
544 | } else if (props) {
545 | icons = props;
546 | }
547 |
548 | return icons;
549 | }
550 |
551 | /** Resolve the required icon dimension/target based on the app config. */
552 | function resolveIconDimensions(
553 | config: ExportedConfig
554 | ): Required[] {
555 | const targets: NonNullable[] = [];
556 |
557 | if (config.ios?.supportsTablet) {
558 | targets.push("ipad");
559 | }
560 |
561 | return IOS_ICON_DIMENSIONS.filter(
562 | ({ target }) => !target || targets.includes(target)
563 | ).map((dimension) => ({
564 | ...dimension,
565 | target: dimension.target ?? null,
566 | width: dimension.width ?? dimension.size * dimension.scale,
567 | height: dimension.height ?? dimension.size * dimension.scale,
568 | }));
569 | }
570 |
571 | /** Get the icon name, used to refer to the icon from within the plist */
572 | function getIconName(name: string, dimension: Props["dimensions"][0]) {
573 | return `${name}-Icon-${dimension.size}x${dimension.size}`;
574 | }
575 |
576 | /** Get the full icon file name, including scale and possible target, used to write each exported icon to */
577 | function getIconFileName(name: string, dimension: Props["dimensions"][0]) {
578 | const target = dimension.target ? `~${dimension.target}` : "";
579 | return `${getIconName(name, dimension)}@${dimension.scale}x${target}.png`;
580 | }
581 |
582 | /** Iterate all combinations of icons and dimensions to export */
583 | async function iterateIconsAndDimensionsAsync(
584 | { icons, dimensions }: Props,
585 | callback: (
586 | iconKey: string,
587 | iconAndDimension: {
588 | icon: Props["icons"][string];
589 | dimension: Props["dimensions"][0];
590 | }
591 | ) => Promise
592 | ) {
593 | for (const [iconKey, icon] of Object.entries(icons)) {
594 | for (const dimension of dimensions) {
595 | await callback(iconKey, { icon, dimension });
596 | }
597 | }
598 | }
599 |
600 | export default withDynamicIcon;
601 |
--------------------------------------------------------------------------------