OXYGENHUB

Your avatar on the dashboard and the Account screen (closes #13) db00632a61d6

The hub's /avatar/{name}: an uploaded PNG or JPEG drawn as it is, or the
hub's identicon read from its SVG (hue and cells) and drawn on a Compose
Canvas, so no SVG library; the initial while there is neither. Fetched
without the token, kept for the run (Avatars), refreshed with the
dashboard; a failed fetch keeps the last one. HubApi.avatar, unit tests
against an identicon oxygenhub.io served, the client and the dashboard
view model. Changelog for #12 and #13, README.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key 62e60420377a
When
· 2026-10-25 11:03 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
db00632a61d620e27d07021cba8b0363b333500c8ca16cf69e5a12763b80da87
Depends on
1416009b06af 144cbf7bf2b5 3f3508bd5f16 57c0824300db 5ae4af48ddd2
Depended on by
29a375612f92 263c6cad5e51 840c75813f86 1dfa631436b6 40ab4df121e0 aa21eb5e0e5b e0004d61958c 62429abe5869 98dbd1220929 ff65ea456ff1 4aee5293461d d1f635be9442 e2df27a81e5d 0381bd8af55e
In bookmarks
main
In tags
v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
Edits
34 atoms, 4 tree operations, 14 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/api/Avatar.kt +42 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
/**
4 +
 * An account's picture as the hub serves it at `/avatar/{name}`: the
5 +
 * picture its owner uploaded, or else the identicon the hub draws from the
6 +
 * name. The identicon comes as SVG, which Android cannot draw by itself, so
7 +
 * it is read into its hue and cells and drawn natively.
8 +
 */
9 +
sealed interface Avatar {
10 +
    /** An uploaded PNG or JPEG, as served. */
11 +
    class Picture(val bytes: ByteArray) : Avatar
12 +
13 +
    /**
14 +
     * The hub's identicon: on a 7 by 7 field of the hue at 45% saturation and
15 +
     * 92% lightness, the [cells] (x, y, each 1 to 5) at 65% and 45%.
16 +
     */
17 +
    data class Identicon(val hue: Int, val cells: Set<Pair<Int, Int>>) : Avatar
18 +
19 +
    companion object {
20 +
        private val FIELD = Regex("""<rect width="7" height="7" fill="hsl\((\d{1,3}),45%,92%\)"/>""")
21 +
        private val CELL = Regex("""<rect x="([1-5])" y="([1-5])" width="1" height="1"/>""")
22 +
23 +
        /**
24 +
         * The answer to `/avatar/{name}` by its media type; null for what this
25 +
         * app cannot draw (a system account's own mark, or anything unexpected),
26 +
         * which then shows as the name's initial.
27 +
         */
28 +
        fun parse(contentType: String?, bytes: ByteArray): Avatar? =
29 +
            when (contentType?.substringBefore(';')?.trim()?.lowercase()) {
30 +
                "image/png", "image/jpeg" -> if (bytes.isEmpty()) null else Picture(bytes)
31 +
                "image/svg+xml" -> identicon(bytes.decodeToString())
32 +
                else -> null
33 +
            }
34 +
35 +
        /** The identicon in the hub's SVG, or null when the SVG is not one. */
36 +
        fun identicon(svg: String): Identicon? {
37 +
            val hue = FIELD.find(svg)?.groupValues?.get(1)?.toIntOrNull()?.takeIf { it < 360 } ?: return null
38 +
            val cells = CELL.findAll(svg).map { it.groupValues[1].toInt() to it.groupValues[2].toInt() }.toSet()
39 +
            return Identicon(hue, cells)
40 +
        }
41 +
    }
42 +
}
added app/src/main/java/io/oxygenhub/android/ui/common/AvatarImage.kt +103 −0
1 +
package io.oxygenhub.android.ui.common
2 +
3 +
import android.graphics.BitmapFactory
4 +
import androidx.compose.foundation.Canvas
5 +
import androidx.compose.foundation.Image
6 +
import androidx.compose.foundation.layout.Box
7 +
import androidx.compose.foundation.layout.fillMaxSize
8 +
import androidx.compose.foundation.layout.size
9 +
import androidx.compose.foundation.shape.CircleShape
10 +
import androidx.compose.material3.MaterialTheme
11 +
import androidx.compose.material3.Surface
12 +
import androidx.compose.material3.Text
13 +
import androidx.compose.runtime.Composable
14 +
import androidx.compose.runtime.getValue
15 +
import androidx.compose.runtime.produceState
16 +
import androidx.compose.runtime.remember
17 +
import androidx.compose.ui.Alignment
18 +
import androidx.compose.ui.Modifier
19 +
import androidx.compose.ui.draw.clip
20 +
import androidx.compose.ui.geometry.Offset
21 +
import androidx.compose.ui.geometry.Size
22 +
import androidx.compose.ui.graphics.Color
23 +
import androidx.compose.ui.graphics.asImageBitmap
24 +
import androidx.compose.ui.layout.ContentScale
25 +
import androidx.compose.ui.semantics.contentDescription
26 +
import androidx.compose.ui.semantics.semantics
27 +
import androidx.compose.ui.unit.Dp
28 +
import androidx.compose.ui.unit.dp
29 +
import io.oxygenhub.android.api.Avatar
30 +
import io.oxygenhub.android.api.HubApi
31 +
import kotlinx.coroutines.CancellationException
32 +
import java.util.concurrent.ConcurrentHashMap
33 +
34 +
/** Avatars already fetched this run, by account name: each screen shows the last one at once. */
35 +
object Avatars {
36 +
    private val known = ConcurrentHashMap<String, Avatar>()
37 +
38 +
    fun cached(name: String): Avatar? = known[name.lowercase()]
39 +
40 +
    /** Fetch `name`'s avatar again; on a failure the one fetched before, if any. */
41 +
    suspend fun load(api: HubApi, name: String): Avatar? {
42 +
        val fresh = try {
43 +
            api.avatar(name)
44 +
        } catch (e: CancellationException) {
45 +
            throw e
46 +
        } catch (_: Exception) {
47 +
            return cached(name)
48 +
        }
49 +
        if (fresh == null) known.remove(name.lowercase()) else known[name.lowercase()] = fresh
50 +
        return fresh
51 +
    }
52 +
}
53 +
54 +
/** `name`'s avatar: the cached one first, then the hub's. */
55 +
@Composable
56 +
fun rememberAvatar(api: HubApi, name: String): Avatar? {
57 +
    val avatar by produceState(Avatars.cached(name), api, name) { value = Avatars.load(api, name) }
58 +
    return avatar
59 +
}
60 +
61 +
/**
62 +
 * An account's avatar in a circle: its picture, the hub's identicon drawn
63 +
 * cell by cell, or its initial while there is neither.
64 +
 */
65 +
@Composable
66 +
fun AvatarImage(avatar: Avatar?, name: String, modifier: Modifier = Modifier, size: Dp = 40.dp) {
67 +
    val described = modifier.size(size).clip(CircleShape).semantics { contentDescription = "$name's avatar" }
68 +
    when (avatar) {
69 +
        is Avatar.Picture -> {
70 +
            val bitmap = remember(avatar) {
71 +
                BitmapFactory.decodeByteArray(avatar.bytes, 0, avatar.bytes.size)?.asImageBitmap()
72 +
            }
73 +
            if (bitmap == null) {
74 +
                Initial(name, size, described)
75 +
            } else {
76 +
                Image(bitmap, contentDescription = null, contentScale = ContentScale.Crop, modifier = described)
77 +
            }
78 +
        }
79 +
        is Avatar.Identicon -> Canvas(described) {
80 +
            // The hub's 7 by 7 field, so the pattern has the same margin.
81 +
            val cell = this.size.width / 7f
82 +
            drawRect(Color.hsl(avatar.hue.toFloat(), 0.45f, 0.92f))
83 +
            val ink = Color.hsl(avatar.hue.toFloat(), 0.65f, 0.45f)
84 +
            for ((x, y) in avatar.cells) {
85 +
                drawRect(ink, topLeft = Offset(x * cell, y * cell), size = Size(cell, cell))
86 +
            }
87 +
        }
88 +
        null -> Initial(name, size, described)
89 +
    }
90 +
}
91 +
92 +
@Composable
93 +
private fun Initial(name: String, size: Dp, modifier: Modifier) {
94 +
    Surface(modifier, color = MaterialTheme.colorScheme.primaryContainer) {
95 +
        Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
96 +
            Text(
97 +
                name.take(1).uppercase(),
98 +
                style = if (size >= 56.dp) MaterialTheme.typography.headlineMedium else MaterialTheme.typography.titleMedium,
99 +
                color = MaterialTheme.colorScheme.onPrimaryContainer,
100 +
            )
101 +
        }
102 +
    }
103 +
}
added app/src/test/java/io/oxygenhub/android/api/AvatarTest.kt +37 −0
1 +
package io.oxygenhub.android.api
2 +
3 +
import io.oxygenhub.android.Fixtures
4 +
import org.junit.Assert.assertEquals
5 +
import org.junit.Assert.assertNull
6 +
import org.junit.Assert.assertTrue
7 +
import org.junit.Test
8 +
9 +
class AvatarTest {
10 +
    @Test
11 +
    fun theHubsIdenticonIsReadIntoItsHueAndCells() {
12 +
        // As oxygenhub.io served /avatar/bob (2026-10-25).
13 +
        val a = Avatar.parse("image/svg+xml", Fixtures.text("identicon-bob.svg").toByteArray()) as Avatar.Identicon
14 +
        assertEquals(196, a.hue)
15 +
        assertEquals(16, a.cells.size)
16 +
        assertTrue((3 to 2) in a.cells && (1 to 5) in a.cells && (5 to 5) in a.cells)
17 +
        // Every cell has its mirror image, as the hub draws them.
18 +
        for ((x, y) in a.cells) assertTrue("($x,$y) has no mirror", (6 - x to y) in a.cells)
19 +
    }
20 +
21 +
    @Test
22 +
    fun anUploadedPictureIsKeptAsItCame() {
23 +
        val png = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47)
24 +
        val a = Avatar.parse("image/png", png) as Avatar.Picture
25 +
        assertTrue(a.bytes.contentEquals(png))
26 +
        assertTrue(Avatar.parse("image/jpeg; charset=binary", png) is Avatar.Picture)
27 +
    }
