diff --git a/README.md b/README.md index 88a3f31..5fc04e2 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,15 @@ any shard's website — there is no compiled-in API host. ## CI -`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the -org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no -secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK -attached to a Gitea release comes at M6. +`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with +`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug +builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on +the runner** (M0). A signed **release** APK attached to a Gitea release comes at M6. + +**`edge` is in the trigger deliberately**: a workstream that lands its phases on a working branch +before one cutover PR into `main` otherwise gets no CI at all until the cutover — which is what +happened to all nine M12 phase PRs (`docs/website/ENGAGEMENT.md` §7.1 Q8). `sonarqube.yml` is +unaffected: it is a push-on-`main` analysis, not a PR gate. The workflow carries a few runner-specific accommodations (each explained in comments in the file), because this self-hosted runner differs from a stock GitHub runner: diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt index c779f3e..8679694 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt @@ -118,11 +118,11 @@ fun InboxScreen( onEndReached = viewModel::loadMore, onOpen = { item -> viewModel.markRead(item.id) - // The url is the site's own page for the item, and most items - // have none — an inbox row is complete on its own. Anything - // that is not http(s) is not opened at all. - item.url?.takeIf { it.startsWith("http://") || it.startsWith("https://") } - ?.let { WebHandoff.open(context, it) } + // Most items have no url at all — an inbox row is complete on + // its own — and the ones that do carry a SITE-RELATIVE path, + // so the view model resolves it against the configured shard + // before anything is opened. + viewModel.linkFor(item)?.let { WebHandoff.open(context, it) } }, ) } diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt index 81b89f2..7bee565 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt @@ -201,6 +201,25 @@ class InboxViewModel @Inject constructor( cacheCurrent() } + /** + * The absolute link for an item, or null when it has none this app can open. + * + * **An item's `url` is SITE-RELATIVE** — `/guilds/the-silver-anvil/forum/403` + * is what the server writes, because it is rendered from the template's button + * block for a browser that is already on the site. A phone is not, so it has to + * be resolved against the configured base or every link in the inbox is dead; + * the live rig is what caught that. + * + * `HttpUrl.resolve` does both jobs: it absolutises a relative path and it + * returns null for anything that would not end up as http(s) — a `javascript:` + * or `intent:` url in a notification body opens nothing at all. + */ + fun linkFor(item: NotificationItemDto): String? { + val raw = item.url?.trim().orEmpty() + if (raw.isEmpty()) return null + return baseUrlHolder.current?.resolve(raw)?.toString() + } + /** * Keep the snapshot in step with a local read. * diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt index acabc3f..4353c12 100644 --- a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt @@ -166,4 +166,31 @@ class InboxViewModelTest { // bring the badge back on the next cold open. assertEquals(writesAfterLoad + 1, cache.writes) } + + // ── The item link (found by the live rig, not by a test) ────────────── + + @Test fun aSiteRelativeUrlIsResolvedAgainstTheShard() { + // What the server actually writes: the template's button block renders a + // path, because on the web the reader is already on the site. + val vm = viewModel() + val item = item(1).copy(url = "/guilds/the-silver-anvil/forum/403") + assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(item)) + } + + @Test fun anAbsoluteUrlIsLeftAlone() { + val vm = viewModel() + assertEquals("https://elsewhere.example/x", vm.linkFor(item(1).copy(url = "https://elsewhere.example/x"))) + } + + @Test fun anItemWithNoUrlHasNoLink() { + val vm = viewModel() + assertNull(vm.linkFor(item(1))) + assertNull(vm.linkFor(item(1).copy(url = " "))) + } + + @Test fun aUrlThatCouldNotBeOpenedSafelyResolvesToNothing() { + val vm = viewModel() + assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)"))) + assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end"))) + } }