neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Show app icons and names in the F-Droid index
b299654 · Gabriel Morell · 2026-09-19 04:16
Message
{commit_body(@commit)}
Files changed
modified
docs/fdroid-registry.md
+28
−4
| 111 | 111 | | `nativecode` | `lib/<abi>/` entries in the zip, not the manifest | |
| 112 | 112 | |
| 113 | 113 | `android:label` and `android:icon` are usually `TYPE_REFERENCE` into |
| 114 | −`resources.arsc`. Resolving those means an ARSC reader, which is a | |
| 115 | −second format of comparable size. **Out of scope**: the index falls | |
| 116 | −back to `packageName` for the name, and the repo serves no icons. An | |
| 117 | −`.arsc` reader can come later if anyone wants pretty listings. | |
| 114 | +`resources.arsc`, so resolving them needs an ARSC reader. That was | |
| 115 | +deferred once as "pretty listings" and then turned out to be the whole | |
| 116 | +listing: a release build shows no name but the package id and no icon | |
| 117 | +at all, because **R8's resource shrinking renames every resource | |
| 118 | +path**. This repo's own app ships `res/0Z.png`, `res/o-.png` and no | |
| 119 | +`mipmap` directory, so the convention the adapter used first — | |
| 120 | +highest-density `res/mipmap-*/ic_launcher.png` — can only ever work on | |
| 121 | +a debug build. | |
| 122 | + | |
| 123 | +`GitGud.Packages.Formats.Fdroid.Arsc` reads the table: the global | |
| 124 | +string pool, then one `ResTable_type` chunk per configuration, keyed | |
| 125 | +`0xPPTTEEEE`. An icon id resolves to several files — one per density, | |
| 126 | +plus the adaptive-icon XML at `anydpi` — so `file_paths/2` ranks them | |
| 127 | +highest-density first with XML last, since a `<adaptive-icon>` is the | |
| 128 | +best icon on a phone and an unservable one in an index. | |
| 129 | + | |
| 130 | +### zipalign, and why `:zip` can't read an icon | |
| 131 | + | |
| 132 | +`:zip.unzip/2` raises `function_clause` on a release APK. zipalign | |
| 133 | +pads local file headers so stored entries start on a 4-byte boundary, | |
| 134 | +and the padding goes in the extra field as bare zero bytes — Erlang's | |
| 135 | +parser walks extra fields expecting `{id, size}` pairs and dies on the | |
| 136 | +remainder (`:zip.parse_extra(<<0>>)`). Every uncompressed member is | |
| 137 | +padded that way, which is every PNG. `AndroidManifest.xml` is deflated | |
| 138 | +and needs no padding, which is why nothing hit this until the icon did. | |
| 139 | +`Fdroid.Zip` reads one member: names and offsets from the central | |
| 140 | +directory (which `:zip.list_dir/1` reads fine), then the local header | |
| 141 | +and the bytes behind it. | |
| 118 | 142 | |
| 119 | 143 | ### Why this is hand-written rather than `rusty-axml` |
| 120 | 144 |
modified
lib/git_gud/packages/formats/fdroid.ex
+59
−27
| 41 | 41 | and name, SDK levels, permissions, ABIs, and the signing |
| 42 | 42 | certificate's SHA-256. See `docs/fdroid-registry.md` for the formats. |
| 43 | 43 | |
| 44 | − Per-app `name`, `summary`, `description` and icons are **not** read. | |
| 45 | − They live in `resources.arsc` behind `TYPE_REFERENCE` values, and an | |
| 46 | − ARSC reader is a second binary format for prettier listings and | |
| 47 | − nothing else. The index falls back to the package name, and the | |
| 48 | − repository description comes from the git repository's own. | |
| 44 | + Per-app `name` and the launcher icon come from `resources.arsc`, via | |
| 45 | + `Fdroid.Arsc`: `android:label` and `android:icon` in the manifest are | |
| 46 | + resource ids, and the table is what maps them to a string and a file. | |
| 47 | + A release build makes that the only route — R8 rewrites every | |
| 48 | + resource path, so this forge's own app ships `res/o-.png` and no | |
| 49 | + `mipmap` directory to guess from. Where the table can't be read the | |
| 50 | + index falls back to the package name and no icon. | |
| 51 | + | |
| 52 | + `summary` and `description` are still not read: they are F-Droid | |
| 53 | + metadata an APK doesn't carry. The repository description comes from | |
| 54 | + the git repository's own. | |
| 49 | 55 | |
| 50 | 56 | The *repository* icon is the namespace's avatar, via `GitGud.Avatars` |
| 51 | 57 | — an uploaded one if the org has set one, and the identicon the |
| 180 | 186 | defp serve(repos, _label, scope, %{path: ["icons", filename]}) do |
| 181 | 187 | {content_type, body} = icon(scope) |
| 182 | 188 | |
| 183 | − cond do | |
| 184 | − filename == icon_filename(content_type) -> | |
| 185 | − {:ok, content_type, body} | |
| 186 | − | |
| 187 | − app_icon = find_app_icon(repos, filename) -> | |
| 188 | − {:ok, "image/png", app_icon} | |
| 189 | − | |
| 190 | − true -> | |
| 191 | − {:error, {404, "no such icon"}} | |
| 189 | + if filename == icon_filename(content_type) do | |
| 190 | + {:ok, content_type, body} | |
| 191 | + else | |
| 192 | + case find_app_icon(repos, filename) do | |
| 193 | + {app_type, app_icon} -> {:ok, app_type, app_icon} | |
| 194 | + nil -> {:error, {404, "no such icon"}} | |
| 195 | + end | |
| 192 | 196 | end |
| 193 | 197 | end |
| 194 | 198 |
| 249 | 253 | # content so re-publishing the same image stores it once. |
| 250 | 254 | defp store_icon(body) do |
| 251 | 255 | case Apk.icon(body) do |
| 252 | − {:ok, png} -> | |
| 253 | − sha256 = :sha256 |> :crypto.hash(png) |> Base.encode16(case: :lower) | |
| 256 | + {:ok, bytes, ext} -> | |
| 257 | + sha256 = :sha256 |> :crypto.hash(bytes) |> Base.encode16(case: :lower) | |
| 254 | 258 | key = "fdroid-icons/#{sha256}" |
| 259 | + type = icon_content_type(ext) | |
| 260 | + | |
| 261 | + case GitGud.Storage.put(Packages.artifact_bucket(), key, bytes, content_type: type) do | |
| 262 | + :ok -> | |
| 263 | + %{ | |
| 264 | + "icon_key" => key, | |
| 265 | + "icon_sha256" => sha256, | |
| 266 | + "icon_size" => byte_size(bytes), | |
| 267 | + "icon_ext" => ext | |
| 268 | + } | |
| 255 | 269 | |
| 256 | − case GitGud.Storage.put(Packages.artifact_bucket(), key, png, content_type: "image/png") do | |
| 257 | − :ok -> %{"icon_key" => key, "icon_sha256" => sha256, "icon_size" => byte_size(png)} | |
| 258 | 270 | # An icon is decoration. Failing the publish over one would |
| 259 | 271 | # trade a working release for a missing picture. |
| 260 | − {:error, _} -> %{} | |
| 272 | + {:error, _} -> | |
| 273 | + %{} | |
| 261 | 274 | end |
| 262 | 275 | |
| 263 | 276 | :none -> |
| 265 | 278 | end |
| 266 | 279 | end |
| 267 | 280 | |
| 281 | + defp icon_content_type("webp"), do: "image/webp" | |
| 282 | + defp icon_content_type("jpg"), do: "image/jpeg" | |
| 283 | + defp icon_content_type(_), do: "image/png" | |
| 284 | + | |
| 285 | + # Versions published before icons carried their format are PNG: that | |
| 286 | + # is what the old extractor could produce. | |
| 287 | + defp icon_ext(info), do: info["icon_ext"] || "png" | |
| 288 | + | |
| 268 | 289 | # `<package>.<versionCode>.png`, which is what fdroidserver emits and |
| 269 | − # what a v1 client expects to find under /icons/. | |
| 290 | + # what a v1 client expects to find under /icons/ — with whatever | |
| 291 | + # extension the APK actually carried, since AGP has emitted WebP by | |
| 292 | + # default for years and a client renders what the name claims. | |
| 270 | 293 | defp app_icon_name(%{info: info}) do |
| 271 | − if info["icon_key"], do: "#{info["package"]}.#{info["version_code"]}.png" | |
| 294 | + if info["icon_key"], do: "#{info["package"]}.#{info["version_code"]}.#{icon_ext(info)}" | |
| 295 | + end | |
| 296 | + | |
| 297 | + # `android:label` off the newest APK, resolved through its resource | |
| 298 | + # table at publish time. Falls back to the package name, which is what | |
| 299 | + # every version published before labels were read still carries. | |
| 300 | + defp app_label(name, group) do | |
| 301 | + newest = Enum.max_by(group, & &1.info["version_code"]) | |
| 302 | + | |
| 303 | + case newest.info["label"] do | |
| 304 | + label when is_binary(label) -> if String.trim(label) == "", do: name, else: label | |
| 305 | + _ -> name | |
| 306 | + end | |
| 272 | 307 | end |
| 273 | 308 | |
| 274 | 309 | defp app_icon_file(group) do |
| 290 | 325 | Enum.find_value(apks(repos), fn apk -> |
| 291 | 326 | if apk.info["icon_key"] && app_icon_name(apk) == filename do |
| 292 | 327 | case GitGud.Storage.get(Packages.artifact_bucket(), apk.info["icon_key"]) do |
| 293 | − {:ok, png} -> png | |
| 328 | + {:ok, bytes} -> {icon_content_type(icon_ext(apk.info)), bytes} | |
| 294 | 329 | {:error, _} -> nil |
| 295 | 330 | end |
| 296 | 331 | end |
| 323 | 358 | %{ |
| 324 | 359 | "added" => added, |
| 325 | 360 | "lastUpdated" => updated, |
| 326 | − # No ARSC reader, so the package name is the name. A | |
| 327 | − # repository of one's own builds is a list you already | |
| 328 | − # recognise. | |
| 329 | − "name" => %{"en-US" => name}, | |
| 361 | + "name" => %{"en-US" => app_label(name, group)}, | |
| 330 | 362 | "icon" => app_icon_file(group) |
| 331 | 363 | } |
| 332 | 364 | |> drop_nils(), |
| 404 | 436 | |
| 405 | 437 | %{ |
| 406 | 438 | "packageName" => name, |
| 407 | − "name" => name, | |
| 439 | + "name" => app_label(name, group), | |
| 408 | 440 | "summary" => "", |
| 409 | 441 | "description" => "", |
| 410 | 442 | "license" => "Unknown", |
modified
lib/git_gud/packages/formats/fdroid/apk.ex
+124
−80
| 8 | 8 | adds what only the zip container knows: the ABIs a package carries, |
| 9 | 9 | and its size and digest. |
| 10 | 10 | |
| 11 | − Only `AndroidManifest.xml` is decompressed. An APK is tens of | |
| 12 | − megabytes and `:zip.unzip/2` in `:memory` mode would inflate all of | |
| 13 | − it to read one 8 KB member, on a request that is already holding the | |
| 14 | − whole upload in memory. | |
| 11 | + Members are read one at a time through `Zip`. An APK is tens of | |
| 12 | + megabytes and inflating all of it to read one 8 KB member would be | |
| 13 | + wasteful on a request that is already holding the whole upload in | |
| 14 | + memory — and for the icon it is not merely wasteful but impossible, | |
| 15 | + since Erlang's zip reader cannot open an aligned entry at all. | |
| 15 | 16 | """ |
| 16 | 17 | |
| 18 | + alias GitGud.Packages.Formats.Fdroid.Arsc | |
| 17 | 19 | alias GitGud.Packages.Formats.Fdroid.Manifest |
| 18 | 20 | alias GitGud.Packages.Formats.Fdroid.Signature |
| 21 | + alias GitGud.Packages.Formats.Fdroid.Zip | |
| 19 | 22 | |
| 20 | − @manifest ~c"AndroidManifest.xml" | |
| 23 | + @manifest "AndroidManifest.xml" | |
| 24 | + @resources "resources.arsc" | |
| 21 | 25 | |
| 22 | 26 | # Densities, best first. F-Droid scales whatever it is given, so the |
| 23 | 27 | # largest available is the right pick rather than one matching a |
| 33 | 37 | package: String.t(), |
| 34 | 38 | version_code: integer(), |
| 35 | 39 | version_name: String.t() | nil, |
| 40 | + label: String.t() | nil, | |
| 36 | 41 | min_sdk: integer() | nil, |
| 37 | 42 | target_sdk: integer() | nil, |
| 38 | 43 | permissions: [String.t()], |
| 63 | 68 | package: package, |
| 64 | 69 | version_code: version_code, |
| 65 | 70 | version_name: manifest.version_name, |
| 71 | + label: label(body, manifest), | |
| 66 | 72 | min_sdk: manifest.min_sdk, |
| 67 | 73 | target_sdk: manifest.target_sdk, |
| 68 | 74 | permissions: manifest.permissions, |
| 78 | 84 | def read(_), do: {:error, {422, "not an APK"}} |
| 79 | 85 | |
| 80 | 86 | @doc """ |
| 81 | − The app's launcher icon, as raw bytes, or `:none`. | |
| 82 | − | |
| 83 | − ## This is a convention, not a resolution | |
| 84 | − | |
| 85 | − `android:icon` in the manifest compiles to a bare resource id, and | |
| 86 | − mapping that id to a file means reading `resources.arsc` — a third | |
| 87 | − binary format, after the manifest and the signing block. What this | |
| 88 | − does instead is look for what AGP emits by default: the | |
| 89 | − highest-density `res/mipmap-*/ic_launcher.png`. | |
| 90 | − | |
| 91 | − That covers an app built with the Android toolchain's defaults, which | |
| 92 | − is nearly all of them and certainly every one this forge builds. It | |
| 93 | − finds nothing for an app whose icon is named something else, and | |
| 94 | − finding nothing is handled everywhere — the listing shows no icon | |
| 95 | − rather than a wrong one. | |
| 96 | − | |
| 97 | − If that stops being good enough, an ARSC reader replaces this | |
| 98 | − function and nothing else: callers get bytes either way. | |
| 99 | − | |
| 100 | − Adaptive-icon *layers* are skipped. `ic_launcher_foreground.png` is a | |
| 101 | − glyph on transparency, and shipping it as the icon looks like a bug | |
| 102 | − rather than a design. | |
| 87 | + The app's launcher icon: `{:ok, bytes, extension}` or `:none`. | |
| 88 | + | |
| 89 | + `android:icon` in the manifest is a bare resource id, so this asks | |
| 90 | + `Arsc` which file that id names. A release build makes that the only | |
| 91 | + workable route — R8 rewrites `res/mipmap-xxxhdpi/ic_launcher.png` to | |
| 92 | + `res/o-.png`, and there is nothing left in the archive to guess from. | |
| 93 | + | |
| 94 | + The convention remains as a fallback for an APK whose `resources.arsc` | |
| 95 | + can't be read: the highest-density `res/mipmap-*/ic_launcher.png`, | |
| 96 | + which is what AGP emits for a debug build. Finding nothing is handled | |
| 97 | + everywhere — a listing shows no icon rather than a wrong one. | |
| 98 | + | |
| 99 | + Adaptive-icon XML is never returned. `mipmap-anydpi-v26/ic_launcher.xml` | |
| 100 | + is the best icon on a phone and an unservable one in an index, which | |
| 101 | + wants an image; the PNG beside it is what F-Droid can draw. The same | |
| 102 | + goes for layers: `ic_launcher_foreground.png` is a glyph on | |
| 103 | + transparency and shipping it as the icon looks like a bug. | |
| 104 | + | |
| 105 | + The extension comes back because it isn't always `png` — AGP has | |
| 106 | + emitted WebP by default for years, and an icon served under the wrong | |
| 107 | + name is one a client won't render. | |
| 103 | 108 | """ |
| 104 | − @spec icon(binary()) :: {:ok, binary()} | :none | |
| 109 | + @spec icon(binary()) :: {:ok, binary(), String.t()} | :none | |
| 105 | 110 | def icon(body) when is_binary(body) do |
| 106 | − with [name | _] <- icon_candidates(body), | |
| 107 | − {:ok, [{^name, bytes}]} <- :zip.unzip(body, [:memory, {:file_list, [name]}]) do | |
| 108 | − {:ok, bytes} | |
| 109 | − else | |
| 110 | − _ -> :none | |
| 111 | + case from_resources(body) do | |
| 112 | + {:ok, bytes, ext} -> {:ok, bytes, ext} | |
| 113 | + :none -> by_convention(body) | |
| 111 | 114 | end |
| 112 | 115 | end |
| 113 | 116 | |
| 114 | 117 | def icon(_), do: :none |
| 115 | 118 | |
| 116 | − defp icon_candidates(body) do | |
| 117 | − case :zip.list_dir(body) do | |
| 118 | − {:ok, entries} -> | |
| 119 | − names = | |
| 120 | − Enum.flat_map(entries, fn | |
| 121 | − {:zip_file, name, _, _, _, _} -> [name] | |
| 122 | − _ -> [] | |
| 123 | − end) | |
| 124 | − | |
| 125 | − # `ic_launcher` by name first; anything else in mipmap/ only if | |
| 126 | − # the conventional name isn't there at all. | |
| 127 | − launcher = Enum.filter(names, &launcher_icon?/1) | |
| 128 | − if launcher == [], do: rank(Enum.filter(names, &mipmap_png?/1)), else: rank(launcher) | |
| 129 | − | |
| 130 | − _ -> | |
| 131 | − [] | |
| 119 | + defp from_resources(body) do | |
| 120 | + with {:ok, manifest_bytes} <- extract_manifest(body), | |
| 121 | + {:ok, manifest} <- Manifest.parse(manifest_bytes), | |
| 122 | + ref when is_integer(ref) <- manifest.icon_ref, | |
| 123 | + {:ok, table} <- resources(body) do | |
| 124 | + table | |
| 125 | + |> Arsc.file_paths(ref) | |
| 126 | + |> Enum.reject(&String.ends_with?(&1, ".xml")) | |
| 127 | + |> first_image(body) | |
| 128 | + else | |
| 129 | + _ -> :none | |
| 132 | 130 | end |
| 133 | 131 | end |
| 134 | 132 | |
| 135 | − defp rank(names) do | |
| 136 | − Enum.sort_by(names, fn name -> | |
| 137 | − string = List.to_string(name) | |
| 133 | + defp by_convention(body) do | |
| 134 | + with [name | _] <- icon_candidates(body), | |
| 135 | + {:ok, bytes} <- Zip.read(body, name), | |
| 136 | + {:ok, ext} <- image_extension(bytes) do | |
| 137 | + {:ok, bytes, ext} | |
| 138 | + else | |
| 139 | + _ -> :none | |
| 140 | + end | |
| 141 | + end | |
| 138 | 142 | |
| 139 | − index = | |
| 140 | − Enum.find_index(@densities, fn density -> | |
| 141 | − String.contains?(string, "-" <> density) | |
| 142 | − end) | |
| 143 | + # The first candidate that is actually an image. A resource id can | |
| 144 | + # name something this can't serve, and a ranked list is only useful if | |
| 145 | + # a bad entry costs the next one rather than the icon. | |
| 146 | + defp first_image([], _body), do: :none | |
| 143 | 147 | |
| 144 | − # Unknown densities sort last rather than being dropped: an icon | |
| 145 | − # of unclear size beats no icon. | |
| 146 | − index || length(@densities) | |
| 147 | − end) | |
| 148 | + defp first_image([path | rest], body) do | |
| 149 | + with {:ok, bytes} <- Zip.read(body, path), | |
| 150 | + {:ok, ext} <- image_extension(bytes) do | |
| 151 | + {:ok, bytes, ext} | |
| 152 | + else | |
| 153 | + _ -> first_image(rest, body) | |
| 154 | + end | |
| 148 | 155 | end |
| 149 | 156 | |
| 150 | − defp launcher_icon?(name) do | |
| 151 | − string = List.to_string(name) | |
| 157 | + # By content, not by name: the name inside a release APK is two | |
| 158 | + # characters of R8's choosing and says nothing. | |
| 159 | + defp image_extension(<<0x89, "PNG", 0x0D, 0x0A, 0x1A, 0x0A, _::binary>>), do: {:ok, "png"} | |
| 160 | + defp image_extension(<<"RIFF", _size::little-32, "WEBP", _::binary>>), do: {:ok, "webp"} | |
| 161 | + defp image_extension(<<0xFF, 0xD8, 0xFF, _::binary>>), do: {:ok, "jpg"} | |
| 162 | + defp image_extension(_), do: :none | |
| 152 | 163 | |
| 153 | − mipmap_png?(name) and String.contains?(string, "ic_launcher") and | |
| 154 | − not Enum.any?(@layer_suffixes, &String.contains?(string, &1 <> ".")) | |
| 164 | + defp resources(body) do | |
| 165 | + with {:ok, bytes} <- Zip.read(body, @resources) do | |
| 166 | + Arsc.parse(bytes) | |
| 167 | + else | |
| 168 | + _ -> :none | |
| 169 | + end | |
| 155 | 170 | end |
| 156 | 171 | |
| 157 | − defp mipmap_png?(name) do | |
| 158 | − string = List.to_string(name) | |
| 172 | + # The app's display name, resolved through the resource table. A | |
| 173 | + # manifest can also carry it as a literal string, which needs no | |
| 174 | + # lookup and is what a hand-written one usually has. | |
| 175 | + defp label(_body, %{label: label}) when is_binary(label) do | |
| 176 | + if String.trim(label) == "", do: nil, else: label | |
| 177 | + end | |
| 159 | 178 | |
| 160 | − String.ends_with?(string, ".png") and | |
| 161 | − (String.starts_with?(string, "res/mipmap-") or String.starts_with?(string, "res/drawable-")) | |
| 179 | + defp label(body, %{label: ref}) when is_integer(ref) do | |
| 180 | + case resources(body) do | |
| 181 | + {:ok, table} -> Arsc.string(table, ref) | |
| 182 | + _ -> nil | |
| 183 | + end | |
| 162 | 184 | end |
| 163 | 185 | |
| 186 | + defp label(_body, _manifest), do: nil | |
| 187 | + | |
| 164 | 188 | @doc """ |
| 165 | 189 | The filename this APK is stored and served under: |
| 166 | 190 | `<package>_<versionCode>.apk`, which is what `fdroid update` |
| 172 | 196 | # ── zip ────────────────────────────────────────────────────────────── |
| 173 | 197 | |
| 174 | 198 | defp extract_manifest(body) do |
| 175 | − case :zip.unzip(body, [:memory, {:file_list, [@manifest]}]) do | |
| 176 | − {:ok, [{@manifest, bytes}]} -> | |
| 177 | − {:ok, bytes} | |
| 199 | + case Zip.read(body, @manifest) do | |
| 200 | + {:ok, bytes} -> {:ok, bytes} | |
| 201 | + :none -> {:error, {422, "APK has no readable AndroidManifest.xml"}} | |
| 202 | + end | |
| 203 | + end | |
| 178 | 204 | |
| 179 | − {:ok, []} -> | |
| 180 | − {:error, {422, "APK has no AndroidManifest.xml"}} | |
| 205 | + # Candidate icon paths by name, for an APK whose resource table can't | |
| 206 | + # be read. AGP's default output for a debug build, highest density | |
| 207 | + # first; adaptive-icon layers excluded, since a foreground glyph on | |
| 208 | + # transparency is not the icon. | |
| 209 | + defp icon_candidates(body) do | |
| 210 | + names = Zip.names(body) | |
| 211 | + launcher = Enum.filter(names, &launcher_icon?/1) | |
| 212 | + if launcher == [], do: rank(Enum.filter(names, &mipmap_image?/1)), else: rank(launcher) | |
| 213 | + end | |
| 181 | 214 | |
| 182 | − {:error, reason} -> | |
| 183 | − {:error, {422, "APK is not a readable zip archive (#{inspect(reason)})"}} | |
| 184 | − end | |
| 185 | − rescue | |
| 186 | − # `:zip` raises rather than returning an error on some malformed | |
| 187 | − # central directories. | |
| 188 | − _ -> {:error, {422, "APK is not a readable zip archive"}} | |
| 215 | + defp rank(names) do | |
| 216 | + Enum.sort_by(names, fn name -> | |
| 217 | + index = Enum.find_index(@densities, &String.contains?(name, "-" <> &1)) | |
| 218 | + | |
| 219 | + # Unknown densities sort last rather than being dropped: an icon | |
| 220 | + # of unclear size beats no icon. | |
| 221 | + index || length(@densities) | |
| 222 | + end) | |
| 223 | + end | |
| 224 | + | |
| 225 | + defp launcher_icon?(name) do | |
| 226 | + mipmap_image?(name) and String.contains?(name, "ic_launcher") and | |
| 227 | + not Enum.any?(@layer_suffixes, &String.contains?(name, &1 <> ".")) | |
| 228 | + end | |
| 229 | + | |
| 230 | + defp mipmap_image?(name) do | |
| 231 | + String.ends_with?(name, [".png", ".webp", ".jpg"]) and | |
| 232 | + String.starts_with?(name, ["res/mipmap-", "res/drawable-"]) | |
| 189 | 233 | end |
| 190 | 234 | |
| 191 | 235 | # ABIs, from `lib/<abi>/…` entries. F-Droid shows these so a user on |
added
lib/git_gud/packages/formats/fdroid/arsc.ex
+452
−0
| 1 | +defmodule GitGud.Packages.Formats.Fdroid.Arsc do | |
| 2 | + @moduledoc """ | |
| 3 | + Reads `resources.arsc` — enough of it to turn a resource id into the | |
| 4 | + file it names, or the string it holds. | |
| 5 | + | |
| 6 | + This is the third binary format in the F-Droid adapter, after the | |
| 7 | + manifest and the signing block, and for a while the adapter did | |
| 8 | + without it: `android:icon` compiles to a bare resource id, and | |
| 9 | + guessing `res/mipmap-xxxhdpi/ic_launcher.png` covers a debug build. | |
| 10 | + | |
| 11 | + It does not survive a release build. R8's resource shrinking rewrites | |
| 12 | + every resource path, so this forge's own app ships `res/0Z.png` and | |
| 13 | + `res/2K.9.png` and no `mipmap` directory at all. The names live only | |
| 14 | + in here, which makes this the only way to answer "which of these 72 | |
| 15 | + PNGs is the launcher icon". | |
| 16 | + | |
| 17 | + ## Shape | |
| 18 | + | |
| 19 | + A table is a header, a global string pool holding every string value | |
| 20 | + and file path, and one package per id (0x7f for an app's own | |
| 21 | + resources, 0x01 for android's framework). A package carries two more | |
| 22 | + pools — type names, key names — then, per type, a `ResTable_type` | |
| 23 | + chunk for each configuration: one for xxhdpi, one for xxxhdpi, one | |
| 24 | + for anydpi-v26, and so on. The same resource id therefore has several | |
| 25 | + values, and picking between them is the caller's problem, which is | |
| 26 | + why `file_paths/2` returns them ranked rather than picking one. | |
| 27 | + | |
| 28 | + A resource id is `0xPPTTEEEE`: package, type, entry index. | |
| 29 | + | |
| 30 | + ## Ranking | |
| 31 | + | |
| 32 | + Highest density first, and any `.xml` last. An adaptive icon lives in | |
| 33 | + `mipmap-anydpi-v26` as XML that references two more drawables; it is | |
| 34 | + the *best* icon on a phone and useless to a repository index, which | |
| 35 | + needs an image it can serve. The PNG next to it is what F-Droid can | |
| 36 | + show. | |
| 37 | + | |
| 38 | + ## Trust | |
| 39 | + | |
| 40 | + An APK is attacker-supplied. Every offset read out of the file is | |
| 41 | + bounds-checked against the chunk it came from, no length is trusted, | |
| 42 | + and a malformed table returns `{:error, _}` rather than raising — | |
| 43 | + the same posture as `Manifest`. | |
| 44 | + """ | |
| 45 | + | |
| 46 | + import Bitwise | |
| 47 | + | |
| 48 | + # Chunk types, from ResourceTypes.h. | |
| 49 | + @res_string_pool 0x0001 | |
| 50 | + @res_table 0x0002 | |
| 51 | + @res_table_package 0x0200 | |
| 52 | + @res_table_type 0x0201 | |
| 53 | + | |
| 54 | + # String pool flags. | |
| 55 | + @utf8_flag 0x100 | |
| 56 | + | |
| 57 | + # ResTable_type flags. | |
| 58 | + @flag_sparse 0x01 | |
| 59 | + @flag_offset16 0x02 | |
| 60 | + | |
| 61 | + # ResTable_entry flags. | |
| 62 | + @entry_complex 0x0001 | |
| 63 | + | |
| 64 | + # Typed-value data types. | |
| 65 | + @type_string 0x03 | |
| 66 | + | |
| 67 | + # Densities that aren't measurements: "any" is the adaptive-icon | |
| 68 | + # bucket and "none" means don't scale. Both sort below every real | |
| 69 | + # density rather than above it, which is what taking them at face | |
| 70 | + # value (0xFFFE) would do. | |
| 71 | + @density_any 0xFFFE | |
| 72 | + @density_none 0xFFFF | |
| 73 | + | |
| 74 | + # 64 MB of resources is not a table this needs to read. | |
| 75 | + @max_size 64 * 1024 * 1024 | |
| 76 | + | |
| 77 | + @typedoc "Parsed table: the global value pool plus each package's entries." | |
| 78 | + @type t :: %{ | |
| 79 | + strings: tuple(), | |
| 80 | + # %{resource id => [{density, value}]} | |
| 81 | + entries: %{non_neg_integer() => [{non_neg_integer(), term()}]} | |
| 82 | + } | |
| 83 | + | |
| 84 | + @doc """ | |
| 85 | + Parse `resources.arsc`. | |
| 86 | + | |
| 87 | + Returns `{:ok, table}` or `{:error, {status, message}}` shaped for the | |
| 88 | + registry controller. | |
| 89 | + """ | |
| 90 | + @spec parse(binary()) :: {:ok, t()} | {:error, {integer(), String.t()}} | |
| 91 | + def parse(data) when is_binary(data) and byte_size(data) > @max_size, | |
| 92 | + do: {:error, {422, "resources.arsc is implausibly large"}} | |
| 93 | + | |
| 94 | + def parse(<<@res_table::little-16, header_size::little-16, size::little-32, _::binary>> = data) | |
| 95 | + when header_size >= 12 do | |
| 96 | + cond do | |
| 97 | + size > byte_size(data) -> | |
| 98 | + {:error, {422, "resources.arsc is truncated"}} | |
| 99 | + | |
| 100 | + header_size > byte_size(data) -> | |
| 101 | + {:error, {422, "resources.arsc header overruns the file"}} | |
| 102 | + | |
| 103 | + true -> | |
| 104 | + body = binary_part(data, header_size, size - header_size) | |
| 105 | + | |
| 106 | + with {:ok, chunks} <- chunks(body, []), | |
| 107 | + {:ok, strings} <- global_pool(chunks) do | |
| 108 | + entries = | |
| 109 | + chunks | |
| 110 | + |> Enum.filter(fn {type, _, _} -> type == @res_table_package end) | |
| 111 | + |> Enum.reduce(%{}, &package_entries/2) | |
| 112 | + | |
| 113 | + {:ok, %{strings: strings, entries: entries}} | |
| 114 | + end | |
| 115 | + end | |
| 116 | + end | |
| 117 | + | |
| 118 | + def parse(_), do: {:error, {422, "not a resources.arsc"}} | |
| 119 | + | |
| 120 | + @doc """ | |
| 121 | + Every file path resource `id` resolves to, best first. | |
| 122 | + | |
| 123 | + Empty for an id that isn't a file reference, isn't in the table, or | |
| 124 | + is a complex value (a style, an array) rather than a single one. | |
| 125 | + """ | |
| 126 | + @spec file_paths(t(), non_neg_integer()) :: [String.t()] | |
| 127 | + def file_paths(%{entries: entries} = table, id) when is_integer(id) do | |
| 128 | + entries | |
| 129 | + |> Map.get(id, []) | |
| 130 | + |> Enum.flat_map(fn {density, value} -> | |
| 131 | + case resolve_string(table, value) do | |
| 132 | + nil -> [] | |
| 133 | + path -> [{density, path}] | |
| 134 | + end | |
| 135 | + end) | |
| 136 | + |> Enum.sort_by(fn {density, path} -> {xml_rank(path), -rank_density(density)} end) | |
| 137 | + |> Enum.map(&elem(&1, 1)) | |
| 138 | + |> Enum.uniq() | |
| 139 | + end | |
| 140 | + | |
| 141 | + def file_paths(_, _), do: [] | |
| 142 | + | |
| 143 | + @doc """ | |
| 144 | + The string resource `id` holds, or nil. | |
| 145 | + | |
| 146 | + Prefers the default configuration, which for a label is the | |
| 147 | + untranslated one — the index has a single name field and the | |
| 148 | + publisher's own language is the honest thing to put in it. | |
| 149 | + """ | |
| 150 | + @spec string(t(), non_neg_integer()) :: String.t() | nil | |
| 151 | + def string(%{entries: entries} = table, id) when is_integer(id) do | |
| 152 | + entries | |
| 153 | + |> Map.get(id, []) | |
| 154 | + |> Enum.sort_by(fn {density, _} -> rank_density(density) end) | |
| 155 | + |> Enum.find_value(fn {_, value} -> resolve_string(table, value) end) | |
| 156 | + end | |
| 157 | + | |
| 158 | + def string(_, _), do: nil | |
| 159 | + | |
| 160 | + # ── values ─────────────────────────────────────────────────────────── | |
| 161 | + | |
| 162 | + defp resolve_string(%{strings: strings}, {:string, index}), do: pool_at(strings, index) | |
| 163 | + defp resolve_string(_table, _other), do: nil | |
| 164 | + | |
| 165 | + # `.xml` sorts after everything: it is a real value for an adaptive | |
| 166 | + # icon and an unservable one for an index. | |
| 167 | + defp xml_rank(path), do: if(String.ends_with?(path, ".xml"), do: 1, else: 0) | |
| 168 | + | |
| 169 | + defp rank_density(density) when density in [@density_any, @density_none], do: 0 | |
| 170 | + defp rank_density(density), do: density | |
| 171 | + | |
| 172 | + # ── chunk walking ──────────────────────────────────────────────────── | |
| 173 | + | |
| 174 | + defp chunks(<<>>, acc), do: {:ok, Enum.reverse(acc)} | |
| 175 | + | |
| 176 | + defp chunks( | |
| 177 | + <<type::little-16, header_size::little-16, size::little-32, _::binary>> = data, | |
| 178 | + acc | |
| 179 | + ) | |
| 180 | + when size >= 8 and header_size >= 8 and header_size <= size do | |
| 181 | + if size > byte_size(data) do | |
| 182 | + {:ok, Enum.reverse(acc)} | |
| 183 | + else | |
| 184 | + chunk = binary_part(data, 0, size) | |
| 185 | + rest = binary_part(data, size, byte_size(data) - size) | |
| 186 | + chunks(rest, [{type, header_size, chunk} | acc]) | |
| 187 | + end | |
| 188 | + end | |
| 189 | + | |
| 190 | + defp chunks(_trailing, acc), do: {:ok, Enum.reverse(acc)} | |
| 191 | + | |
| 192 | + defp global_pool(chunks) do | |
| 193 | + case Enum.find(chunks, fn {type, _, _} -> type == @res_string_pool end) do | |
| 194 | + {_, header_size, chunk} -> parse_string_pool(chunk, header_size) | |
| 195 | + nil -> {:error, {422, "resources.arsc has no string pool"}} | |
| 196 | + end | |
| 197 | + end | |
| 198 | + | |
| 199 | + # ── packages ───────────────────────────────────────────────────────── | |
| 200 | + | |
| 201 | + defp package_entries({_, header_size, chunk}, acc) do | |
| 202 | + case chunk do | |
| 203 | + <<_::binary-size(8), id::little-32, _rest::binary>> when header_size <= byte_size(chunk) -> | |
| 204 | + package_id = band(id, 0xFF) | |
| 205 | + body = binary_part(chunk, header_size, byte_size(chunk) - header_size) | |
| 206 | + | |
| 207 | + case chunks(body, []) do | |
| 208 | + {:ok, inner} -> | |
| 209 | + inner | |
| 210 | + |> Enum.filter(fn {type, _, _} -> type == @res_table_type end) | |
| 211 | + |> Enum.reduce(acc, &type_entries(&1, &2, package_id)) | |
| 212 | + | |
| 213 | + _ -> | |
| 214 | + acc | |
| 215 | + end | |
| 216 | + | |
| 217 | + _ -> | |
| 218 | + acc | |
| 219 | + end | |
| 220 | + end | |
| 221 | + | |
| 222 | + # One `ResTable_type` is one configuration's worth of one type: every | |
| 223 | + # mipmap at xxhdpi, say. Entries are addressed by an offset table | |
| 224 | + # whose encoding depends on the chunk's flags. | |
| 225 | + defp type_entries({_, header_size, chunk}, acc, package_id) do | |
| 226 | + case chunk do | |
| 227 | + <<_::binary-size(8), type_id, flags, _reserved::little-16, entry_count::little-32, | |
| 228 | + entries_start::little-32, config::binary>> | |
| 229 | + when type_id > 0 and header_size <= byte_size(chunk) and | |
| 230 | + entries_start <= byte_size(chunk) -> | |
| 231 | + density = config_density(config) | |
| 232 | + table_size = byte_size(chunk) - entries_start | |
| 233 | + | |
| 234 | + if table_size < 0 or entry_count > table_size do | |
| 235 | + acc | |
| 236 | + else | |
| 237 | + data = binary_part(chunk, entries_start, table_size) | |
| 238 | + | |
| 239 | + offsets(chunk, header_size, entry_count, flags) | |
| 240 | + |> Enum.reduce(acc, fn {index, offset}, acc -> | |
| 241 | + case entry_value(data, offset) do | |
| 242 | + {:ok, value} -> | |
| 243 | + id = bsl(package_id, 24) ||| bsl(type_id, 16) ||| index | |
| 244 | + Map.update(acc, id, [{density, value}], &[{density, value} | &1]) | |
| 245 | + | |
| 246 | + :none -> | |
| 247 | + acc | |
| 248 | + end | |
| 249 | + end) | |
| 250 | + end | |
| 251 | + | |
| 252 | + _ -> | |
| 253 | + acc | |
| 254 | + end | |
| 255 | + end | |
| 256 | + | |
| 257 | + # `{entry index, offset into the entry data}` for each present entry. | |
| 258 | + # A 0xFFFFFFFF offset means "not defined in this configuration", | |
| 259 | + # which is most of them in most configurations. | |
| 260 | + defp offsets(chunk, header_size, count, flags) do | |
| 261 | + cond do | |
| 262 | + band(flags, @flag_sparse) != 0 -> | |
| 263 | + read_offsets(chunk, header_size, count, 4, fn <<index::little-16, offset::little-16>> -> | |
| 264 | + {index, offset * 4} | |
| 265 | + end) | |
| 266 | + | |
| 267 | + band(flags, @flag_offset16) != 0 -> | |
| 268 | + read_offsets(chunk, header_size, count, 2, fn <<offset::little-16>> -> | |
| 269 | + {:sequential, if(offset == 0xFFFF, do: :absent, else: offset * 4)} | |
| 270 | + end) | |
| 271 | + |> sequential() | |
| 272 | + | |
| 273 | + true -> | |
| 274 | + read_offsets(chunk, header_size, count, 4, fn <<offset::little-32>> -> | |
| 275 | + {:sequential, if(offset == 0xFFFFFFFF, do: :absent, else: offset)} | |
| 276 | + end) | |
| 277 | + |> sequential() | |
| 278 | + end | |
| 279 | + end | |
| 280 | + | |
| 281 | + defp read_offsets(chunk, header_size, count, width, decode) do | |
| 282 | + need = count * width | |
| 283 | + | |
| 284 | + if header_size + need > byte_size(chunk) do | |
| 285 | + [] | |
| 286 | + else | |
| 287 | + chunk | |
| 288 | + |> binary_part(header_size, need) | |
| 289 | + |> chunk_every_binary(width) | |
| 290 | + |> Enum.map(decode) | |
| 291 | + end | |
| 292 | + end | |
| 293 | + | |
| 294 | + # The dense encodings list one slot per entry index, in order; the | |
| 295 | + # sparse one carries its own indices and needs no numbering. | |
| 296 | + defp sequential(decoded) do | |
| 297 | + decoded | |
| 298 | + |> Enum.with_index() | |
| 299 | + |> Enum.flat_map(fn | |
| 300 | + {{:sequential, :absent}, _index} -> [] | |
| 301 | + {{:sequential, offset}, index} -> [{index, offset}] | |
| 302 | + {other, _index} -> [other] | |
| 303 | + end) | |
| 304 | + end | |
| 305 | + | |
| 306 | + defp chunk_every_binary(<<>>, _width), do: [] | |
| 307 | + | |
| 308 | + defp chunk_every_binary(data, width) when byte_size(data) >= width do | |
| 309 | + [ | |
| 310 | + binary_part(data, 0, width) | |
| 311 | + | chunk_every_binary(binary_part(data, width, byte_size(data) - width), width) | |
| 312 | + ] | |
| 313 | + end | |
| 314 | + | |
| 315 | + defp chunk_every_binary(_data, _width), do: [] | |
| 316 | + | |
| 317 | + # ResTable_entry, then the Res_value that follows it. Complex entries | |
| 318 | + # (bags: styles, arrays, adaptive-icon *definitions*) carry a map | |
| 319 | + # instead of a value and are not what a file reference looks like. | |
| 320 | + defp entry_value(data, offset) when is_integer(offset) and offset >= 0 do | |
| 321 | + with true <- offset + 8 <= byte_size(data), | |
| 322 | + <<entry_size::little-16, entry_flags::little-16, _key::little-32, rest::binary>> <- | |
| 323 | + binary_part(data, offset, byte_size(data) - offset), | |
| 324 | + false <- band(entry_flags, @entry_complex) != 0, | |
| 325 | + true <- entry_size >= 8, | |
| 326 | + value_offset = entry_size - 8, | |
| 327 | + <<_::binary-size(^value_offset), _size::little-16, _res0, data_type, value::little-32, | |
| 328 | + _::binary>> <- rest do | |
| 329 | + case data_type do | |
| 330 | + @type_string -> {:ok, {:string, value}} | |
| 331 | + _ -> :none | |
| 332 | + end | |
| 333 | + else | |
| 334 | + _ -> :none | |
| 335 | + end | |
| 336 | + end | |
| 337 | + | |
| 338 | + defp entry_value(_data, _offset), do: :none | |
| 339 | + | |
| 340 | + # ResTable_config: size, imsi, locale, then screenType — whose third | |
| 341 | + # field is the density. Anything shorter than that simply has none. | |
| 342 | + defp config_density( | |
| 343 | + <<_size::little-32, _imsi::binary-size(4), _locale::binary-size(4), _orientation, | |
| 344 | + _touchscreen, density::little-16, _::binary>> | |
| 345 | + ), | |
| 346 | + do: density | |
| 347 | + | |
| 348 | + defp config_density(_), do: 0 | |
| 349 | + | |
| 350 | + # ── string pool ────────────────────────────────────────────────────── | |
| 351 | + | |
| 352 | + defp parse_string_pool(chunk, header_size) do | |
| 353 | + case chunk do | |
| 354 | + <<_::binary-size(8), count::little-32, _styles::little-32, flags::little-32, | |
| 355 | + strings_start::little-32, _styles_start::little-32, _::binary>> -> | |
| 356 | + utf8? = band(flags, @utf8_flag) != 0 | |
| 357 | + | |
| 358 | + with {:ok, offsets} <- pool_offsets(chunk, header_size, count) do | |
| 359 | + data_len = byte_size(chunk) - strings_start | |
| 360 | + | |
| 361 | + if strings_start > byte_size(chunk) or data_len < 0 do | |
| 362 | + {:error, {422, "string pool points outside its chunk"}} | |
| 363 | + else | |
| 364 | + data = binary_part(chunk, strings_start, data_len) | |
| 365 | + | |
| 366 | + strings = | |
| 367 | + offsets | |
| 368 | + |> Enum.map(&read_string(data, &1, utf8?)) | |
| 369 | + |> List.to_tuple() | |
| 370 | + | |
| 371 | + {:ok, strings} | |
| 372 | + end | |
| 373 | + end | |
| 374 | + | |
| 375 | + _ -> | |
| 376 | + {:error, {422, "malformed string pool header"}} | |
| 377 | + end | |
| 378 | + end | |
| 379 | + | |
| 380 | + defp pool_offsets(chunk, header_size, count) do | |
| 381 | + need = count * 4 | |
| 382 | + | |
| 383 | + if header_size + need > byte_size(chunk) do | |
| 384 | + {:error, {422, "string pool offset table is truncated"}} | |
| 385 | + else | |
| 386 | + {:ok, for(<<off::little-32 <- binary_part(chunk, header_size, need)>>, do: off)} | |
| 387 | + end | |
| 388 | + end | |
| 389 | + | |
| 390 | + defp read_string(data, offset, _utf8?) when offset >= byte_size(data), do: "" | |
| 391 | + | |
| 392 | + defp read_string(data, offset, true) do | |
| 393 | + rest = binary_part(data, offset, byte_size(data) - offset) | |
| 394 | + | |
| 395 | + with {_utf16_len, rest} <- utf8_len(rest), | |
| 396 | + {byte_len, rest} <- utf8_len(rest), | |
| 397 | + true <- byte_len <= byte_size(rest) do | |
| 398 | + binary_part(rest, 0, byte_len) | |
| 399 | + else | |
| 400 | + _ -> "" | |
| 401 | + end | |
| 402 | + end | |
| 403 | + | |
| 404 | + defp read_string(data, offset, false) do | |
| 405 | + rest = binary_part(data, offset, byte_size(data) - offset) | |
| 406 | + | |
| 407 | + case rest do | |
| 408 | + <<len::little-16, body::binary>> -> | |
| 409 | + # High bit set means a two-word length; the low word follows. | |
| 410 | + {len, body} = | |
| 411 | + if band(len, 0x8000) != 0 do | |
| 412 | + <<low::little-16, body::binary>> = body | |
| 413 | + {bor(bsl(band(len, 0x7FFF), 16), low), body} | |
| 414 | + else | |
| 415 | + {len, body} | |
| 416 | + end | |
| 417 | + | |
| 418 | + bytes = len * 2 | |
| 419 | + | |
| 420 | + if bytes <= byte_size(body) do | |
| 421 | + body |> binary_part(0, bytes) |> utf16_to_utf8() | |
| 422 | + else | |
| 423 | + "" | |
| 424 | + end | |
| 425 | + | |
| 426 | + _ -> | |
| 427 | + "" | |
| 428 | + end | |
| 429 | + end | |
| 430 | + | |
| 431 | + defp utf8_len(<<high, rest::binary>>) when band(high, 0x80) != 0 do | |
| 432 | + case rest do | |
| 433 | + <<low, rest::binary>> -> {bor(bsl(band(high, 0x7F), 8), low), rest} | |
| 434 | + _ -> :error | |
| 435 | + end | |
| 436 | + end | |
| 437 | + | |
| 438 | + defp utf8_len(<<len, rest::binary>>), do: {len, rest} | |
| 439 | + defp utf8_len(_), do: :error | |
| 440 | + | |
| 441 | + defp utf16_to_utf8(bytes) do | |
| 442 | + case :unicode.characters_to_binary(bytes, {:utf16, :little}, :utf8) do | |
| 443 | + converted when is_binary(converted) -> converted | |
| 444 | + _ -> "" | |
| 445 | + end | |
| 446 | + end | |
| 447 | + | |
| 448 | + defp pool_at(pool, index) when is_integer(index) and index >= 0 and index < tuple_size(pool), | |
| 449 | + do: elem(pool, index) | |
| 450 | + | |
| 451 | + defp pool_at(_pool, _index), do: nil | |
| 452 | +end |
modified
lib/git_gud/packages/formats/fdroid/manifest.ex
+22
−6
| 9 | 9 | library call. |
| 10 | 10 | |
| 11 | 11 | Only what the index needs is extracted: package name, version, SDK |
| 12 | − levels and permissions. See `docs/fdroid-registry.md` for the chunk | |
| 13 | − layouts and for why `android:label` / `android:icon` are deliberately | |
| 14 | − left alone (they are usually `TYPE_REFERENCE` into `resources.arsc`, | |
| 15 | − and an ARSC reader is a second format of comparable size). | |
| 12 | + levels, permissions, and the icon and label attributes. See | |
| 13 | + `docs/fdroid-registry.md` for the chunk layouts. | |
| 14 | + | |
| 15 | + `android:icon` and `android:label` are usually `TYPE_REFERENCE` — a | |
| 16 | + resource id, meaningless on its own. They are reported raw and | |
| 17 | + resolved by `Arsc`, which is the module that can. | |
| 16 | 18 | |
| 17 | 19 | Attributes are matched by their **name string**, not by resource id. |
| 18 | 20 | The resource map chunk maps pool index to resource id and a fully |
| 55 | 57 | version_name: String.t() | nil, |
| 56 | 58 | min_sdk: integer() | nil, |
| 57 | 59 | target_sdk: integer() | nil, |
| 58 | − permissions: [String.t()] | |
| 60 | + permissions: [String.t()], | |
| 61 | + icon_ref: non_neg_integer() | nil, | |
| 62 | + label: String.t() | non_neg_integer() | nil | |
| 59 | 63 | } |
| 60 | 64 | |
| 61 | 65 | @doc """ |
| 301 | 305 | defp assemble(elements) do |
| 302 | 306 | manifest = first_attrs(elements, "manifest") |
| 303 | 307 | uses_sdk = first_attrs(elements, "uses-sdk") |
| 308 | + application = first_attrs(elements, "application") | |
| 304 | 309 | |
| 305 | 310 | %{ |
| 306 | 311 | package: string(manifest["package"]), |
| 308 | 313 | version_name: string(manifest["versionName"]), |
| 309 | 314 | min_sdk: integer(uses_sdk["minSdkVersion"]), |
| 310 | 315 | target_sdk: integer(uses_sdk["targetSdkVersion"]), |
| 311 | − permissions: permissions(elements) | |
| 316 | + permissions: permissions(elements), | |
| 317 | + # Both are almost always TYPE_REFERENCE — a resource id that means | |
| 318 | + # nothing without `resources.arsc`. Reported as-is: `Arsc` turns | |
| 319 | + # them into a file path and a name, and a caller without it can | |
| 320 | + # tell an unresolved id from an absent attribute. | |
| 321 | + icon_ref: reference(application["icon"]), | |
| 322 | + label: application["label"] | |
| 312 | 323 | } |
| 313 | 324 | end |
| 314 | 325 | |
| 326 | + # A resource id, or nil for an absent attribute or a literal 0 (which | |
| 327 | + # is what a manifest carries for "no icon"). | |
| 328 | + defp reference(value) when is_integer(value) and value > 0, do: value | |
| 329 | + defp reference(_), do: nil | |
| 330 | + | |
| 315 | 331 | defp first_attrs(elements, tag) do |
| 316 | 332 | case Enum.find(elements, fn {name, _} -> name == tag end) do |
| 317 | 333 | {_, attrs} -> attrs |
modified
lib/git_gud/packages/formats/fdroid/signature.ex
+20
−17
Click to load diff…
added
lib/git_gud/packages/formats/fdroid/zip.ex
+132
−0
Click to load diff…
added
test/git_gud/packages/fdroid_apk_icon_test.exs
+103
−0
Click to load diff…
added
test/git_gud/packages/fdroid_arsc_test.exs
+86
−0
Click to load diff…
modified
test/git_gud/packages/fdroid_format_test.exs
+22
−1
Click to load diff…
added
test/git_gud/packages/fdroid_zip_test.exs
+58
−0
Click to load diff…
added
test/support/fixtures/apk_fixtures.ex
+236
−0
Click to load diff…
Parents: bdd2439