Show each app's launcher icon in the F-Droid listing

c6afc6c · Gabriel Morell · 2026-09-12 20:26

11 files +253 -18
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages/formats/fdroid.ex
+86 −16
@@ -22,7 +22,8 @@ defmodule GitGud.Packages.Formats.Fdroid do
22 22 /index-v2.json the index proper, verified by hash from entry
23 23 /index-v1.jar signed; the whole index, for older clients
24 24 /index-v1.json the same, unsigned
25 − /icons/icon.png the namespace identicon
25 + /icons/icon.png the namespace avatar
26 + /icons/<pkg>.<code>.png an app's launcher icon
26 27 /<package>_<code>.apk
27 28
28 29 ## Both index versions, because the failure is invisible
@@ -176,13 +177,18 @@ defmodule GitGud.Packages.Formats.Fdroid do
176 177 # The repository icon: the namespace's identicon, the same grid the
177 178 # profile page draws, baked into a PNG because a client has no
178 179 # stylesheet to resolve the web avatar's CSS variables against.
179 − defp serve(_repos, _label, scope, %{path: ["icons", filename]}) do
180 + defp serve(repos, _label, scope, %{path: ["icons", filename]}) do
180 181 {content_type, body} = icon(scope)
181 182
182 − if filename == icon_filename(content_type) do
183 − {:ok, content_type, body}
184 − else
185 − {:error, {404, "no such icon"}}
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"}}
186 192 end
187 193 end
188 194
@@ -239,6 +245,58 @@ defmodule GitGud.Packages.Formats.Fdroid do
239 245 }
240 246 end
241 247
248 + # The launcher icon pulled out of the APK at publish time, keyed by
249 + # content so re-publishing the same image stores it once.
250 + defp store_icon(body) do
251 + case Apk.icon(body) do
252 + {:ok, png} ->
253 + sha256 = :sha256 |> :crypto.hash(png) |> Base.encode16(case: :lower)
254 + key = "fdroid-icons/#{sha256}"
255 +
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 + # An icon is decoration. Failing the publish over one would
259 + # trade a working release for a missing picture.
260 + {:error, _} -> %{}
261 + end
262 +
263 + :none ->
264 + %{}
265 + end
266 + end
267 +
268 + # `<package>.<versionCode>.png`, which is what fdroidserver emits and
269 + # what a v1 client expects to find under /icons/.
270 + defp app_icon_name(%{info: info}) do
271 + if info["icon_key"], do: "#{info["package"]}.#{info["version_code"]}.png"
272 + end
273 +
274 + defp app_icon_file(group) do
275 + newest = Enum.max_by(group, & &1.info["version_code"])
276 + info = newest.info
277 +
278 + if info["icon_key"] do
279 + %{
280 + "en-US" => %{
281 + "name" => "/icons/" <> app_icon_name(newest),
282 + "sha256" => info["icon_sha256"],
283 + "size" => info["icon_size"]
284 + }
285 + }
286 + end
287 + end
288 +
289 + defp find_app_icon(repos, filename) do
290 + Enum.find_value(apks(repos), fn apk ->
291 + if apk.info["icon_key"] && app_icon_name(apk) == filename do
292 + case GitGud.Storage.get(Packages.artifact_bucket(), apk.info["icon_key"]) do
293 + {:ok, png} -> png
294 + {:error, _} -> nil
295 + end
296 + end
297 + end)
298 + end
299 +
242 300 # Whatever the namespace's avatar is — an upload if there is one,
243 301 # the identicon otherwise. `Avatars.render/1` makes that one call
244 302 # rather than a branch here.
@@ -261,13 +319,17 @@ defmodule GitGud.Packages.Formats.Fdroid do
261 319 updated = group |> Enum.map(& &1.added) |> Enum.max()
262 320
263 321 %{
264 − "metadata" => %{
265 − "added" => added,
266 − "lastUpdated" => updated,
267 − # No ARSC reader, so the package name is the name. A repository
268 − # of one's own builds is a list you already recognise.
269 − "name" => %{"en-US" => name}
270 − },
322 + "metadata" =>
323 + %{
324 + "added" => added,
325 + "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},
330 + "icon" => app_icon_file(group)
331 + }
332 + |> drop_nils(),
271 333 "versions" => Map.new(group, fn apk -> {apk.file.sha256, version_entry(apk)} end)
272 334 }
273 335 end
@@ -350,7 +412,10 @@ defmodule GitGud.Packages.Formats.Fdroid do
350 412 "added" => added,
351 413 "lastUpdated" => updated,
352 414 "suggestedVersionCode" => to_string(newest.info["version_code"]),
353 − "suggestedVersionName" => newest.info["version_name"]
415 + "suggestedVersionName" => newest.info["version_name"],
416 + # v1 names the icon relative to <address>/icons/, so a bare
417 + # filename rather than v2's path-and-hash object.
418 + "icon" => app_icon_name(newest)
354 419 }
355 420 |> drop_nils()
356 421 end
@@ -435,12 +500,17 @@ defmodule GitGud.Packages.Formats.Fdroid do
435 500 def store(repo, %{method: "POST", path: path} = ctx, body)
436 501 when is_binary(body) and (path == ["upload"] or path == []) do
437 502 with {:ok, info} <- Apk.read(body) do
503 + # Extracted once, here, rather than on every icon request — the
504 + # alternative is pulling a 19 MB APK out of storage to serve
505 + # 30 KB of PNG.
506 + metadata = info |> string_keys() |> Map.merge(store_icon(body))
507 +
438 508 attrs = %{
439 509 filename: Apk.filename(info),
440 510 body: body,
441 511 content_type: content_type(nil),
442 − metadata: string_keys(info),
443 − version_metadata: string_keys(info),
512 + metadata: metadata,
513 + version_metadata: metadata,
444 514 uploader: uploader(ctx)
445 515 }
446 516
modified lib/git_gud/packages/formats/fdroid/apk.ex
+94 −0
@@ -19,6 +19,16 @@ defmodule GitGud.Packages.Formats.Fdroid.Apk do
19 19
20 20 @manifest ~c"AndroidManifest.xml"
21 21
22 + # Densities, best first. F-Droid scales whatever it is given, so the
23 + # largest available is the right pick rather than one matching a
24 + # device we know nothing about.
25 + @densities ~w(xxxhdpi xxhdpi xhdpi hdpi mdpi nodpi anydpi)
26 +
27 + # Layers of an adaptive icon, which are not the icon. Taking
28 + # `ic_launcher_foreground.png` yields a glyph floating on
29 + # transparency with no background behind it.
30 + @layer_suffixes ~w(_background _foreground _monochrome)
31 +
22 32 @type info :: %{
23 33 package: String.t(),
24 34 version_code: integer(),
@@ -67,6 +77,90 @@ defmodule GitGud.Packages.Formats.Fdroid.Apk do
67 77
68 78 def read(_), do: {:error, {422, "not an APK"}}
69 79
80 + @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.
103 + """
104 + @spec icon(binary()) :: {:ok, binary()} | :none
105 + 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 + end
112 + end
113 +
114 + def icon(_), do: :none
115 +
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 + []
132 + end
133 + end
134 +
135 + defp rank(names) do
136 + Enum.sort_by(names, fn name ->
137 + string = List.to_string(name)
138 +
139 + index =
140 + Enum.find_index(@densities, fn density ->
141 + String.contains?(string, "-" <> density)
142 + end)
143 +
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 + end
149 +
150 + defp launcher_icon?(name) do
151 + string = List.to_string(name)
152 +
153 + mipmap_png?(name) and String.contains?(string, "ic_launcher") and
154 + not Enum.any?(@layer_suffixes, &String.contains?(string, &1 <> "."))
155 + end
156 +
157 + defp mipmap_png?(name) do
158 + string = List.to_string(name)
159 +
160 + String.ends_with?(string, ".png") and
161 + (String.starts_with?(string, "res/mipmap-") or String.starts_with?(string, "res/drawable-"))
162 + end
163 +
70 164 @doc """
71 165 The filename this APK is stored and served under:
72 166 `<package>_<versionCode>.apk`, which is what `fdroid update`
modified test/git_gud/packages/fdroid_format_test.exs
+73 −2
@@ -20,6 +20,9 @@ defmodule GitGud.Packages.Formats.FdroidTest do
20 20 @cert File.read!("test/support/fixtures/apk/debug-signer.der")
21 21 @manifest File.read!("test/support/fixtures/apk/gitgud-AndroidManifest.xml")
22 22
23 + # A tiny but real PNG, so what comes back out is checkable.
24 + @icon_png <<137, 80, 78, 71, 13, 10, 26, 10>> <> :binary.copy(<<7>>, 40)
25 +
23 26 @magic "APK Sig Block 42"
24 27 @v2_id 0x7109871A
25 28
@@ -47,10 +50,21 @@ defmodule GitGud.Packages.Formats.FdroidTest do
47 50 entries =
48 51 [
49 52 {~c"AndroidManifest.xml", @manifest},
50 − {~c"lib/arm64-v8a/libfoo.so", "not really an elf"}
53 + {~c"lib/arm64-v8a/libfoo.so", "not really an elf"},
54 + # What AGP emits. The adapter finds the icon by this
55 + # convention, so a fixture without it wouldn't exercise it.
56 + {~c"res/mipmap-xxxhdpi-v4/ic_launcher.png", @icon_png},
57 + {~c"res/mipmap-mdpi-v4/ic_launcher.png", "low density, should lose"},
58 + {~c"res/mipmap-xxxhdpi-v4/ic_launcher_foreground.png", "a layer, not the icon"}
51 59 ] ++ extra_entries
52 60
53 61 {:ok, {_name, zip}} = :zip.create(~c"t.apk", entries, [:memory])
62 + splice_signing_block(zip)
63 + end
64 +
65 + # Splice a v2 signing block in where an APK keeps one, fixing the
66 + # EOCD's pointer to the central directory by the block's length.
67 + defp splice_signing_block(zip) do
54 68 block = signing_block(pair(@v2_id, scheme_value(@cert)))
55 69
56 70 eocd = find_eocd(zip, byte_size(zip) - 22)
@@ -72,7 +86,7 @@ defmodule GitGud.Packages.Formats.FdroidTest do
72 86 end
73 87 end
74 88
75 − defp ctx(overrides \\ %{}) do
89 + defp ctx(overrides) do
76 90 Map.merge(
77 91 %{
78 92 path: [],
@@ -394,6 +408,63 @@ defmodule GitGud.Packages.Formats.FdroidTest do
394 408 end
395 409 end
396 410
411 + describe "app icons" do
412 + setup %{repo: repo} do
413 + {:ok, _, _} = publish(repo, build_apk())
414 + :ok
415 + end
416 +
417 + test "the launcher icon is served under its package and versionCode", %{repo: repo} do
418 + assert {:ok, "image/png", @icon_png} =
419 + Fdroid.index(repo, ctx(%{path: ["icons", "org.neiam.gitgud.app.1.png"]}))
420 + end
421 +
422 + test "picks the highest density, and never an adaptive layer", %{repo: repo} do
423 + {:ok, _, png} = Fdroid.index(repo, ctx(%{path: ["icons", "org.neiam.gitgud.app.1.png"]}))
424 +
425 + # ic_launcher_foreground is a glyph on transparency; shipping it
426 + # as the icon looks like a bug rather than a design.
427 + assert png == @icon_png
428 + refute png == "a layer, not the icon"
429 + refute png == "low density, should lose"
430 + end
431 +
432 + test "index-v2 pins it by hash and size", %{repo: repo} do
433 + index = decode(Fdroid.index(repo, ctx(%{path: ["index-v2.json"]})))
434 + icon = index["packages"]["org.neiam.gitgud.app"]["metadata"]["icon"]["en-US"]
435 +
436 + assert icon["name"] == "/icons/org.neiam.gitgud.app.1.png"
437 + assert icon["size"] == byte_size(@icon_png)
438 + assert icon["sha256"] == :sha256 |> :crypto.hash(@icon_png) |> Base.encode16(case: :lower)
439 + end
440 +
441 + test "index-v1 names it relative to /icons/", %{repo: repo} do
442 + index = decode(Fdroid.index(repo, ctx(%{path: ["index-v1.json"]})))
443 +
444 + assert [app] = index["apps"]
445 + assert app["icon"] == "org.neiam.gitgud.app.1.png"
446 + end
447 +
448 + test "an APK with no icon publishes fine and claims none", %{repo: repo} do
449 + {:ok, other} =
450 + GitGud.Repositories.create_repository(
451 + GitGud.Repo.preload(repo, :owner).owner,
452 + %{"name" => "iconless", "visibility" => "public"}
453 + )
454 +
455 + # No res/mipmap entries at all.
456 + {:ok, {_n, zip}} = :zip.create(~c"t.apk", [{~c"AndroidManifest.xml", @manifest}], [:memory])
457 + bare = splice_signing_block(zip)
458 +
459 + assert {:ok, _, _} = publish(other, bare)
460 +
461 + index = decode(Fdroid.index(other, ctx(%{path: ["index-v2.json"]})))
462 + # Absent rather than null: an icon that isn't there shouldn't
463 + # show up as a key a client has to interpret.
464 + refute Map.has_key?(index["packages"]["org.neiam.gitgud.app"]["metadata"], "icon")
465 + end
466 + end
467 +
397 468 describe "the /repo suffix F-Droid insists on" do
398 469 setup %{repo: repo} do
399 470 {:ok, _, _} = publish(repo, build_apk())

Parents: 74e1fb0