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:
+9
-6
@@ -56,7 +56,7 @@ data class PairingUiState(
|
||||
val code: String = "",
|
||||
val adminName: String? = null,
|
||||
val name: String = "",
|
||||
val serverUrl: String = RELEASE_SERVER_URL,
|
||||
val serverUrl: String,
|
||||
val serverExpanded: Boolean = false,
|
||||
val pairing: Boolean = false,
|
||||
val error: PairingError? = null,
|
||||
@@ -75,17 +75,20 @@ data class PairingUiState(
|
||||
|
||||
/**
|
||||
* State holder of S-1 Pairing (design-spec S-1; AC-2.4, AC-2.6 to AC-2.8, AC-2.10; §8.3; §14.4.8). Pairing never
|
||||
* queues: offline, the call reports the network error. [allowLanHttp] is true in debug builds (AC-2.8).
|
||||
* queues: offline, the call reports the network error. [allowLanHttp] is true in debug builds (AC-2.8);
|
||||
* [releaseServerUrl] is the prefilled release server `:app` provides (AC-2.7, D-121).
|
||||
*/
|
||||
@HiltViewModel
|
||||
class PairingViewModel internal constructor(
|
||||
private val session: SessionRepository,
|
||||
private val allowLanHttp: Boolean,
|
||||
private val releaseServerUrl: String,
|
||||
) : ViewModel() {
|
||||
@Inject
|
||||
constructor(session: SessionRepository) : this(session, BuildConfig.DEBUG)
|
||||
constructor(session: SessionRepository, @ReleaseServerUrl releaseServerUrl: String) :
|
||||
this(session, BuildConfig.DEBUG, releaseServerUrl)
|
||||
|
||||
private val mutable = MutableStateFlow(PairingUiState())
|
||||
private val mutable = MutableStateFlow(PairingUiState(serverUrl = releaseServerUrl))
|
||||
val state: StateFlow<PairingUiState> = mutable.asStateFlow()
|
||||
private var prefilled = false
|
||||
|
||||
@@ -110,7 +113,7 @@ class PairingViewModel internal constructor(
|
||||
code = code?.let(::normalizeInviteCode) ?: s.code,
|
||||
adminName = adminName ?: s.adminName,
|
||||
serverUrl = server ?: s.serverUrl,
|
||||
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
|
||||
serverExpanded = s.serverExpanded || (server != null && server != releaseServerUrl),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +142,7 @@ class PairingViewModel internal constructor(
|
||||
code = invite.code,
|
||||
adminName = invite.adminName ?: s.adminName,
|
||||
serverUrl = server ?: s.serverUrl,
|
||||
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
|
||||
serverExpanded = s.serverExpanded || (server != null && server != releaseServerUrl),
|
||||
error = null,
|
||||
)
|
||||
}
|
||||
|
||||
+14
-2
@@ -2,9 +2,21 @@ package se.hagstedt.kvitt.feature.onboarding
|
||||
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import javax.inject.Qualifier
|
||||
|
||||
/** The release server, prefilled on S-1 (AC-2.7, SPEC §10.1). */
|
||||
const val RELEASE_SERVER_URL = "https://icehack.asuscomm.com:8443"
|
||||
/** The release server's port: the router forwards external 8443 to the TLS proxy (SPEC §10.1, D-120). */
|
||||
const val RELEASE_SERVER_PORT = 8443
|
||||
|
||||
/**
|
||||
* The release server of a public host name, `https://<host>:8443` (AC-2.7, D-121). `:app` builds it from its
|
||||
* `BuildConfig.PUBLIC_HOST` (the Gradle property `kvitt.publicHost`) and provides it as [ReleaseServerUrl].
|
||||
*/
|
||||
fun releaseServerUrl(publicHost: String): String = "https://$publicHost:$RELEASE_SERVER_PORT"
|
||||
|
||||
/** The release server prefilled on S-1 (AC-2.7), `RELEASE_SERVER_URL` in `:app` (D-121). */
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class ReleaseServerUrl
|
||||
|
||||
/** What is wrong with a server address on S-1. */
|
||||
enum class ServerAddressProblem {
|
||||
|
||||
+9
-9
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -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
-3
@@ -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/ "))
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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)
|
||||
+9
-4
@@ -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")
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user