dynamic_app_icon_changer 0.0.3
dynamic_app_icon_changer: ^0.0.3 copied to clipboard
A Flutter plugin for changing app icons dynamically at runtime on Android and iOS.
dynamic_app_icon_changer #
A Flutter plugin for changing app icons dynamically at runtime on Android and iOS, with built-in state recovery that works seamlessly alongside third-party SDKs.
Features #
- Switch app launcher icons at runtime on both Android and iOS
- Scheduled icon changes with automatic reset (e.g., holiday icons)
- Relaunch support — optionally restart the app after icon switch (Android)
- Built-in state recovery on boot, app update, and engine attach
- Protected Components API to safeguard third-party SDK components (e.g., MoEngage, Firebase) during icon switches
- OEM blacklist support to skip problematic Android devices
- Badge number support on iOS
- Zero manual recovery code needed in consuming apps
Platform Support #
| Feature | Android | iOS |
|---|---|---|
| Alternate icon switch | API 21+ | iOS 10.3+ |
| Get current icon name | API 21+ | iOS 10.3+ |
| Scheduled icon change | API 21+ | iOS 10.3+ |
| Relaunch after switch | API 21+ | -- |
| Protected components | API 21+ | -- |
| OEM blacklist | API 21+ | -- |
| Badge number | -- | iOS 10.3+ |
Installation #
Add this to your pubspec.yaml:
dependencies:
dynamic_app_icon_changer: ^0.0.3
Then run:
flutter pub get
Android Setup #
On Android, dynamic icon switching works by enabling/disabling <activity-alias> entries declared in AndroidManifest.xml. Each alias represents one icon variant.
Step 1: Add icon drawables #
Place your icon PNG files in the appropriate mipmap-* density directories:
android/app/src/main/res/
mipmap-hdpi/
ic_launcher.png (default icon, already exists)
ic_launcher_blue.png
ic_launcher_green.png
mipmap-xhdpi/ (same set)
mipmap-xxhdpi/ (same set)
mipmap-xxxhdpi/ (same set)
Step 2: Update AndroidManifest.xml #
Critical:
MainActivitymust NOT have aLAUNCHERintent-filter. The launcher entry is handled entirely through<activity-alias>entries. This ensuresMainActivityalways remains enabled, preventing the "Activity class does not exist" error.
<application ...>
<!--
MainActivity: NO LAUNCHER intent-filter.
Always enabled so Flutter/adb can start it directly.
-->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
</activity>
<!--
DEFAULT icon alias: android:enabled="true"
The plugin identifies this as the default because enabled="true".
-->
<activity-alias
android:name=".DefaultIcon"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:label="My App"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<!--
ALTERNATE icon aliases: android:enabled="false"
Add one block per alternate icon.
-->
<activity-alias
android:name=".IconBlue"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_blue"
android:label="My App"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<activity-alias
android:name=".IconGreen"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_green"
android:label="My App"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
</application>
Manifest Rules #
All five rules must be followed or the plugin will not work correctly:
| Rule | What breaks if you skip it |
|---|---|
MainActivity has no LAUNCHER intent-filter |
flutter run / adb am start .MainActivity fails after any icon switch |
MainActivity uses launchMode="singleTask" |
Tapping the new icon creates a new app instance instead of resuming |
MainActivity has no taskAffinity="" |
Same new-instance problem — aliases can't find the existing task |
Default alias has android:enabled="true" |
Plugin can't identify or restore the default icon |
All other aliases have android:enabled="false" |
Multiple launcher icons appear simultaneously |
Why
singleTaskinstead of Flutter's defaultsingleTop?Activity-aliases inherit both
launchModeandtaskAffinityfrom theirtargetActivity. WithsingleTask, when any alias is tapped Android searches for an existing task whose affinity matches the package name (the default), finds the running app, and brings it to the foreground. Flutter's default (singleTop+taskAffinity="") is incompatible with dynamic icons because aliases with no affinity always spawn a new task.
iOS Setup #
On iOS, dynamic icon switching uses UIApplication.setAlternateIconName (iOS 10.3+).
Step 1: Add icon files #
Place your alternate icon PNG files in the Runner directory (or a subfolder):
IconBlue@2x.png(120x120)IconBlue@3x.png(180x180)IconGreen@2x.png(120x120)IconGreen@3x.png(180x180)
Step 2: Configure Info.plist #
In ios/Runner/Info.plist, add the CFBundleIcons dictionary:
<key>CFBundleIcons</key>
<dict>
<key>CFBundleAlternateIcons</key>
<dict>
<key>IconBlue</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>IconBlue</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
<key>IconGreen</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>IconGreen</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
</dict>
</dict>
Step 3: Include files in Xcode #
- Open
ios/Runner.xcworkspacein Xcode - Right-click the Runner folder > "Add Files to Runner..."
- Select your icon PNG files (check "Copy items if needed")
- Verify they appear in Build Phases > Copy Bundle Resources
Usage #
Import #
import 'package:dynamic_app_icon_changer/dynamic_app_icon_changer.dart';
Check support #
final isSupported = await DynamicAppIconChanger.supportsAlternateIcons;
Get current icon #
final iconName = await DynamicAppIconChanger.alternateIconName;
// Returns null when the default icon is active.
Switch icon #
// Switch to an alternate icon
await DynamicAppIconChanger.setAlternateIconName('IconBlue');
// Restore the default icon
await DynamicAppIconChanger.setAlternateIconName(null);
Relaunch after icon switch (Android only) #
By default, the launcher may take a few seconds to reflect the new icon. Pass relaunch: true to kill and restart the app immediately so the change is visible right away:
await DynamicAppIconChanger.setAlternateIconName(
'IconBlue',
relaunch: true, // App restarts after ~500ms
);
Note: This finishes the current activity and relaunches via
AlarmManager. The user will briefly see the app close and reopen. On iOS this parameter is ignored.
OEM blacklist (Android only) #
Silently skip icon changes on devices known to have issues:
await DynamicAppIconChanger.setAlternateIconName(
'IconBlue',
blacklistedBrands: ['Samsung', 'Xiaomi'],
);
Badge number (iOS only) #
await DynamicAppIconChanger.setBadgeNumber(5);
await DynamicAppIconChanger.setBadgeNumber(0); // clear
final badge = await DynamicAppIconChanger.badgeNumber;
Scheduled Icon Changes #
Schedule an icon to be active during a specific time window, then automatically reset to the default icon when the window ends. Perfect for seasonal/holiday icons, events, or time-limited promotions.
Schedule an icon #
// Christmas icon: Dec 20 to Dec 26
await DynamicAppIconChanger.scheduleAlternateIcon(
'IconChristmas',
startAt: DateTime(2026, 12, 20),
endAt: DateTime(2026, 12, 26, 23, 59, 59),
);
// Start immediately, reset after 24 hours
await DynamicAppIconChanger.scheduleAlternateIcon(
'IconPromo',
endAt: DateTime.now().add(Duration(hours: 24)),
);
| Parameter | Required | Description |
|---|---|---|
iconName |
Yes | The alias name to activate |
endAt |
Yes | When to reset back to the default icon |
startAt |
No | When to activate the icon. If null or in the past, activates immediately |
blacklistedBrands |
No | Same as setAlternateIconName |
Check active schedule #
final schedule = await DynamicAppIconChanger.activeSchedule;
if (schedule != null) {
print('Icon: ${schedule.iconName}');
print('Active: ${schedule.isActive}');
print('Ends: ${schedule.endAt}');
}
Cancel a schedule #
// Cancel and reset to default
await DynamicAppIconChanger.cancelScheduledIcon();
// Cancel but keep the current icon
await DynamicAppIconChanger.cancelScheduledIcon(resetToDefault: false);
How scheduling works #
| Platform | Mechanism | Reliability |
|---|---|---|
| Android | AlarmManager with setExactAndAllowWhileIdle |
Fires in background even if app is killed. Alarms are re-registered on reboot via BOOT_COMPLETED receiver. |
| iOS | UserDefaults + willEnterForegroundNotification |
Schedule is checked every time the app enters the foreground. If the app isn't opened after endAt, the reset happens on the next launch. |
Schedule lifecycle:
scheduleAlternateIcon()is called- If
startAtis now/past → icon changes immediately. If future → alarm is set. AlarmManagerfiresACTION_SCHEDULE_START→ icon changes (Android)AlarmManagerfiresACTION_SCHEDULE_END→ icon resets to default- On reboot →
IconStateRecoveryReceiverre-registers alarms and checks for expired schedules - On app update → recovery runs + default alias re-enabled for
flutter run
Only one schedule can be active at a time. Calling scheduleAlternateIcon() again replaces any existing schedule.
Protected Components (Android) #
The Problem #
When the plugin toggles activity-alias states to change the launcher icon, Android's PackageManager can inadvertently disrupt the enabled state of other components declared in the manifest. This breaks third-party SDK features like:
- MoEngage PushTracker — notification taps stop opening the app
- Firebase messaging components — push notifications fail
- Legacy activity-aliases — leftover aliases from previous builds get re-enabled
Previously, apps had to write manual recovery classes with BroadcastReceiver to fix this on every boot and app update. This plugin handles it automatically.
The Solution #
Register components that need protection, and the plugin ensures they stay in the correct state:
await DynamicAppIconChanger.registerProtectedComponents([
// Keep MoEngage PushTracker always enabled
const ProtectedComponent(
className: 'com.moengage.pushbase.activities.PushTracker',
desiredState: ComponentState.enabled,
),
// Keep a legacy alias always disabled
const ProtectedComponent(
className: '.momTurnsOne',
desiredState: ComponentState.disabled,
),
]);
Call this once at app startup (e.g., in your main() or root widget's initState). The plugin persists the configuration and enforces it:
| Trigger | What happens |
|---|---|
| Every icon switch | Protected components are restored immediately after alias toggle |
| Device reboot | IconStateRecoveryReceiver restores all states (auto-registered via manifest merger) |
| App update | Same receiver triggers on MY_PACKAGE_REPLACED |
| Flutter engine attach | Recovery runs as a safety net on every app launch |
ComponentState values #
| Value | Effect |
|---|---|
ComponentState.enabled |
Explicitly enable the component |
ComponentState.disabled |
Explicitly disable the component |
ComponentState.defaultState |
Respect the manifest's declared value |
This is a no-op on iOS — iOS doesn't use component states.
How Recovery Works #
The plugin includes a multi-layered recovery system that runs automatically:
- After every icon switch — re-enables
MainActivity, restores protected components - On Flutter engine attach — runs a full state reconciliation from persisted SharedPreferences, checks active schedules
- On
BOOT_COMPLETED—IconStateRecoveryReceiverrestores alias states + protected components, re-registers schedule alarms (lost on reboot), checks for expired schedules - On
MY_PACKAGE_REPLACED— same receiver handles app updates + re-enables default alias forflutter runcompatibility
The recovery receiver has zero Flutter dependencies and runs before the Flutter engine starts. No manual manifest changes or receiver registration needed in consuming apps.
Error Handling #
The plugin throws DynamicIconException on failures:
try {
await DynamicAppIconChanger.setAlternateIconName('IconBlue');
} on DynamicIconException catch (e) {
print('Error: ${e.message}'); // Human-readable message
print('Code: ${e.code}'); // Machine-readable code (e.g., ICON_NOT_FOUND)
}
Error codes #
| Code | Meaning |
|---|---|
NO_ALIASES_FOUND |
No <activity-alias> entries with MAIN+LAUNCHER in manifest |
ICON_NOT_FOUND |
The requested alias name doesn't exist in the manifest |
ICON_CHANGE_FAILED |
An exception occurred during the component state change |
INVALID_ARGUMENTS |
Invalid arguments passed to a method |
INVALID_SCHEDULE |
endAt is in the past, or endAt is before startAt |
SCHEDULE_FAILED |
An exception occurred while setting up the schedule |
CANCEL_SCHEDULE_FAILED |
An exception occurred while cancelling the schedule |
Troubleshooting #
"Activity class does not exist" when running flutter run #
After switching icons, flutter run fails with:
Error: Activity class {com.example.app/com.example.app.MainActivity} does not exist.
Fix: MainActivity must have no LAUNCHER intent-filter and must never be disabled. Follow the manifest setup above. The plugin automatically re-enables the default alias on MY_PACKAGE_REPLACED (triggered by every flutter run install) to prevent this error.
If you're already in this state, re-enable it manually:
adb shell pm enable com.your.package/.MainActivity
Tapping the new icon opens a second copy of the app #
Fix: Change MainActivity to use launchMode="singleTask" and remove taskAffinity="". See the Manifest Rules section.
Icon doesn't change on Android #
- Verify default alias has
enabled="true", alternates haveenabled="false" - Ensure
MainActivityhas no LAUNCHER intent-filter - Ensure the alias
android:name(without the dot) matches what you pass tosetAlternateIconName() - Some launchers cache aggressively — try restarting the launcher or rebooting
Icon doesn't change on iOS #
- Confirm icon files are listed in
Info.plistunderCFBundleIcons > CFBundleAlternateIcons - Ensure PNG files are included in Xcode's Copy Bundle Resources
- The icon name must exactly match the string passed to
setAlternateIconName()
Scheduled icon didn't reset on iOS #
The iOS schedule check runs on foreground entry. If the app isn't opened after the endAt time, the reset happens on the next launch. Background execution is not guaranteed on iOS.
Notifications stop working after icon switch (Android) #
Third-party notification components (e.g., MoEngage PushTracker) can get disrupted by alias state changes. Use registerProtectedComponents() to keep them in the correct state. See Protected Components.
Notes and Caveats #
- iOS system alert: iOS always shows a confirmation dialog when changing icons. This cannot be suppressed.
- Android launcher delay: The launcher may take a few seconds to reflect the new icon. Use
relaunch: trueto force an immediate restart. - Pre-bundled icons only: You cannot download and set arbitrary icons at runtime. All icons must be included in the app bundle at build time.
- Android OEM quirks: Some OEM launchers may not reflect the change correctly. Use
blacklistedBrandsto skip problematic devices. - Badge numbers: Only supported on iOS. On Android,
setBadgeNumberis a no-op andbadgeNumberalways returns 0. - BOOT_COMPLETED permission: The plugin's manifest declares
RECEIVE_BOOT_COMPLETEDfor the recovery receiver. This merges automatically into your app. - Exact alarm permission (Android 12+): For precise schedule timing, the app should have the
SCHEDULE_EXACT_ALARMpermission. If not granted, the plugin falls back to inexact alarms which may have a few minutes of delay. - One schedule at a time: Calling
scheduleAlternateIcon()replaces any existing schedule.
License #
See LICENSE for details.