9.7 KiB · text History b868907
defmodule GitGud.Packages.Formats.Fdroid.Apk do
  @moduledoc """
  Everything the F-Droid index needs to know about an uploaded APK, in
  one pass.

  Combines the two parsers — `Manifest` for the compiled
  `AndroidManifest.xml`, `Signature` for the signing certificate — and
  adds what only the zip container knows: the ABIs a package carries,
  and its size and digest.

  Members are read one at a time through `Zip`. An APK is tens of
  megabytes and inflating all of it to read one 8 KB member would be
  wasteful on a request that is already holding the whole upload in
  memory — and for the icon it is not merely wasteful but impossible,
  since Erlang's zip reader cannot open an aligned entry at all.
  """

  alias GitGud.Packages.Formats.Fdroid.Arsc
  alias GitGud.Packages.Formats.Fdroid.Manifest
  alias GitGud.Packages.Formats.Fdroid.Signature
  alias GitGud.Packages.Formats.Fdroid.Zip

  @manifest "AndroidManifest.xml"
  @resources "resources.arsc"

  # Densities, best first. F-Droid scales whatever it is given, so the
  # largest available is the right pick rather than one matching a
  # device we know nothing about.
  @densities ~w(xxxhdpi xxhdpi xhdpi hdpi mdpi nodpi anydpi)

  # Layers of an adaptive icon, which are not the icon. Taking
  # `ic_launcher_foreground.png` yields a glyph floating on
  # transparency with no background behind it.
  @layer_suffixes ~w(_background _foreground _monochrome)

  @type info :: %{
          package: String.t(),
          version_code: integer(),
          version_name: String.t() | nil,
          label: String.t() | nil,
          min_sdk: integer() | nil,
          target_sdk: integer() | nil,
          permissions: [String.t()],
          nativecode: [String.t()],
          signer: String.t(),
          signer_scheme: :v1 | :v2 | :v3,
          size: non_neg_integer(),
          sha256: String.t()
        }

  @doc """
  Read `body`. Returns `{:ok, info}` or `{:error, {status, message}}`
  shaped for the registry controller.

  A package with no name or no version code is refused: F-Droid keys
  every app on `packageName` and orders updates by `versionCode`, so an
  APK missing either cannot be indexed, only mis-indexed.
  """
  @spec read(binary()) :: {:ok, info()} | {:error, {integer(), String.t()}}
  def read(body) when is_binary(body) do
    with {:ok, manifest_bytes} <- extract_manifest(body),
         {:ok, manifest} <- Manifest.parse(manifest_bytes),
         {:ok, package} <- require_package(manifest),
         {:ok, version_code} <- require_version_code(manifest),
         {:ok, cert} <- Signature.certificate(body) do
      {:ok,
       %{
         package: package,
         version_code: version_code,
         version_name: manifest.version_name,
         label: label(body, manifest),
         min_sdk: manifest.min_sdk,
         target_sdk: manifest.target_sdk,
         permissions: manifest.permissions,
         nativecode: nativecode(body),
         signer: cert.sha256,
         signer_scheme: cert.scheme,
         size: byte_size(body),
         sha256: :sha256 |> :crypto.hash(body) |> Base.encode16(case: :lower)
       }}
    end
  end

  def read(_), do: {:error, {422, "not an APK"}}

  @doc """
  The app's launcher icon: `{:ok, bytes, extension}` or `:none`.

  `android:icon` in the manifest is a bare resource id, so this asks
  `Arsc` which file that id names. A release build makes that the only
  workable route — R8 rewrites `res/mipmap-xxxhdpi/ic_launcher.png` to
  `res/o-.png`, and there is nothing left in the archive to guess from.

  The convention remains as a fallback for an APK whose `resources.arsc`
  can't be read: the highest-density `res/mipmap-*/ic_launcher.png`,
  which is what AGP emits for a debug build. Finding nothing is handled
  everywhere — a listing shows no icon rather than a wrong one.

  Adaptive-icon XML is never returned. `mipmap-anydpi-v26/ic_launcher.xml`
  is the best icon on a phone and an unservable one in an index, which
  wants an image; the PNG beside it is what F-Droid can draw. The same
  goes for layers: `ic_launcher_foreground.png` is a glyph on
  transparency and shipping it as the icon looks like a bug.

  The extension comes back because it isn't always `png` — AGP has
  emitted WebP by default for years, and an icon served under the wrong
  name is one a client won't render.
  """
  @spec icon(binary()) :: {:ok, binary(), String.t()} | :none
  def icon(body) when is_binary(body) do
    case from_resources(body) do
      {:ok, bytes, ext} -> {:ok, bytes, ext}
      :none -> by_convention(body)
    end
  end

  def icon(_), do: :none

  defp from_resources(body) do
    with {:ok, manifest_bytes} <- extract_manifest(body),
         {:ok, manifest} <- Manifest.parse(manifest_bytes),
         ref when is_integer(ref) <- manifest.icon_ref,
         {:ok, table} <- resources(body) do
      table
      |> Arsc.file_paths(ref)
      |> Enum.reject(&String.ends_with?(&1, ".xml"))
      |> first_image(body)
    else
      _ -> :none
    end
  end

  defp by_convention(body) do
    with [name | _] <- icon_candidates(body),
         {:ok, bytes} <- Zip.read(body, name),
         {:ok, ext} <- image_extension(bytes) do
      {:ok, bytes, ext}
    else
      _ -> :none
    end
  end

  # The first candidate that is actually an image. A resource id can
  # name something this can't serve, and a ranked list is only useful if
  # a bad entry costs the next one rather than the icon.
  defp first_image([], _body), do: :none

  defp first_image([path | rest], body) do
    with {:ok, bytes} <- Zip.read(body, path),
         {:ok, ext} <- image_extension(bytes) do
      {:ok, bytes, ext}
    else
      _ -> first_image(rest, body)
    end
  end

  # By content, not by name: the name inside a release APK is two
  # characters of R8's choosing and says nothing.
  defp image_extension(<<0x89, "PNG", 0x0D, 0x0A, 0x1A, 0x0A, _::binary>>), do: {:ok, "png"}
  defp image_extension(<<"RIFF", _size::little-32, "WEBP", _::binary>>), do: {:ok, "webp"}
  defp image_extension(<<0xFF, 0xD8, 0xFF, _::binary>>), do: {:ok, "jpg"}
  defp image_extension(_), do: :none

  defp resources(body) do
    case Zip.read(body, @resources) do
      {:ok, bytes} ->
        Arsc.parse(bytes)

      _ ->
        :none
    end
  end

  # The app's display name, resolved through the resource table. A
  # manifest can also carry it as a literal string, which needs no
  # lookup and is what a hand-written one usually has.
  defp label(_body, %{label: label}) when is_binary(label) do
    if String.trim(label) == "", do: nil, else: label
  end

  defp label(body, %{label: ref}) when is_integer(ref) do
    case resources(body) do
      {:ok, table} -> Arsc.string(table, ref)
      _ -> nil
    end
  end

  defp label(_body, _manifest), do: nil

  @doc """
  The filename this APK is stored and served under:
  `<package>_<versionCode>.apk`, which is what `fdroid update`
  produces and what a human scanning a repository directory expects.
  """
  @spec filename(info()) :: String.t()
  def filename(%{package: package, version_code: code}), do: "#{package}_#{code}.apk"

  # ── zip ──────────────────────────────────────────────────────────────

  defp extract_manifest(body) do
    case Zip.read(body, @manifest) do
      {:ok, bytes} -> {:ok, bytes}
      :none -> {:error, {422, "APK has no readable AndroidManifest.xml"}}
    end
  end

  # Candidate icon paths by name, for an APK whose resource table can't
  # be read. AGP's default output for a debug build, highest density
  # first; adaptive-icon layers excluded, since a foreground glyph on
  # transparency is not the icon.
  defp icon_candidates(body) do
    names = Zip.names(body)
    launcher = Enum.filter(names, &launcher_icon?/1)
    if launcher == [], do: rank(Enum.filter(names, &mipmap_image?/1)), else: rank(launcher)
  end

  defp rank(names) do
    Enum.sort_by(names, fn name ->
      index = Enum.find_index(@densities, &String.contains?(name, "-" <> &1))

      # Unknown densities sort last rather than being dropped: an icon
      # of unclear size beats no icon.
      index || length(@densities)
    end)
  end

  defp launcher_icon?(name) do
    mipmap_image?(name) and String.contains?(name, "ic_launcher") and
      not Enum.any?(@layer_suffixes, &String.contains?(name, &1 <> "."))
  end

  defp mipmap_image?(name) do
    String.ends_with?(name, [".png", ".webp", ".jpg"]) and
      String.starts_with?(name, ["res/mipmap-", "res/drawable-"])
  end

  # ABIs, from `lib/<abi>/…` entries. F-Droid shows these so a user on
  # arm64 isn't offered an x86-only build, and a package with no native
  # code at all correctly reports none rather than "unknown".
  defp nativecode(body) do
    case :zip.list_dir(body) do
      {:ok, entries} ->
        entries
        |> Enum.flat_map(fn
          {:zip_file, name, _info, _comment, _offset, _comp_size} -> [List.to_string(name)]
          _ -> []
        end)
        |> Enum.flat_map(fn name ->
          case String.split(name, "/") do
            ["lib", abi | _rest] when abi != "" -> [abi]
            _ -> []
          end
        end)
        |> Enum.uniq()
        |> Enum.sort()

      _ ->
        []
    end
  rescue
    _ -> []
  end

  # ── required fields ──────────────────────────────────────────────────

  defp require_package(%{package: package}) when is_binary(package) and package != "",
    do: {:ok, package}

  defp require_package(_),
    do: {:error, {422, "APK manifest declares no package name"}}

  defp require_version_code(%{version_code: code}) when is_integer(code), do: {:ok, code}

  defp require_version_code(_),
    do: {:error, {422, "APK manifest declares no versionCode"}}
end