neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
Add a backfill for F-Droid names and icons
8808a78 · Gabriel Morell · 2026-09-19 04:52
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