diff --git a/README.md b/README.md index b83207b..7e78e1e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Expo Alternate App Icons is a library that allows you to easily switch between d | Android Device | Android Emulator | iOS Device | iOS Simulator | Web | | -------------- | ---------------- | ---------- | ------------- | --- | -| ❌ | ❌ | ✅ | ✅ | ❌ | +| ✅ | ✅ | ✅ | ✅ | ❌ | ## Introduction @@ -23,37 +23,69 @@ Customizing app icons can be a valuable way to provide users with a personalized To get started, install the library using Expo CLI: ```sh -expo install expo-alternate-app-icons +npx expo install expo-alternate-app-icons ``` +> Ensure your project is running Expo SDK 44+. + ## How To Use -This package contains an Expo Plugin that copies your alternative icons to the Xcode project. +This package contains an Expo Plugin that copies your alternative icons to native projects. 1. Add `expo-alternate-app-icons` to the plugins array inside your [app.json](https://docs.expo.dev/versions/latest/config/app/). -2. The second item in the array accepts an array with paths to your alternate icons. +2. The second item in the array accepts an array with details about your alternate icons. +3. [Prebuild](https://docs.expo.dev/workflow/prebuild/) a project using `npx expo prebuild --clean` to apply the plugin changes. -```json5 +```json // app.json { // ... - plugins: [ + "plugins": [ // ... [ - 'expo-alternate-app-icons', // add "expo-alternate-app-icons" to the plugins array - ['./assets/icon-a.png', './assets/icon-b.png', './assets/icon-c.png'], // array with paths to the icons - ], - ], + "expo-alternate-app-icons", + [ + { + "name": "IconA", // The name of the alternate icon + "ios": "./assets/icon-a.png", // Path to the iOS app icon + "android": { + "foregroundImage": "./assets/icon-a-foreground.png", // Path to Android foreground image + "backgroundColor": "#001413" // Background color for Android adaptive icon + } + }, + { + "name": "IconB", + "ios": "./assets/icon-b.png", + "android": { + "foregroundImage": "./assets/icon-b-foreground.png", + "backgroundColor": "#001413" + } + }, + { + "name": "IconC", + "ios": "./assets/icon-c.png", + "android": { + "foregroundImage": "./assets/icon-c-foreground.png", + "backgroundColor": "#001413" + } + } + ] + ] + ] } ``` ### Icons -Your icons should follow the same format as your [default app icon](https://docs.expo.dev/develop/user-interface/app-icons/#ios). +Your icons should follow the same format as your [default app icon](https://docs.expo.dev/develop/user-interface/splash-screen-and-app-icon/#export-the-icon-image-as-a-png). -- Use a **.png** file. -- Square format with resolution **1024x1024 px**. -- Without transparency layer. +- Use a **.png** file +- Square format with resolution **1024x1024 px** +- iOS + - Without transparency layer +- Android - Adaptive icon + - Foreground image + - Background fill color ### API Documentation @@ -67,7 +99,7 @@ const supportsAlternateIcons: boolean; #### Set Alternate App Icon -To set app icon to **icon-a.png**, use `setAlternateAppIcon("icon-a")`. This function takes exact icon name without suffix. +To set app icon to **IconA**, use `setAlternateAppIcon("IconA")`. This function takes icon name as argument. To reset the app icon to the default pass `null` like `setAlternateAppIcon(null)`. @@ -92,3 +124,12 @@ Reset app icon to the default one. ```ts function resetAppIcon(): Promise; ``` + +## Development + +### Expo Config Plugin + +```shell +npm run build plugin # Start build on save +cd example && npx expo prebuild # Execute the config plugin +``` diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..938947f --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,92 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'maven-publish' + +group = 'expo.modules.alternateappicons' +version = '0.1.0' + +buildscript { + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") + if (expoModulesCorePlugin.exists()) { + apply from: expoModulesCorePlugin + applyKotlinExpoModulesCorePlugin() + } + + // Simple helper that allows the root project to override versions declared by this library. + ext.safeExtGet = { prop, fallback -> + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback + } + + // Ensures backward compatibility + ext.getKotlinVersion = { + if (ext.has("kotlinVersion")) { + ext.kotlinVersion() + } else { + ext.safeExtGet("kotlinVersion", "1.8.10") + } + } + + repositories { + mavenCentral() + } + + dependencies { + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}") + } +} + +afterEvaluate { + publishing { + publications { + release(MavenPublication) { + from components.release + } + } + repositories { + maven { + url = mavenLocal().url + } + } + } +} + +android { + compileSdkVersion safeExtGet("compileSdkVersion", 33) + + def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION + if (agpVersion.tokenize('.')[0].toInteger() < 8) { + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.majorVersion + } + } + + namespace "expo.modules.alternateappicons" + defaultConfig { + minSdkVersion safeExtGet("minSdkVersion", 21) + targetSdkVersion safeExtGet("targetSdkVersion", 34) + versionCode 1 + versionName "0.1.0" + } + lintOptions { + abortOnError false + } + publishing { + singleVariant("release") { + withSourcesJar() + } + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation project(':expo-modules-core') + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" +} diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bdae66c --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/android/src/main/java/expo/modules/alternateappicons/ExpoAlternateAppIconsModule.kt b/android/src/main/java/expo/modules/alternateappicons/ExpoAlternateAppIconsModule.kt new file mode 100644 index 0000000..3b93450 --- /dev/null +++ b/android/src/main/java/expo/modules/alternateappicons/ExpoAlternateAppIconsModule.kt @@ -0,0 +1,53 @@ +package expo.modules.alternateappicons + +import android.content.ComponentName +import android.content.pm.PackageManager +import expo.modules.kotlin.Promise +import expo.modules.kotlin.functions.Coroutine +import expo.modules.kotlin.modules.Module +import expo.modules.kotlin.modules.ModuleDefinition +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +const val MAIN_ACTIVITY_NAME = ".MainActivity" + +class ExpoAlternateAppIconsModule : Module() { + override fun definition() = ModuleDefinition { + Name("ExpoAlternateAppIcons") + + Constants( + "supportsAlternateIcons" to true + ) + + Function("getAppIconName", this@ExpoAlternateAppIconsModule::getAppIconName) + AsyncFunction("setAlternateAppIcon").Coroutine(this@ExpoAlternateAppIconsModule::setAlternateAppIcon) + } + + private fun getAppIconName(): String? { + val activityName = appContext.activityProvider?.currentActivity?.componentName?.shortClassName + + if(activityName !== null && !activityName.startsWith(MAIN_ACTIVITY_NAME) || activityName == MAIN_ACTIVITY_NAME) return null + + return activityName?.substring(MAIN_ACTIVITY_NAME.length) + } + + private suspend fun setAlternateAppIcon(icon: String?): String? = withContext(Dispatchers.Main) { + val currentActivityComponent = appContext.activityProvider?.currentActivity?.componentName + + if (currentActivityComponent == null || !currentActivityComponent.shortClassName.startsWith(MAIN_ACTIVITY_NAME)) return@withContext null + + val newActivityName = "$MAIN_ACTIVITY_NAME${icon ?: ""}" + + if (currentActivityComponent.shortClassName == newActivityName) return@withContext icon + + val packageName = currentActivityComponent.packageName; + val newActivityComponent = ComponentName(packageName, "$packageName$newActivityName") + + appContext.reactContext?.packageManager?.run { + setComponentEnabledSetting(newActivityComponent, PackageManager.COMPONENT_ENABLED_STATE_ENABLED, PackageManager.DONT_KILL_APP) + setComponentEnabledSetting(currentActivityComponent, PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP) + } + + return@withContext icon + } +} diff --git a/example/App.preset.ts b/example/App.preset.ts index 24dd3c7..e1592af 100644 --- a/example/App.preset.ts +++ b/example/App.preset.ts @@ -1,5 +1,5 @@ export const icons = [ - ['icon-a', require('./assets/icon-a.png')], - ['icon-b', require('./assets/icon-b.png')], - ['icon-c', require('./assets/icon-c.png')], + ['IconA', require('./assets/icon-a.png')], + ['IconB', require('./assets/icon-b.png')], + ['IconC', require('./assets/icon-c.png')], ]; diff --git a/example/app.json b/example/app.json index 7735c3d..bbf6897 100644 --- a/example/app.json +++ b/example/app.json @@ -18,8 +18,8 @@ }, "android": { "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#ffffff" + "foregroundImage": "./assets/icon-foreground.png", + "backgroundColor": "#001413" }, "package": "expo.modules.alternateappicons.example" }, @@ -27,7 +27,35 @@ "favicon": "./assets/favicon.png" }, "plugins": [ - ["../app.plugin.js", ["./assets/icon-a.png", "./assets/icon-b.png", "./assets/icon-c.png"]] + [ + "../app.plugin.js", + [ + { + "name": "IconA", + "ios": "./assets/icon-a.png", + "android": { + "foregroundImage": "./assets/icon-a-foreground.png", + "backgroundColor": "#001413" + } + }, + { + "name": "IconB", + "ios": "./assets/icon-b.png", + "android": { + "foregroundImage": "./assets/icon-b-foreground.png", + "backgroundColor": "#001413" + } + }, + { + "name": "IconC", + "ios": "./assets/icon-c.png", + "android": { + "foregroundImage": "./assets/icon-c-foreground.png", + "backgroundColor": "#001413" + } + } + ] + ] ] } } diff --git a/example/assets/icon-a-foreground.png b/example/assets/icon-a-foreground.png new file mode 100644 index 0000000..6477868 Binary files /dev/null and b/example/assets/icon-a-foreground.png differ diff --git a/example/assets/icon-b-foreground.png b/example/assets/icon-b-foreground.png new file mode 100644 index 0000000..383007b Binary files /dev/null and b/example/assets/icon-b-foreground.png differ diff --git a/example/assets/icon-c-foreground.png b/example/assets/icon-c-foreground.png new file mode 100644 index 0000000..9e0cab5 Binary files /dev/null and b/example/assets/icon-c-foreground.png differ diff --git a/example/assets/icon-foreground.png b/example/assets/icon-foreground.png new file mode 100644 index 0000000..92c6e25 Binary files /dev/null and b/example/assets/icon-foreground.png differ diff --git a/example/package-lock.json b/example/package-lock.json index 284e16d..8c274b7 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -8,7 +8,7 @@ "name": "expo-alternate-app-icons-example", "version": "1.0.0", "dependencies": { - "expo": "^50.0.6", + "expo": "~50.0.8", "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", "react": "18.2.0", @@ -2010,9 +2010,9 @@ } }, "node_modules/@expo/cli": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.17.5.tgz", - "integrity": "sha512-9cMquL/5bBfV73CbZcWipk3KZSo8mBqdgvkoWCtEtnnlm/879ayxzMWjVIgT5yV4w+X7+N6KkBSUIIj4t9Xqew==", + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.17.6.tgz", + "integrity": "sha512-vpwQOyhkqQ5Ao96AGaFntRf6dX7h7/e9T7oKZ5KfJiaLRgfmNa/yHFu5cpXG76T2R7Q6aiU4ik0KU3P7nFMzEw==", "dependencies": { "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "0.0.5", @@ -2792,9 +2792,9 @@ } }, "node_modules/@expo/metro-config": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-0.17.4.tgz", - "integrity": "sha512-PxqDMuVjXQeboa6Aj8kNj4iTxIpwpfoYlF803qOjf1LE1ePlREnWNwqy65ESCBnCmekYIO5hhm1Nksa+xCvuyg==", + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-0.17.5.tgz", + "integrity": "sha512-2YUebeIwr6gFxcIRSVAjWK5D8YSaXBzQoRRl3muJWsH8AC8a+T60xbA3cGhsEICD2zKS5zwnL2yobgs41Ur7nQ==", "dependencies": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", @@ -7423,23 +7423,23 @@ } }, "node_modules/expo": { - "version": "50.0.6", - "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.6.tgz", - "integrity": "sha512-CVg0h9bmYeTWtw4EOL0HKNL+zu84YZl5nLWRPKrcpt8jox1VQQAYmvJGMdM5gSRxq5CFNLlWGxq9O8Zvfi1SOQ==", + "version": "50.0.8", + "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.8.tgz", + "integrity": "sha512-8yXsoMbFRjWyEDNuFRtH0vTFvEjFnnwP+LceS6xmXGp+IW1hKdN1X6Bj1EUocFtepH0ruHDPCof1KvPoWfUWkw==", "dependencies": { "@babel/runtime": "^7.20.0", - "@expo/cli": "0.17.5", + "@expo/cli": "0.17.6", "@expo/config": "8.5.4", "@expo/config-plugins": "7.8.4", - "@expo/metro-config": "0.17.4", + "@expo/metro-config": "0.17.5", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~10.0.1", "expo-asset": "~9.0.2", - "expo-file-system": "~16.0.6", - "expo-font": "~11.10.2", + "expo-file-system": "~16.0.7", + "expo-font": "~11.10.3", "expo-keep-awake": "~12.8.2", "expo-modules-autolinking": "1.10.3", - "expo-modules-core": "1.11.8", + "expo-modules-core": "1.11.9", "fbemitter": "^3.0.0", "whatwg-url-without-unicode": "8.0.0-3" }, @@ -7472,17 +7472,17 @@ } }, "node_modules/expo-file-system": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.6.tgz", - "integrity": "sha512-ATCHL7nEg2WwKeamW/SDTR9jBEqM5wncFq594ftKS5QFmhKIrX48d9jyPFGnNq+6h8AGPg4QKh2KCA4OY49L4g==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.7.tgz", + "integrity": "sha512-BELr1Agj6WK0PKVMcD0rqC3fP5unKfp2KW8/sNhtTHgdzQ/F0Pylq9pTk9u7KEu0ZbEdTpk5EMarLMPwffi3og==", "peerDependencies": { "expo": "*" } }, "node_modules/expo-font": { - "version": "11.10.2", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-11.10.2.tgz", - "integrity": "sha512-AE0Q0LiWiVosQ/jlKUPoWoob7p3GwYM2xmLoUkuopO9RYh9NL1hZKHiMKcWBZyDG8Gww1GtBQwh7ZREST8+jjQ==", + "version": "11.10.3", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-11.10.3.tgz", + "integrity": "sha512-q1Td2zUvmLbCA9GV4OG4nLPw5gJuNY1VrPycsnemN1m8XWTzzs8nyECQQqrcBhgulCgcKZZJJ6U0kC2iuSoQHQ==", "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -7612,9 +7612,9 @@ } }, "node_modules/expo-modules-core": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.11.8.tgz", - "integrity": "sha512-rlctE3nCNLCGv3LosGQNaTuwGrr2SyQA+hOgci/0l+VRc0gFNtvl0gskph9C0tnN1jzBeb8rRZQYVj5ih1yxcA==", + "version": "1.11.9", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.11.9.tgz", + "integrity": "sha512-GTUb81vcPaF+5MtlBI1u9IjrZbGdF1ZUwz3u8Gc+rOLBblkZ7pYsj2mU/tu+k0khTckI9vcH4ZBksXWvE1ncjQ==", "dependencies": { "invariant": "^2.2.4" } @@ -14233,9 +14233,9 @@ } }, "@expo/cli": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.17.5.tgz", - "integrity": "sha512-9cMquL/5bBfV73CbZcWipk3KZSo8mBqdgvkoWCtEtnnlm/879ayxzMWjVIgT5yV4w+X7+N6KkBSUIIj4t9Xqew==", + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.17.6.tgz", + "integrity": "sha512-vpwQOyhkqQ5Ao96AGaFntRf6dX7h7/e9T7oKZ5KfJiaLRgfmNa/yHFu5cpXG76T2R7Q6aiU4ik0KU3P7nFMzEw==", "requires": { "@babel/runtime": "^7.20.0", "@expo/code-signing-certificates": "0.0.5", @@ -14851,9 +14851,9 @@ } }, "@expo/metro-config": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-0.17.4.tgz", - "integrity": "sha512-PxqDMuVjXQeboa6Aj8kNj4iTxIpwpfoYlF803qOjf1LE1ePlREnWNwqy65ESCBnCmekYIO5hhm1Nksa+xCvuyg==", + "version": "0.17.5", + "resolved": "https://registry.npmjs.org/@expo/metro-config/-/metro-config-0.17.5.tgz", + "integrity": "sha512-2YUebeIwr6gFxcIRSVAjWK5D8YSaXBzQoRRl3muJWsH8AC8a+T60xbA3cGhsEICD2zKS5zwnL2yobgs41Ur7nQ==", "requires": { "@babel/core": "^7.20.0", "@babel/generator": "^7.20.5", @@ -18250,23 +18250,23 @@ } }, "expo": { - "version": "50.0.6", - "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.6.tgz", - "integrity": "sha512-CVg0h9bmYeTWtw4EOL0HKNL+zu84YZl5nLWRPKrcpt8jox1VQQAYmvJGMdM5gSRxq5CFNLlWGxq9O8Zvfi1SOQ==", + "version": "50.0.8", + "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.8.tgz", + "integrity": "sha512-8yXsoMbFRjWyEDNuFRtH0vTFvEjFnnwP+LceS6xmXGp+IW1hKdN1X6Bj1EUocFtepH0ruHDPCof1KvPoWfUWkw==", "requires": { "@babel/runtime": "^7.20.0", - "@expo/cli": "0.17.5", + "@expo/cli": "0.17.6", "@expo/config": "8.5.4", "@expo/config-plugins": "7.8.4", - "@expo/metro-config": "0.17.4", + "@expo/metro-config": "0.17.5", "@expo/vector-icons": "^14.0.0", "babel-preset-expo": "~10.0.1", "expo-asset": "~9.0.2", - "expo-file-system": "~16.0.6", - "expo-font": "~11.10.2", + "expo-file-system": "~16.0.7", + "expo-font": "~11.10.3", "expo-keep-awake": "~12.8.2", "expo-modules-autolinking": "1.10.3", - "expo-modules-core": "1.11.8", + "expo-modules-core": "1.11.9", "fbemitter": "^3.0.0", "whatwg-url-without-unicode": "8.0.0-3" } @@ -18293,15 +18293,15 @@ } }, "expo-file-system": { - "version": "16.0.6", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.6.tgz", - "integrity": "sha512-ATCHL7nEg2WwKeamW/SDTR9jBEqM5wncFq594ftKS5QFmhKIrX48d9jyPFGnNq+6h8AGPg4QKh2KCA4OY49L4g==", + "version": "16.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.7.tgz", + "integrity": "sha512-BELr1Agj6WK0PKVMcD0rqC3fP5unKfp2KW8/sNhtTHgdzQ/F0Pylq9pTk9u7KEu0ZbEdTpk5EMarLMPwffi3og==", "requires": {} }, "expo-font": { - "version": "11.10.2", - "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-11.10.2.tgz", - "integrity": "sha512-AE0Q0LiWiVosQ/jlKUPoWoob7p3GwYM2xmLoUkuopO9RYh9NL1hZKHiMKcWBZyDG8Gww1GtBQwh7ZREST8+jjQ==", + "version": "11.10.3", + "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-11.10.3.tgz", + "integrity": "sha512-q1Td2zUvmLbCA9GV4OG4nLPw5gJuNY1VrPycsnemN1m8XWTzzs8nyECQQqrcBhgulCgcKZZJJ6U0kC2iuSoQHQ==", "requires": { "fontfaceobserver": "^2.1.0" } @@ -18396,9 +18396,9 @@ } }, "expo-modules-core": { - "version": "1.11.8", - "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.11.8.tgz", - "integrity": "sha512-rlctE3nCNLCGv3LosGQNaTuwGrr2SyQA+hOgci/0l+VRc0gFNtvl0gskph9C0tnN1jzBeb8rRZQYVj5ih1yxcA==", + "version": "1.11.9", + "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.11.9.tgz", + "integrity": "sha512-GTUb81vcPaF+5MtlBI1u9IjrZbGdF1ZUwz3u8Gc+rOLBblkZ7pYsj2mU/tu+k0khTckI9vcH4ZBksXWvE1ncjQ==", "requires": { "invariant": "^2.2.4" } diff --git a/example/package.json b/example/package.json index 8f670a4..53535bd 100644 --- a/example/package.json +++ b/example/package.json @@ -10,7 +10,7 @@ "prebuild": "expo prebuild" }, "dependencies": { - "expo": "^50.0.6", + "expo": "~50.0.8", "expo-splash-screen": "~0.26.4", "expo-status-bar": "~1.11.1", "react": "18.2.0", diff --git a/expo-module.config.json b/expo-module.config.json index bf99bf1..603254d 100644 --- a/expo-module.config.json +++ b/expo-module.config.json @@ -1,6 +1,9 @@ { - "platforms": ["ios"], + "platforms": ["ios", "android"], "ios": { "modules": ["ExpoAlternateAppIconsModule"] + }, + "android": { + "modules": ["expo.modules.alternateappicons.ExpoAlternateAppIconsModule"] } } diff --git a/package-lock.json b/package-lock.json index 52e1e43..9d60513 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,10 +11,11 @@ "devDependencies": { "@auto-it/conventional-commits": "^11.0.4", "@auto-it/npm": "^11.0.4", + "@types/fs-extra": "^11.0.4", "auto": "^11.0.4", "expo-module-scripts": "^3.4.1", "expo-modules-core": "^1.11.8", - "prettier": "^3.0.3" + "prettier": "^3.2.5" }, "peerDependencies": { "expo": "*", @@ -2969,6 +2970,38 @@ "expo-internal": "build/bin/cli" } }, + "node_modules/@expo/cli/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/@expo/cli/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/@expo/cli/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@expo/code-signing-certificates": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.5.tgz", @@ -3098,27 +3131,6 @@ "node": ">=10" } }, - "node_modules/@expo/dev-server/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@expo/dev-server/node_modules/jsonfile/node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@expo/dev-server/node_modules/universalify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/universalify/-/universalify-1.0.0.tgz", @@ -3214,27 +3226,6 @@ "node": ">=10" } }, - "node_modules/@expo/image-utils/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/@expo/image-utils/node_modules/jsonfile/node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@expo/image-utils/node_modules/semver": { "version": "7.3.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", @@ -3422,33 +3413,6 @@ "expo-modules-autolinking": ">=0.8.1" } }, - "node_modules/@expo/prebuild-config/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "peer": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@expo/prebuild-config/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/@expo/prebuild-config/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -3476,15 +3440,6 @@ "node": ">=10" } }, - "node_modules/@expo/prebuild-config/node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/@expo/prebuild-config/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", @@ -5850,6 +5805,20 @@ "node": ">=8" } }, + "node_modules/@react-native-community/cli/node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, "node_modules/@react-native-community/cli/node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -5883,6 +5852,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@react-native-community/cli/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "peer": true, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/@react-native-community/cli/node_modules/locate-path": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", @@ -5997,6 +5975,15 @@ "node": ">=6" } }, + "node_modules/@react-native-community/cli/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "peer": true, + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/@react-native-community/cli/node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6357,6 +6344,16 @@ "integrity": "sha512-n7RlEEJ+4x4TS7ZQddTmNSxP+zziEG0TNsMfiRIxcIVXt71ENJ9ojeXmGO3wPoTdn7pJcU2xc3CJYMktNT6DPg==", "dev": true }, + "node_modules/@types/fs-extra": { + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", + "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "dev": true, + "dependencies": { + "@types/jsonfile": "*", + "@types/node": "*" + } + }, "node_modules/@types/graceful-fs": { "version": "4.1.7", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.7.tgz", @@ -6452,6 +6449,15 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/jsonfile": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", + "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/minimist": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.3.tgz", @@ -10991,42 +10997,6 @@ "node": ">= 10" } }, - "node_modules/expo-modules-autolinking/node_modules/fs-extra": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", - "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", - "peer": true, - "dependencies": { - "at-least-node": "^1.0.0", - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/expo-modules-autolinking/node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "peer": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/expo-modules-autolinking/node_modules/universalify": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", - "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", - "peer": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/expo-modules-core": { "version": "1.12.25", "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-1.12.25.tgz", @@ -11511,17 +11481,18 @@ ] }, "node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", "peer": true, "dependencies": { + "at-least-node": "^1.0.0", "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" }, "engines": { - "node": ">=6 <7 || >=8" + "node": ">=10" } }, "node_modules/fs-minipass": { @@ -14855,10 +14826,13 @@ } }, "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "peer": true, + "dependencies": { + "universalify": "^2.0.0" + }, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -20778,12 +20752,12 @@ "dev": true }, "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "peer": true, "engines": { - "node": ">= 4.0.0" + "node": ">= 10.0.0" } }, "node_modules/unpipe": { diff --git a/package.json b/package.json index ff7c842..c3d3fb8 100644 --- a/package.json +++ b/package.json @@ -31,10 +31,11 @@ "devDependencies": { "@auto-it/conventional-commits": "^11.0.4", "@auto-it/npm": "^11.0.4", + "@types/fs-extra": "^11.0.4", "auto": "^11.0.4", "expo-module-scripts": "^3.4.1", "expo-modules-core": "^1.11.8", - "prettier": "^3.0.3" + "prettier": "^3.2.5" }, "peerDependencies": { "expo": "*", diff --git a/plugin/src/StringUtils.ts b/plugin/src/StringUtils.ts new file mode 100644 index 0000000..16b379a --- /dev/null +++ b/plugin/src/StringUtils.ts @@ -0,0 +1,13 @@ +const toPascalCase = (text: string)=> text + .replace(/[\s\-_]+/g, ' ') + .replace(/([A-Z])/g, ' $1') + .replace(/\w+/g, (word) => word[0].toUpperCase() + word.slice(1).toLowerCase()) + .replace(/\s+/g, ''); + +const toSnakeCase = (text: string) => text + .replace(/([A-Z])/g, '_$1') + .replace(/[\s\-]+/g, '_') + .replace(/^_+|_+$/g, '') + .toLowerCase(); + +export { toPascalCase, toSnakeCase }; \ No newline at end of file diff --git a/plugin/src/generateAdaptiveIcon.ts b/plugin/src/generateAdaptiveIcon.ts new file mode 100644 index 0000000..db3d63e --- /dev/null +++ b/plugin/src/generateAdaptiveIcon.ts @@ -0,0 +1,242 @@ +import { AlternateIcon } from './types'; +import { compositeImagesAsync, generateImageAsync } from '@expo/image-utils'; +import path from 'path'; +import { toPascalCase, toSnakeCase } from './StringUtils'; +import { writeFile, mkdir, rm } from 'fs/promises'; + +type DPIString = 'mdpi' | 'hdpi' | 'xhdpi' | 'xxhdpi' | 'xxxhdpi'; +type dpiMap = Record; + +const BASELINE_PIXEL_SIZE = 108; +export const ANDROID_RES_PATH = 'android/app/src/main/res/'; +const MIPMAP_ANYDPI_V26 = 'mipmap-anydpi-v26'; +export const dpiValues: dpiMap = { + mdpi: { folderName: 'mipmap-mdpi', scale: 1 }, + hdpi: { folderName: 'mipmap-hdpi', scale: 1.5 }, + xhdpi: { folderName: 'mipmap-xhdpi', scale: 2 }, + xxhdpi: { folderName: 'mipmap-xxhdpi', scale: 3 }, + xxxhdpi: { folderName: 'mipmap-xxxhdpi', scale: 4 }, +}; + +export async function generateAdaptiveIcon( + name: string, + projectRoot: string, + adaptiveIcon: Exclude, +) { + const { foregroundImage, backgroundImage, backgroundColor, monochromeImage } = adaptiveIcon; + if (!foregroundImage) return; + + const snake_case_name = toSnakeCase(name); + const isAdaptive = Boolean(backgroundImage || backgroundColor); + + // generate legacy icons + await generateMultiLayerImageAsync(projectRoot, { + icon: foregroundImage, + backgroundImage, + backgroundColor, + outputImageFileName: `ic_launcher_${snake_case_name}.png`, + imageCacheFolder: `android-standard-square-${snake_case_name}`, + backgroundImageCacheFolder: `android-standard-square-background-${snake_case_name}`, + }); + + if (!isAdaptive) { + return; + } + + if (monochromeImage) { + await generateMonochromeImageAsync(projectRoot, { + icon: monochromeImage, + imageCacheFolder: `android-adaptive-monochrome-${snake_case_name}`, + outputImageFileName: `ic_launcher_monochrome_${snake_case_name}.png`, + }); + } + + // generate adaptive icons + await generateMultiLayerImageAsync(projectRoot, { + backgroundColor: 'transparent', + backgroundImage: backgroundImage, + backgroundImageCacheFolder: `android-adaptive-background-${snake_case_name}`, + outputImageFileName: `ic_launcher_foreground_${snake_case_name}.png`, + icon: foregroundImage, + imageCacheFolder: `android-adaptive-foreground-${snake_case_name}`, + backgroundImageFileName: `ic_launcher_background_${snake_case_name}.png`, + }); + + // create ic_launcher.xml + const icLauncherXmlString = createAdaptiveIconXmlString(name, backgroundImage, monochromeImage); + await createAdaptiveIconXmlFiles( + name, + projectRoot, + icLauncherXmlString, + ); +} + +async function generateMultiLayerImageAsync( + projectRoot: string, + { + icon, + backgroundColor, + backgroundImage, + imageCacheFolder, + backgroundImageCacheFolder, + borderRadiusRatio, + outputImageFileName, + backgroundImageFileName, + }: { + icon: string; + backgroundImage?: string; + backgroundColor?: string; + imageCacheFolder: string; + backgroundImageCacheFolder: string; + backgroundImageFileName?: string; + borderRadiusRatio?: number; + outputImageFileName: string; + } +) { + await iterateDpiValues(projectRoot, async ({ dpiFolder, scale }) => { + let iconLayer = await generateIconAsync(projectRoot, { + cacheType: imageCacheFolder, + src: icon, + scale, + // backgroundImage overrides backgroundColor + backgroundColor: backgroundImage ? 'transparent' : (backgroundColor ?? 'transparent'), + borderRadiusRatio, + }); + + if (backgroundImage) { + const backgroundLayer = await generateIconAsync(projectRoot, { + cacheType: backgroundImageCacheFolder, + src: backgroundImage, + scale, + backgroundColor: 'transparent', + borderRadiusRatio, + }); + + if (backgroundImageFileName) { + await writeFile(path.resolve(dpiFolder, backgroundImageFileName), backgroundLayer); + } else { + iconLayer = await compositeImagesAsync({ + foreground: iconLayer, + background: backgroundLayer, + }); + } + } else if (backgroundImageFileName) { + // Remove any instances of ic_launcher_background.png that are there from previous icons + await deleteIconNamedAsync(projectRoot, backgroundImageFileName); + } + + await mkdir(dpiFolder, { recursive: true }); + await writeFile(path.resolve(dpiFolder, outputImageFileName), iconLayer); + }); +} + +async function generateMonochromeImageAsync( + projectRoot: string, + { + icon, + imageCacheFolder, + outputImageFileName, + }: { icon: string; imageCacheFolder: string; outputImageFileName: string } +) { + await iterateDpiValues(projectRoot, async ({ dpiFolder, scale }) => { + const monochromeIcon = await generateIconAsync(projectRoot, { + cacheType: imageCacheFolder, + src: icon, + scale, + backgroundColor: 'transparent', + }); + await mkdir(dpiFolder, { recursive: true }); + await writeFile(path.resolve(dpiFolder, outputImageFileName), monochromeIcon); + }); +} + +async function deleteIconNamedAsync(projectRoot: string, name: string) { + return iterateDpiValues(projectRoot, ({ dpiFolder }) => { + return rm(path.resolve(dpiFolder, name), { force: true }); + }); +} + +function iterateDpiValues( + projectRoot: string, + callback: (value: { dpiFolder: string; folderName: string; scale: number }) => Promise +) { + return Promise.all( + Object.values(dpiValues).map((value) => + callback({ + dpiFolder: path.resolve(projectRoot, ANDROID_RES_PATH, value.folderName), + ...value, + }) + ) + ); +} + +async function generateIconAsync( + projectRoot: string, + { + cacheType, + src, + scale, + backgroundColor, + borderRadiusRatio, + }: { + cacheType: string; + src: string; + scale: number; + backgroundColor: string; + borderRadiusRatio?: number; + } +) { + const iconSizePx = BASELINE_PIXEL_SIZE * scale; + + return ( + await generateImageAsync( + { projectRoot, cacheType }, + { + src, + width: iconSizePx, + height: iconSizePx, + resizeMode: 'cover', + backgroundColor, + borderRadius: borderRadiusRatio ? iconSizePx * borderRadiusRatio : undefined, + } + ) + ).source; +} + +export const createAdaptiveIconXmlString = ( + name: string, + backgroundImage?: string, + monochromeImage?: string +) => { + const snake_case_name = toSnakeCase(name); + const PascalCaseName = toPascalCase(name); + + const background = backgroundImage + ? `@mipmap/ic_launcher_background_${snake_case_name}` + : `@color/iconBackground${PascalCaseName}`; + + const iconElements: string[] = [ + ``, + ``, + ]; + + if (monochromeImage) { + iconElements.push(``); + } + + return ` + + ${iconElements.join('\n ')} +`; +}; + +async function createAdaptiveIconXmlFiles( + name: string, + projectRoot: string, + icLauncherXmlString: string +) { + const anyDpiV26Directory = path.resolve(projectRoot, ANDROID_RES_PATH, MIPMAP_ANYDPI_V26); + await mkdir(anyDpiV26Directory, { recursive: true }); + const launcherPath = path.resolve(anyDpiV26Directory, `ic_launcher_${toSnakeCase(name)}.xml`); + await writeFile(launcherPath, icLauncherXmlString); +} diff --git a/plugin/src/generateUniversalIcon.ts b/plugin/src/generateUniversalIcon.ts index 13644e0..d153066 100644 --- a/plugin/src/generateUniversalIcon.ts +++ b/plugin/src/generateUniversalIcon.ts @@ -6,11 +6,12 @@ import { join, parse } from 'path'; import { writeContentsJson } from './writeContentsJson'; export async function generateUniversalIcon( + name: string, projectRoot: string, src: string, options: { width: number; height: number }, ) { - const { base: filename, name } = parse(src); + const { base: filename } = parse(src); const iosProjectPath = join(projectRoot, 'ios', IOSConfig.XcodeUtils.getProjectName(projectRoot)); const appIconSetPath = join(iosProjectPath, `Images.xcassets/${name}.appiconset`); const appIconPath = join(appIconSetPath, filename); @@ -33,6 +34,4 @@ export async function generateUniversalIcon( } catch (error) { console.log(error); } - - return name; } diff --git a/plugin/src/index.ts b/plugin/src/index.ts index d0d2249..b2da494 100644 --- a/plugin/src/index.ts +++ b/plugin/src/index.ts @@ -1,16 +1,47 @@ import { type ExpoConfig } from '@expo/config-types'; import { withAlternateAppIconsGenerator } from './withAlternateAppIconsGenerator'; +import { withAndroidManifestUpdate } from './withAndroidManifestUpdate'; import { withXcodeProjectUpdate } from './withXcodeProjectUpdate'; +import { AlternateIcon } from './types'; +import { withAdaptiveIconsGenerator } from './withAdaptiveIconsGenerator'; +import { parse } from 'path'; + +const isPathArray = (alternateIcons: AlternateIcon[] | string[]): alternateIcons is string[] => + alternateIcons.some((icon) => typeof icon === 'string'); export default function withAlternateAppIcons( config: ExpoConfig, - iconPaths: string[] = [], + props: AlternateIcon[] | string[] = [], ): ExpoConfig { - const iconNames = new Set(); + if (!props.length) { + return config; + } + + let alternateIcons: AlternateIcon[]; + if (isPathArray(props)) { + alternateIcons = props.map(mapToAlternateIcon); + } else { + alternateIcons = props; + } - config = withAlternateAppIconsGenerator(config, iconPaths, iconNames); + const iconNames = alternateIcons.map((icon) => icon.name); + + config = withAlternateAppIconsGenerator(config, alternateIcons); config = withXcodeProjectUpdate(config, iconNames); + config = withAdaptiveIconsGenerator(config, alternateIcons); + config = withAndroidManifestUpdate(config, iconNames); return config; } + +function mapToAlternateIcon(path: string): AlternateIcon { + const { name } = parse(path); + return { + name, + ios: path, + android: { + foregroundImage: path, + } + } +} \ No newline at end of file diff --git a/plugin/src/types.ts b/plugin/src/types.ts new file mode 100644 index 0000000..d1c70ca --- /dev/null +++ b/plugin/src/types.ts @@ -0,0 +1,10 @@ +import type { ExpoConfig } from '@expo/config-types'; + +type AndroidConfig = Exclude; +type iOSConfig = Exclude; + +export type AlternateIcon = { + name: string; + ios: iOSConfig['icon']; + android: AndroidConfig['adaptiveIcon'] +} diff --git a/plugin/src/withAdaptiveIconsGenerator.ts b/plugin/src/withAdaptiveIconsGenerator.ts new file mode 100644 index 0000000..45c975a --- /dev/null +++ b/plugin/src/withAdaptiveIconsGenerator.ts @@ -0,0 +1,45 @@ +import { + AndroidConfig, + ConfigPlugin, + withAndroidColors, + withDangerousMod +} from '@expo/config-plugins'; +import { ExpoConfig } from '@expo/config-types'; +import { AlternateIcon } from './types'; +import { generateAdaptiveIcon } from './generateAdaptiveIcon'; +import { toPascalCase } from './StringUtils'; + +const { Colors } = AndroidConfig; + +export function withAdaptiveIconsGenerator( + config: ExpoConfig, + alternateIcons: AlternateIcon[] +): ExpoConfig { + for (const alternateIcon of alternateIcons) { + withAndroidAdaptiveIconColors(config, alternateIcon) + } + return withDangerousMod(config, [ + 'android', + async (config) => { + for (const alternateIcon of alternateIcons) { + const { android: adaptiveIcon, name } = alternateIcon; + if (!adaptiveIcon) break; + const { foregroundImage, backgroundColor } = adaptiveIcon; + if (!foregroundImage) break; + const projectRoot = config.modRequest.projectRoot; + await generateAdaptiveIcon(name, projectRoot, adaptiveIcon); + } + return config; + } + ]); +} + +const withAndroidAdaptiveIconColors: ConfigPlugin = (config, alternateIcon) => { + return withAndroidColors(config, (config) => { + config.modResults = Colors.assignColorValue(config.modResults, { + value: alternateIcon.android?.backgroundColor ?? '#FFFFFF', + name: `iconBackground${toPascalCase(alternateIcon.name)}` + }); + return config; + }); +}; diff --git a/plugin/src/withAlternateAppIconsGenerator.ts b/plugin/src/withAlternateAppIconsGenerator.ts index c946cf2..c5e19ce 100644 --- a/plugin/src/withAlternateAppIconsGenerator.ts +++ b/plugin/src/withAlternateAppIconsGenerator.ts @@ -2,23 +2,23 @@ import { type ExpoConfig } from '@expo/config-types'; import { withDangerousMod } from 'expo/config-plugins'; import { generateUniversalIcon } from './generateUniversalIcon'; +import { AlternateIcon } from './types'; export function withAlternateAppIconsGenerator( config: ExpoConfig, - alternateIcons: string[], - alternateAppIconNames: Set, + alternateIcons: AlternateIcon[], ): ExpoConfig { return withDangerousMod(config, [ 'ios', async (config) => { - for await (const iconPath of alternateIcons) { + for await (const alternateIcon of alternateIcons) { + const { ios: iconPath, name } = alternateIcon; + if (!iconPath) break; const projectRoot = config.modRequest.projectRoot; - const name = await generateUniversalIcon(projectRoot, iconPath, { + await generateUniversalIcon(name, projectRoot, iconPath, { width: 1024, height: 1024, }); - - alternateAppIconNames.add(name); } return config; diff --git a/plugin/src/withAndroidManifestUpdate.ts b/plugin/src/withAndroidManifestUpdate.ts new file mode 100644 index 0000000..98990d7 --- /dev/null +++ b/plugin/src/withAndroidManifestUpdate.ts @@ -0,0 +1,71 @@ +import { AndroidConfig, withAndroidManifest } from '@expo/config-plugins'; +import { ExpoConfig, Android } from '@expo/config-types'; +import { toPascalCase, toSnakeCase } from './StringUtils'; + +type AndroidIntentFilters = NonNullable; + +const { getMainApplicationOrThrow } = AndroidConfig.Manifest; +const { default: renderIntentFilters, getIntentFilters } = AndroidConfig.IntentFilters; + +type ActivityAlias = AndroidConfig.Manifest.ManifestActivity; + +type ApplicationWithAliases = AndroidConfig.Manifest.ManifestApplication & { + ['activity-alias']?: ActivityAlias[], +} + +export function withAndroidManifestUpdate( + config: ExpoConfig, + alternateIconNames: string[], +) { + const intentFilters = getIntentFilters(config); + + config = withAndroidManifest(config, (config, ) => { + const mainApplication = getMainApplicationOrThrow(config.modResults); + + for (const name of alternateIconNames) { + addActivityAliasToMainApplication(mainApplication, name, intentFilters); + } + + return config; + }); + + return config; +} + +function addActivityAliasToMainApplication( + mainApplication: ApplicationWithAliases, + iconName: string, + intentFilters?: AndroidIntentFilters, +) { + const activityAlias: ActivityAlias = { + $: { + 'android:name': `.MainActivity${toPascalCase(iconName)}`, + 'android:enabled': 'false', + 'android:exported': 'true', + 'android:icon': `@mipmap/ic_launcher_${toSnakeCase(iconName)}`, + 'android:targetActivity': '.MainActivity', + }, + 'intent-filter': [ + { + action: [{ $: { 'android:name': 'android.intent.action.MAIN' } }], + category: [{ $: { 'android:name': 'android.intent.category.LAUNCHER' } }], + }, + ...renderIntentFilters(intentFilters ?? []), + ], + } + + if (mainApplication['activity-alias']) { + const currentIndex = mainApplication['activity-alias'].findIndex( + (e: any) => e.$['android:name'] === activityAlias.$['android:name'] + ); + if (currentIndex >= 0) { + mainApplication['activity-alias'][currentIndex] = activityAlias; + } else { + mainApplication['activity-alias'].push(activityAlias); + } + } else { + mainApplication['activity-alias'] = [activityAlias]; + } + + return mainApplication; +} diff --git a/plugin/src/withXcodeProjectUpdate.ts b/plugin/src/withXcodeProjectUpdate.ts index d1cdda6..b8bb05a 100644 --- a/plugin/src/withXcodeProjectUpdate.ts +++ b/plugin/src/withXcodeProjectUpdate.ts @@ -5,12 +5,12 @@ const ALTERNATE_APP_ICONS_NAMES_PROPERTY = 'ASSETCATALOG_COMPILER_ALTERNATE_APPI export function withXcodeProjectUpdate( config: ExpoConfig, - alternateAppIconNames: Set, + alternateAppIconNames: string[], ): ExpoConfig { config = withXcodeProject(config, (config) => { config.modResults.updateBuildProperty( ALTERNATE_APP_ICONS_NAMES_PROPERTY, - Array.from(alternateAppIconNames), + alternateAppIconNames, ); return config; diff --git a/src/ExpoAlternateAppIconsModule.android.ts b/src/ExpoAlternateAppIconsModule.android.ts deleted file mode 100644 index 99364d1..0000000 --- a/src/ExpoAlternateAppIconsModule.android.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { UnavailabilityError } from 'expo-modules-core'; - -export default { - supportsAlternateIcons: false, - getAppIconName: () => { - throw new UnavailabilityError('AlternateAppIcons', 'getAppIconName'); - }, - setAlternateAppIcon: () => { - throw new UnavailabilityError('AlternateAppIcons', 'setAlternateAppIcon'); - }, -};