28 +
29 +
    @Test
30 +
    fun whatTheAppCannotDrawIsNoAvatar() {
31 +
        // A system account's own mark is SVG, but not the identicon.
32 +
        assertNull(Avatar.parse("image/svg+xml", "<svg><circle r=\"3\"/></svg>".toByteArray()))
33 +
        assertNull(Avatar.parse("text/html", "<html>".toByteArray()))
34 +
        assertNull(Avatar.parse(null, ByteArray(3)))
35 +
        assertNull(Avatar.parse("image/png", ByteArray(0)))
36 +
    }
37 +
}
added app/src/test/resources/fixtures/identicon-bob.svg +1 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 7 7" shape-rendering="crispEdges"><rect width="7" height="7" fill="hsl(196,45%,92%)"/><g fill="hsl(196,65%,45%)"><rect x="1" y="1" width="1" height="1"/><rect x="5" y="1" width="1" height="1"/><rect x="2" y="2" width="1" height="1"/><rect x="4" y="2" width="1" height="1"/><rect x="3" y="2" width="1" height="1"/><rect x="1" y="3" width="1" height="1"/><rect x="5" y="3" width="1" height="1"/><rect x="2" y="3" width="1" height="1"/><rect x="4" y="3" width="1" height="1"/><rect x="3" y="3" width="1" height="1"/><rect x="1" y="4" width="1" height="1"/><rect x="5" y="4" width="1" height="1"/><rect x="1" y="5" width="1" height="1"/><rect x="5" y="5" width="1" height="1"/><rect x="2" y="5" width="1" height="1"/><rect x="4" y="5" width="1" height="1"/></g></svg>
modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Your avatar (#13):** the dashboard's greeting and the Account screen show the signed-in account's avatar from the hub's `/avatar/{name}`: an uploaded picture as it is, or the hub's identicon, read from its SVG (hue and cells) and drawn natively, so no SVG library; the initial while there is neither, or the hub cannot be reached. Fetched without the token (it is public), kept for the run, and refreshed with the dashboard.
6 +
- **The keyboard no longer comes up on its own on Repositories (#12):** opening Repositories gave the filter focus and brought the keyboard up on every visit (tapping the navigation bar gives the app's view focus, which Compose hands to the screen's first focusable). The filter now takes focus only when it is touched, or reached from a keyboard. Checked on a Pixel 10 Pro Fold.
5 7
- **Sign in, the hub's API, navigation and foldable layouts (#2):** sign in with the hub's device flow, as `oh auth login` does (a code to approve on the hub's `/device` page, with the password and second factor there, never in the app), against oxygenhub.io or any hub; the token sealed with a Keystore key, kept out of backups, and revoked on the hub when signing out. A hand-written Kotlin client for the hub's API: the user, repositories, a repository's issues, the inbox and marking it read. A dashboard (greeting, recent repositories, unread count), repositories with their issues side by side from a medium width up (a foldable open, a tablet), and the inbox with a badge; a navigation bar on a phone and a rail when wider; light and dark, in dynamic colour or the hub's own palette; loading, empty and error states with pull to refresh. The placeholder login (username and password) and tabs are gone. Unit tests for the client (against fixtures from hub-api's types) and the view models; README screenshots rendered from Compose previews without an emulator.
6 8
- **The project set up properly (#1):** the application id and package are `io.oxygenhub.android` (permanent), minSdk 31 (Android 12), the Gradle and build caches no longer tracked, `rust-core/` part of the repository (it was a nested, empty Git repository, so it had never been committed), and the Rust core built for arm64-v8a and x86_64 and its Kotlin bindings generated by the build itself (`buildRustCore`, `generateRustBindings`) instead of being committed. The pipeline has no jobs until OxygenFlow has an Android image (oxygenflow#19).
modified README.md +3 −1
…
29 unchanged lines
30 30
  device token is sealed with a key in the Android Keystore (StrongBox where
31 31
  the phone has one), never written in the clear, logged or backed up.
32 32
  Signing out (Account) revokes it on the hub.
33 -
* 🏠 **Dashboard:** a greeting, your newest repositories, the unread count.
33 +
* 🏠 **Dashboard:** a greeting with your avatar, your newest repositories,
34 +
  the unread count. Your avatar is the picture you uploaded to the hub, or
35 +
  the hub's identicon for you (#13).
34 36
* 📚 **Repositories:** yours or all you can see, filtered as you type; a
35 37
  repository's **issues** (open, closed, all; pinned first). An issue opens on
36 38
  the hub's web page for now (#4 brings it into the app).
…
70 unchanged lines
modified app/src/main/java/io/oxygenhub/android/api/HubClient.kt +24 −0
…
34 unchanged lines
35 35
36 36
    /** Revokes the token this client signs in with (`DELETE /user/token`). */
37 37
    suspend fun revokeToken()
38 +
39 +
    /** An account's picture (`/avatar/{name}`, public); null when the hub has none this app can draw. */
40 +
    suspend fun avatar(name: String): Avatar?
38 41
}
39 42
40 43
/**
…
8 unchanged lines
49 52
    private val onUnauthorized: () -> Unit = {},
50 53
) : HubApi {
51 54
    private val base: HttpUrl = "${hub.trimEnd('/')}/api/v1/".toHttpUrl()
55 +
    private val root: HttpUrl = "${hub.trimEnd('/')}/".toHttpUrl()
52 56
53 57
    override suspend fun user(): User = get("user", User.serializer())
54 58
…
15 unchanged lines
70 74
71 75
    override suspend fun revokeToken() {
72 76
        send("DELETE", url("user", "token"), notifyUnauthorized = false)
77 +
    }
78 +
79 +
    override suspend fun avatar(name: String): Avatar? {
80 +
        val url = root.newBuilder().addPathSegment("avatar").addPathSegment(name).build()
81 +
        // A public picture: no token goes with it.
82 +
        val response = try {
83 +
            http.newCall(Request.Builder().url(url).get().build()).await()
84 +
        } catch (e: IOException) {
85 +
            throw HubException.Network(e)
86 +
        }
87 +
        response.use { r ->
88 +
            if (r.code == 404) return null
89 +
            val bytes = try {
90 +
                r.body?.bytes() ?: ByteArray(0)
91 +
            } catch (e: IOException) {
92 +
                throw HubException.Network(e)
93 +
            }
94 +
            if (!r.isSuccessful) throw HubException.Http(r.code, errorDetail(bytes.decodeToString()))
95 +
            return Avatar.parse(r.header("Content-Type"), bytes)
96 +
        }
73 97
    }
74 98
75 99
    // The bearer token is never part of a string anything prints.
…
52 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/account/AccountScreen.kt +4 −12
…
9 unchanged lines
10 10
import androidx.compose.foundation.layout.size
11 11
import androidx.compose.foundation.layout.widthIn
12 12
import androidx.compose.foundation.rememberScrollState
13 -
import androidx.compose.foundation.shape.CircleShape
14 13
import androidx.compose.foundation.verticalScroll
15 14
import androidx.compose.material.icons.Icons
16 15
import androidx.compose.material.icons.automirrored.outlined.Logout
…
7 unchanged lines
24 23
import androidx.compose.material3.ListItem
25 24
import androidx.compose.material3.MaterialTheme
26 25
import androidx.compose.material3.OutlinedButton
27 -
import androidx.compose.material3.Surface
28 26
import androidx.compose.material3.Text
29 27
import androidx.compose.material3.TextButton
30 28
import androidx.compose.runtime.Composable
…
7 unchanged lines
38 36
import androidx.compose.ui.text.font.FontWeight
39 37
import androidx.compose.ui.unit.dp
40 38
import io.oxygenhub.android.auth.Session
39 +
import io.oxygenhub.android.api.Avatar
41 40
import io.oxygenhub.android.ui.common.AppScreen
41 +
import io.oxygenhub.android.ui.common.AvatarImage
42 42
import kotlinx.coroutines.launch
43 43
44 44
/** Who is signed in, to which hub, with what; and signing out. */
45 45
@Composable
46 -
fun AccountScreen(session: Session, nativeCore: () -> String, onSignOut: suspend () -> Unit) {
46 +
fun AccountScreen(session: Session, avatar: Avatar?, nativeCore: () -> String, onSignOut: suspend () -> Unit) {
47 47
    var confirming by remember { mutableStateOf(false) }
48 48
    var signingOut by remember { mutableStateOf(false) }
49 49
    val scope = rememberCoroutineScope()
…
6 unchanged lines
56 56
                verticalArrangement = Arrangement.spacedBy(16.dp),
57 57
            ) {
58 58
                Column(Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) {
59 -
                    Surface(Modifier.size(72.dp), shape = CircleShape, color = MaterialTheme.colorScheme.primaryContainer) {
60 -
                        Box(contentAlignment = Alignment.Center) {
61 -
                            Text(
62 -
                                session.username.take(1).uppercase(),
63 -
                                style = MaterialTheme.typography.headlineMedium,
64 -
                                color = MaterialTheme.colorScheme.onPrimaryContainer,
65 -
                            )
66 -
                        }
67 -
                    }
59 +
                    AvatarImage(avatar, session.username, size = 72.dp)
68 60
                    Spacer(Modifier.height(8.dp))
69 61
                    Text(session.username, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
70 62
                }
…
52 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardScreen.kt +6 −1
…
23 unchanged lines
24 24
import androidx.compose.material3.TextButton
25 25
import androidx.compose.runtime.Composable
26 26
import androidx.compose.runtime.getValue
27 +
import androidx.compose.ui.Alignment
27 28
import androidx.compose.ui.Modifier
28 29
import androidx.compose.ui.graphics.Color
29 30
import androidx.compose.ui.text.font.FontWeight
…
2 unchanged lines
32 33
import io.oxygenhub.android.api.Inbox
33 34
import io.oxygenhub.android.api.Repository
34 35
import io.oxygenhub.android.ui.common.AppScreen
36 +
import io.oxygenhub.android.ui.common.AvatarImage
35 37
import io.oxygenhub.android.ui.common.Load
36 38
import io.oxygenhub.android.ui.common.LoadContent
37 39
import io.oxygenhub.android.ui.repos.RepositoryRow
…
33 unchanged lines
71 73
                .verticalScroll(rememberScrollState())
72 74
                .padding(16.dp),
73 75
        ) {
74 -
            Text(d.greeting, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
76 +
            Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
77 +
                AvatarImage(d.avatar, d.username, size = 48.dp)
78 +
                Text(d.greeting, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold)
79 +
            }
75 80
            Spacer(Modifier.height(16.dp))
76 81
            if (wide) {
77 82
                Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
…
64 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/dashboard/DashboardViewModel.kt +10 −0
1 1
package io.oxygenhub.android.ui.dashboard
2 2
3 3
import androidx.lifecycle.ViewModel
4 +
import io.oxygenhub.android.api.Avatar
4 5
import io.oxygenhub.android.api.HubApi
5 6
import io.oxygenhub.android.api.Repository
6 7
import io.oxygenhub.android.api.User
7 8
import io.oxygenhub.android.auth.Session
9 +
import io.oxygenhub.android.ui.common.Avatars
8 10
import io.oxygenhub.android.ui.common.Load
9 11
import io.oxygenhub.android.ui.common.load
10 12
import kotlinx.coroutines.CancellationException
…
9 unchanged lines
20 22
    val greeting: String,
21 23
    /** The account, or null when the token may not read it (no `user:read`). */
22 24
    val user: User?,
25 +
    /** The account's avatar (#13), or null while the hub has none this app can draw. */
26 +
    val avatar: Avatar? = null,
27 +
    /** Whose avatar: the signed-in account's name. */
28 +
    val username: String = "",
23 29
    /** The newest-updated repositories: the user's own, or everyone's when they own none. */
24 30
    val recent: List<Repository>,
25 31
    val recentAreYours: Boolean,
…
29 unchanged lines
55 61
                null
56 62
            }
57 63
        }
64 +
        // As is the avatar: its failure leaves the last one, or the initial.
65 +
        val avatar = async { Avatars.load(api, session.username) }
58 66
        val repos = api.repositories()
59 67
        val me = user.await()
60 68
        val username = me?.username ?: session.username
…
2 unchanged lines
63 71
        Dashboard(
64 72
            greeting = greeting(hourOfDay(), me?.shownName ?: session.username),
65 73
            user = me,
74 +
            avatar = avatar.await(),
75 +
            username = username,
66 76
            recent = pool.sortedByDescending { it.updatedMs }.take(RECENT),
67 77
            recentAreYours = mine.isNotEmpty(),
68 78
            repositoryCount = mine.size,
…
16 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +2 −1
…
33 unchanged lines
34 34
import io.oxygenhub.android.api.HubApi
35 35
import io.oxygenhub.android.auth.Session
36 36
import io.oxygenhub.android.ui.account.AccountScreen
37 +
import io.oxygenhub.android.ui.common.rememberAvatar
37 38
import io.oxygenhub.android.ui.dashboard.DashboardScreen
38 39
import io.oxygenhub.android.ui.dashboard.DashboardViewModel
39 40
import io.oxygenhub.android.ui.inbox.InboxScreen
…
97 unchanged lines
137 138
                IssuesScreen(rememberViewModel { IssuesViewModel(api, r.owner, r.name) }, onBack = { nav.popBackStack() })
138 139
            }
139 140
            composable<InboxRoute> { InboxScreen(inbox) }
140 -
            composable<AccountRoute> { AccountScreen(session, nativeCore, onSignOut) }
141 +
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), nativeCore, onSignOut) }
141 142
        }
142 143
    }
143 144
}
…
9 unchanged lines
modified app/src/test/java/io/oxygenhub/android/FakeHubApi.kt +3 −0
1 1
package io.oxygenhub.android
2 2
3 +
import io.oxygenhub.android.api.Avatar
3 4
import io.oxygenhub.android.api.HubApi
4 5
import io.oxygenhub.android.api.Inbox
5 6
import io.oxygenhub.android.api.Issue
…
12 unchanged lines
18 19
    var markRead: (Long) -> Unit = {}
