Skip to content

Fix duplicate custom app icons - #5609

Open
joashrajin wants to merge 4 commits into
mainfrom
codex/fix-custom-app-icons
Open

Fix duplicate custom app icons#5609
joashrajin wants to merge 4 commits into
mainfrom
codex/fix-custom-app-icons

Conversation

@joashrajin

@joashrajin joashrajin commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Description

Pocket Casts currently exposes both MainActivity and the selected activity alias as launcher entries. On affected launchers, especially Samsung One UI, selecting a custom icon therefore creates a second app-drawer icon.

This changes the launcher architecture so MainActivity remains enabled for notifications, sharing, deep links, media intents, shortcuts, and explicit launches, while every launcher icon, including the default icon, is represented by an activity alias. Exactly one alias is enabled at a time.

The change also:

  • migrates existing installations to a new default alias after an app update
  • switches aliases atomically on Android 13 and newer
  • enables the replacement before disabling the previous alias on older Android versions
  • keeps static shortcuts and Samsung multi-window launcher categories on every alias
  • updates the icon confirmation copy and adds regression tests

Fixes #1252

Testing Instructions

  1. Install an existing Pocket Casts build and select a custom app icon.
  2. Install this branch over that build without clearing app data.
  3. Confirm only the selected icon appears in the all-apps screen.
  4. Launch Pocket Casts from the selected icon.
  5. Switch from the custom icon to another custom icon, then back to the default icon.
  6. After each switch, confirm exactly one Pocket Casts entry appears in the all-apps screen.
  7. Verify a notification tap, app shortcut, shared OPML file, Pocket Casts deep link, and media search intent still open the app.
  8. Verify launching from recents and Samsung multi-window still works.

Automated checks completed:

  • focused AppIcon unit tests on API 32 and API 33
  • debug, debugProd, prototype, and release manifest merging
  • debug and debugProd APK assembly
  • app lint
  • Spotless

Note

One UI launcher cache: after switching icons, Samsung's launcher may keep showing app-drawer entries for previously enabled aliases for a while, even though PackageManager reports exactly one enabled launcher component (adb shell cmd package query-activities -a android.intent.action.MAIN -c android.intent.category.LAUNCHER --brief | grep pocketcasts). These stale entries disappear once the launcher refreshes (restarting the launcher or rebooting forces it). Testers should use the query-activities output as ground truth rather than the drawer, so launcher cache isn't mistaken for a regression.

Verified on a Galaxy S21 FE (Android 16 / One UI 8): duplicate icons reproduced on the pre-fix build, then a dirty upgrade to this branch migrated to exactly one enabled alias (custom selection preserved), with notification launches, launch-intent resolution, static shortcuts, and icon switching all confirmed working. Android Auto is unaffected: it surfaces the app via the MediaBrowserService declarations and com.google.android.gms.car.application meta-data, which this change doesn't touch.

Screenshots or Screencast

Captured on a Galaxy S21 FE (Android 16 / One UI 8), same app-drawer page, debug build. Before: pre-fix build with the Dark icon selected shows two entries (default + Dark). After: dirty upgrade to this branch, the migration receiver leaves only the selected Dark icon.

Before (pre-fix build, Dark icon selected, two entries):

before-duplicate-icons

After (this branch installed over it, one entry, selection preserved):

after-single-icon

Checklist

  • If this is a user-facing change, I have added an entry in CHANGELOG.md
  • Ensure the linter passes (./gradlew spotlessApply to automatically apply formatting/linting)
  • I have considered whether it makes sense to add tests for my changes — added AppIconTest covering the atomic API 33+ switch, the pre-13 enable-before-disable ordering, and the default-icon case
  • All strings that need to be localized are in modules/services/localization/src/main/res/values/strings.xml — the reworded dialog message uses a new key (settings_app_icon_updated_message_v2) so stale translations of the old copy don't shadow it
  • Any jetpack compose components I added or changed are covered by compose previews — n/a, no Compose components changed
  • I have updated (or requested that someone edit) the Event Horizon schema to reflect any new or changed analytics. — n/a, no analytics added or changed (the existing SettingsAppearanceAppIconChangedEvent is untouched)

