fix(notifications): resolve an item's relative url, and document the CI trigger
Some checks failed
PR Checks / android-build (pull_request) Failing after 42m5s
Some checks failed
PR Checks / android-build (pull_request) Failing after 42m5s
Two things the live rig found, and the README half of the trigger change. Phase 7 specifies an inbox item's `url` is RELATIVE-ONLY and validates it as such — right for a browser already on the site, a dead link on a phone. The first cut here only opened `http(s)`-prefixed strings, so on the rig every link in the inbox did nothing at all. `InboxViewModel.linkFor` now resolves against the configured base with OkHttp's `HttpUrl.resolve`, which absolutises the path and returns null for anything that would not end up http(s) — so a `javascript:` or `intent:` url in a notification body opens nothing. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
13
README.md
13
README.md
@@ -48,10 +48,15 @@ any shard's website — there is no compiled-in API host.
|
|||||||
|
|
||||||
## CI
|
## CI
|
||||||
|
|
||||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
|
`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with
|
||||||
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
|
`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug
|
||||||
secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK
|
builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on
|
||||||
attached to a Gitea release comes at M6.
|
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),
|
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:
|
because this self-hosted runner differs from a stock GitHub runner:
|
||||||
|
|||||||
@@ -118,11 +118,11 @@ fun InboxScreen(
|
|||||||
onEndReached = viewModel::loadMore,
|
onEndReached = viewModel::loadMore,
|
||||||
onOpen = { item ->
|
onOpen = { item ->
|
||||||
viewModel.markRead(item.id)
|
viewModel.markRead(item.id)
|
||||||
// The url is the site's own page for the item, and most items
|
// Most items have no url at all — an inbox row is complete on
|
||||||
// have none — an inbox row is complete on its own. Anything
|
// its own — and the ones that do carry a SITE-RELATIVE path,
|
||||||
// that is not http(s) is not opened at all.
|
// so the view model resolves it against the configured shard
|
||||||
item.url?.takeIf { it.startsWith("http://") || it.startsWith("https://") }
|
// before anything is opened.
|
||||||
?.let { WebHandoff.open(context, it) }
|
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,6 +201,25 @@ class InboxViewModel @Inject constructor(
|
|||||||
cacheCurrent()
|
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.
|
* Keep the snapshot in step with a local read.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -166,4 +166,31 @@ class InboxViewModelTest {
|
|||||||
// bring the badge back on the next cold open.
|
// bring the badge back on the next cold open.
|
||||||
assertEquals(writesAfterLoad + 1, cache.writes)
|
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")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user