expo-app-icon 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/android/build.gradle +43 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/dynamicappicon/ExpoAppIconChangerModule.kt +81 -0
- package/android/src/main/java/expo/modules/dynamicappicon/ExpoAppIconChangerPackage.kt +13 -0
- package/android/src/main/java/expo/modules/dynamicappicon/ExpoAppIconChangerReactActivityLifecycleListener.kt +224 -0
- package/android/src/main/java/expo/modules/dynamicappicon/ExpoAppIconChangerView.kt +8 -0
- package/app.plugin.js +1 -0
- package/build/ExpoAppIconChangerModule.d.ts +3 -0
- package/build/ExpoAppIconChangerModule.d.ts.map +1 -0
- package/build/ExpoAppIconChangerModule.js +5 -0
- package/build/ExpoAppIconChangerModule.js.map +1 -0
- package/build/index.d.ts +21 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +21 -0
- package/build/index.js.map +1 -0
- package/build/index.web.d.ts +12 -0
- package/build/index.web.d.ts.map +1 -0
- package/build/index.web.js +16 -0
- package/build/index.web.js.map +1 -0
- package/build/types.d.ts +4 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoAppIconChanger.podspec +27 -0
- package/ios/ExpoAppIconChangerModule.swift +34 -0
- package/package.json +59 -0
- package/plugin/build/android-resources.d.ts +89 -0
- package/plugin/build/android-resources.js +151 -0
- package/plugin/build/android.d.ts +18 -0
- package/plugin/build/android.js +203 -0
- package/plugin/build/apple.d.ts +14 -0
- package/plugin/build/apple.js +143 -0
- package/plugin/build/icons.d.ts +23 -0
- package/plugin/build/icons.js +62 -0
- package/plugin/build/index.d.ts +8 -0
- package/plugin/build/index.js +46 -0
- package/plugin/build/types.d.ts +62 -0
- package/plugin/build/types.js +2 -0
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "expo-app-icon",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Programmatically change the app icon at runtime in Expo, with Android adaptive-icon support.",
|
|
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
|
+
"typecheck": "tsgo --noEmit -p tsconfig.json && tsgo --noEmit -p plugin/tsconfig.json",
|
|
21
|
+
"test": "vp test run",
|
|
22
|
+
"test:watch": "vp test",
|
|
23
|
+
"prepublishOnly": "expo-module prepublishOnly",
|
|
24
|
+
"expo-module": "expo-module"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"react-native",
|
|
28
|
+
"expo",
|
|
29
|
+
"app-icon",
|
|
30
|
+
"adaptive-icon"
|
|
31
|
+
],
|
|
32
|
+
"repository": "https://github.com/Daniel-Griffiths/expo-app-icon",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/Daniel-Griffiths/expo-app-icon/issues"
|
|
35
|
+
},
|
|
36
|
+
"author": "Daniel Griffiths",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"homepage": "https://github.com/Daniel-Griffiths/expo-app-icon#readme",
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@expo/image-utils": "^0.8.7",
|
|
41
|
+
"jimp-compact": "^0.16.1",
|
|
42
|
+
"xcode": "^3.0.1"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^25.9.2",
|
|
46
|
+
"@types/react": "~19.2.17",
|
|
47
|
+
"@typescript/native-preview": "^7.0.0-dev.20260618.1",
|
|
48
|
+
"expo": "~56.0.8",
|
|
49
|
+
"expo-module-scripts": "~56.0.3",
|
|
50
|
+
"typescript": "~6.0.3",
|
|
51
|
+
"vite-plus": "^0.1.22",
|
|
52
|
+
"vitest": "4.1.8"
|
|
53
|
+
},
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"expo": "^52 || ^53 || ^54 || ^55 || ^56",
|
|
56
|
+
"react": "*",
|
|
57
|
+
"react-native": "*"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers describing how Android launcher icons are laid out on disk.
|
|
3
|
+
* Kept free of `expo`/`fs` imports so the logic can be unit-tested directly.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Density bucket directory → foreground pixel size (108dp at each scale).
|
|
7
|
+
*/
|
|
8
|
+
export declare const ANDROID_DENSITY_SIZES: {
|
|
9
|
+
readonly "mipmap-mdpi": 108;
|
|
10
|
+
readonly "mipmap-hdpi": 162;
|
|
11
|
+
readonly "mipmap-xhdpi": 216;
|
|
12
|
+
readonly "mipmap-xxhdpi": 324;
|
|
13
|
+
readonly "mipmap-xxxhdpi": 432;
|
|
14
|
+
};
|
|
15
|
+
export type AndroidDensityDir = keyof typeof ANDROID_DENSITY_SIZES;
|
|
16
|
+
export declare const ANDROID_DENSITY_DIRS: AndroidDensityDir[];
|
|
17
|
+
/**
|
|
18
|
+
* Directory holding version-26+ adaptive-icon XML.
|
|
19
|
+
*/
|
|
20
|
+
export declare const ADAPTIVE_ICON_DIR = "mipmap-anydpi-v26";
|
|
21
|
+
/**
|
|
22
|
+
* Adaptive icons require this API level; below it legacy bitmaps are needed.
|
|
23
|
+
*/
|
|
24
|
+
export declare const ADAPTIVE_ICON_MIN_SDK = 26;
|
|
25
|
+
/**
|
|
26
|
+
* Fraction of the 108dp adaptive canvas the artwork occupies. Android reserves
|
|
27
|
+
* the outer 18dp on every side (visible viewport ≈ inner 72dp, guaranteed-safe
|
|
28
|
+
* inner 66dp), so full-bleed art (1.0) is cropped by the launcher mask and
|
|
29
|
+
* looks zoomed in. Scaling to this fraction and centering keeps the logo safe.
|
|
30
|
+
*/
|
|
31
|
+
export declare const FOREGROUND_SAFE_ZONE_SCALE = 0.72;
|
|
32
|
+
/**
|
|
33
|
+
* Reserved icon name for the project's primary (`ic_launcher`) icon. A dedicated
|
|
34
|
+
* alias under this name owns the launcher role so the real `MainActivity` can
|
|
35
|
+
* stay enabled — otherwise disabling it to swap icons breaks tools (e.g. the
|
|
36
|
+
* Expo dev client) that launch `MainActivity` by explicit component.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_ICON_NAME = "DEFAULT";
|
|
39
|
+
/**
|
|
40
|
+
* Fully-qualified activity-alias name for an icon. Must match the name the
|
|
41
|
+
* native module toggles at runtime (`<package>.MainActivity<iconName>`).
|
|
42
|
+
*/
|
|
43
|
+
export declare function activityAliasName(packageName: string, iconName: string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Turn an arbitrary icon key into a valid Android resource name.
|
|
46
|
+
*/
|
|
47
|
+
export declare function toResourceName(iconKey: string): string;
|
|
48
|
+
/**
|
|
49
|
+
* XML body for an adaptive icon whose foreground points at `<foregroundResource>`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function adaptiveIconXml(foregroundResource: string): string;
|
|
52
|
+
/**
|
|
53
|
+
* Centered content size + inset for placing artwork inside the safe zone.
|
|
54
|
+
*/
|
|
55
|
+
export declare function foregroundLayout(canvasSize: number, scale?: number): {
|
|
56
|
+
contentSize: number;
|
|
57
|
+
inset: number;
|
|
58
|
+
};
|
|
59
|
+
/**
|
|
60
|
+
* Whether the project still needs pre-26 legacy square/round bitmaps.
|
|
61
|
+
*/
|
|
62
|
+
export declare function needsLegacyBitmaps(minSdkVersion: number | null): boolean;
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the Android `minSdkVersion`. Prefers the declared
|
|
65
|
+
* `expo-build-properties` plugin config (order-independent — `gradle.properties`
|
|
66
|
+
* may not be written yet when our dangerous mod runs), then falls back to a
|
|
67
|
+
* `gradle.properties` snapshot. Returns null when undeterminable.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveMinSdkVersion(plugins: unknown, gradlePropertiesContent?: string | null): number | null;
|
|
70
|
+
/**
|
|
71
|
+
* Legacy bitmap basenames this plugin owns for an icon key (square + round).
|
|
72
|
+
*/
|
|
73
|
+
export declare function legacyBitmapNames(iconKey: string): [string, string];
|
|
74
|
+
/**
|
|
75
|
+
* Adaptive foreground bitmap basename for an icon key.
|
|
76
|
+
*/
|
|
77
|
+
export declare function foregroundBitmapName(iconKey: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* Adaptive-icon XML basenames this plugin owns for an icon key.
|
|
80
|
+
*/
|
|
81
|
+
export declare function adaptiveXmlNames(iconKey: string): [string, string];
|
|
82
|
+
/**
|
|
83
|
+
* True when `fileName` is a mipmap resource this plugin generated for `iconKey`.
|
|
84
|
+
*/
|
|
85
|
+
export declare function isOwnedMipmapFile(fileName: string, iconKey: string): boolean;
|
|
86
|
+
/**
|
|
87
|
+
* True when `fileName` is an adaptive XML this plugin generated for `iconKey`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function isOwnedAdaptiveXml(fileName: string, iconKey: string): boolean;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Pure helpers describing how Android launcher icons are laid out on disk.
|
|
4
|
+
* Kept free of `expo`/`fs` imports so the logic can be unit-tested directly.
|
|
5
|
+
*/
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.DEFAULT_ICON_NAME = exports.FOREGROUND_SAFE_ZONE_SCALE = exports.ADAPTIVE_ICON_MIN_SDK = exports.ADAPTIVE_ICON_DIR = exports.ANDROID_DENSITY_DIRS = exports.ANDROID_DENSITY_SIZES = void 0;
|
|
8
|
+
exports.activityAliasName = activityAliasName;
|
|
9
|
+
exports.toResourceName = toResourceName;
|
|
10
|
+
exports.adaptiveIconXml = adaptiveIconXml;
|
|
11
|
+
exports.foregroundLayout = foregroundLayout;
|
|
12
|
+
exports.needsLegacyBitmaps = needsLegacyBitmaps;
|
|
13
|
+
exports.resolveMinSdkVersion = resolveMinSdkVersion;
|
|
14
|
+
exports.legacyBitmapNames = legacyBitmapNames;
|
|
15
|
+
exports.foregroundBitmapName = foregroundBitmapName;
|
|
16
|
+
exports.adaptiveXmlNames = adaptiveXmlNames;
|
|
17
|
+
exports.isOwnedMipmapFile = isOwnedMipmapFile;
|
|
18
|
+
exports.isOwnedAdaptiveXml = isOwnedAdaptiveXml;
|
|
19
|
+
/**
|
|
20
|
+
* Density bucket directory → foreground pixel size (108dp at each scale).
|
|
21
|
+
*/
|
|
22
|
+
exports.ANDROID_DENSITY_SIZES = {
|
|
23
|
+
"mipmap-mdpi": 108,
|
|
24
|
+
"mipmap-hdpi": 162,
|
|
25
|
+
"mipmap-xhdpi": 216,
|
|
26
|
+
"mipmap-xxhdpi": 324,
|
|
27
|
+
"mipmap-xxxhdpi": 432,
|
|
28
|
+
};
|
|
29
|
+
exports.ANDROID_DENSITY_DIRS = Object.keys(exports.ANDROID_DENSITY_SIZES);
|
|
30
|
+
/**
|
|
31
|
+
* Directory holding version-26+ adaptive-icon XML.
|
|
32
|
+
*/
|
|
33
|
+
exports.ADAPTIVE_ICON_DIR = "mipmap-anydpi-v26";
|
|
34
|
+
/**
|
|
35
|
+
* Adaptive icons require this API level; below it legacy bitmaps are needed.
|
|
36
|
+
*/
|
|
37
|
+
exports.ADAPTIVE_ICON_MIN_SDK = 26;
|
|
38
|
+
/**
|
|
39
|
+
* Fraction of the 108dp adaptive canvas the artwork occupies. Android reserves
|
|
40
|
+
* the outer 18dp on every side (visible viewport ≈ inner 72dp, guaranteed-safe
|
|
41
|
+
* inner 66dp), so full-bleed art (1.0) is cropped by the launcher mask and
|
|
42
|
+
* looks zoomed in. Scaling to this fraction and centering keeps the logo safe.
|
|
43
|
+
*/
|
|
44
|
+
exports.FOREGROUND_SAFE_ZONE_SCALE = 0.72;
|
|
45
|
+
/**
|
|
46
|
+
* Reserved icon name for the project's primary (`ic_launcher`) icon. A dedicated
|
|
47
|
+
* alias under this name owns the launcher role so the real `MainActivity` can
|
|
48
|
+
* stay enabled — otherwise disabling it to swap icons breaks tools (e.g. the
|
|
49
|
+
* Expo dev client) that launch `MainActivity` by explicit component.
|
|
50
|
+
*/
|
|
51
|
+
exports.DEFAULT_ICON_NAME = "DEFAULT";
|
|
52
|
+
/**
|
|
53
|
+
* Fully-qualified activity-alias name for an icon. Must match the name the
|
|
54
|
+
* native module toggles at runtime (`<package>.MainActivity<iconName>`).
|
|
55
|
+
*/
|
|
56
|
+
function activityAliasName(packageName, iconName) {
|
|
57
|
+
return `${packageName}.MainActivity${iconName}`;
|
|
58
|
+
}
|
|
59
|
+
const NON_RESOURCE_CHARS = /[^a-zA-Z0-9_]/g;
|
|
60
|
+
/**
|
|
61
|
+
* Turn an arbitrary icon key into a valid Android resource name.
|
|
62
|
+
*/
|
|
63
|
+
function toResourceName(iconKey) {
|
|
64
|
+
return iconKey.replace(NON_RESOURCE_CHARS, "_").toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* XML body for an adaptive icon whose foreground points at `<foregroundResource>`.
|
|
68
|
+
*/
|
|
69
|
+
function adaptiveIconXml(foregroundResource) {
|
|
70
|
+
return [
|
|
71
|
+
`<?xml version="1.0" encoding="utf-8"?>`,
|
|
72
|
+
`<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">`,
|
|
73
|
+
` <background android:drawable="@color/iconBackground"/>`,
|
|
74
|
+
` <foreground android:drawable="@mipmap/${foregroundResource}"/>`,
|
|
75
|
+
`</adaptive-icon>`,
|
|
76
|
+
``,
|
|
77
|
+
].join("\n");
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Centered content size + inset for placing artwork inside the safe zone.
|
|
81
|
+
*/
|
|
82
|
+
function foregroundLayout(canvasSize, scale = exports.FOREGROUND_SAFE_ZONE_SCALE) {
|
|
83
|
+
const contentSize = Math.round(canvasSize * scale);
|
|
84
|
+
const inset = Math.round((canvasSize - contentSize) / 2);
|
|
85
|
+
return { contentSize, inset };
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Whether the project still needs pre-26 legacy square/round bitmaps.
|
|
89
|
+
*/
|
|
90
|
+
function needsLegacyBitmaps(minSdkVersion) {
|
|
91
|
+
return minSdkVersion == null || minSdkVersion < exports.ADAPTIVE_ICON_MIN_SDK;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Resolve the Android `minSdkVersion`. Prefers the declared
|
|
95
|
+
* `expo-build-properties` plugin config (order-independent — `gradle.properties`
|
|
96
|
+
* may not be written yet when our dangerous mod runs), then falls back to a
|
|
97
|
+
* `gradle.properties` snapshot. Returns null when undeterminable.
|
|
98
|
+
*/
|
|
99
|
+
function resolveMinSdkVersion(plugins, gradlePropertiesContent) {
|
|
100
|
+
if (Array.isArray(plugins)) {
|
|
101
|
+
for (const entry of plugins) {
|
|
102
|
+
if (Array.isArray(entry) && entry[0] === "expo-build-properties") {
|
|
103
|
+
const declared = entry[1]?.android?.minSdkVersion;
|
|
104
|
+
if (typeof declared === "number")
|
|
105
|
+
return declared;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (gradlePropertiesContent) {
|
|
110
|
+
const captured = gradlePropertiesContent.match(/android\.minSdkVersion\s*=\s*(\d+)/)?.[1];
|
|
111
|
+
if (captured)
|
|
112
|
+
return Number.parseInt(captured, 10);
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Legacy bitmap basenames this plugin owns for an icon key (square + round).
|
|
118
|
+
*/
|
|
119
|
+
function legacyBitmapNames(iconKey) {
|
|
120
|
+
const safe = toResourceName(iconKey);
|
|
121
|
+
return [`${safe}.png`, `${safe}_round.png`];
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Adaptive foreground bitmap basename for an icon key.
|
|
125
|
+
*/
|
|
126
|
+
function foregroundBitmapName(iconKey) {
|
|
127
|
+
return `${toResourceName(iconKey)}_foreground.png`;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Adaptive-icon XML basenames this plugin owns for an icon key.
|
|
131
|
+
*/
|
|
132
|
+
function adaptiveXmlNames(iconKey) {
|
|
133
|
+
const safe = toResourceName(iconKey);
|
|
134
|
+
return [`${safe}.xml`, `${safe}_round.xml`];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* True when `fileName` is a mipmap resource this plugin generated for `iconKey`.
|
|
138
|
+
*/
|
|
139
|
+
function isOwnedMipmapFile(fileName, iconKey) {
|
|
140
|
+
const [square, round] = legacyBitmapNames(iconKey);
|
|
141
|
+
return (fileName === square ||
|
|
142
|
+
fileName === round ||
|
|
143
|
+
fileName === foregroundBitmapName(iconKey));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* True when `fileName` is an adaptive XML this plugin generated for `iconKey`.
|
|
147
|
+
*/
|
|
148
|
+
function isOwnedAdaptiveXml(fileName, iconKey) {
|
|
149
|
+
const [square, round] = adaptiveXmlNames(iconKey);
|
|
150
|
+
return fileName === square || fileName === round;
|
|
151
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ConfigPlugin } from "expo/config-plugins";
|
|
2
|
+
import type { IconSet } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Set up the launcher entries for icon switching.
|
|
5
|
+
*
|
|
6
|
+
* `MainActivity` keeps every non-launcher filter (deep links, etc.) and stays
|
|
7
|
+
* enabled so tools that launch it by explicit component (e.g. the Expo dev
|
|
8
|
+
* client) keep working — but its MAIN/LAUNCHER filter is removed so it isn't a
|
|
9
|
+
* second home-screen icon. A dedicated `DEFAULT` alias owns the launcher role
|
|
10
|
+
* for the project's primary icon, and one disabled alias is added per
|
|
11
|
+
* configured icon. The native module enables exactly one alias at a time.
|
|
12
|
+
*/
|
|
13
|
+
export declare const withAndroidIconAliases: ConfigPlugin<IconSet>;
|
|
14
|
+
/**
|
|
15
|
+
* Generate Android launcher icons as adaptive icons (so the launcher mask never
|
|
16
|
+
* shrinks them) plus, when the project's minSdk is below 26, legacy bitmaps.
|
|
17
|
+
*/
|
|
18
|
+
export declare const withAndroidIconResources: ConfigPlugin<IconSet>;
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.withAndroidIconResources = exports.withAndroidIconAliases = void 0;
|
|
7
|
+
const config_plugins_1 = require("expo/config-plugins");
|
|
8
|
+
const image_utils_1 = require("@expo/image-utils");
|
|
9
|
+
// @ts-ignore - no types; ships with @expo/image-utils. Used to pad the adaptive
|
|
10
|
+
// foreground onto a transparent canvas without requiring `sharp`.
|
|
11
|
+
const jimp_compact_1 = __importDefault(require("jimp-compact"));
|
|
12
|
+
const fs_1 = __importDefault(require("fs"));
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const android_resources_1 = require("./android-resources");
|
|
15
|
+
const { getMainApplicationOrThrow, getMainActivityOrThrow } = config_plugins_1.AndroidConfig.Manifest;
|
|
16
|
+
const ANDROID_RES_PATH = ["app", "src", "main", "res"];
|
|
17
|
+
const LAUNCHER_CATEGORY = "android.intent.category.LAUNCHER";
|
|
18
|
+
/** A standalone MAIN/LAUNCHER intent-filter for an alias. */
|
|
19
|
+
const launcherIntentFilter = () => ({
|
|
20
|
+
action: [{ $: { "android:name": "android.intent.action.MAIN" } }],
|
|
21
|
+
category: [{ $: { "android:name": LAUNCHER_CATEGORY } }],
|
|
22
|
+
});
|
|
23
|
+
/** Whether an intent-filter is the home-screen launcher entry. */
|
|
24
|
+
function isLauncherIntentFilter(filter) {
|
|
25
|
+
return (Array.isArray(filter?.category) &&
|
|
26
|
+
filter.category.some((entry) => entry?.$?.["android:name"] === LAUNCHER_CATEGORY));
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Set up the launcher entries for icon switching.
|
|
30
|
+
*
|
|
31
|
+
* `MainActivity` keeps every non-launcher filter (deep links, etc.) and stays
|
|
32
|
+
* enabled so tools that launch it by explicit component (e.g. the Expo dev
|
|
33
|
+
* client) keep working — but its MAIN/LAUNCHER filter is removed so it isn't a
|
|
34
|
+
* second home-screen icon. A dedicated `DEFAULT` alias owns the launcher role
|
|
35
|
+
* for the project's primary icon, and one disabled alias is added per
|
|
36
|
+
* configured icon. The native module enables exactly one alias at a time.
|
|
37
|
+
*/
|
|
38
|
+
const withAndroidIconAliases = (config, icons) => {
|
|
39
|
+
return (0, config_plugins_1.withAndroidManifest)(config, (config) => {
|
|
40
|
+
const mainApplication = getMainApplicationOrThrow(config.modResults);
|
|
41
|
+
const mainActivity = getMainActivityOrThrow(config.modResults);
|
|
42
|
+
const packageName = config.android.package;
|
|
43
|
+
const aliasPrefix = `${packageName}.MainActivity`;
|
|
44
|
+
if (Array.isArray(mainActivity["intent-filter"])) {
|
|
45
|
+
mainActivity["intent-filter"] = mainActivity["intent-filter"].filter((filter) => !isLauncherIntentFilter(filter));
|
|
46
|
+
}
|
|
47
|
+
const defaultAlias = {
|
|
48
|
+
$: {
|
|
49
|
+
"android:name": (0, android_resources_1.activityAliasName)(packageName, android_resources_1.DEFAULT_ICON_NAME),
|
|
50
|
+
"android:enabled": "true",
|
|
51
|
+
"android:exported": "true",
|
|
52
|
+
"android:icon": "@mipmap/ic_launcher",
|
|
53
|
+
"android:roundIcon": "@mipmap/ic_launcher_round",
|
|
54
|
+
"android:targetActivity": ".MainActivity",
|
|
55
|
+
},
|
|
56
|
+
"intent-filter": [launcherIntentFilter()],
|
|
57
|
+
};
|
|
58
|
+
const iconAliases = Object.keys(icons).map((iconKey) => {
|
|
59
|
+
const resource = (0, android_resources_1.toResourceName)(iconKey);
|
|
60
|
+
return {
|
|
61
|
+
$: {
|
|
62
|
+
"android:name": (0, android_resources_1.activityAliasName)(packageName, iconKey),
|
|
63
|
+
"android:enabled": "false",
|
|
64
|
+
"android:exported": "true",
|
|
65
|
+
"android:icon": `@mipmap/${resource}`,
|
|
66
|
+
"android:roundIcon": `@mipmap/${resource}_round`,
|
|
67
|
+
"android:targetActivity": ".MainActivity",
|
|
68
|
+
},
|
|
69
|
+
"intent-filter": [launcherIntentFilter()],
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
const preserved = (mainApplication["activity-alias"] || []).filter((alias) => !String(alias.$["android:name"]).startsWith(aliasPrefix));
|
|
73
|
+
mainApplication["activity-alias"] = [
|
|
74
|
+
...preserved,
|
|
75
|
+
defaultAlias,
|
|
76
|
+
...iconAliases,
|
|
77
|
+
];
|
|
78
|
+
return config;
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
exports.withAndroidIconAliases = withAndroidIconAliases;
|
|
82
|
+
/**
|
|
83
|
+
* Read `gradle.properties` if it already exists (used as a minSdk fallback).
|
|
84
|
+
*/
|
|
85
|
+
function readGradleProperties(platformProjectRoot) {
|
|
86
|
+
try {
|
|
87
|
+
return fs_1.default.readFileSync(path_1.default.join(platformProjectRoot, "gradle.properties"), "utf8");
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Generate, then write, a single density's foreground bitmap (padded + centered).
|
|
95
|
+
*/
|
|
96
|
+
async function writeForegroundBitmap(projectRoot, outputDir, iconKey, source, canvasSize) {
|
|
97
|
+
const fileName = (0, android_resources_1.foregroundBitmapName)(iconKey);
|
|
98
|
+
const { contentSize, inset } = (0, android_resources_1.foregroundLayout)(canvasSize);
|
|
99
|
+
const { source: scaledArt } = await (0, image_utils_1.generateImageAsync)({
|
|
100
|
+
projectRoot,
|
|
101
|
+
cacheType: `expo-app-icon-fg-${(0, android_resources_1.toResourceName)(iconKey)}-${canvasSize}`,
|
|
102
|
+
}, {
|
|
103
|
+
name: fileName,
|
|
104
|
+
src: source,
|
|
105
|
+
removeTransparency: false,
|
|
106
|
+
resizeMode: "cover",
|
|
107
|
+
width: contentSize,
|
|
108
|
+
height: contentSize,
|
|
109
|
+
});
|
|
110
|
+
const canvas = new jimp_compact_1.default(canvasSize, canvasSize, 0x00000000);
|
|
111
|
+
canvas.composite(await jimp_compact_1.default.read(scaledArt), inset, inset);
|
|
112
|
+
await fs_1.default.promises.writeFile(path_1.default.join(outputDir, fileName), await canvas.getBufferAsync(jimp_compact_1.default.MIME_PNG));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Generate, then write, the legacy square + round bitmaps for one density.
|
|
116
|
+
*/
|
|
117
|
+
async function writeLegacyBitmaps(projectRoot, outputDir, iconKey, source, canvasSize) {
|
|
118
|
+
const [squareName, roundName] = (0, android_resources_1.legacyBitmapNames)(iconKey);
|
|
119
|
+
const resource = (0, android_resources_1.toResourceName)(iconKey);
|
|
120
|
+
const { source: square } = await (0, image_utils_1.generateImageAsync)({ projectRoot, cacheType: `expo-app-icon-${resource}-${canvasSize}` }, {
|
|
121
|
+
name: squareName,
|
|
122
|
+
src: source,
|
|
123
|
+
removeTransparency: true,
|
|
124
|
+
backgroundColor: "#ffffff",
|
|
125
|
+
resizeMode: "cover",
|
|
126
|
+
width: canvasSize,
|
|
127
|
+
height: canvasSize,
|
|
128
|
+
});
|
|
129
|
+
await fs_1.default.promises.writeFile(path_1.default.join(outputDir, squareName), square);
|
|
130
|
+
const { source: round } = await (0, image_utils_1.generateImageAsync)({ projectRoot, cacheType: `expo-app-icon-round-${resource}-${canvasSize}` }, {
|
|
131
|
+
name: roundName,
|
|
132
|
+
src: source,
|
|
133
|
+
removeTransparency: false,
|
|
134
|
+
resizeMode: "cover",
|
|
135
|
+
width: canvasSize,
|
|
136
|
+
height: canvasSize,
|
|
137
|
+
borderRadius: canvasSize / 2,
|
|
138
|
+
});
|
|
139
|
+
await fs_1.default.promises.writeFile(path_1.default.join(outputDir, roundName), round);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Remove every mipmap/adaptive resource a previous run of this plugin emitted.
|
|
143
|
+
*/
|
|
144
|
+
async function cleanGeneratedResources(resPath, iconKeys) {
|
|
145
|
+
for (const densityDir of android_resources_1.ANDROID_DENSITY_DIRS) {
|
|
146
|
+
const dir = path_1.default.join(resPath, densityDir);
|
|
147
|
+
const files = await fs_1.default.promises.readdir(dir).catch(() => []);
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
if (file.startsWith("ic_launcher.") || file.startsWith("ic_launcher_round.")) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (iconKeys.some((iconKey) => (0, android_resources_1.isOwnedMipmapFile)(file, iconKey))) {
|
|
153
|
+
await fs_1.default.promises.rm(path_1.default.join(dir, file), { force: true }).catch(() => null);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const adaptiveDir = path_1.default.join(resPath, android_resources_1.ADAPTIVE_ICON_DIR);
|
|
158
|
+
const adaptiveFiles = await fs_1.default.promises.readdir(adaptiveDir).catch(() => []);
|
|
159
|
+
for (const file of adaptiveFiles) {
|
|
160
|
+
if (iconKeys.some((iconKey) => (0, android_resources_1.isOwnedAdaptiveXml)(file, iconKey))) {
|
|
161
|
+
await fs_1.default.promises
|
|
162
|
+
.rm(path_1.default.join(adaptiveDir, file), { force: true })
|
|
163
|
+
.catch(() => null);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Generate Android launcher icons as adaptive icons (so the launcher mask never
|
|
169
|
+
* shrinks them) plus, when the project's minSdk is below 26, legacy bitmaps.
|
|
170
|
+
*/
|
|
171
|
+
const withAndroidIconResources = (config, icons) => {
|
|
172
|
+
return (0, config_plugins_1.withDangerousMod)(config, [
|
|
173
|
+
"android",
|
|
174
|
+
async (config) => {
|
|
175
|
+
const { platformProjectRoot, projectRoot } = config.modRequest;
|
|
176
|
+
const resPath = path_1.default.join(platformProjectRoot, ...ANDROID_RES_PATH);
|
|
177
|
+
const adaptiveDir = path_1.default.join(resPath, android_resources_1.ADAPTIVE_ICON_DIR);
|
|
178
|
+
await fs_1.default.promises.mkdir(adaptiveDir, { recursive: true });
|
|
179
|
+
const minSdkVersion = (0, android_resources_1.resolveMinSdkVersion)(config.plugins, readGradleProperties(platformProjectRoot));
|
|
180
|
+
const emitLegacy = (0, android_resources_1.needsLegacyBitmaps)(minSdkVersion);
|
|
181
|
+
const iconKeys = Object.keys(icons);
|
|
182
|
+
await cleanGeneratedResources(resPath, iconKeys);
|
|
183
|
+
for (const [iconKey, { android }] of Object.entries(icons)) {
|
|
184
|
+
if (!android)
|
|
185
|
+
continue;
|
|
186
|
+
for (const densityDir of android_resources_1.ANDROID_DENSITY_DIRS) {
|
|
187
|
+
const canvasSize = android_resources_1.ANDROID_DENSITY_SIZES[densityDir];
|
|
188
|
+
const outputDir = path_1.default.join(resPath, densityDir);
|
|
189
|
+
if (emitLegacy) {
|
|
190
|
+
await writeLegacyBitmaps(projectRoot, outputDir, iconKey, android, canvasSize);
|
|
191
|
+
}
|
|
192
|
+
await writeForegroundBitmap(projectRoot, outputDir, iconKey, android, canvasSize);
|
|
193
|
+
}
|
|
194
|
+
const xml = (0, android_resources_1.adaptiveIconXml)((0, android_resources_1.foregroundBitmapName)(iconKey).replace(/\.png$/, ""));
|
|
195
|
+
const [squareXml, roundXml] = (0, android_resources_1.adaptiveXmlNames)(iconKey);
|
|
196
|
+
await fs_1.default.promises.writeFile(path_1.default.join(adaptiveDir, squareXml), xml);
|
|
197
|
+
await fs_1.default.promises.writeFile(path_1.default.join(adaptiveDir, roundXml), xml);
|
|
198
|
+
}
|
|
199
|
+
return config;
|
|
200
|
+
},
|
|
201
|
+
]);
|
|
202
|
+
};
|
|
203
|
+
exports.withAndroidIconResources = withAndroidIconResources;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ConfigPlugin } from "expo/config-plugins";
|
|
2
|
+
import type { ResolvedIconProps } from "./types";
|
|
3
|
+
/**
|
|
4
|
+
* Add the generated icon files to the Xcode project (removing stale ones).
|
|
5
|
+
*/
|
|
6
|
+
export declare const withAppleIconAssets: ConfigPlugin<ResolvedIconProps>;
|
|
7
|
+
/**
|
|
8
|
+
* Register the alternate icons in Info.plist (per device family).
|
|
9
|
+
*/
|
|
10
|
+
export declare const withAppleAlternateIcons: ConfigPlugin<ResolvedIconProps>;
|
|
11
|
+
/**
|
|
12
|
+
* Render and write the actual icon PNGs into the iOS project.
|
|
13
|
+
*/
|
|
14
|
+
export declare const withAppleIconImages: ConfigPlugin<ResolvedIconProps>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.withAppleIconImages = exports.withAppleAlternateIcons = exports.withAppleIconAssets = void 0;
|
|
7
|
+
const config_plugins_1 = require("expo/config-plugins");
|
|
8
|
+
const image_utils_1 = require("@expo/image-utils");
|
|
9
|
+
// @ts-ignore - no types
|
|
10
|
+
const pbxFile_1 = __importDefault(require("xcode/lib/pbxFile"));
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const icons_1 = require("./icons");
|
|
14
|
+
/**
|
|
15
|
+
* Xcode group / on-disk folder the generated icon assets live in.
|
|
16
|
+
*/
|
|
17
|
+
const APPLE_ASSET_GROUP = "AppIconVariants";
|
|
18
|
+
/**
|
|
19
|
+
* Add the generated icon files to the Xcode project (removing stale ones).
|
|
20
|
+
*/
|
|
21
|
+
const withAppleIconAssets = (config, props) => {
|
|
22
|
+
return (0, config_plugins_1.withXcodeProject)(config, async (config) => {
|
|
23
|
+
const groupPath = `${config.modRequest.projectName}/${APPLE_ASSET_GROUP}`;
|
|
24
|
+
const group = config_plugins_1.IOSConfig.XcodeUtils.ensureGroupRecursively(config.modResults, groupPath);
|
|
25
|
+
const project = config.modResults;
|
|
26
|
+
const options = {};
|
|
27
|
+
const findGroupId = (section) => Object.keys(project.hash.project.objects[section] ?? {}).find((id) => project.hash.project.objects[section][id].name === group.name);
|
|
28
|
+
const groupId = findGroupId("PBXGroup");
|
|
29
|
+
if (!project.hash.project.objects["PBXVariantGroup"]) {
|
|
30
|
+
project.hash.project.objects["PBXVariantGroup"] = {};
|
|
31
|
+
}
|
|
32
|
+
const variantGroupId = findGroupId("PBXVariantGroup");
|
|
33
|
+
// Unlink any previously generated assets.
|
|
34
|
+
for (const child of [...(group.children || [])]) {
|
|
35
|
+
const file = new pbxFile_1.default(path_1.default.join(group.name, child.comment), options);
|
|
36
|
+
file.target = options ? options.target : undefined;
|
|
37
|
+
project.removeFromPbxBuildFileSection(file);
|
|
38
|
+
project.removeFromPbxFileReferenceSection(file);
|
|
39
|
+
if (groupId) {
|
|
40
|
+
project.removeFromPbxGroup(file, groupId);
|
|
41
|
+
}
|
|
42
|
+
else if (variantGroupId) {
|
|
43
|
+
project.removeFromPbxVariantGroup(file, variantGroupId);
|
|
44
|
+
}
|
|
45
|
+
project.removeFromPbxResourcesBuildPhase(file);
|
|
46
|
+
}
|
|
47
|
+
// Link the freshly generated assets.
|
|
48
|
+
await (0, icons_1.forEachAppleIcon)(props, async (iconKey, _icon, variant) => {
|
|
49
|
+
const fileName = (0, icons_1.appleIconFileName)(iconKey, variant);
|
|
50
|
+
const alreadyLinked = group?.children.some(({ comment }) => comment === fileName);
|
|
51
|
+
if (alreadyLinked)
|
|
52
|
+
return;
|
|
53
|
+
config.modResults = config_plugins_1.IOSConfig.XcodeUtils.addResourceFileToGroup({
|
|
54
|
+
filepath: path_1.default.join(groupPath, fileName),
|
|
55
|
+
groupName: groupPath,
|
|
56
|
+
project: config.modResults,
|
|
57
|
+
isBuildFile: true,
|
|
58
|
+
verbose: true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
return config;
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
exports.withAppleIconAssets = withAppleIconAssets;
|
|
65
|
+
/**
|
|
66
|
+
* Register the alternate icons in Info.plist (per device family).
|
|
67
|
+
*/
|
|
68
|
+
const withAppleAlternateIcons = (config, props) => {
|
|
69
|
+
return (0, config_plugins_1.withInfoPlist)(config, async (config) => {
|
|
70
|
+
const phoneIcons = {};
|
|
71
|
+
const iconsByTarget = {};
|
|
72
|
+
await (0, icons_1.forEachAppleIcon)(props, async (iconKey, icon, variant) => {
|
|
73
|
+
if (!icon.ios)
|
|
74
|
+
return;
|
|
75
|
+
const entry = {
|
|
76
|
+
CFBundleIconFiles: [(0, icons_1.appleIconBaseName)(iconKey, variant)],
|
|
77
|
+
UIPrerenderedIcon: !!icon.prerendered,
|
|
78
|
+
};
|
|
79
|
+
if (variant.target) {
|
|
80
|
+
(iconsByTarget[variant.target] ??= {})[iconKey] = entry;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
phoneIcons[iconKey] = entry;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const writeIconsBlock = (key, icons) => {
|
|
87
|
+
const block = config.modResults[key];
|
|
88
|
+
if (typeof block !== "object" || Array.isArray(block) || !block) {
|
|
89
|
+
config.modResults[key] = {};
|
|
90
|
+
}
|
|
91
|
+
// @ts-ignore - plist values are loosely typed
|
|
92
|
+
config.modResults[key].CFBundleAlternateIcons = icons;
|
|
93
|
+
// @ts-ignore
|
|
94
|
+
config.modResults[key].CFBundlePrimaryIcon = {
|
|
95
|
+
CFBundleIconFiles: ["AppIcon"],
|
|
96
|
+
};
|
|
97
|
+
};
|
|
98
|
+
writeIconsBlock("CFBundleIcons", phoneIcons);
|
|
99
|
+
for (const [target, icons] of Object.entries(iconsByTarget)) {
|
|
100
|
+
if (Object.keys(icons).length > 0) {
|
|
101
|
+
writeIconsBlock(`CFBundleIcons~${target}`, icons);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return config;
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
exports.withAppleAlternateIcons = withAppleAlternateIcons;
|
|
108
|
+
/**
|
|
109
|
+
* Render and write the actual icon PNGs into the iOS project.
|
|
110
|
+
*/
|
|
111
|
+
const withAppleIconImages = (config, props) => {
|
|
112
|
+
return (0, config_plugins_1.withDangerousMod)(config, [
|
|
113
|
+
"ios",
|
|
114
|
+
async (config) => {
|
|
115
|
+
const iosRoot = path_1.default.join(config.modRequest.platformProjectRoot, config.modRequest.projectName);
|
|
116
|
+
const assetDir = path_1.default.join(iosRoot, APPLE_ASSET_GROUP);
|
|
117
|
+
await fs_1.default.promises
|
|
118
|
+
.rm(assetDir, { recursive: true, force: true })
|
|
119
|
+
.catch(() => null);
|
|
120
|
+
await fs_1.default.promises.mkdir(assetDir, { recursive: true });
|
|
121
|
+
await (0, icons_1.forEachAppleIcon)(props, async (iconKey, icon, variant) => {
|
|
122
|
+
if (!icon.ios)
|
|
123
|
+
return;
|
|
124
|
+
const fileName = (0, icons_1.appleIconFileName)(iconKey, variant);
|
|
125
|
+
const { source } = await (0, image_utils_1.generateImageAsync)({
|
|
126
|
+
projectRoot: config.modRequest.projectRoot,
|
|
127
|
+
cacheType: `expo-app-icon-${variant.width}-${variant.height}`,
|
|
128
|
+
}, {
|
|
129
|
+
name: fileName,
|
|
130
|
+
src: icon.ios,
|
|
131
|
+
removeTransparency: true,
|
|
132
|
+
backgroundColor: "#ffffff",
|
|
133
|
+
resizeMode: "cover",
|
|
134
|
+
width: variant.width,
|
|
135
|
+
height: variant.height,
|
|
136
|
+
});
|
|
137
|
+
await fs_1.default.promises.writeFile(path_1.default.join(assetDir, fileName), source);
|
|
138
|
+
});
|
|
139
|
+
return config;
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
};
|
|
143
|
+
exports.withAppleIconImages = withAppleIconImages;
|