19 20
    var markAllRead: () -> Unit = {}
20 21
    var revoke: () -> Unit = {}
22 +
    var avatar: (String) -> Avatar? = { null }
21 23
22 24
    val calls = mutableListOf<String>()
23 25
…
10 unchanged lines
34 36
        calls += "read all"
35 37
        markAllRead.invoke()
36 38
    }
39 +
    override suspend fun avatar(name: String): Avatar? = avatar.invoke(name).also { calls += "avatar $name" }
37 40
    override suspend fun revokeToken() {
38 41
        calls += "revoke"
39 42
        revoke.invoke()
…
2 unchanged lines
modified app/src/test/java/io/oxygenhub/android/api/HubClientTest.kt +14 −0
…
7 unchanged lines
8 8
import okhttp3.mockwebserver.SocketPolicy
9 9
import org.junit.After
10 10
import org.junit.Assert.assertEquals
11 +
import org.junit.Assert.assertNull
11 12
import org.junit.Assert.assertFalse
12 13
import org.junit.Assert.assertTrue
13 14
import org.junit.Assert.fail
…
36 unchanged lines
50 51
    }
51 52
52 53
    @Test
54 +
    fun anAvatarIsFetchedWithoutTheToken() = runTest {
55 +
        server.enqueue(MockResponse().setHeader("Content-Type", "image/svg+xml").setBody(Fixtures.text("identicon-bob.svg")))
56 +
        val a = client().avatar("bob")
57 +
        assertEquals(196, (a as Avatar.Identicon).hue)
58 +
        val req = server.takeRequest()
59 +
        assertEquals("/avatar/bob", req.path)
60 +
        assertNull(req.getHeader("Authorization"))
61 +
        server.enqueue(MockResponse().setResponseCode(404))
62 +
        assertNull(client().avatar("nobody"))
63 +
        assertEquals(0, unauthorized)
64 +
    }
