defmodule Mix.Tasks.Gitgud.BackfillFdroidIcons do
@moduledoc """
Re-read published APKs so the F-Droid index can show a name and an
icon for apps that were published before it could.
Both are read out of the APK at upload and stored with the file, so
an app published before the adapter could resolve `resources.arsc`
carries neither — and nothing re-reads an APK afterwards. Its entry
shows the package id and no icon until it publishes again, which for
an app that isn't building today is never.
mix gitgud.backfill_fdroid_icons # newest APK per app
mix gitgud.backfill_fdroid_icons --all # every version
mix gitgud.backfill_fdroid_icons --dry-run # say what would change
The newest APK per app is the default because it is what the index
reads: `name` and `icon` come from the highest versionCode, and
re-reading twenty older builds would pull twenty APKs out of storage
to change nothing anyone can see.
Safe to run twice: an icon already in storage is stored under the
same content-addressed key, and the write is idempotent.
It only ever adds a name and an icon. Everything else the file
records was written when it was published, and stays.
"""
@shortdoc "Fill in F-Droid app names and icons from published APKs"
use Mix.Task
import Ecto.Query
alias GitGud.Packages
alias GitGud.Packages.Formats.Fdroid
alias GitGud.Packages.Formats.Fdroid.Apk
alias GitGud.Packages.PackageFile
alias GitGud.Repo
@format "fdroid"
# Only these are taken from the re-read. Everything else the file
# already records — the package name, the versionCode, the signer —
# is what it was published as, and a backfill is the wrong place to
# renumber a published version: the index orders by versionCode and
# the filename encodes it.
@backfilled ~w(label icon_key icon_sha256 icon_size icon_ext)
@impl true
def run(args) do
{opts, _rest, _invalid} =
OptionParser.parse(args, strict: [all: :boolean, dry_run: :boolean])
Mix.Task.run("app.start")
all? = Keyword.get(opts, :all, false)
dry? = Keyword.get(opts, :dry_run, false)
files = candidates(all?)
if files == [] do
Mix.shell().info("No APKs published.")
else
Mix.shell().info("#{length(files)} APK(s) to read#{if dry?, do: " (dry run)", else: ""}")
Enum.each(files, &backfill(&1, dry?))
end
end
# Every published APK, or just the newest of each app. "Newest" is by
# versionCode, which is what the index sorts on — a file whose
# metadata predates this adapter has none, and sorts last rather than
# crashing the comparison.
defp candidates(true), do: apk_files()
defp candidates(false) do
apk_files()
|> Enum.group_by(& &1.package_version.package_id)
|> Enum.map(fn {_package_id, files} -> Enum.max_by(files, &version_code/1) end)
|> Enum.sort_by(& &1.id)
end
defp apk_files do
from(f in PackageFile,
join: v in assoc(f, :package_version),
join: p in assoc(v, :package),
where: p.format == ^@format,
preload: [package_version: {v, package: p}]
)
|> Repo.all()
end
defp version_code(%PackageFile{metadata: metadata}) do
case metadata do
%{"version_code" => code} when is_integer(code) -> code
_ -> -1
end
end
defp backfill(%PackageFile{} = file, dry?) do
label = "#{file.package_version.package.name} #{file.filename}"
with {:ok, body} <- Packages.read_file(file),
{:ok, info} <- Apk.read(body) do
report(label, info, body, dry?)
unless dry? do
additions = info |> Fdroid.apk_metadata(body) |> Map.take(@backfilled)
metadata = Map.merge(file.metadata || %{}, additions)
file
|> Ecto.Changeset.change(metadata: metadata)
|> Repo.update!()
# The version carries the same map at publish time. Kept in
# step here so the two don't disagree about what an APK holds.
file.package_version
|> Ecto.Changeset.change(
metadata: Map.merge(file.package_version.metadata || %{}, metadata)
)
|> Repo.update!()
end
else
{:error, reason} ->
Mix.shell().error("#{label}: could not read — #{inspect(reason)}")
end
end
# Reported before anything is written, so a dry run and a real run
# print the same lines and the difference is only whether they stick.
defp report(label, info, body, dry?) do
icon =
case Apk.icon(body) do
{:ok, bytes, ext} -> "#{byte_size(bytes)} byte .#{ext} icon"
:none -> "no icon"
end
name = info[:label] || "no name (falls back to #{info[:package]})"
Mix.shell().info("#{label}: #{name}, #{icon}#{if dry?, do: " — not written", else: ""}")
end
end
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
4.7 KiB · text
History
8808a78