4.7 KiB · text History 8808a78
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