Packages: say what each one is, and show it

a625083 · Gabriel Morell · 2026-09-29 18:30

59 files +1536 -110
Message
{commit_body(@commit)}

Files changed

modified android/app/src/main/java/org/neiam/gitgud/app/data/GitGudClient.kt
+10 −0
@@ -4,6 +4,7 @@ import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFact
4 4 import kotlinx.serialization.json.Json
5 5 import okhttp3.MediaType.Companion.toMediaType
6 6 import okhttp3.OkHttpClient
7 +import okhttp3.ResponseBody
7 8 import okhttp3.logging.HttpLoggingInterceptor
8 9 import retrofit2.HttpException
9 10 import retrofit2.Retrofit
@@ -12,6 +13,7 @@ import retrofit2.http.GET
12 13 import retrofit2.http.POST
13 14 import retrofit2.http.Path
14 15 import retrofit2.http.Query
16 +import retrofit2.http.Url
15 17 import java.util.concurrent.TimeUnit
16 18
17 19 /**
@@ -185,6 +187,14 @@ interface GitGudApi {
185 187 @Path("pkg") pkg: String,
186 188 ): PackageDetail
187 189
190 + /**
191 + * Image bytes at a path the API handed back (a package's `icon_url`).
192 + * Relative to the base URL, so a forge served under a path prefix
193 + * still resolves.
194 + */
195 + @GET
196 + suspend fun bytes(@Url path: String): ResponseBody
197 +
188 198 // Its own path rather than a sub-path of the package, because a
189 199 // package may legitimately be named "usage".
190 200 @GET("api/mobile/v1/repos/{owner}/{name}/package-usage/{format}")
modified android/app/src/main/java/org/neiam/gitgud/app/data/Models.kt
+10 −0
@@ -355,6 +355,12 @@ data class PackageFormat(
355 355 @Serializable
356 356 data class PackageSummary(
357 357 val name: String,
358 + /** What a person calls it — an app's label, else the name. */
359 + val title: String? = null,
360 + /** One line on what it is, or null when nothing says. */
361 + val summary: String? = null,
362 + /** API-relative path to its icon, or null for a package with none. */
363 + val icon_url: String? = null,
358 364 val format: String,
359 365 val kind: String? = null,
360 366 val latest_version: String? = null,
@@ -366,6 +372,10 @@ data class PackageSummary(
366 372 @Serializable
367 373 data class PackageDetail(
368 374 val name: String,
375 + val title: String? = null,
376 + val summary: String? = null,
377 + val description: String? = null,
378 + val icon_url: String? = null,
369 379 val format: String,
370 380 val kind: String? = null,
371 381 val latest_version: String? = null,
added android/app/src/main/java/org/neiam/gitgud/app/ui/PackageIcon.kt
+71 −0
@@ -0,0 +1,71 @@
1 +package org.neiam.gitgud.app.ui
2 +
3 +import android.graphics.BitmapFactory
4 +import androidx.compose.foundation.Image
5 +import androidx.compose.foundation.layout.size
6 +import androidx.compose.foundation.shape.RoundedCornerShape
7 +import androidx.compose.material.icons.Icons
8 +import androidx.compose.material.icons.filled.Inventory2
9 +import androidx.compose.material3.Icon
10 +import androidx.compose.runtime.Composable
11 +import androidx.compose.runtime.LaunchedEffect
12 +import androidx.compose.runtime.getValue
13 +import androidx.compose.runtime.mutableStateOf
14 +import androidx.compose.runtime.remember
15 +import androidx.compose.runtime.setValue
16 +import androidx.compose.ui.Modifier
17 +import androidx.compose.ui.draw.clip
18 +import androidx.compose.ui.graphics.ImageBitmap
19 +import androidx.compose.ui.graphics.asImageBitmap
20 +import androidx.compose.ui.layout.ContentScale
21 +import androidx.compose.ui.unit.Dp
22 +import androidx.compose.ui.unit.dp
23 +import kotlinx.coroutines.Dispatchers
24 +import kotlinx.coroutines.withContext
25 +import org.neiam.gitgud.app.data.GitGudClient
26 +import org.neiam.gitgud.app.data.TokenStore
27 +
28 +/**
29 + * Decoded icons, by path, for the life of the process. A package list
30 + * redraws on every scroll and a launcher icon is a few KB; fetching it
31 + * once is the whole of the caching this needs.
32 + */
33 +private object IconCache {
34 + val bitmaps = HashMap<String, ImageBitmap>()
35 +}
36 +
37 +/**
38 + * A package's icon, fetched through the API with the paired token —
39 + * or the generic package glyph when it has none or the fetch fails.
40 + * No image library for one small picture per row.
41 + */
42 +@Composable
43 +fun PackageIcon(
44 + iconUrl: String?,
45 + creds: TokenStore.Credentials?,
46 + size: Dp = 40.dp,
47 +) {
48 + var bitmap by remember(iconUrl) { mutableStateOf(iconUrl?.let { IconCache.bitmaps[it] }) }
49 +
50 + LaunchedEffect(iconUrl) {
51 + if (iconUrl == null || creds == null || bitmap != null) return@LaunchedEffect
52 + bitmap = runCatching {
53 + withContext(Dispatchers.IO) {
54 + val bytes = GitGudClient.authenticated(creds)
55 + .bytes(iconUrl.removePrefix("/"))
56 + .use { it.bytes() }
57 + BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
58 + }
59 + }.getOrNull()?.also { IconCache.bitmaps[iconUrl] = it }
60 + }
61 +
62 + val shape = RoundedCornerShape(size / 4)
63 + bitmap?.let {
64 + Image(
65 + bitmap = it,
66 + contentDescription = null,
67 + contentScale = ContentScale.Crop,
68 + modifier = Modifier.size(size).clip(shape),
69 + )
70 + } ?: Icon(Icons.Filled.Inventory2, contentDescription = null)
71 +}
modified android/app/src/main/java/org/neiam/gitgud/app/ui/PackageScreen.kt
+31 −1
@@ -4,6 +4,7 @@ import androidx.compose.foundation.clickable
4 4 import androidx.compose.foundation.layout.Arrangement
5 5 import androidx.compose.foundation.layout.Column
6 6 import androidx.compose.foundation.layout.PaddingValues
7 +import androidx.compose.foundation.layout.Row
7 8 import androidx.compose.foundation.layout.fillMaxSize
8 9 import androidx.compose.foundation.layout.fillMaxWidth
9 10 import androidx.compose.foundation.layout.padding
@@ -72,7 +73,7 @@ fun PackageScreen(
72 73 Scaffold(
73 74 topBar = {
74 75 TopAppBar(
75 − title = { AppBarTitle(pkg) },
76 + title = { AppBarTitle(detail?.title ?: pkg) },
76 77 navigationIcon = {
77 78 IconButton(onClick = onBack) {
78 79 Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@@ -91,6 +92,7 @@ fun PackageScreen(
91 92 contentPadding = PaddingValues(12.dp),
92 93 verticalArrangement = Arrangement.spacedBy(8.dp),
93 94 ) {
95 + item(key = "about") { PackageHeader(detail!!, creds) }
94 96 items(detail!!.versions, key = { it.version }) { version ->
95 97 val expanded = open == version.version
96 98
@@ -167,3 +169,31 @@ fun formatBytes(bytes: Long): String = when {
167 169 bytes < 1024L * 1024 * 1024 -> String.format("%.1f MB", bytes / 1024.0 / 1024.0)
168 170 else -> String.format("%.1f GB", bytes / 1024.0 / 1024.0 / 1024.0)
169 171 }
172 +
173 +/**
174 + * What the package is, above its versions: icon, the name a person
175 + * calls it, and whatever the publisher wrote about it.
176 + */
177 +@Composable
178 +private fun PackageHeader(detail: PackageDetail, creds: TokenStore.Credentials?) {
179 + Row(
180 + horizontalArrangement = Arrangement.spacedBy(12.dp),
181 + modifier = Modifier.fillMaxWidth().padding(bottom = 4.dp),
182 + ) {
183 + PackageIcon(detail.icon_url, creds, size = 56.dp)
184 + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
185 + Text(detail.title ?: detail.name, style = MaterialTheme.typography.titleMedium)
186 + if (detail.title != null && detail.title != detail.name) {
187 + Text(detail.name, style = MaterialTheme.typography.labelSmall)
188 + }
189 + detail.summary?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
190 + detail.description?.takeIf { it != detail.summary }?.let {
191 + Text(
192 + it,
193 + style = MaterialTheme.typography.bodySmall,
194 + modifier = Modifier.padding(top = 4.dp),
195 + )
196 + }
197 + }
198 + }
199 +}
modified android/app/src/main/java/org/neiam/gitgud/app/ui/PackagesTab.kt
+16 −6
@@ -22,7 +22,6 @@ import androidx.compose.material.icons.Icons
22 22 import androidx.compose.material.icons.filled.ContentCopy
23 23 import androidx.compose.material.icons.filled.ExpandLess
24 24 import androidx.compose.material.icons.filled.ExpandMore
25 −import androidx.compose.material.icons.filled.Inventory2
26 25 import androidx.compose.material3.Card
27 26 import androidx.compose.material3.Icon
28 27 import androidx.compose.material3.IconButton
@@ -151,6 +150,7 @@ fun PackagesTab(
151 150 onToggle = { toggle(section.format) },
152 151 usage = usage[section.format],
153 152 onOpenPackage = onOpenPackage,
153 + creds = creds,
154 154 )
155 155 }
156 156 }
@@ -166,6 +166,7 @@ private fun PackageSection(
166 166 onToggle: () -> Unit,
167 167 usage: PackageUsage?,
168 168 onOpenPackage: (format: String, name: String) -> Unit,
169 + creds: TokenStore.Credentials,
169 170 ) {
170 171 Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
171 172 FormatCard(
@@ -177,20 +178,29 @@ private fun PackageSection(
177 178 )
178 179
179 180 section.packages.forEach { pkg ->
181 + // An app's label heads the row; its package id sits above
182 + // it, small, where `org.example.app` is still findable.
183 + val overline: (@Composable () -> Unit)? =
184 + if (pkg.title != null && pkg.title != pkg.name) {
185 + { Text(pkg.name, maxLines = 1) }
186 + } else null
187 +
180 188 Card(modifier = Modifier.fillMaxWidth()) {
181 189 Column {
182 190 ListItem(
183 191 modifier = Modifier.clickable {
184 192 onOpenPackage(section.format, pkg.name)
185 193 },
186 − headlineContent = { Text(pkg.name) },
194 + headlineContent = { Text(pkg.title ?: pkg.name) },
195 + overlineContent = overline,
187 196 supportingContent = {
188 197 val version = pkg.latest_version ?: "no versions"
189 − Text("$version · ${relativeTime(pkg.updated_at)}")
190 − },
191 − leadingContent = {
192 − Icon(Icons.Filled.Inventory2, contentDescription = null)
198 + Column {
199 + pkg.summary?.let { Text(it, maxLines = 2) }
200 + Text("$version · ${relativeTime(pkg.updated_at)}")
201 + }
193 202 },
203 + leadingContent = { PackageIcon(pkg.icon_url, creds) },
194 204 )
195 205 // The line you came here for, right under the name.
196 206 pkg.install_line?.let { CodeLine(it) }
modified docs/package-registries.md
+9 −0

Click to load diff…

modified lib/git_gud/packages.ex
+80 −3

Click to load diff…

added lib/git_gud/packages/about.ex
+125 −0

Click to load diff…

modified lib/git_gud/packages/format.ex
+37 −0

Click to load diff…

modified lib/git_gud/packages/formats/fdroid.ex
+78 −22

Click to load diff…

modified lib/git_gud/packages/package.ex
+51 −0

Click to load diff…

modified lib/git_gud_web/controllers/api/mobile/json.ex
+27 −2

Click to load diff…

modified lib/git_gud_web/controllers/api/mobile/package_controller.ex
+25 −0

Click to load diff…

added lib/git_gud_web/controllers/package_icon_controller.ex
+54 −0

Click to load diff…

modified lib/git_gud_web/live/org_live/packages.ex
+293 −67

Click to load diff…

modified lib/git_gud_web/live/org_live/show.ex
+10 −0

Click to load diff…

modified lib/git_gud_web/live/repo_live/packages.ex
+159 −3

Click to load diff…

modified lib/git_gud_web/router.ex
+4 −0

Click to load diff…

added priv/repo/migrations/20260929174843_add_package_descriptions.exs
+14 −0

Click to load diff…

added test/git_gud/packages/about_test.exs
+120 −0

Click to load diff…

modified test/git_gud/packages/fdroid_format_test.exs
+75 −0

Click to load diff…

modified test/git_gud_web/controllers/api/mobile/mobile_packages_test.exs
+44 −0

Click to load diff…

modified test/git_gud_web/live/org_packages_catalog_test.exs
+10 −5

Click to load diff…

added test/git_gud_web/live/package_about_ui_test.exs
+182 −0

Click to load diff…

modified test/git_gud_web/live/package_retention_ui_test.exs
+1 −1

Click to load diff…

Parents: c09893f