65 +
66 +
    @Test
53 67
    fun repositories() = runTest {
54 68
        server.enqueue(json("repos.json"))
55 69
        assertEquals(3, client().repositories().size)
…
114 unchanged lines
modified app/src/test/java/io/oxygenhub/android/ui/DashboardViewModelTest.kt +14 −0
…
2 unchanged lines
3 3
import io.oxygenhub.android.FakeHubApi
4 4
import io.oxygenhub.android.Fixtures
5 5
import io.oxygenhub.android.MainDispatcherRule
6 +
import io.oxygenhub.android.api.Avatar
6 7
import io.oxygenhub.android.api.HubException
7 8
import io.oxygenhub.android.auth.Session
8 9
import io.oxygenhub.android.ui.common.Load
…
24 unchanged lines
33 34
    }
34 35
35 36
    @Test
37 +
    fun showsTheAccountsAvatarAndKeepsItWhenTheHubFails() {
38 +
        val mine = Avatar.Identicon(196, setOf(3 to 3))
39 +
        api.avatar = { if (it == "andymccall") mine else null }
40 +
        val vm = DashboardViewModel(api, session) { 9 }
41 +
        assertEquals(mine, ready(vm).avatar)
42 +
        assertEquals("andymccall", ready(vm).username)
43 +
        // A failed fetch keeps the one before; the page still loads.
44 +
        api.avatar = { throw HubException.Http(500, "down") }
45 +
        vm.refresh()
46 +
        assertEquals(mine, ready(vm).avatar)
47 +
    }
48 +
49 +
    @Test
36 50
    fun withNoRepositoriesOfTheirOwnShowsTheHubs() {
37 51
        val d = ready(DashboardViewModel(api, session.copy(username = "newcomer")) { 14 }.also {
38 52
            api.user = { Fixtures.user.copy(username = "newcomer", displayName = "") }
…
33 unchanged lines