Add a backfill for F-Droid names and icons

8808a78 · Gabriel Morell · 2026-09-19 04:52

16 files +390 -67
Message
{commit_body(@commit)}

Files changed

modified lib/git_gud/packages/formats/fdroid.ex
+15 −1
@@ -249,6 +249,20 @@ defmodule GitGud.Packages.Formats.Fdroid do
249 249 }
250 250 end
251 251
252 + @doc """
253 + Everything the index knows about an APK: what the manifest and
254 + resource table say, plus the launcher icon, stored.
255 +
256 + Public because `mix gitgud.backfill_fdroid_icons` re-reads APKs that
257 + were published before the adapter could resolve an icon, and a
258 + backfill that computed metadata its own way would be a second
259 + implementation to keep in step.
260 + """
261 + @spec apk_metadata(map(), binary()) :: map()
262 + def apk_metadata(info, body) when is_map(info) and is_binary(body) do
263 + info |> string_keys() |> Map.merge(store_icon(body))
264 + end
265 +
252 266 # The launcher icon pulled out of the APK at publish time, keyed by
253 267 # content so re-publishing the same image stores it once.
254 268 defp store_icon(body) do
@@ -535,7 +549,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
535 549 # Extracted once, here, rather than on every icon request — the
536 550 # alternative is pulling a 19 MB APK out of storage to serve
537 551 # 30 KB of PNG.
538 − metadata = info |> string_keys() |> Map.merge(store_icon(body))
552 + metadata = apk_metadata(info, body)
539 553
540 554 attrs = %{
541 555 filename: Apk.filename(info),
added lib/mix/tasks/gitgud.backfill_fdroid_icons.ex
+140 −0
@@ -0,0 +1,140 @@
1 +defmodule Mix.Tasks.Gitgud.BackfillFdroidIcons do
2 + @moduledoc """
3 + Re-read published APKs so the F-Droid index can show a name and an
4 + icon for apps that were published before it could.
5 +
6 + Both are read out of the APK at upload and stored with the file, so
7 + an app published before the adapter could resolve `resources.arsc`
8 + carries neither — and nothing re-reads an APK afterwards. Its entry
9 + shows the package id and no icon until it publishes again, which for
10 + an app that isn't building today is never.
11 +
12 + mix gitgud.backfill_fdroid_icons # newest APK per app
13 + mix gitgud.backfill_fdroid_icons --all # every version
14 + mix gitgud.backfill_fdroid_icons --dry-run # say what would change
15 +
16 + The newest APK per app is the default because it is what the index
17 + reads: `name` and `icon` come from the highest versionCode, and
18 + re-reading twenty older builds would pull twenty APKs out of storage
19 + to change nothing anyone can see.
20 +
21 + Safe to run twice: an icon already in storage is stored under the
22 + same content-addressed key, and the write is idempotent.
23 +
24 + It only ever adds a name and an icon. Everything else the file
25 + records was written when it was published, and stays.
26 + """
27 + @shortdoc "Fill in F-Droid app names and icons from published APKs"
28 +
29 + use Mix.Task
30 +
31 + import Ecto.Query
32 +
33 + alias GitGud.Packages
34 + alias GitGud.Packages.Formats.Fdroid
35 + alias GitGud.Packages.Formats.Fdroid.Apk
36 + alias GitGud.Packages.PackageFile
37 + alias GitGud.Repo
38 +
39 + @format "fdroid"
40 +
41 + # Only these are taken from the re-read. Everything else the file
42 + # already records — the package name, the versionCode, the signer —
43 + # is what it was published as, and a backfill is the wrong place to
44 + # renumber a published version: the index orders by versionCode and
45 + # the filename encodes it.
46 + @backfilled ~w(label icon_key icon_sha256 icon_size icon_ext)
47 +
48 + @impl true
49 + def run(args) do
50 + {opts, _rest, _invalid} =
51 + OptionParser.parse(args, strict: [all: :boolean, dry_run: :boolean])
52 +
53 + Mix.Task.run("app.start")
54 +
55 + all? = Keyword.get(opts, :all, false)
56 + dry? = Keyword.get(opts, :dry_run, false)
57 +
58 + files = candidates(all?)
59 +
60 + if files == [] do
61 + Mix.shell().info("No APKs published.")
62 + else
63 + Mix.shell().info("#{length(files)} APK(s) to read#{if dry?, do: " (dry run)", else: ""}")
64 + Enum.each(files, &backfill(&1, dry?))
65 + end
66 + end
67 +
68 + # Every published APK, or just the newest of each app. "Newest" is by
69 + # versionCode, which is what the index sorts on — a file whose
70 + # metadata predates this adapter has none, and sorts last rather than
71 + # crashing the comparison.
72 + defp candidates(true), do: apk_files()
73 +
74 + defp candidates(false) do
75 + apk_files()
76 + |> Enum.group_by(& &1.package_version.package_id)
77 + |> Enum.map(fn {_package_id, files} -> Enum.max_by(files, &version_code/1) end)
78 + |> Enum.sort_by(& &1.id)
79 + end
80 +
81 + defp apk_files do
82 + from(f in PackageFile,
83 + join: v in assoc(f, :package_version),
84 + join: p in assoc(v, :package),
85 + where: p.format == ^@format,
86 + preload: [package_version: {v, package: p}]
87 + )
88 + |> Repo.all()
89 + end
90 +
91 + defp version_code(%PackageFile{metadata: metadata}) do
92 + case metadata do
93 + %{"version_code" => code} when is_integer(code) -> code
94 + _ -> -1
95 + end
96 + end
97 +
98 + defp backfill(%PackageFile{} = file, dry?) do
99 + label = "#{file.package_version.package.name} #{file.filename}"
100 +
101 + with {:ok, body} <- Packages.read_file(file),
102 + {:ok, info} <- Apk.read(body) do
103 + report(label, info, body, dry?)
104 +
105 + unless dry? do
106 + additions = info |> Fdroid.apk_metadata(body) |> Map.take(@backfilled)
107 + metadata = Map.merge(file.metadata || %{}, additions)
108 +
109 + file
110 + |> Ecto.Changeset.change(metadata: metadata)
111 + |> Repo.update!()
112 +
113 + # The version carries the same map at publish time. Kept in
114 + # step here so the two don't disagree about what an APK holds.
115 + file.package_version
116 + |> Ecto.Changeset.change(
117 + metadata: Map.merge(file.package_version.metadata || %{}, metadata)
118 + )
119 + |> Repo.update!()
120 + end
121 + else
122 + {:error, reason} ->
123 + Mix.shell().error("#{label}: could not read — #{inspect(reason)}")
124 + end
125 + end
126 +
127 + # Reported before anything is written, so a dry run and a real run
128 + # print the same lines and the difference is only whether they stick.
129 + defp report(label, info, body, dry?) do
130 + icon =
131 + case Apk.icon(body) do
132 + {:ok, bytes, ext} -> "#{byte_size(bytes)} byte .#{ext} icon"
133 + :none -> "no icon"
134 + end
135 +
136 + name = info[:label] || "no name (falls back to #{info[:package]})"
137 +
138 + Mix.shell().info("#{label}: #{name}, #{icon}#{if dry?, do: " — not written", else: ""}")
139 + end
140 +end
added test/git_gud/packages/fdroid_backfill_test.exs
+133 −0
@@ -0,0 +1,133 @@
1 +defmodule GitGud.Packages.Fdroid.BackfillTest do
2 + @moduledoc """
3 + `mix gitgud.backfill_fdroid_icons`.
4 +
5 + The situation it exists for: an APK published before the adapter
6 + could read `resources.arsc` carries no icon and no label, and nothing
7 + re-reads an APK afterwards — so the app shows as its package id
8 + forever, or until it publishes again. These publish a file with the
9 + metadata that era produced and assert the task repairs it in place.
10 + """
11 + use GitGud.DataCase, async: false
12 +
13 + import GitGud.ForgeFixtures
14 +
15 + alias GitGud.ApkFixtures
16 + alias GitGud.Packages
17 + alias GitGud.Packages.PackageFile
18 + alias GitGud.Repo
19 + alias Mix.Tasks.Gitgud.BackfillFdroidIcons
20 +
21 + setup do
22 + {_user, repo} = repository_fixture()
23 + %{repo: repo}
24 + end
25 +
26 + # What a publish stored before icons and labels were read: the
27 + # manifest's own fields, and nothing from the resource table.
28 + defp publish_legacy(repo, version_code) do
29 + body = ApkFixtures.signed_apk()
30 +
31 + metadata = %{
32 + "package" => "org.neiam.gitgud.app",
33 + "version_code" => version_code,
34 + "version_name" => "0.1.0"
35 + }
36 +
37 + {:ok, _package, _version, file} =
38 + Packages.publish_file(repo, "fdroid", "org.neiam.gitgud.app", "0.1.0 (#{version_code})", %{
39 + filename: "org.neiam.gitgud.app_#{version_code}.apk",
40 + body: body,
41 + content_type: "application/vnd.android.package-archive",
42 + metadata: metadata,
43 + version_metadata: metadata
44 + })
45 +
46 + file
47 + end
48 +
49 + defp reload(file), do: Repo.get!(PackageFile, file.id)
50 +
51 + defp run(args) do
52 + ExUnit.CaptureIO.capture_io(fn -> BackfillFdroidIcons.run(args) end)
53 + end
54 +
55 + test "fills in the label and the icon", %{repo: repo} do
56 + file = publish_legacy(repo, 224)
57 +
58 + refute Map.has_key?(file.metadata, "label")
59 + refute Map.has_key?(file.metadata, "icon_key")
60 +
61 + run([])
62 +
63 + metadata = reload(file).metadata
64 +
65 + assert metadata["label"] == "GitGud"
66 + assert metadata["icon_key"] =~ "fdroid-icons/"
67 + assert metadata["icon_ext"] == "png"
68 + assert metadata["icon_size"] == byte_size(ApkFixtures.icon_png())
69 +
70 + # And the icon is where the metadata says it is.
71 + assert {:ok, bytes} = GitGud.Storage.get(Packages.artifact_bucket(), metadata["icon_key"])
72 + assert bytes == ApkFixtures.icon_png()
73 + end
74 +
75 + test "adds a name and an icon and touches nothing else", %{repo: repo} do
76 + # The stored versionCode is 224; the APK's own manifest says 1. A
77 + # backfill that trusted the re-read would renumber a published
78 + # version, which the index orders by and the filename encodes.
79 + file = publish_legacy(repo, 224)
80 +
81 + run([])
82 +
83 + metadata = reload(file).metadata
84 +
85 + assert metadata["package"] == "org.neiam.gitgud.app"
86 + assert metadata["version_code"] == 224
87 + assert metadata["version_name"] == "0.1.0"
88 + assert metadata["label"] == "GitGud"
89 + end
90 +
91 + test "reads only the newest APK of each app by default", %{repo: repo} do
92 + old = publish_legacy(repo, 1)
93 + new = publish_legacy(repo, 224)
94 +
95 + run([])
96 +
97 + # The index takes an app's name and icon from its highest
98 + # versionCode, so the older builds are twenty APKs of storage read
99 + # to change nothing anyone can see.
100 + assert reload(new).metadata["label"] == "GitGud"
101 + refute Map.has_key?(reload(old).metadata, "label")
102 + end
103 +
104 + test "--all reads every version", %{repo: repo} do
105 + old = publish_legacy(repo, 1)
106 + new = publish_legacy(repo, 224)
107 +
108 + run(["--all"])
109 +
110 + assert reload(old).metadata["label"] == "GitGud"
111 + assert reload(new).metadata["label"] == "GitGud"
112 + end
113 +
114 + test "--dry-run reports without writing", %{repo: repo} do
115 + file = publish_legacy(repo, 224)
116 +
117 + output = run(["--dry-run"])
118 +
119 + assert output =~ "GitGud"
120 + assert output =~ "not written"
121 + refute Map.has_key?(reload(file).metadata, "label")
122 + end
123 +
124 + test "is safe to run twice", %{repo: repo} do
125 + file = publish_legacy(repo, 224)
126 +
127 + run([])
128 + first = reload(file).metadata
129 + run([])
130 +
131 + assert reload(file).metadata == first
132 + end
133 +end
modified test/git_gud/packages/fdroid_format_test.exs
+8 −66
@@ -17,77 +17,17 @@ defmodule GitGud.Packages.Formats.FdroidTest do
17 17 alias GitGud.Packages
18 18 alias GitGud.Packages.Formats.Fdroid
19 19
20 − @cert File.read!("test/support/fixtures/apk/debug-signer.der")
21 − @manifest File.read!("test/support/fixtures/apk/gitgud-AndroidManifest.xml")
20 + @cert GitGud.ApkFixtures.signing_cert()
22 21
23 22 # 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 −
26 − @magic "APK Sig Block 42"
27 − @v2_id 0x7109871A
23 + @icon_png GitGud.ApkFixtures.icon_png()
28 24
29 25 setup do
30 26 {user, repo} = repository_fixture()
31 27 %{user: user, repo: repo}
32 28 end
33 29
34 − # ── building an APK ──────────────────────────────────────────────────
35 −
36 − defp lp(data), do: <<byte_size(data)::little-32, data::binary>>
37 −
38 − defp scheme_value(der), do: lp(lp(lp(lp("digests") <> lp(lp(der)))))
39 −
40 − defp signing_block(pairs) do
41 − size = byte_size(pairs) + 24
42 − <<size::little-64, pairs::binary, size::little-64, @magic::binary>>
43 − end
44 −
45 − defp pair(id, value), do: <<byte_size(value) + 4::little-64, id::little-32, value::binary>>
46 −
47 − # A zip carrying the real compiled manifest and a native library, with
48 − # a v2 signing block spliced in where an APK keeps one.
49 − defp build_apk(extra_entries \\ []) do
50 − entries =
51 − [
52 − {~c"AndroidManifest.xml", @manifest},
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"},
59 − # The label the real manifest points at (0x7F0A0001) lives here,
60 − # as it does in an APK: the manifest carries an id, not a name.
61 − {~c"resources.arsc", GitGud.ApkFixtures.resources_arsc(0x7F010000, 0x7F0A0001)}
62 − ] ++ extra_entries
63 −
64 − {:ok, {_name, zip}} = :zip.create(~c"t.apk", entries, [:memory])
65 − splice_signing_block(zip)
66 − end
67 −
68 − # Splice a v2 signing block in where an APK keeps one, fixing the
69 − # EOCD's pointer to the central directory by the block's length.
70 − defp splice_signing_block(zip) do
71 − block = signing_block(pair(@v2_id, scheme_value(@cert)))
72 −
73 − eocd = find_eocd(zip, byte_size(zip) - 22)
74 − <<_::binary-size(16), cd_offset::little-32, _::binary>> = binary_part(zip, eocd, 22)
75 −
76 − body =
77 − binary_part(zip, 0, cd_offset) <>
78 − block <> binary_part(zip, cd_offset, byte_size(zip) - cd_offset)
79 −
80 − prefix = eocd + byte_size(block) + 16
81 − <<head::binary-size(^prefix), _old::little-32, tail::binary>> = body
82 − head <> <<cd_offset + byte_size(block)::little-32>> <> tail
83 − end
84 −
85 − defp find_eocd(zip, pos) do
86 − case binary_part(zip, pos, 4) do
87 − <<0x50, 0x4B, 0x05, 0x06>> -> pos
88 − _ -> find_eocd(zip, pos - 1)
89 − end
90 − end
30 + defp build_apk(extra_entries \\ []), do: GitGud.ApkFixtures.signed_apk(extra_entries)
91 31
92 32 defp ctx(overrides) do
93 33 Map.merge(
@@ -473,9 +413,11 @@ defmodule GitGud.Packages.Formats.FdroidTest do
473 413 %{"name" => "iconless", "visibility" => "public"}
474 414 )
475 415
476 − # No res/mipmap entries at all.
477 − {:ok, {_n, zip}} = :zip.create(~c"t.apk", [{~c"AndroidManifest.xml", @manifest}], [:memory])
478 − bare = splice_signing_block(zip)
416 + # No res/mipmap entries at all, and no resource table either.
417 + bare =
418 + GitGud.ApkFixtures.signed_zip([
419 + {~c"AndroidManifest.xml", GitGud.ApkFixtures.compiled_manifest()}
420 + ])
479 421
480 422 assert {:ok, _, _} = publish(other, bare)
481 423
modified test/support/fixtures/apk_fixtures.ex
+94 −0
@@ -132,6 +132,100 @@ defmodule GitGud.ApkFixtures do
132 132 defp type_id(resource_id), do: Bitwise.band(Bitwise.bsr(resource_id, 16), 0xFF)
133 133 defp entry_index(resource_id), do: Bitwise.band(resource_id, 0xFFFF)
134 134
135 + @cert File.read!("test/support/fixtures/apk/debug-signer.der")
136 + @manifest File.read!("test/support/fixtures/apk/gitgud-AndroidManifest.xml")
137 +
138 + @magic "APK Sig Block 42"
139 + @v2_id 0x7109871A
140 +
141 + @doc """
142 + The certificate `signed_apk/1` signs with, for a test asserting on the
143 + signer fingerprint the index reports.
144 + """
145 + def signing_cert, do: @cert
146 +
147 + @doc """
148 + This repo's own compiled `AndroidManifest.xml`, from its debug build.
149 + """
150 + def compiled_manifest, do: @manifest
151 +
152 + @doc """
153 + A tiny but real PNG, so what comes back out of a publish is checkable.
154 + """
155 + def icon_png, do: <<137, 80, 78, 71, 13, 10, 26, 10>> <> :binary.copy(<<7>>, 40)
156 +
157 + @doc """
158 + An APK the adapter will accept: this repo's real compiled manifest, a
159 + native library, launcher icons by the conventional name, a resource
160 + table carrying the label that manifest points at, and a v2 signing
161 + block spliced in where an APK keeps one.
162 +
163 + Real APKs are 19 MB and the parsers this feeds have their own tests
164 + against real input. What a fixture is for is the adapter.
165 + """
166 + def signed_apk(extra_entries \\ []) do
167 + entries =
168 + [
169 + {~c"AndroidManifest.xml", @manifest},
170 + {~c"lib/arm64-v8a/libfoo.so", "not really an elf"},
171 + # What AGP emits for a debug build. The adapter falls back to
172 + # this convention when a resource table can't answer, so a
173 + # fixture without it wouldn't exercise the fallback.
174 + {~c"res/mipmap-xxxhdpi-v4/ic_launcher.png", icon_png()},
175 + {~c"res/mipmap-mdpi-v4/ic_launcher.png", "low density, should lose"},
176 + {~c"res/mipmap-xxxhdpi-v4/ic_launcher_foreground.png", "a layer, not the icon"},
177 + # The label the real manifest points at (0x7F0A0001) lives here,
178 + # as it does in an APK: the manifest carries an id, not a name.
179 + {~c"resources.arsc", resources_arsc(0x7F010000, 0x7F0A0001)}
180 + ] ++ extra_entries
181 +
182 + signed_zip(entries)
183 + end
184 +
185 + @doc """
186 + Signs exactly the entries given, for a test that needs an APK
187 + *missing* something the standard fixture carries.
188 + """
189 + def signed_zip(entries) do
190 + {:ok, {_name, zip}} = :zip.create(~c"t.apk", entries, [:memory])
191 + splice_signing_block(zip)
192 + end
193 +
194 + defp lp(data), do: <<byte_size(data)::little-32, data::binary>>
195 +
196 + defp scheme_value(der), do: lp(lp(lp(lp("digests") <> lp(lp(der)))))
197 +
198 + defp signing_block(pairs) do
199 + size = byte_size(pairs) + 24
200 + <<size::little-64, pairs::binary, size::little-64, @magic::binary>>
201 + end
202 +
203 + defp pair(id, value), do: <<byte_size(value) + 4::little-64, id::little-32, value::binary>>
204 +
205 + # Splice a v2 signing block in where an APK keeps one, fixing the
206 + # EOCD's pointer to the central directory by the block's length.
207 + defp splice_signing_block(zip) do
208 + block = signing_block(pair(@v2_id, scheme_value(@cert)))
209 +
210 + eocd = find_eocd(zip, byte_size(zip) - 22)
211 + <<_::binary-size(16), cd_offset::little-32, _::binary>> = binary_part(zip, eocd, 22)
212 +
213 + body =
214 + binary_part(zip, 0, cd_offset) <>
215 + block <> binary_part(zip, cd_offset, byte_size(zip) - cd_offset)
216 +
217 + prefix = eocd + byte_size(block) + 16
218 + <<head::binary-size(^prefix), _old::little-32, tail::binary>> = body
219 + head <> <<cd_offset + byte_size(block)::little-32>> <> tail
220 + end
221 +
222 + defp find_eocd(zip, pos) do
223 + case binary_part(zip, pos, 4) do
224 + <<0x50, 0x4B, 0x05, 0x06>> -> pos
225 + _ -> find_eocd(zip, pos - 1)
226 + end
227 + end
228 +
135 229 # ── chunk builders ───────────────────────────────────────────────────
136 230
137 231 # RES_XML_START_ELEMENT: the node header, the attribute extension,

Parents: b299654