I have tested any UI changes...

n/a — this PR changes launcher/alias behavior and one dialog message; there are no layout or screen changes. Launcher behavior was verified on-device (Galaxy S21 FE, One UI 8): duplicate reproduced pre-fix, dirty upgrade migrated to a single entry with the user's selection preserved, and notification launches, launch-intent resolution, static shortcuts, and icon switching all confirmed working.

  • with different themes
  • with a landscape orientation
  • with the device set to have a large display and font size
  • for accessibility with TalkBack

@joashrajin joashrajin added [Type] Bug Not functioning as intended. [Area] Settings labels Jul 18, 2026
@joashrajin joashrajin added this to the 8.17 milestone Jul 18, 2026
@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Claude finished @joashrajin's task in 3m 45s —— View job


Code Review — PR #5609: Fix duplicate custom app icons

  • Read PR diff
  • Review AppIcon.kt logic & migration receiver
  • Review AndroidManifest.xml changes (${appIcon} placeholder, aliases)
  • Review tests & strings
  • Post review

Nice work — this targets the actual root cause. The real bug was that MainActivity itself carried the LAUNCHER category while a custom alias was also enabled, so two entries appeared. Moving LAUNCHER/MULTIWINDOW_LAUNCHER onto a dedicated MainActivityDefault alias and keeping exactly one alias enabled is the correct fix. I verified the supporting pieces:

  • ✅ The ${appIcon} placeholder used on the default alias (AndroidManifest.xml:338) is defined for all build types in app/build.gradle.kts:38-50 (radioactive/_3/prototype/ic_launcher), matching the <application android:icon="${appIcon}">. Debug builds keep their distinct launcher icon.
  • ✅ No remaining references to the old .ui.MainActivity_0 alias anywhere (AppIcon.kt, manifest, wear/automotive). Rename is clean.
  • ✅ Atomic switch guarded correctly (setComponentEnabledSettings is API 33+); enable-before-disable fallback keeps the app launchable on older OSes.
  • getLaunchIntentForPackage consumers (widgets, deeplink, playback, refresh) resolve the enabled alias dynamically, so deep links / media / widget launches still work.
  • ✅ Tests are focused and assert the key invariant (exactly one alias enabled).

A few points worth addressing / confirming before merge:

1. Overlap with the existing app-start sync — worth a clarifying comment (low)

PocketCastsApplication.setupApp() already calls appIcon.enableSelectedAlias(appIcon.activeAppIcon) on every launch (PocketCastsApplication.kt:207). So the new AppIconUpdateReceiver is not strictly required for correctness — its value is closing the window where the drawer shows two icons after an update but before the user first opens the app. That's a legitimate reason to keep it, but since it duplicates existing behavior, a short comment on the receiver explaining why it exists (pre-launch migration) would help future maintainers not remove one of the two.

2. Brief double-icon window during migration (expected, but confirm)

For an existing user with a custom icon, immediately after update both MainActivityDefault (android:enabled="true" in the manifest) and their persisted custom alias are enabled until the receiver fires. This is inherent to the approach and the receiver minimizes it — just flagging that the "only one icon" guarantee in the testing steps holds after MY_PACKAGE_REPLACED is delivered, not instantaneously. Worth an eye during the Samsung One UI verification you mentioned.

3. Inert leftover intent-filter on MainActivity (nit)

After the change, MainActivity keeps:

<intent-filter>
    <action android:name="android.intent.action.MAIN" />
</intent-filter>

