Android: public host as the build property kvitt.publicHost (D-121)

The release server's host name comes from -Pkvitt.publicHost, else kvitt.publicHost in
android/local.properties, else the placeholder kvitt.example.invalid. :app exposes it as
BuildConfig.PUBLIC_HOST, the manifest placeholder publicHost (the https invite-link intent
filter, port 8443) and RELEASE_SERVER_URL, which :feature:onboarding receives through the
@ReleaseServerUrl Hilt qualifier for S-1's prefill. Tests and goldens use fixed hosts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-19 19:58:58 +02:00
parent 9231a1d755
commit d090c6c850
15 changed files with 156 additions and 50 deletions
@@ -24,30 +24,30 @@ class InviteLinkTest {
@Test
fun theAppLinkGivesCodeAndServer() {
val parsed = InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA")
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), parsed)
val parsed = InviteLink.parse("kvitt://invite?server=https%3A%2F%2Fkvitt.example.org%3A8443&code=7K3MQX9D2HPA")
assertEquals(PastedInvite("7K3MQX9D2HPA", TEST_RELEASE_SERVER_URL), parsed)
assertNull(InviteLink.parse("kvitt://other?code=7K3MQX9D2HPA"))
}
@Test
fun linksMayNameTheAdmin_andABadNameIsDropped() {
assertEquals(
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Jacob"),
InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA&from=Jacob"),
PastedInvite("7K3MQX9D2HPA", TEST_RELEASE_SERVER_URL, "Jacob"),
InviteLink.parse("kvitt://invite?server=https%3A%2F%2Fkvitt.example.org%3A8443&code=7K3MQX9D2HPA&from=Jacob"),
)
assertEquals(
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Åsa Berg"),
InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=%C3%85sa+Berg"),
PastedInvite("7K3MQX9D2HPA", TEST_RELEASE_SERVER_URL, "Åsa Berg"),
InviteLink.parse("https://kvitt.example.org:8443/i/7K3MQX9D2HPA?from=%C3%85sa+Berg"),
)
for (bad in listOf("", "%20", "a%0Ab", "A".repeat(41))) {
assertEquals(bad, PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=$bad"))
assertEquals(bad, PastedInvite("7K3MQX9D2HPA", TEST_RELEASE_SERVER_URL), InviteLink.parse("https://kvitt.example.org:8443/i/7K3MQX9D2HPA?from=$bad"))
}
}
@Test
fun theLandingLinkGivesCodeAndServer() {
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA"))
assertEquals(PastedInvite("7K3MQX9D2HPA", TEST_RELEASE_SERVER_URL), InviteLink.parse("https://kvitt.example.org:8443/i/7K3MQX9D2HPA"))
assertEquals(PastedInvite("7K3MQX9D2HPA", "http://192.168.1.210:8080"), InviteLink.parse("http://192.168.1.210:8080/i/7k3mqx9d2hpa"))
assertNull(InviteLink.parse("https://icehack.asuscomm.com:8443/x/7K3MQX9D2HPA"))
assertNull(InviteLink.parse("https://kvitt.example.org:8443/x/7K3MQX9D2HPA"))
}
}
@@ -22,7 +22,7 @@ class PairingViewModelTest {
val main = MainDispatcherRule()
private fun vm(scenario: FakeScenario = OnboardingScenarios.unpaired, outcome: CallOutcome = CallOutcome.Succeed, allowLanHttp: Boolean = false) =
ScriptedSessionRepository(FakeRepositories(scenario).store, outcome).let { it to PairingViewModel(it, allowLanHttp) }
ScriptedSessionRepository(FakeRepositories(scenario).store, outcome).let { it to PairingViewModel(it, allowLanHttp, TEST_RELEASE_SERVER_URL) }
@Test
fun theCodeIsNormalisedWhileTyping() {
@@ -36,7 +36,7 @@ class PairingViewModelTest {
@Test
fun pairIsEnabledWithTwelveCharactersANameAndAWellFormedAddress() {
val (_, vm) = vm()
assertEquals(RELEASE_SERVER_URL, vm.state.value.serverUrl)
assertEquals(TEST_RELEASE_SERVER_URL, vm.state.value.serverUrl)
vm.onCodeChange("7K3MQX9D2HP")
vm.onNameChange("Anna")
assertFalse(vm.state.value.canPair)
@@ -55,17 +55,17 @@ class PairingViewModelTest {
@Test
fun aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName() {
val (session, vm) = vm(OnboardingScenarios.unpairedPartner)
vm.prefill(RELEASE_SERVER_URL + "/", "7k3m-qx9d-2hpa")
vm.prefill(TEST_RELEASE_SERVER_URL + "/", "7k3m-qx9d-2hpa")
vm.onNameChange(" Anna ")
vm.pair("Galaxy S26+")
assertEquals(listOf(PairCall(RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Anna", "Galaxy S26+")), session.pairCalls)
assertEquals(listOf(PairCall(TEST_RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Anna", "Galaxy S26+")), session.pairCalls)
assertEquals(PairingDone.ProfileStep, vm.state.value.done)
}
@Test
fun aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin() {
val (_, vm) = vm(OnboardingScenarios.unpairedPartner.copy(pairingInviteKind = InviteKind.DEVICE))
vm.prefill(RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Jacob")
vm.prefill(TEST_RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Jacob")
assertEquals("Jacob", vm.state.value.adminName)
vm.onNameChange("Anna")
vm.pair("Galaxy S26+")
@@ -94,10 +94,10 @@ class PairingViewModelTest {
val (_, vm) = vm()
vm.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA")
assertTrue(vm.state.value.serverExpanded)
vm.prefill(RELEASE_SERVER_URL, "AAAAAAAAAAAA")
vm.prefill(TEST_RELEASE_SERVER_URL, "AAAAAAAAAAAA")
assertEquals("7K3MQX9D2HPA", vm.state.value.code)
val (_, plain) = vm()
plain.prefill(RELEASE_SERVER_URL, "7K3MQX9D2HPA")
plain.prefill(TEST_RELEASE_SERVER_URL, "7K3MQX9D2HPA")
assertFalse(plain.state.value.serverExpanded)
}
@@ -169,7 +169,7 @@ class PairingViewModelTest {
@Test
fun pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters() {
val (_, vm) = vm()
vm.onPaste("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA")
vm.onPaste("https://kvitt.example.org:8443/i/7K3MQX9D2HPA")
assertEquals("7K3MQX9D2HPA", vm.state.value.code)
assertNull(vm.state.value.error)
vm.onPaste("hello")
@@ -10,7 +10,7 @@ import org.junit.Test
class ServerAddressTest {
@Test
fun httpsIsAlwaysAccepted() {
assertNull(ServerAddress.check(RELEASE_SERVER_URL, allowLanHttp = false))
assertNull(ServerAddress.check(TEST_RELEASE_SERVER_URL, allowLanHttp = false))
assertNull(ServerAddress.check("https://example.org/", allowLanHttp = false))
}
@@ -34,15 +34,22 @@ class ServerAddressTest {
@Test
fun malformedAddressesAreInvalid() {
listOf("", "icehack.asuscomm.com", "ftp://example.org", "https://", "https://exa mple.org", "kvitt://invite").forEach {
listOf("", "kvitt.example.org", "ftp://example.org", "https://", "https://exa mple.org", "kvitt://invite").forEach {
assertEquals(it, ServerAddressProblem.Invalid, ServerAddress.check(it, allowLanHttp = true))
assertFalse(it, ServerAddress.isWellFormed(it))
}
assertTrue(ServerAddress.isWellFormed("http://example.org"))
}
@Test
fun theReleaseServerIsHttpsOnPort8443OfThePublicHost() {
// D-121: the build's default kvitt.publicHost is the placeholder, which never resolves (RFC 2606).
assertEquals("https://kvitt.example.invalid:8443", releaseServerUrl("kvitt.example.invalid"))
assertEquals("https://kvitt.duckdns.org:8443", releaseServerUrl("kvitt.duckdns.org"))
}
@Test
fun normaliseTrimsSpacesAndTrailingSlash() {
assertEquals(RELEASE_SERVER_URL, ServerAddress.normalise(" $RELEASE_SERVER_URL/ "))
assertEquals(TEST_RELEASE_SERVER_URL, ServerAddress.normalise(" $TEST_RELEASE_SERVER_URL/ "))
}
}
@@ -0,0 +1,8 @@
package se.hagstedt.kvitt.feature.onboarding
/**
* The release server the onboarding tests run with (D-121): a fixed host, not `:app`'s `BuildConfig.PUBLIC_HOST`, so
* the tests and goldens do not depend on the `kvitt.publicHost` a checkout is built with.
*/
internal const val TEST_PUBLIC_HOST = "kvitt.example.org"
internal val TEST_RELEASE_SERVER_URL = releaseServerUrl(TEST_PUBLIC_HOST)
@@ -14,7 +14,6 @@ import se.hagstedt.kvitt.feature.onboarding.PairingViewModel
import se.hagstedt.kvitt.feature.onboarding.ProfileStepActions
import se.hagstedt.kvitt.feature.onboarding.ProfileStepContent
import se.hagstedt.kvitt.feature.onboarding.ProfileStepViewModel
import se.hagstedt.kvitt.feature.onboarding.RELEASE_SERVER_URL
import se.hagstedt.kvitt.feature.onboarding.RemovedContent
import se.hagstedt.kvitt.feature.onboarding.WaitingActions
import se.hagstedt.kvitt.feature.onboarding.WaitingContent
@@ -26,6 +25,12 @@ class ScreenState(val name: String, val largeFont: Boolean = false, val render:
private const val CODE = "7K3MQX9D2HPA"
private const val LAN_SERVER = "http://192.168.1.210:8080"
/**
* The release server the goldens show on S-1's server row. Fixed, not `:app`'s `BuildConfig.PUBLIC_HOST`, so a
* checkout's `kvitt.publicHost` never changes a render (D-121); the host is the one the goldens were recorded with.
*/
private const val GOLDEN_SERVER = "https://icehack.asuscomm.com:8443"
/**
* Every state of S-1, S-2, S-3 and S-18 (design-spec §4 and PLAN-M7a §5). Each is produced by the screen's real
* ViewModel over a `:core:fakes` scenario (with a scripted outcome where a scenario cannot express it); the captured
@@ -39,7 +44,7 @@ object OnboardingStates {
act: PairingViewModel.() -> Unit,
): () -> @Composable () -> Unit = {
val store = FakeRepositories(scenario).store
val vm = PairingViewModel(ScriptedSessionRepository(store, outcome), allowLanHttp)
val vm = PairingViewModel(ScriptedSessionRepository(store, outcome), allowLanHttp, GOLDEN_SERVER)
vm.act()
val state = vm.state.value
{ PairingContent(state, PairingActions()) }
@@ -64,7 +69,7 @@ object OnboardingStates {
{ WaitingContent(state, WaitingActions()) }
}
private fun PairingViewModel.fillFromLink(server: String = RELEASE_SERVER_URL) {
private fun PairingViewModel.fillFromLink(server: String = GOLDEN_SERVER) {
prefill(server, CODE)
onNameChange("Anna")
}
@@ -80,7 +85,7 @@ object OnboardingStates {
ScreenState(
"s1_pairing_link_admin",
render = pairing(OnboardingScenarios.unpaired) {
prefill(RELEASE_SERVER_URL, CODE, "Jacob")
prefill(GOLDEN_SERVER, CODE, "Jacob")
onNameChange("Anna")
},
),