OXYGENHUB

The inbox opens what it is about in the app, not the website (refs #5) fbc95ee8777c

Andy (2026-10-25): whatever is tapped in the inbox should open natively.
A notification's hub page now opens the app's own screen when it has
one: an issue, proposal, run, change, file, history, board or
repository; the browser only for pages the app does not show (a
release, the admin pages) or other sites.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c73efeec6512
When
· 2026-10-25 22:49 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
fbc95ee8777c22fa22e2bc6f55a6dd8cd88da22ddcf76acc598edd213799b2ae
Depends on
144cbf7bf2b5 4aee5293461d
Depended on by
62429abe5869
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
10 atoms, 2 tree operations, 4 files

Browse the tree at this change

added app/src/main/java/io/oxygenhub/android/ui/nav/Links.kt +39 −0
1 +
package io.oxygenhub.android.ui.nav
2 +
3 +
import java.net.URI
4 +
5 +
/**
6 +
 * The app's own screen for a page of the hub (Andy, 2026-10-25: whatever
7 +
 * is tapped in the inbox opens natively): an issue, proposal, run, change,
8 +
 * file, history, board or repository. Null for a page the app does not
9 +
 * show itself, or another site's, which the browser opens.
10 +
 */
11 +
fun routeFor(url: String, hub: String): Any? {
12 +
    val u = runCatching { URI(url) }.getOrNull() ?: return null
13 +
    val home = runCatching { URI(hub) }.getOrNull() ?: return null
14 +
    if (u.host != null && !u.host.equals(home.host, ignoreCase = true)) return null
15 +
    val parts = (u.rawPath ?: "").split('/').filter { it.isNotEmpty() }.map { java.net.URLDecoder.decode(it, "UTF-8") }
16 +
    if (parts.size >= 3 && parts[0] == "projects") {
17 +
        return parts[2].toLongOrNull()?.let { BoardRoute(parts[1], it) }
18 +
    }
19 +
    if (parts.size < 2 || parts[0] in HUB_PAGES) return null
20 +
    val (owner, name) = parts[0] to parts[1]
21 +
    val rest = parts.drop(2)
22 +
    return when (rest.firstOrNull()) {
23 +
        null -> IssuesRoute(owner, name)
24 +
        "issue" -> rest.getOrNull(1)?.toLongOrNull()?.let { IssueRoute(owner, name, it) }
25 +
        "proposal" -> rest.getOrNull(1)?.toLongOrNull()?.let { ProposalRoute(owner, name, it) }
26 +
        "flow" -> rest.getOrNull(1)?.toLongOrNull()?.let { RunRoute(owner, name, it) }
27 +
        "change" -> rest.getOrNull(1)?.let { ChangeRoute(owner, name, it) }
28 +
        "file" -> if (rest.size >= 3) FileRoute(owner, name, rest[1], rest.drop(2).joinToString("/")) else null
29 +
        "changes" -> rest.getOrNull(1)?.let { HistoryRoute(owner, name, it) }
30 +
        else -> null
31 +
    }
32 +
}
33 +
34 +
/** The hub's own top-level pages: never an owner's name. */
35 +
private val HUB_PAGES = setOf(
36 +
    "about", "admin", "api", "apt", "avatar", "device", "drops", "explore", "health", "help", "insights", "live", "log",
37 +
    "login", "logout", "metrics", "new", "notifications", "orgs", "register", "search", "security-contact", "settings",
38 +
    "sky", "static", "verify", ".well-known",
39 +
)
added app/src/test/java/io/oxygenhub/android/ui/LinksTest.kt +39 −0
1 +
package io.oxygenhub.android.ui
2 +
3 +
import io.oxygenhub.android.ui.nav.BoardRoute
4 +
import io.oxygenhub.android.ui.nav.ChangeRoute
5 +
import io.oxygenhub.android.ui.nav.FileRoute
6 +
import io.oxygenhub.android.ui.nav.HistoryRoute
7 +
import io.oxygenhub.android.ui.nav.IssueRoute
8 +
import io.oxygenhub.android.ui.nav.IssuesRoute
9 +
import io.oxygenhub.android.ui.nav.ProposalRoute
10 +
import io.oxygenhub.android.ui.nav.RunRoute
11 +
import io.oxygenhub.android.ui.nav.routeFor
12 +
import org.junit.Assert.assertEquals
13 +
import org.junit.Assert.assertNull
14 +
import org.junit.Test
15 +
16 +
class LinksTest {
17 +
    private val hub = "https://oxygenhub.io"
18 +
    private fun r(path: String) = routeFor("https://oxygenhub.io$path", hub)
19 +
20 +
    @Test
21 +
    fun hubPagesOpenTheirOwnScreens() {
22 +
        assertEquals(IssueRoute("doomsdayone", "supermarioland2", 317), r("/doomsdayone/supermarioland2/issue/317#c-12"))
23 +
        assertEquals(ProposalRoute("andymccall", "oxygenhub", 314), r("/andymccall/oxygenhub/proposal/314"))
24 +
        assertEquals(RunRoute("andymccall", "phone-runner-demo", 56), r("/andymccall/phone-runner-demo/flow/56"))
25 +
        assertEquals(ChangeRoute("andymccall", "oxygen", "4f07c5a0"), r("/andymccall/oxygen/change/4f07c5a0"))
26 +
        assertEquals(FileRoute("andymccall", "oxygen", "main", "crates/oxy-core/src/strata.rs"), r("/andymccall/oxygen/file/main/crates/oxy-core/src/strata.rs"))
27 +
        assertEquals(HistoryRoute("andymccall", "oxygen", "main"), r("/andymccall/oxygen/changes/main"))
28 +
        assertEquals(BoardRoute("doomsdayone", 4), r("/projects/doomsdayone/4"))
29 +
        assertEquals(IssuesRoute("andymccall", "oxygen"), r("/andymccall/oxygen"))
30 +
    }
31 +
32 +
    @Test
33 +
    fun otherPagesAndSitesGoToTheBrowser() {
34 +
        assertNull(r("/andymccall/oxygenhub/releases/tag/v0.9.4"))
35 +
        assertNull(r("/admin/runners"))
36 +
        assertNull(routeFor("https://example.com/andymccall/oxygen/issue/1", hub))
37 +
        assertNull(r("/andymccall/oxygen/issue/not-a-number"))
38 +
    }
39 +
}
modified app/src/main/java/io/oxygenhub/android/ui/inbox/InboxScreen.kt +7 −5
…
33 unchanged lines
34 34
import androidx.compose.ui.Alignment
35 35
import androidx.compose.ui.Modifier
36 36
import androidx.compose.ui.graphics.vector.ImageVector
37 -
import androidx.compose.ui.platform.LocalUriHandler
38 37
import androidx.compose.ui.text.font.FontWeight
39 38
import androidx.compose.ui.text.style.TextOverflow
40 39
import androidx.compose.ui.unit.dp
…
4 unchanged lines
45 44
import io.oxygenhub.android.ui.common.LoadContent
46 45
import io.oxygenhub.android.ui.common.relativeTime
47 46
48 -
/** The inbox. A notification opens its page on the hub (in-app pages come with #4 and #5). */
47 +
/**
48 +
 * The inbox. A notification opens what it is about on its own screen in
49 +
 * the app ([onOpen]): an issue, proposal, run, change, file or board; the
50 +
 * browser only for a page the app does not show.
51 +
 */
49 52
@Composable
50 -
fun InboxScreen(vm: InboxViewModel) {
53 +
fun InboxScreen(vm: InboxViewModel, onOpen: (url: String) -> Unit) {
51 54
    val state by vm.state.collectAsStateWithLifecycle()
52 55
    val message by vm.message.collectAsStateWithLifecycle()
53 56
    val snackbar = remember { SnackbarHostState() }
54 -
    val uri = LocalUriHandler.current
55 57
    LaunchedEffect(message) {
56 58
        message?.let {
57 59
            snackbar.showSnackbar(it)
…
19 unchanged lines
77 79
                    items(inbox.items, key = { it.id }) { n ->
78 80
                        NotificationRow(n, onClick = {
79 81
                            vm.markRead(n)
80 -
                            if (n.url.isNotBlank()) uri.openUri(n.url)
82 +
                            if (n.url.isNotBlank()) onOpen(n.url)
81 83
                        })
82 84
                        HorizontalDivider()
83 85
                    }
…
63 unchanged lines
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +5 −1
…
16 unchanged lines
17 17
import androidx.compose.runtime.Composable
18 18
import androidx.compose.runtime.getValue
19 19
import androidx.compose.ui.graphics.vector.ImageVector
20 +
import androidx.compose.ui.platform.LocalUriHandler
20 21
import androidx.lifecycle.ViewModel
21 22
import androidx.lifecycle.compose.collectAsStateWithLifecycle
22 23
import androidx.lifecycle.viewmodel.compose.viewModel
…
76 unchanged lines
99 100
    val inboxState by inbox.state.collectAsStateWithLifecycle()
100 101
    val unread = inboxState.valueOrNull?.unread ?: 0
101 102
    val entry by nav.currentBackStackEntryAsState()
103 +
    // A hub page opens on its own screen when the app has one, else in the browser.
104 +
    val uri = LocalUriHandler.current
105 +
    val openLink: (String) -> Unit = { url -> routeFor(url, session.hub)?.let { nav.navigate(it) } ?: uri.openUri(url) }
102 106
    val destination = entry?.destination
103 107
104 108
    NavigationSuiteScaffold(
…
62 unchanged lines
167 171
                    onSignInAgain = { scope.launch { onSignOut() } },
168 172
                )
169 173
            }
170 -
            composable<InboxRoute> { InboxScreen(inbox) }
174 +
            composable<InboxRoute> { InboxScreen(inbox, onOpen = openLink) }
171 175
            composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) }
172 176
        }
173 177
    }
…
10 unchanged lines