With no category this filter no longer contributes anything (it's not a launcher and MAIN alone matches nothing routed to it). Consider removing it for clarity, or leave a comment noting it's intentional. Not functional-breaking.
Fix this →

4. Pinned home-screen shortcuts / static shortcuts (confirm)

Switching aliases disables the previously-enabled alias, so a home-screen icon the user pinned to it becomes dead — the updated settings_app_icon_updated_message string now warns about this, which is good. Please double-check the static shortcuts (@xml/shortcuts) still open correctly from every alias (the <meta-data android:app.shortcuts> was correctly copied to all aliases) and that dynamic shortcuts (if any set an explicit target activity) still resolve.

5. Test module dependencies (minor)

modules/services/ui/build.gradle.kts adds junit, mockito.core, mockito.kotlin, robolectric as testImplementation. This is fine, but consider whether a shared test convention/bundle exists in this repo for Robolectric setup to keep versions consistent. Also, a small test for AppIconUpdateReceiver (that it forwards activeAppIcon to enableSelectedAlias) would round out coverage, though it's low priority given the receiver is a thin delegate.

Non-blocking observations

  • On API < 33 the fallback issues up to 19 setComponentEnabledSetting binder calls; this runs inside runBlocking on the main thread at every startup. This matches the pre-existing behavior (the old code looped the same way), and 33+ now collapses to a single call — a net improvement — so no action needed.
  • No try/catch around the PackageManager calls; the original code didn't have one either, so not a regression.

Overall: solid, well-scoped fix with correct root-cause handling and good test coverage. The items above are refinements/verifications rather than blockers. The main thing I'd want confirmed is the Samsung One UI before/after recording you flagged in the description.
• Branch: codex/fix-custom-app-icons

@wpmobilebot

wpmobilebot commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Project manifest changes for app

The following changes in the app's merged AndroidManifest.xml file were detected (build variant: release):

--- ./build/reports/diff_manifest/app/release/base_manifest.txt	2026-08-07 15:03:04.564234611 +0000
+++ ./build/reports/diff_manifest/app/release/head_manifest.txt	2026-08-07 15:03:08.836546381 +0000
@@ -118,12 +118,6 @@
             android:launchMode="singleTop"
             android:windowSoftInputMode="stateAlwaysHidden|adjustPan" >
             <intent-filter>
-                <action android:name="android.intent.action.MAIN" />
-
-                <category android:name="android.intent.category.LAUNCHER" />
-                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
-            </intent-filter>
-            <intent-filter>
                 <action android:name="android.intent.action.VIEW" />
 
                 <category android:name="android.intent.category.DEFAULT" />
@@ -433,10 +427,6 @@
                 <data android:path="/get" />
                 <data android:pathPrefix="/get/" />
             </intent-filter>
-
-            <meta-data
-                android:name="android.app.shortcuts"
-                android:resource="@xml/shortcuts" />
         </activity>
 
         <activity-alias
@@ -450,7 +440,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_17"
@@ -463,7 +458,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_16"
@@ -476,7 +476,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_15"
@@ -489,7 +494,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_14"
@@ -502,7 +512,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_13"
@@ -515,7 +530,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_12"
@@ -528,7 +548,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_11"
@@ -541,7 +566,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_10"
@@ -554,7 +584,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_9"
@@ -567,7 +602,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_8"
@@ -580,7 +620,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_7"
@@ -593,7 +638,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_6"
@@ -606,7 +656,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_5"
@@ -619,7 +674,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_4"
@@ -632,7 +692,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_3"
@@ -645,7 +710,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_2"
@@ -658,7 +728,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
             android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_1"
@@ -671,11 +746,16 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
         <activity-alias
-            android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivity_0"
-            android:enabled="false"
+            android:name="au.com.shiftyjelly.pocketcasts.ui.MainActivityDefault"
+            android:enabled="true"
             android:exported="true"
             android:icon="@mipmap/ic_launcher"
             android:label="@string/app_name"
@@ -684,7 +764,12 @@
                 <action android:name="android.intent.action.MAIN" />
 
                 <category android:name="android.intent.category.LAUNCHER" />
+                <category android:name="android.intent.category.MULTIWINDOW_LAUNCHER" />
             </intent-filter>
+
+            <meta-data
+                android:name="android.app.shortcuts"
+                android:resource="@xml/shortcuts" />
         </activity-alias>
 
         <activity
@@ -720,6 +805,13 @@
             </intent-filter>
         </receiver>
         <receiver
+            android:name="au.com.shiftyjelly.pocketcasts.AppIconUpdateReceiver"
+            android:exported="false" >
+            <intent-filter>
+                <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
+            </intent-filter>
+        </receiver>
+        <receiver
             android:name="androidx.mediarouter.media.MediaTransferReceiver"
             android:exported="true" >
         </receiver>

Go to https://buildkite.com/automattic/pocket-casts-android/builds/18198/canvas?sid=019fdcbd-3866-4e87-aa27-20c6c7ffd9e0, click on the Artifacts tab and audit the files.

- Rename settings_app_icon_updated_message to settings_app_icon_updated_message_v2 and
  drop the stale translations so other locales fall back to the new English copy instead
  of the old text that told users to expect two launcher icons
- Remove the now-inert MAIN-only intent filter from MainActivity (LAUNCHER lives on the
  activity aliases)
- Document why AppIconUpdateReceiver exists alongside the app-start alias sync
- Link the changelog entry to the PR instead of the issue
@joashrajin joashrajin self-assigned this Jul 18, 2026
@joashrajin joashrajin modified the milestones: 8.17, 8.18 Jul 18, 2026
@joashrajin
joashrajin marked this pull request as ready for review July 18, 2026 16:02
@joashrajin
joashrajin requested a review from a team as a code owner July 18, 2026 16:02
@joashrajin
joashrajin requested review from sztomek and removed request for a team July 18, 2026 16:02
@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Claude Code is working…

I'll analyze this and get back to you.

View job run

@joashrajin joashrajin added the [Priority] Low A less urgent issue that can be addressed when time allows label Jul 18, 2026
@claude

claude Bot commented Jul 18, 2026

Copy link
Copy Markdown

Claude finished @joashrajin's task in 2m 34s —— View job


Code Review — PR #5609: Fix duplicate custom app icons

  • Read PR diff (post-Address review feedback)
  • Review AppIcon.kt + AppIconUpdateReceiver
  • Review AndroidManifest.xml aliases
  • Review strings / tests / gradle / shortcut & cast targets
  • Post review

Re-reviewed the branch after the Address review feedback and changelog commits. This is a well-targeted fix for the real root cause (MainActivity carrying LAUNCHER and an enabled alias → two drawer entries). The current state is solid. Details verified below, plus a few non-blocking notes.

✅ Verified correct

  • Launcher moved off MainActivity entirely. The MAIN/LAUNCHER/MULTIWINDOW_LAUNCHER filter is fully removed from MainActivity (AndroidManifest.xml:60), so the earlier "inert MAIN-only filter" nit is resolved — no orphan filter remains. MainActivity keeps only its functional VIEW/deep-link/media filters.
  • MULTIWINDOW_LAUNCHER + @xml/shortcuts now on every alias (previously only on MainActivity). This is the correct place for both — static shortcuts and Samsung multi-window follow whichever alias is enabled.
  • Rename is clean. No remaining references to .ui.MainActivity_0 anywhere in .kt/.xml (checked app + wear + automotive). AppIcon.kt, the test, and the manifest all agree on .ui.MainActivityDefault.
  • Non-launcher entry points still resolve. Dynamic shortcuts use a VIEW deep-link intent (DynamicShortcutsSynchronizer.kt:64) that hits MainActivity's surviving VIEW filter, and Cast targets MainActivity::class.java.name directly (CastOptionsProvider.kt:26) — both unaffected by alias switching.
  • Switch logic is safe on every OS. API 33+ collapses to a single atomic setComponentEnabledSettings call; the pre-13 path enables the replacement before disabling the others, so the app is never left with zero launcher entries. Tests assert the key invariant (exactly one enabled) on both API 32 and 33.
  • String key rename is consistent. The old settings_app_icon_updated_message is gone from base + all values-* files and both dialog call-sites now use _v2 (AppearanceSettingsFragment.kt:106,154). No dangling references to the old key.

Non-blocking notes

  1. _v2 string is currently untranslated in every locale. The rename removed the old key's translations from all values-* files and the new key only exists in values/strings.xml, so until GlotPress round-trips, non-English users see the English copy. That's the intended trade-off (avoids stale translations shadowing the reworded text) — just flagging it for release timing so it isn't shipped mid-cycle expecting translated copy.

  2. Transient double-icon window during a dirty upgrade is inherent, not fixed instantly. For an existing user with a custom icon, right after MY_PACKAGE_REPLACED both the persisted custom alias and the new MainActivityDefault (android:enabled="true") are enabled until either AppIconUpdateReceiver fires or the app is next opened (setupApp()enableSelectedAlias, PocketCastsApplication.kt:207). The receiver minimizes it; the "exactly one icon" guarantee holds after the broadcast is delivered. This matches your on-device Galaxy S21 FE verification, and the Samsung launcher-cache caveat you documented is the right guidance.

  3. Receiver is redundant with app-start sync — and the comment says so. Because the process starts (running Application.onCreatesetupAppenableSelectedAlias) when the broadcast is delivered anyway, AppIconUpdateReceiver largely overlaps setupApp. Keeping both as belt-and-braces is reasonable and the KDoc explains why, so no change needed — just don't let a future cleanup remove one without the other.

  4. Optional test. A tiny unit test for AppIconUpdateReceiver (forwards activeAppIcon to enableSelectedAlias, ignores other actions) would round out coverage of the thin delegate. Low priority.

  5. Perf (pre-existing). On API < 33, enableSelectedAlias issues up to 19 synchronous setComponentEnabledSetting binder calls inside runBlocking on the main thread at every startup. This is unchanged from before (the old code looped the same way) and 33+ is now a single call — a net win — so no action required.

Overall: correct root-cause fix, good manifest hygiene, and focused tests. No blocking issues from my side. The main confirmations rest on the on-device behavior you already captured (dirty-upgrade migration to a single alias with selection preserved), which the screenshots and query-activities ground-truth guidance cover well.
• Branch: codex/fix-custom-app-icons

@wpmobilebot wpmobilebot modified the milestones: 8.18, 8.19 Aug 3, 2026
@wpmobilebot

Copy link
Copy Markdown
Collaborator

Version 8.18 has now entered code-freeze, so the milestone of this PR has been updated to 8.19.

@claude

claude Bot commented Aug 7, 2026

Copy link
Copy Markdown

Claude finished @joashrajin's task in 7m 28s —— View job


Code Review — PR #5609: Fix duplicate custom app icons

  • Read PR diff (post-merge with origin/main)
  • Review AppIcon.kt + AppIconUpdateReceiver
  • Review manifest aliases (enum ↔ manifest consistency)
  • Review strings / tests / gradle / shortcut & entry-point targets
  • Post review (3 inline comments)

Re-reviewed after the merge of origin/main into the branch. The approach is right and the current state is in good shape — the root cause really was MainActivity holding LAUNCHER while a custom alias was also enabled, and representing the default icon as its own alias so that exactly one launcher component is ever enabled is the correct fix. Three inline comments posted; none are blockers.

✅ Verified

  • Enum ↔ manifest are exactly in sync. 19 AppIconType entries ↔ 19 activity-alias declarations, no duplicates, and each alias has MAIN + LAUNCHER + MULTIWINDOW_LAUNCHER and the android.app.shortcuts meta-data (19/19 for all three). No remaining reference to the old .ui.MainActivity_0 anywhere in .kt/.xml.
  • The rename to MainActivityDefault is load-bearing, not cosmetic. Users who previously had the default icon have a persisted COMPONENT_ENABLED_STATE_DISABLED override for MainActivity_0 (the old code explicitly disabled it). Because the new component name has no persisted state, it falls back to the manifest's android:enabled="true" — so those users don't end up with zero launcher entries. Reusing MainActivity_0 would have broken them.
  • ${appIcon} placeholder is defined for every build type (app/build.gradle.kts:38-50: radioactive / _3 / prototype / ic_launcher), matching <application android:icon="${appIcon}"> at AndroidManifest.xml:36. Debug builds keep their distinct launcher icon. No roundIcon is declared anywhere in the manifest, so the alias doesn't need to re-declare one.
  • API-33 class reference is guarded correctly. PackageManager.ComponentEnabledSetting is only touched from enableSelectedAliasAtomically (AppIcon.kt:254), a separate method behind the SDK_INT >= TIRAMISU check — ART verifies lazily per method, so there's no risk of a verification failure on older devices. The pre-13 path enables the replacement before disabling the rest, so the app is never left unlaunchable.
  • Non-launcher entry points survive. Static shortcuts target MainActivity via an explicit VIEW intent (modules/services/ui/src/main/res/xml/shortcuts.xml), which matches MainActivity's surviving VIEW filters; deep links, media intents, notifications, and Cast all address MainActivity directly. The now-inert MAIN-only filter flagged in the earlier review is fully removed.
  • Automotive and Wear are unaffected — neither declares any activity-alias, and neither calls enableSelectedAlias (only PocketCastsApplication and SettingsAppearanceViewModel do).
  • String rename is clean. No dangling references to settings_app_icon_updated_message; the old key is removed from values/ and every values-* file, so there's no ExtraTranslation lint failure. robolectric in modules/services/ui/build.gradle.kts matches the convention used by 6 other service modules.
  • CHANGELOG entry sits under 8.19 and matches the surrounding formatting.

Inline comments

  1. AppIconUpdateReceiver KDoc rationale is inverted — the broadcast is what starts the process, and Application.onCreate()setupApp()enableSelectedAlias (PocketCastsApplication.kt:173,221) always completes before onReceive runs. So the receiver isn't waking anything; it's a second identical PackageManager batch. Either drop it or reword the comment as a setupApp()-failed backstop.
  2. No build-time guard on enum ↔ manifest drift — the failure mode this PR fixes. Suggested a small androidTest asserting every aliasName is actually declared (GET_ACTIVITIES or MATCH_DISABLED_COMPONENTS). Also noted a stray blank line left at AndroidManifest.xml:326.
  3. Test gap: DEFAULT on API 33+ — the atomic path is only covered for a custom icon (DARK); DEFAULT is only covered on API 32, yet the default case is exactly where the old logic was wrong. One extra test closes it.

Non-blocking observations

  • All locales lose this dialog copy until GlotPress round-trips. The _v2 rename intentionally drops the old translations, so non-English users see English until translations land. That's the right trade-off versus stale copy that no longer mentions the shortcut caveat — just worth timing against the release cut rather than shipping mid-cycle.
  • Startup cost is unchanged-to-better, but could be free. setupApp() blocks on enableSelectedAlias inside runBlocking on the main thread at every launch — up to 19 synchronous binder calls on API < 33 (one on 33+, a net improvement). Nothing downstream depends on it completing, so moving it to applicationScope.launch would take it off the startup critical path. Pre-existing, out of scope, but cheap.
  • The transient two-icon window on a dirty upgrade is inherent to the approach and can't be fully eliminated: between install and the first enableSelectedAlias, both the persisted custom alias and the manifest-enabled default alias are on. Your Galaxy S21 FE verification plus the query-activities ground-truth guidance in the description is the right way to document it.

Overall: correct root-cause fix, clean manifest hygiene, and the migration path for both previously-default and previously-custom users is handled properly. The three inline items are refinements — the KDoc correction is the one I'd actually want done, since a future maintainer reading it could remove the wrong half of the pair.
• Branch: codex/fix-custom-app-icons

/**
* Re-syncs the launcher activity aliases right after an app update, without waiting for the user
* to open the app. [PocketCastsApplication] performs the same sync on every app start, so this
* receiver's main job is waking the process after MY_PACKAGE_REPLACED; keep both in place.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stated rationale is inverted: it's the broadcast delivery that starts the process, and Application.onCreate() always runs to completion before any component callback. PocketCastsApplication.onCreate()setupApp() (PocketCastsApplication.kt:173) synchronously calls appIcon.enableSelectedAlias(appIcon.activeAppIcon) inside runBlocking (PocketCastsApplication.kt:221), so by the time onReceive runs the aliases have already been re-synced. The receiver isn't "waking the process" — it's issuing a second, identical PackageManager batch on the main thread right after the first.

Two reasonable options:

  • Drop the receiver entirely (smaller manifest surface, one fewer main-thread PackageManager batch per app update).
  • Keep it as a belt-and-braces backstop for the case where setupApp() throws before reaching the sync, but reword the comment so it doesn't mislead a future reader into thinking the receiver is what triggers the migration.

Fix this →

Comment on lines +84 to +99
@Test
@Config(sdk = [33])
fun `aliases are updated atomically when supported`() {
appIcon.enableSelectedAlias(AppIconType.DARK)

val settingsCaptor = argumentCaptor<List<PackageManager.ComponentEnabledSetting>>()
verify(packageManager).setComponentEnabledSettings(settingsCaptor.capture())
verify(packageManager, never()).setComponentEnabledSetting(any(), any(), any())

val states = settingsCaptor.firstValue.associate { setting ->
requireNotNull(setting.componentName).className to setting.enabledState
}
assertEquals(AppIconType.entries.size, states.size)
assertEquals(PackageManager.COMPONENT_ENABLED_STATE_ENABLED, states[DARK_ALIAS])
assertEquals(1, states.values.count { it == PackageManager.COMPONENT_ENABLED_STATE_ENABLED })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Coverage gap: the atomic (API 33+) path is only exercised for a custom icon (DARK), while DEFAULT is only tested on API 32. The default case is precisely where the old code was wrong (it disabled every alias and relied on MainActivity carrying LAUNCHER), so it's the case most worth pinning down on the path that the majority of devices will actually take. A parameterised variant, or just one extra test, would close it:

@Test
@Config(sdk = [33])
fun `default icon enables only the default alias when updated atomically`() {
    appIcon.enableSelectedAlias(AppIconType.DEFAULT)

    val settingsCaptor = argumentCaptor<List<PackageManager.ComponentEnabledSetting>>()
    verify(packageManager).setComponentEnabledSettings(settingsCaptor.capture())

    val states = settingsCaptor.firstValue.associate { requireNotNull(it.componentName).className to it.enabledState }
    assertEquals(PackageManager.COMPONENT_ENABLED_STATE_ENABLED, states[DEFAULT_ALIAS])
    assertEquals(1, states.values.count { it == PackageManager.COMPONENT_ENABLED_STATE_ENABLED })
}

android:resource="@xml/shortcuts"/>
</activity>

<activity-alias

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 19 aliases are now identical apart from android:name and android:icon, and their names have to stay in lockstep with AppIcon.AppIconType.aliasName in a different module (modules/services/ui). Drift between those two lists is the exact shape of the bug this PR fixes (MainActivity_0 permanently disabled while MainActivity carried LAUNCHER), and nothing currently fails the build if a future icon is added to the enum but not the manifest — setComponentEnabledSetting on a non-existent component throws IllegalArgumentException at runtime.

Worth adding a cheap guard, e.g. an androidTest in app that walks the enum and asserts each alias really exists and is wired up:

val pkg = context.packageManager.getPackageInfo(
    context.packageName,
    PackageManager.GET_ACTIVITIES or PackageManager.MATCH_DISABLED_COMPONENTS,
)
val declared = pkg.activities.orEmpty().map { it.name }.toSet()
AppIcon.AppIconType.entries.forEach { type ->
    assertTrue("Missing alias for $type", "au.com.shiftyjelly.pocketcasts${type.aliasName}" in declared)
}

(For reference, I verified the current state is consistent: 19 enum entries ↔ 19 aliases, each with LAUNCHER, MULTIWINDOW_LAUNCHER, and the android.app.shortcuts meta-data.)

Unrelated nit two lines up: removing the android.app.shortcuts meta-data from MainActivity left a stray blank line before </activity> at line 326.

Fix this →

@wpmobilebot wpmobilebot modified the milestones: 8.19, 8.20 Aug 17, 2026
@wpmobilebot

Copy link
Copy Markdown
Collaborator

Version 8.19 has now entered code-freeze, so the milestone of this PR has been updated to 8.20.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[Area] Settings [Priority] Low A less urgent issue that can be addressed when time allows [Type] Bug Not functioning as intended.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Change the custom App Icon implementation

2 participants