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
neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
⭐
1
Log in to mark this repository.
9.7 KiB · text
History
b868907