Fill in the F-Droid Links section and app description from the forge

7e05eb8 · Gabriel Morell · 2026-09-22 04:49

11 files +224 -6
Message
{commit_body(@commit)}

Files changed

modified docs/fdroid-registry.md
+12 −0
@@ -120,6 +120,18 @@ path**. This repo's own app ships `res/0Z.png`, `res/o-.png` and no
120 120 highest-density `res/mipmap-*/ic_launcher.png` — can only ever work on
121 121 a debug build.
122 122
123 +An app's *other* metadata isn't in the APK at all. F-Droid's Links
124 +section — source code, issue tracker, changelog, author — and the
125 +description are things a publisher writes down, and on a forge they are
126 +already written down: the repository is the source, its issues are the
127 +tracker, its default branch's log is the nearest honest changelog, and
128 +its description is the app's. Both index versions spell these fields
129 +the same way (`sourceCode`, `issueTracker`, `changelog`, `authorName`,
130 +`authorWebSite`) and neither localizes them, while `name`, `summary`
131 +and `description` are `{"en-US": …}` maps in v2 and plain strings in
132 +v1. `webSite` is left unset on purpose: it would be the repository page
133 +a second time.
134 +
123 135 `GitGud.Packages.Formats.Fdroid.Arsc` reads the table: the global
124 136 string pool, then one `ResTable_type` chunk per configuration, keyed
125 137 `0xPPTTEEEE`. An icon id resolves to several files — one per density,
modified lib/git_gud/packages/formats/fdroid.ex
+114 −6
@@ -49,9 +49,17 @@ defmodule GitGud.Packages.Formats.Fdroid do
49 49 `mipmap` directory to guess from. Where the table can't be read the
50 50 index falls back to the package name and no icon.
51 51
52 − `summary` and `description` are still not read: they are F-Droid
53 − metadata an APK doesn't carry. The repository description comes from
54 − the git repository's own.
52 + `summary`, `description` and the Links section come from the *forge*,
53 + not the APK, which carries none of it: the repository's description
54 + is the app's description, its page is `sourceCode`, its issues are
55 + `issueTracker`, its default branch's log is `changelog`, and its
56 + namespace is `authorName` / `authorWebSite`. Both index versions
57 + spell those fields identically and neither localizes them, unlike
58 + `name`, `summary` and `description`.
59 +
60 + `webSite` is deliberately left unset: on a forge it would be the
61 + repository page again, and F-Droid would show the same link twice
62 + under two labels.
55 63
56 64 The *repository* icon is the namespace's avatar, via `GitGud.Avatars`
57 65 — an uploaded one if the org has set one, and the identicon the
@@ -429,6 +437,97 @@ defmodule GitGud.Packages.Formats.Fdroid do
429 437 if info["icon_key"], do: "#{info["package"]}.#{info["version_code"]}.#{icon_ext(info)}"
430 438 end
431 439
440 + # The Links section of an app's page: where its source is, where its
441 + # bugs go, who publishes it. F-Droid has fields for all of it and an
442 + # APK carries none of them — but the forge knows, because the app is
443 + # published from a repository on it.
444 + #
445 + # Both index versions spell these the same way and neither localizes
446 + # them, unlike `name`, `summary` and `description`.
447 + #
448 + # Taken from the newest APK's repository: an app is published from
449 + # one, and `_all` aggregates a namespace where in principle two could
450 + # publish the same package name. The version a phone would install is
451 + # the one whose links belong on the page.
452 + defp links(group) do
453 + repo = Enum.max_by(group, & &1.info["version_code"]).repo
454 + url = repo_page_url(repo)
455 +
456 + %{
457 + "sourceCode" => url,
458 + "issueTracker" => url <> "/issues",
459 + # Not a changelog file — the commit log of the default branch,
460 + # which is the closest honest thing a forge has and more than the
461 + # nothing that was there before.
462 + "changelog" => changelog_url(repo, url),
463 + "authorName" => Storage.repo_handle(repo),
464 + "authorWebSite" => namespace_url(repo)
465 + }
466 + end
467 +
468 + defp repo_page_url(%Repository{} = repo),
469 + do: "#{forge_url()}/r/#{Storage.repo_handle(repo)}/#{repo.name}"
470 +
471 + # Users and organizations have different profile routes, and a
472 + # namespace is one or the other.
473 + defp namespace_url(%Repository{organization: %{handle: handle}}),
474 + do: "#{forge_url()}/orgs/#{handle}"
475 +
476 + defp namespace_url(%Repository{} = repo),
477 + do: "#{forge_url()}/u/#{Storage.repo_handle(repo)}"
478 +
479 + defp changelog_url(%Repository{default_branch: branch}, url)
480 + when is_binary(branch) and branch != "",
481 + do: url <> "/commits/" <> branch
482 +
483 + defp changelog_url(_repo, _url), do: nil
484 +
485 + defp forge_url, do: String.trim_trailing(GitGudWeb.Endpoint.url(), "/")
486 +
487 + # The repository's description is the app's description: it is what
488 + # its author wrote about it, and an APK carries nothing of the kind.
489 + defp full_description(group) do
490 + case Enum.max_by(group, & &1.info["version_code"]).repo do
491 + %Repository{description: description} when is_binary(description) ->
492 + if String.trim(description) == "", do: nil, else: description
493 +
494 + _ ->
495 + nil
496 + end
497 + end
498 +
499 + # F-Droid shows the summary under the app's name in a list, where a
500 + # paragraph doesn't fit. First sentence, or a word-boundary cut.
501 + @summary_length 80
502 +
503 + defp summary(group) do
504 + case full_description(group) do
505 + nil -> nil
506 + description -> description |> String.split("\n", parts: 2) |> hd() |> shorten()
507 + end
508 + end
509 +
510 + defp shorten(text) do
511 + text = String.trim(text)
512 +
513 + if String.length(text) <= @summary_length do
514 + text
515 + else
516 + text
517 + |> String.slice(0, @summary_length)
518 + |> String.split(" ")
519 + |> Enum.drop(-1)
520 + |> Enum.join(" ")
521 + |> Kernel.<>("…")
522 + end
523 + end
524 +
525 + # v2 localizes the human-readable fields and nothing else. A nil stays
526 + # nil so `drop_nils/1` can take the key out entirely — an empty
527 + # `{"en-US" => null}` is a key a client has to interpret.
528 + defp localized(nil), do: nil
529 + defp localized(text), do: %{"en-US" => text}
530 +
432 531 # `android:label` off the newest APK, resolved through its resource
433 532 # table at publish time. Falls back to the package name, which is what
434 533 # every version published before labels were read still carries.
@@ -494,8 +593,11 @@ defmodule GitGud.Packages.Formats.Fdroid do
494 593 "added" => added,
495 594 "lastUpdated" => updated,
496 595 "name" => %{"en-US" => app_label(name, group)},
596 + "summary" => localized(summary(group)),
597 + "description" => localized(full_description(group)),
497 598 "icon" => app_icon_file(group)
498 599 }
600 + |> Map.merge(links(group))
499 601 |> drop_nils(),
500 602 "versions" => Map.new(group, fn apk -> {apk.file.sha256, version_entry(apk)} end)
501 603 }
@@ -572,8 +674,8 @@ defmodule GitGud.Packages.Formats.Fdroid do
572 674 %{
573 675 "packageName" => name,
574 676 "name" => app_label(name, group),
575 − "summary" => "",
576 − "description" => "",
677 + "summary" => summary(group) || "",
678 + "description" => full_description(group) || "",
577 679 "license" => "Unknown",
578 680 "categories" => [],
579 681 "added" => added,
@@ -584,6 +686,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
584 686 # filename rather than v2's path-and-hash object.
585 687 "icon" => app_icon_name(newest)
586 688 }
689 + |> Map.merge(links(group))
587 690 |> drop_nils()
588 691 end
589 692
@@ -728,6 +831,11 @@ defmodule GitGud.Packages.Formats.Fdroid do
728 831 # Every published APK across `repos`, with its decoded metadata.
729 832 defp apks(repos) do
730 833 Enum.flat_map(repos, fn %Repository{} = repo ->
834 + # Preloaded once here rather than per app: the links an app's page
835 + # shows are the repository's, and naming its namespace needs both
836 + # associations.
837 + repo = GitGud.Repo.preload(repo, [:owner, :organization])
838 +
731 839 repo
732 840 |> Packages.list_packages(@format)
733 841 |> Enum.flat_map(&Packages.list_package_files/1)
@@ -739,7 +847,7 @@ defmodule GitGud.Packages.Formats.Fdroid do
739 847 # Skipping beats emitting a null key that breaks the whole
740 848 # document for every other app in the repository.
741 849 if is_binary(info["package"]) do
742 − [%{file: file, repo_name: repo.name, info: info, added: added_at(file)}]
850 + [%{file: file, repo: repo, repo_name: repo.name, info: info, added: added_at(file)}]
743 851 else
744 852 []
745 853 end
modified test/git_gud/packages/fdroid_format_test.exs
+98 −0
@@ -286,6 +286,104 @@ defmodule GitGud.Packages.Formats.FdroidTest do
286 286 end
287 287 end
288 288
289 + describe "app links and description" do
290 + setup %{repo: repo} do
291 + {:ok, described} =
292 + GitGud.Repositories.update_repository(repo, %{
293 + "description" =>
294 + "A GitGud client for Android, with a CI dashboard you can watch a build from, " <>
295 + "issues you can triage, and pull requests you can merge from a bus stop."
296 + })
297 +
298 + assert {:ok, _, _} = publish(described, build_apk())
299 + %{repo: described}
300 + end
301 +
302 + test "index-v2 carries the repository's description", %{repo: repo} do
303 + metadata = v2_metadata(repo)
304 +
305 + assert metadata["description"]["en-US"] =~ "A GitGud client for Android"
306 + end
307 +
308 + test "the summary is short enough for a list", %{repo: repo} do
309 + metadata = v2_metadata(repo)
310 + summary = metadata["summary"]["en-US"]
311 +
312 + # F-Droid puts it under the app's name in a list, where the whole
313 + # paragraph doesn't fit.
314 + assert String.length(summary) <= 81
315 + assert String.starts_with?(summary, "A GitGud client for Android")
316 + assert String.ends_with?(summary, "…")
317 + # Cut on a word boundary, not mid-word.
318 + refute summary =~ ~r/\w…$/u
319 + end
320 +
321 + test "a description that already fits is the summary", %{user: user} do
322 + {_user, short} = repository_fixture(%{owner: user})
323 +
324 + {:ok, short} =
325 + GitGud.Repositories.update_repository(short, %{
326 + "description" => "Watch CI from your phone."
327 + })
328 +
329 + assert {:ok, _, _} = publish(short, build_apk())
330 +
331 + metadata = v2_metadata(short)
332 +
333 + assert metadata["summary"]["en-US"] == "Watch CI from your phone."
334 + assert metadata["description"]["en-US"] == "Watch CI from your phone."
335 + end
336 +
337 + test "index-v2 links back to the repository the app is published from", %{repo: repo} do
338 + metadata = v2_metadata(repo)
339 +
340 + handle =
341 + GitGud.Repositories.Storage.repo_handle(
342 + GitGud.Repo.preload(repo, [:owner, :organization])
343 + )
344 +
345 + base = GitGudWeb.Endpoint.url() <> "/r/#{handle}/#{repo.name}"
346 +
347 + assert metadata["sourceCode"] == base
348 + assert metadata["issueTracker"] == base <> "/issues"
349 + assert metadata["changelog"] == base <> "/commits/" <> repo.default_branch
350 + assert metadata["authorName"] == handle
351 + assert metadata["authorWebSite"] == GitGudWeb.Endpoint.url() <> "/u/#{handle}"
352 + end
353 +
354 + test "index-v1 carries the same links, unlocalized", %{repo: repo} do
355 + {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v1.json"]}))
356 + [app] = Jason.decode!(json)["apps"]
357 +
358 + assert app["description"] =~ "A GitGud client for Android"
359 + assert app["summary"] =~ "A GitGud client"
360 + assert app["sourceCode"] =~ "/r/"
361 + assert app["issueTracker"] =~ "/issues"
362 + end
363 +
364 + defp v2_metadata(repo) do
365 + {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))
366 +
367 + Jason.decode!(json)["packages"]["org.neiam.gitgud.app"]["metadata"]
368 + end
369 + end
370 +
371 + describe "an app whose repository has no description" do
372 + test "omits the description rather than emitting an empty one", %{repo: repo} do
373 + assert {:ok, _, _} = publish(repo, build_apk())
374 +
375 + {:ok, _type, json} = Fdroid.index(repo, ctx(%{path: ["index-v2.json"]}))
376 + metadata = Jason.decode!(json)["packages"]["org.neiam.gitgud.app"]["metadata"]
377 +
378 + # A key holding null is a key a client has to interpret.
379 + refute Map.has_key?(metadata, "description")
380 + refute Map.has_key?(metadata, "summary")
381 +
382 + # The links don't depend on a description and stay.
383 + assert metadata["sourceCode"] =~ "/r/"
384 + end
385 + end
386 +
289 387 describe "the repository icon" do
290 388 setup %{repo: repo} do
291 389 {:ok, _, _} = publish(repo, build_apk())

Parents: a30d17e