# Android to HarmonyOS Code-Defined Vector Icon Rules

This document defines how to migrate vector icons that an Android project draws from **code** rather than from `res/`. Read this before converting any project that uses Jetpack Compose. Every code-defined icon observed during conversion must produce an inventory row and a mapping row per the rules in `references/resource-mapping-rules.md`.

## Table of Contents
1. [Scope: Why `res/` Scanning Misses These Icons](#scope-why-res-scanning-misses-these-icons)
2. [Detection: When to Run This Analysis](#detection-when-to-run-this-analysis)
3. [Three Sources of Truth](#three-sources-of-truth)
4. [Extracting the Icon Inventory from `classes*.dex`](#extracting-the-icon-inventory-from-classesdex)
5. [Extracting Screen Attribution from Android Source](#extracting-screen-attribution-from-android-source)
6. [Resolving an Icon Name to Upstream Artwork](#resolving-an-icon-name-to-upstream-artwork)
7. [HarmonyOS Target Path and Filename](#harmonyos-target-path-and-filename)
8. [SVG Post-Processing](#svg-post-processing)
9. [AutoMirrored and Right-to-Left](#automirrored-and-right-to-left)
10. [Report and Mapping Requirements](#report-and-mapping-requirements)
11. [Fallbacks and Out-of-Scope Cases](#fallbacks-and-out-of-scope-cases)

---

## Scope: Why `res/` Scanning Misses These Icons

Jetpack Compose applications take most of their iconography from `androidx.compose.material:material-icons-core` and `androidx.compose.material:material-icons-extended`. Those icons are **Kotlin-generated `ImageVector` objects** built with a path DSL. They are compiled into `classes*.dex` and never appear in:

- the project's `res/drawable*/`,
- the APK's `resources.arsc`,
- therefore also not in `<decoded_output_path>/res/` after Step 3.

Every other step of this skill reads a `res/` tree. That makes these icons **structurally invisible** to the rest of the pipeline, and the failure is silent in a particularly bad way:

- Step 5.3 scans `layout*/` and `menu/` for `@type/name` references. A Compose app has no layout XML, so the scan finds nothing missing.
- Step 6.8 cross-checks `$r('app.media.xxx')` references that already exist in `.ets` files. A HarmonyOS shell project that has not been written yet contains no such references, so the check finds nothing missing.

Both gates report "all dependencies satisfied" while the migrated page renders no icons at all.

**Observed case.** On a real Compose application (Mihon), the More screen displays 10 icons. Exactly **one** of them (`R.drawable.ic_glasses_24dp`) is an Android resource. The other nine — including the icons in front of *Download queue* and *Categories* — come from `Icons.Outlined.*` / `Icons.AutoMirrored.Outlined.*`. Resource conversion converted the one resource correctly and produced nothing for the other nine, with no warning anywhere in the report.

## Detection: When to Run This Analysis

Run this analysis when **any** of the following is true:

1. `build.gradle`, `build.gradle.kts`, or `gradle/libs.versions.toml` declares `androidx.compose.material:material-icons-core` or `androidx.compose.material:material-icons-extended`.
2. Any `.kt` file in the project imports a path under `androidx.compose.material.icons.`.
3. `classes*.dex` in the decoded APK contains at least one string matching the icon-name pattern in the next section.

Condition 3 alone is sufficient — a project can pull the icon library in transitively through another dependency without declaring it.

If none hold, write `Code-defined vector icons: none detected` in the report's summary and skip the rest of this document.

## Three Sources of Truth

Three separate questions need three separate sources. Do not try to answer all three from one place.

| Question | Source | Needs `android_project_dir`? |
|---|---|---|
| **Which** icons does this app use? | `classes*.dex` string pool | No |
| **What** does each icon look like? | Upstream Material Icons snapshot | No |
| **Where** is each icon used (screen attribution)? | Android source `.kt` files | **Yes** |

### Why the dex is authoritative for scope

The dex reflects what was actually packaged: it covers icons used by **library** code as well as application code, and it **excludes** icons eliminated as dead code by R8. Grepping the Kotlin source alone both over-reports (references that were shrunk away) and under-reports (library-internal usage, and references reached through import aliases or indirection).

### Why the dex cannot give attribution

R8 obfuscates the icon library. In a release APK the package path `androidx/compose/material/icons` is absent from every dex, and the surviving icon identifiers are the plain name strings passed to the `materialIcon(name = "...")` builder, sitting in a flat, sorted string pool:

```
... Outlined.Public  Outlined.PushPin  Outlined.QueryStats  Outlined.RadioButt ...
```

The string tells you `QueryStats` is used. It does not tell you the *Statistics* row uses it. Recovering that link from the dex would require disassembly plus call-graph analysis to connect an obfuscated getter to both its name string and its call sites. Application class names typically **do** survive (e.g. `Leu/kanade/presentation/more/MoreScreenKt`), so the analysis is tractable — but reading the one line of Kotlin source is exact and costs nothing:

```kotlin
TextPreferenceWidget(
    title = stringResource(MR.strings.label_download_queue),
    icon = Icons.Outlined.GetApp,          // <- attribution, directly readable
    onPreferenceClick = onClickDownloadQueue,
)
```

`android_project_dir` is already a required input of this skill (Step 2 resolves the application module from it; Step 5 correlates library dependencies from it), so attribution adds no new input requirement.

## Extracting the Icon Inventory from `classes*.dex`

Read each `classes*.dex` at `<decoded_output_path>/` as raw bytes and match this pattern against the byte stream (the strings are plain ASCII inside the DEX string pool):

```
(AutoMirrored\.)?(Filled|Outlined|Rounded|Sharp|TwoTone)\.[A-Z_][A-Za-z0-9_]*
```

Deduplicate across all dex files. Each unique match is one **icon reference**.

Notes:

- **The name must start with an uppercase letter or `_`.** Compose icon names are PascalCase, and digit-leading names carry a `_` prefix. Without this constraint the pattern also captures Kotlin source filenames that the compiler embeds as debug strings — `Outlined.kt`, `Filled.kt`, `Rounded.kt` fit the shape exactly. Three such false positives were measured on one real application.
- `Icons.Default` is an alias of `Icons.Filled`, and `Icons.AutoMirrored.Default` of `Icons.AutoMirrored.Filled`. The string recorded in the dex is always the `Filled` form; no normalization is needed on the dex side (it *is* needed on the source side — see below).
- Names that begin with a digit are prefixed with `_` in Compose (`Icons.Filled._360`). Strip the leading `_` before name resolution.
- Do not silently drop a captured string that fails to resolve. Report it. A capture that resolves to nothing is either a false positive worth knowing about or a genuine gap in the snapshot.

When the decoded APK is unavailable (the Step 3 decode fell back to source `res/`), fall back to source-only extraction using the patterns in the next section, and record in the report that the inventory is source-derived and therefore may miss library-internal icon usage.

### When the icon library was not shrunk

The dex is authoritative for scope **only when R8 actually shrank the icon library**. When it did not, the entire `material-icons-extended` set is packaged and the dex inventory stops meaning "icons this app uses".

The signal is a large reference count that is almost entirely unattributed. One measured application produced **2149** dex references against **92** source-attributed ones — 96% dex-only. Synthesizing artwork for all of them would write ~2100 unused SVGs into `base/media/`.

When the reference count is at or above 300 **and** at least 80% of references have no source attribution, treat the library as unshrunk:

- synthesize artwork only for source-attributed references (`--attributed-only`),
- still emit an inventory and mapping row for every other reference, with `mapping_kind = unmappable` and reason `packaged by an unshrunk icon library; not referenced by application code`,
- state the situation in the report.

This is a deliberate trade-off, not a failure: the rows are all still there, so nothing is silently omitted, and the output stays proportionate to what the application actually renders.

## Extracting Screen Attribution from Android Source

Grep `.kt` files under `android_project_dir` for:

```
Icons\.(AutoMirrored\.)?(Filled|Outlined|Rounded|Sharp|TwoTone|Default)\.[A-Z_][A-Za-z0-9_]*
```

Normalize `Default` → `Filled` so source matches join cleanly against dex matches.

Attribute each match to a screen using this evidence priority — the same best-effort discipline as `references/resource-mapping-rules.md` §6:

1. **Enclosing composable.** The nearest preceding `fun <Name>(` declaration in the file. If that name ends in `Screen`, `Page`, `Tab`, or `Dialog`, use it directly.
2. **File name.** `MoreScreen.kt` → `MoreScreen`.
3. **Shared widget.** If the enclosing composable is a reusable widget referenced from several screens, record the known screens, or `Common` when the list is too broad to be useful.
4. **No evidence.** `Unknown`.

Then propose a HarmonyOS page name the same way `references/lottie-conversion-rules.md` does: strip the `Screen` / `Activity` / `Fragment` / `Tab` suffix and append `Page` (`MoreScreen` → `MorePage`). Record it in `notes` as `page=<PageName>` and mark it as a suggestion to verify against the actual ArkUI pages.

An icon that appears in the dex but has **no** source match is still a required row. Record `Screen(s) = Unknown` and note `dex-only; no source reference found (likely used by a library)`.

## Resolving an Icon Name to Upstream Artwork

The Compose icon set is generated from the classic Material Icons in `google/material-design-icons`. The mapping is a pure string transform plus one index lookup.

### Step 1 — name transform

Strip any leading `_`, then split on **case boundaries only** and lowercase:

```
(?<=[a-z0-9])(?=[A-Z])      -> _        aB  -> a_B
(?<=[A-Z])(?=[A-Z][a-z])    -> _        ABc -> A_Bc
```

**Do not split on digit boundaries.** Upstream has no consistent rule there, and any fixed rule gets a batch of names wrong:

| Compose name | Material name | A digit rule would produce |
|---|---|---|
| `Filter1` | `filter_1` | — (needs the split) |
| `Co2` | `co2` | `co_2` ✗ |
| `Rotate90DegreesCcw` | `rotate_90_degrees_ccw` | — (needs the split) |
| `StarPurple500` | `star_purple500` | `star_purple_500` ✗ |
| `Grid3x3` | `grid_3x3` | `grid_3_x_3` ✗ |
| `Crop169` | `crop_16_9` | `crop_169` ✗ |
| `_3dRotation` | `3d_rotation` | `3_d_rotation` ✗ |

### Step 2 — three-stage lookup

Resolve the transformed name against the index in this order. Every stage is deterministic.

1. **Exact match.** `GetApp` → `get_app`, present in the index. Covers the large majority.
2. **Underscore-insensitive match.** Compare after deleting every `_` on both sides. This absorbs the entire digit-boundary problem in one rule: `crop169` matches `crop_16_9`, `grid3x3` matches `grid_3x3`, `filter1` matches `filter_1`, `starpurple500` matches `star_purple500`, `3drotation` matches `3d_rotation`. Measured across the upstream index, 2208 of 2209 names remain unique after deleting underscores; the single collision (`add_chart` / `addchart`, a legacy upstream duplicate) is already settled by stage 1, and otherwise breaks ties lexicographically.
3. **Alias table.** A few icons were renamed upstream while Compose kept the old name. Only entries verified against the index belong here — never guess:

   | Compose name | Upstream name |
   |---|---|
   | `Motorcycle` | `two_wheeler` |
   | `LeaveBagsAtHome` | `no_luggage` |

Anything still unresolved after stage 3 becomes an `unmappable` row. Do not invent artwork.

Measured over 10 real applications (2500 icon references), stage 1 resolved 2408, stage 2 resolved 91, the alias table resolved 2, and nothing was left unresolved.

### Step 3 — category lookup

The upstream repository stores icons at `src/<category>/<name>/<style_dir>/24px.svg`, and the category is not derivable from the name. Build a `name → category` index once from `update/current_versions.json`, whose keys have the form `<category>::<name>`.

**Exclude the `symbols` category.** That key namespace is the parallel *Material Symbols* set, which lives at `symbols/web/<name>/...` with a different on-disk layout. It duplicates nearly every classic icon name, so leaving it in makes every single lookup ambiguous and can produce paths that do not exist in `src/`.

After excluding `symbols`, a name that still maps to more than one category is a genuine ambiguity: pick the first in lexicographic order for determinism and record the alternatives in `notes`.

### Step 4 — style directory

| Compose style | Upstream directory | Filename suffix |
|---|---|---|
| `Filled` (and `Default`) | `materialicons` | `filled` |
| `Outlined` | `materialiconsoutlined` | `outlined` |
| `Rounded` | `materialiconsround` | `round` |
| `Sharp` | `materialiconssharp` | `sharp` |
| `TwoTone` | `materialiconstwotone` | `twotone` |

`AutoMirrored` is **not** a style. `AutoMirrored.Outlined.Label` and a hypothetical `Outlined.Label` resolve to the same upstream file.

### Step 5 — assemble

```
src/<category>/<material_name>/<style_dir>/24px.svg
```

Worked examples from the Mihon More screen:

| Compose reference | Upstream path |
|---|---|
| `Outlined.GetApp` | `src/action/get_app/materialiconsoutlined/24px.svg` |
| `AutoMirrored.Outlined.Label` | `src/action/label/materialiconsoutlined/24px.svg` |
| `Outlined.QueryStats` | `src/editor/query_stats/materialiconsoutlined/24px.svg` |
| `Outlined.Storage` | `src/device/storage/materialiconsoutlined/24px.svg` |
| `Outlined.CloudOff` | `src/file/cloud_off/materialiconsoutlined/24px.svg` |
| `Filled.VolunteerActivism` | `src/maps/volunteer_activism/materialicons/24px.svg` |

### Snapshot pinning

The artwork source MUST be a pinned snapshot, not a moving branch. Reproducibility is the whole point of Step 2 resource patches: two runs of the same commit must produce byte-identical output. Record the pinned ref in the report and in the mapping document metadata.

**The default is `--fetch`: artwork is downloaded from a pinned upstream commit at run time.** The pin is what makes the run reproducible — never point the tool at a moving branch.

This carries one operational requirement worth recording in the run environment: **the machine needs network access to `raw.githubusercontent.com`**. A network-isolated runner (a hardened container, an offline test bench) will fail this step. Two consequences follow, and both should be handled by reporting rather than by silently degrading:

- the step exits non-zero and the icons are recorded as `unmappable` with reason `icon snapshot unavailable`;
- the `res/` conversion is unaffected and still valuable, so the run continues.

For such environments, `--snapshot <dir>` reads a local copy instead. It does not need the whole upstream repository — the clone is dominated by PNG exports, the Android XML tree, and the Material Symbols tree, none of which this skill reads. Only these are required (measured at commit `50f0603`: 10,751 files, 5.4 MB):

```
<material_icon_snapshot_dir>/
  update/current_versions.json          # the name -> category index
  src/<category>/<name>/<style_dir>/24px.svg
```

Record which upstream commit the copy was taken from.

## HarmonyOS Target Path and Filename

```
entry/src/main/resources/base/media/ic_<material_name>_<style_suffix>.svg
```

**The style suffix is mandatory.** In the classic Material Icons set, `Outlined.X` is sometimes different artwork from `Filled.X` and sometimes byte-identical:

```
bookmark  filled    d="M17 3H7c-1.1 0-1.99.9-1.99 2L5 21l7-3 7 3V5c0-1.1-.9-2-2-2z"
bookmark  outlined  d="M17 3H7c-1.1 0-2 .9-2 2v16l7-3 7 3V5c0-1.1-.9-2-2-2z"        <- differs
favorite  filled / outlined                                                          <- identical
```

Naming by bare `ic_<name>.svg` therefore silently drops one of the two whenever they differ. Always include the style. Keeping the byte-identical duplicates costs a few hundred bytes each and keeps the naming rule free of special cases.

`AutoMirrored` is **not** part of the filename — it is a property of the *reference*, handled at the component level (next section). So `AutoMirrored.Filled.KeyboardArrowLeft` and `Filled.KeyboardArrowLeft` correctly share one file.

**Collision with a `res/`-derived media file.** If the target filename already exists in the HarmonyOS output because a real Android drawable produced it, append `_material` (`ic_settings_outlined_material.svg`) and record both the collision and the rename in `notes`. Never overwrite a `res/`-derived asset.

## SVG Post-Processing

The upstream files are already 24×24 SVG with `viewBox="0 0 24 24"`, so no geometry transform is required. Apply exactly two transforms:

1. **Remove the no-op guard path.** Upstream files open with a transparent bounding rectangle that carries no visual information:

   ```xml
   <path d="M0 0h24v24H0V0z" fill="none"/>
   <path d="M0 0h24v24H0z" fill="none"/>      <!-- variant -->
   ```

   Both variants are removed.

2. **Do not inject `fill`.** The remaining path deliberately carries no `fill` attribute. That matches Compose semantics, where the icon is tinted at the use site by `LocalContentColor`; on HarmonyOS the equivalent is `Image($r('app.media.…')).fillColor('#666666')`. Injecting a hard-coded `fill` here is the same defect that `references/xml-drawable-to-svg-rules.md` calls `FILL_INJECTED`, reached from a different source.

Keep `viewBox`, `width`, and `height` as upstream emits them.

## AutoMirrored and Right-to-Left

HarmonyOS qualifier directories have no left-to-right / right-to-left dimension — the qualifier order is `MCC_MNC-language_script_country/region-orientation-device-colormode-density`. Mirroring therefore **cannot** be expressed as a resource variant the way Android's `-ldrtl` does.

Handle it at the component level in ArkUI and record the requirement so the developer does not lose it:

- set `auto_mirrored = true` on the mapping record,
- write `AutoMirrored: mirror horizontally under right-to-left locales` into `notes`.

Do not emit a second, pre-mirrored SVG file. A pre-mirrored asset would have to be selected by code anyway, and it doubles the assets for no gain.

## Report and Mapping Requirements

### Conversion report

Add a dedicated section (see SKILL.md Step 7 for its position):

```
### Code-Defined Vector Icons

| Compose Reference | Material Name | Style | HarmonyOS Target | Host Screen(s) | Suggested Page | Notes |
|---|---|---|---|---|---|---|
| Icons.Outlined.GetApp | get_app | Outlined | base/media/ic_get_app_outlined.svg | MoreScreen | MorePage | |
| Icons.AutoMirrored.Outlined.Label | label | Outlined | base/media/ic_label_outlined.svg | MoreScreen | MorePage | AutoMirrored: mirror under RTL |
```

and these summary counters:

- Code-defined icon references found (dex)
- Resolved to upstream artwork
- Unresolved (recorded as unmappable)
- Distinct media files written
- AutoMirrored references
- Screen-attributed / `Unknown`

### Mapping document

Every icon reference gets one inventory row and one mapping row.

- **`android_resource_path`** — use `<source file>#Icons.<Style>.<Name>` when a source reference was found (e.g. `app/src/main/java/eu/kanade/presentation/more/MoreScreen.kt#Icons.Outlined.GetApp`); otherwise `classes.dex#Icons.<Style>.<Name>`.
- **`android_resource_name`** — the material snake_case name (`get_app`).
- **`function`** — describe the role from the host row's label when known (`Download queue row icon`), otherwise a conservative fallback (`Material icon used in code`).
- **`screens`** — per the attribution rules above; `Unknown` when dex-only.
- **`source_category`** — `第三方库资源`, with `notes` naming `androidx.compose.material:material-icons-extended`. These icons are library-provided; they are not application `res/` assets.
- **`type_category`** — `drawable`. Do not invent a new top-level type category.
- **`mapping_kind`** — `code-vector synthesized`, or `unmappable` when unresolved.
- **`notes`** — MUST record the upstream path and the pinned snapshot ref, so a reviewer can verify the artwork.

**Hard requirement:** never silently omit a code-defined icon reference. This is the same rule as §13 of `references/resource-mapping-rules.md`, and it is the rule whose absence caused the whole class of defects this document exists to fix.

## Fallbacks and Out-of-Scope Cases

| Situation | Handling |
|---|---|
| Name captured from dex does not resolve in the index | Emit the row with `harmony_target = N/A`, `mapping_kind = unmappable`, reason `not found in Material Icons index`. **Never invent artwork.** |
| Snapshot unavailable (no vendored copy, no network) | Emit every icon row as `unmappable` with reason `icon snapshot unavailable`, and state it prominently in the report. Do **not** fail the whole conversion — the `res/` conversion is still valuable. |
| `android_project_dir` unavailable or unreadable | Inventory still comes from the dex; set every `screens` to `Unknown` and note the limitation. |
| Decode failed, only source `res/` available | Extract from source `.kt` only; note that library-internal icon usage may be missing. |
| App-defined `ImageVector` built by hand in application code | **Out of scope.** Detect via `ImageVector.Builder` in application source, record as `unmappable` with reason `app-defined ImageVector, no upstream artwork`, and list it in the report so the developer redraws it manually. |
| Non-Material code icon libraries (e.g. a third-party Compose icon pack) | Out of scope for artwork. Record the references as `unmappable` with the library name in `notes` so the gap is visible. |

The last three rows matter: this document narrows a known blind spot, it does not close it completely. Anything it cannot resolve must become a visible row, never a silent omission.
