neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Show each app's launcher icon in the F-Droid listing
c6afc6c · Gabriel Morell · 2026-09-12 20:26
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