Show app icons and names in the F-Droid index

b299654 · Gabriel Morell · 2026-09-19 04:16

23 files +1342 -135
Message
{commit_body(@commit)}

Files changed

modified docs/fdroid-registry.md
+28 −4
@@ -111,10 +111,34 @@ What gets pulled out:
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,11 +41,17 @@ defmodule GitGud.Packages.Formats.Fdroid do
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,15 +186,13 @@ defmodule GitGud.Packages.Formats.Fdroid do
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,15 +253,24 @@ defmodule GitGud.Packages.Formats.Fdroid do
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,10 +278,32 @@ defmodule GitGud.Packages.Formats.Fdroid do
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,7 +325,7 @@ defmodule GitGud.Packages.Formats.Fdroid 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,10 +358,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
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,7 +436,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
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,16 +8,20 @@ defmodule GitGud.Packages.Formats.Fdroid.Apk do
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,6 +37,7 @@ defmodule GitGud.Packages.Formats.Fdroid.Apk do
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,6 +68,7 @@ defmodule GitGud.Packages.Formats.Fdroid.Apk do
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,89 +84,107 @@ defmodule GitGud.Packages.Formats.Fdroid.Apk do
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,20 +196,40 @@ defmodule GitGud.Packages.Formats.Fdroid.Apk do
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
@@ -0,0 +1,452 @@
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,10 +9,12 @@ defmodule GitGud.Packages.Formats.Fdroid.Manifest do
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,7 +57,9 @@ defmodule GitGud.Packages.Formats.Fdroid.Manifest do
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,6 +305,7 @@ defmodule GitGud.Packages.Formats.Fdroid.Manifest do
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,10 +313,21 @@ defmodule GitGud.Packages.Formats.Fdroid.Manifest do
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