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
+8
View File
@@ -5,6 +5,14 @@ One Gradle project, modules as in SPEC §6.2 and §14.7. Build only through `too
compile, unit tests (including every vector of `contracts/test-vectors/`), lint (`MissingTranslation` and compile, unit tests (including every vector of `contracts/test-vectors/`), lint (`MissingTranslation` and
`ExtraTranslation` are errors), the architecture check and the screenshot tests. `ExtraTranslation` are errors), the architecture check and the screenshot tests.
**Public host (D-121).** The release server's host name is the build property `kvitt.publicHost`, never a literal in
the code: `:app` exposes it as `BuildConfig.PUBLIC_HOST`, as the manifest placeholder `publicHost` (the host of the
`https://<host>:8443/i/<CODE>` invite links, AC-2.6) and as `RELEASE_SERVER_URL` = `https://<host>:8443`, which S-1
prefills (AC-2.7). Gradle takes `-Pkvitt.publicHost=…` first (`infra/publish-apk.sh` passes it), else the key
`kvitt.publicHost` in `android/local.properties`, where the owner sets it (git-ignored, never committed), else the
placeholder `kvitt.example.invalid`, so a release built without it cannot talk to a real host. Debug builds pair with
the address typed on S-1. Tests and goldens never depend on it: they use fixed hosts.
## Modules ## Modules
| Module | Plugin (build-logic) | Notes | | Module | Plugin (build-logic) | Notes |
+18 -1
View File
@@ -17,6 +17,21 @@ val localProperties = Properties().apply {
} }
val releaseStoreFile: String? = localProperties.getProperty("kvitt.release.storeFile") val releaseStoreFile: String? = localProperties.getProperty("kvitt.release.storeFile")
// D-121: Kvitt's public host name (SPEC §8.5, §10.6), as BuildConfig.PUBLIC_HOST, the manifest placeholder publicHost
// (the https invite links of AC-2.6, port 8443) and RELEASE_SERVER_URL = https://<host>:8443 (AC-2.7). From
// -Pkvitt.publicHost (infra/publish-apk.sh passes it, from KVITT_PUBLIC_HOST in infra/pi/.env), else the key
// kvitt.publicHost in android/local.properties (the owner sets it there; never committed), else the placeholder
// kvitt.example.invalid (RFC 2606: never resolves), so a release built without it cannot talk to a real host. Debug
// builds pair with the server address typed on S-1 and need none of it.
val publicHost: String = (
providers.gradleProperty("kvitt.publicHost").orNull?.trim()?.takeIf { it.isNotEmpty() }
?: localProperties.getProperty("kvitt.publicHost")?.trim()?.takeIf { it.isNotEmpty() }
?: "kvitt.example.invalid"
).lowercase()
require(Regex("[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+").matches(publicHost)) {
"kvitt.publicHost must be a host name such as kvitt.duckdns.org (no scheme, port or path), was '$publicHost'"
}
// SPEC §10.5.3: the pinned Caddy root replaces the system anchors for icehack.asuscomm.com only when this is true. // SPEC §10.5.3: the pinned Caddy root replaces the system anchors for icehack.asuscomm.com only when this is true.
val pinnedRoot = providers.gradleProperty("kvitt.tls.pinnedRoot").orElse("false").get().toBoolean() val pinnedRoot = providers.gradleProperty("kvitt.tls.pinnedRoot").orElse("false").get().toBoolean()
@@ -27,13 +42,15 @@ android {
// SPEC §10.6: the release script passes kvitt.versionCode and kvitt.versionName; debug builds use 1 and "dev". // SPEC §10.6: the release script passes kvitt.versionCode and kvitt.versionName; debug builds use 1 and "dev".
versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt() versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt()
versionName = providers.gradleProperty("kvitt.versionName").orElse("dev").get() versionName = providers.gradleProperty("kvitt.versionName").orElse("dev").get()
buildConfigField("String", "PUBLIC_HOST", "\"$publicHost\"")
manifestPlaceholders["publicHost"] = publicHost
} }
// SPEC G-8 and AC-19.1: the app is English (fallback) and Swedish only; library translations of other languages // SPEC G-8 and AC-19.1: the app is English (fallback) and Swedish only; library translations of other languages
// are left out of the APK. // are left out of the APK.
androidResources { androidResources {
localeFilters += listOf("en", "sv") localeFilters += listOf("en", "sv")
} }
// BuildConfig carries versionCode, versionName and DEBUG into AppBuildInfo (:data, D-041). // BuildConfig carries versionCode, versionName and DEBUG into AppBuildInfo (:data, D-041), and PUBLIC_HOST (D-121).
buildFeatures { buildFeatures {
buildConfig = true buildConfig = true
} }
+3 -2
View File
@@ -34,14 +34,15 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="kvitt" android:host="invite" /> <data android:scheme="kvitt" android:host="invite" />
</intent-filter> </intent-filter>
<!-- Unverified link: the user may get a chooser (AC-2.6). --> <!-- Unverified link: the user may get a chooser (AC-2.6). The host is the Gradle property kvitt.publicHost
(BuildConfig.PUBLIC_HOST, D-121). -->
<intent-filter android:autoVerify="false"> <intent-filter android:autoVerify="false">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data <data
android:scheme="https" android:scheme="https"
android:host="icehack.asuscomm.com" android:host="${publicHost}"
android:port="8443" android:port="8443"
android:pathPrefix="/i/" /> android:pathPrefix="/i/" />
</intent-filter> </intent-filter>
@@ -9,6 +9,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import se.hagstedt.kvitt.adapter.fcm.PushListener import se.hagstedt.kvitt.adapter.fcm.PushListener
import se.hagstedt.kvitt.feature.onboarding.ReleaseServerUrl
import se.hagstedt.kvitt.link.RELEASE_SERVER_URL
import se.hagstedt.kvitt.push.PushHandler import se.hagstedt.kvitt.push.PushHandler
import javax.inject.Qualifier import javax.inject.Qualifier
import javax.inject.Singleton import javax.inject.Singleton
@@ -23,6 +25,10 @@ annotation class AppScope
object AppModule { object AppModule {
@Provides @Singleton @AppScope @Provides @Singleton @AppScope
fun scope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) fun scope(): CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** S-1's prefilled release server, from `BuildConfig.PUBLIC_HOST` (AC-2.7, D-121). */
@Provides @ReleaseServerUrl
fun releaseServerUrl(): String = RELEASE_SERVER_URL
} }
@Module @Module
@@ -26,8 +26,9 @@ sealed interface AppLink {
/** /**
* Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…[&from=…]` and * Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…[&from=…]` and
* `https://icehack.asuscomm.com:8443/i/<CODE>[?from=…]` (AC-2.6, both through `:feature:onboarding`'s [InviteLink], the reader * `https://<BuildConfig.PUBLIC_HOST>:8443/i/<CODE>[?from=…]` (AC-2.6; the manifest's intent filter takes its host from the
* S-1 uses for pasted links), and the notification taps this app builds ([openIntent]). * `publicHost` placeholder, D-121), both through `:feature:onboarding`'s [InviteLink], the reader S-1 uses for pasted
* links, which takes the server from the link itself, and the notification taps this app builds ([openIntent]).
*/ */
object AppLinks { object AppLinks {
const val ACTION_OPEN = "se.hagstedt.kvitt.action.OPEN" const val ACTION_OPEN = "se.hagstedt.kvitt.action.OPEN"
@@ -0,0 +1,11 @@
package se.hagstedt.kvitt.link
import se.hagstedt.kvitt.BuildConfig
import se.hagstedt.kvitt.feature.onboarding.releaseServerUrl
/**
* The release server, `https://<BuildConfig.PUBLIC_HOST>:8443` (AC-2.7, D-121): prefilled on S-1 and the host of the
* `https` invite links the manifest declares (AC-2.6, the `publicHost` placeholder). `PUBLIC_HOST` is the Gradle
* property `kvitt.publicHost`; without it the build uses the placeholder `kvitt.example.invalid`, which never resolves.
*/
val RELEASE_SERVER_URL: String = releaseServerUrl(BuildConfig.PUBLIC_HOST)
@@ -11,6 +11,7 @@ import se.hagstedt.kvitt.fakes.FakeRepositories
import se.hagstedt.kvitt.fakes.FakeScenario import se.hagstedt.kvitt.fakes.FakeScenario
import se.hagstedt.kvitt.fakes.FakeScenarios import se.hagstedt.kvitt.fakes.FakeScenarios
import se.hagstedt.kvitt.link.AppLink import se.hagstedt.kvitt.link.AppLink
import se.hagstedt.kvitt.link.RELEASE_SERVER_URL
import se.hagstedt.kvitt.model.SessionState import se.hagstedt.kvitt.model.SessionState
import se.hagstedt.kvitt.design.testing.MainDispatcherRule import se.hagstedt.kvitt.design.testing.MainDispatcherRule
@@ -22,7 +23,7 @@ class AppViewModelTest {
private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts) private fun vm(fakes: FakeRepositories) = AppViewModel(fakes.session, fakes.sync, fakes.accounts)
private val link = AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCD2345EFGH", "Jacob") private val link = AppLink.Invite(RELEASE_SERVER_URL, "ABCD2345EFGH", "Jacob")
@Test @Test
fun anInviteLinkWhilePaired_isDropped_andNeverPrefillsALaterPairing() { fun anInviteLinkWhilePaired_isDropped_andNeverPrefillsALaterPairing() {
@@ -4,17 +4,43 @@ import android.app.Application
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config import org.robolectric.annotation.Config
import se.hagstedt.kvitt.BuildConfig
import se.hagstedt.kvitt.feature.onboarding.releaseServerUrl
/** AC-2.6 invite links and AC-13.6 notification taps as `MainActivity` receives them. */ /** AC-2.6 invite links and AC-13.6 notification taps as `MainActivity` receives them. */
@RunWith(RobolectricTestRunner::class) @RunWith(RobolectricTestRunner::class)
@Config(sdk = [36], application = Application::class) @Config(sdk = [36], application = Application::class)
class AppLinksTest { class AppLinksTest {
/** The host this build was configured with (`kvitt.publicHost`, D-121). */
private val host = BuildConfig.PUBLIC_HOST
@Test
fun theReleaseServerIsHttpsOnPort8443OfThePublicHost() {
assertEquals("https://$host:8443", RELEASE_SERVER_URL)
// Without kvitt.publicHost the build uses the placeholder, which never resolves (RFC 2606).
assertEquals("https://kvitt.example.invalid:8443", releaseServerUrl("kvitt.example.invalid"))
}
@Test
fun theManifestOpensLandingLinksOfThePublicHostOnly() {
val context = RuntimeEnvironment.getApplication()
fun opens(url: String) = context.packageManager.queryIntentActivities(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addCategory(Intent.CATEGORY_BROWSABLE).setPackage(context.packageName),
0,
).any { it.activityInfo.name == "se.hagstedt.kvitt.MainActivity" }
assertTrue(opens("$RELEASE_SERVER_URL/i/ABCDEFGHJKMN"))
assertFalse(opens("https://other.example.org:8443/i/ABCDEFGHJKMN"))
assertFalse(opens("https://$host/i/ABCDEFGHJKMN"))
}
@Test @Test
fun appLinkFillsServerAndCode() { fun appLinkFillsServerAndCode() {
val link = AppLinks.parseUri("kvitt://invite?server=http%3A%2F%2F192.168.1.210%3A8080&code=ABCD-EFGH-JKMN") val link = AppLinks.parseUri("kvitt://invite?server=http%3A%2F%2F192.168.1.210%3A8080&code=ABCD-EFGH-JKMN")
@@ -24,33 +50,33 @@ class AppLinksTest {
@Test @Test
fun appLinkNormalisesTheCode() { fun appLinkNormalisesTheCode() {
// §8.3: lowercase, O -> 0, I and L -> 1. // §8.3: lowercase, O -> 0, I and L -> 1.
assertEquals("ABCD0113EFGH", AppLinks.parseUri("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=abcdo1l3efgh")?.code) assertEquals("ABCD0113EFGH", AppLinks.parseUri("kvitt://invite?server=https%3A%2F%2F$host%3A8443&code=abcdo1l3efgh")?.code)
} }
@Test @Test
fun landingLinkFillsTheServerFromTheLink() { fun landingLinkFillsTheServerFromTheLink() {
val link = AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN") val link = AppLinks.parseUri("$RELEASE_SERVER_URL/i/ABCDEFGHJKMN")
assertEquals(AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN"), link) assertEquals(AppLink.Invite(RELEASE_SERVER_URL, "ABCDEFGHJKMN"), link)
} }
@Test @Test
fun linksWithTheAdminNameCarryIt_andOldLinksStillParse() { fun linksWithTheAdminNameCarryIt_andOldLinksStillParse() {
assertEquals( assertEquals(
AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN", "Åsa Berg"), AppLink.Invite(RELEASE_SERVER_URL, "ABCDEFGHJKMN", "Åsa Berg"),
AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN?from=%C3%85sa%20Berg"), AppLinks.parseUri("$RELEASE_SERVER_URL/i/ABCDEFGHJKMN?from=%C3%85sa%20Berg"),
) )
assertEquals( assertEquals(
AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN", "Jacob"), AppLink.Invite(RELEASE_SERVER_URL, "ABCDEFGHJKMN", "Jacob"),
AppLinks.parseUri("kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&code=ABCDEFGHJKMN&from=Jacob"), AppLinks.parseUri("kvitt://invite?server=https%3A%2F%2F${host.replace(".", "%2E")}%3A8443&code=ABCDEFGHJKMN&from=Jacob"),
) )
assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN")?.adminName) assertNull(AppLinks.parseUri("$RELEASE_SERVER_URL/i/ABCDEFGHJKMN")?.adminName)
} }
@Test @Test
fun otherLinksAreIgnored() { fun otherLinksAreIgnored() {
assertNull(AppLinks.parseUri(null)) assertNull(AppLinks.parseUri(null))
assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/apk/latest")) assertNull(AppLinks.parseUri("$RELEASE_SERVER_URL/apk/latest"))
assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/SHORT")) assertNull(AppLinks.parseUri("$RELEASE_SERVER_URL/i/SHORT"))
assertNull(AppLinks.parseUri("kvitt://invite?server=x")) assertNull(AppLinks.parseUri("kvitt://invite?server=x"))
assertNull(AppLinks.parseUri("kvitt://other?code=ABCDEFGHJKMN")) assertNull(AppLinks.parseUri("kvitt://other?code=ABCDEFGHJKMN"))
// A bare code is a paste on S-1, not a link. // A bare code is a paste on S-1, not a link.
@@ -59,8 +85,8 @@ class AppLinksTest {
@Test @Test
fun viewIntentIsParsed() { fun viewIntentIsParsed() {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://icehack.asuscomm.com:8443/i/ABCD-EFGH-JKMN")) val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$RELEASE_SERVER_URL/i/ABCD-EFGH-JKMN"))
assertEquals(AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN"), AppLinks.parse(intent)) assertEquals(AppLink.Invite(RELEASE_SERVER_URL, "ABCDEFGHJKMN"), AppLinks.parse(intent))
} }
@Test @Test
@@ -56,7 +56,7 @@ data class PairingUiState(
val code: String = "", val code: String = "",
val adminName: String? = null, val adminName: String? = null,
val name: String = "", val name: String = "",
val serverUrl: String = RELEASE_SERVER_URL, val serverUrl: String,
val serverExpanded: Boolean = false, val serverExpanded: Boolean = false,
val pairing: Boolean = false, val pairing: Boolean = false,
val error: PairingError? = null, 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 * 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 @HiltViewModel
class PairingViewModel internal constructor( class PairingViewModel internal constructor(
private val session: SessionRepository, private val session: SessionRepository,
private val allowLanHttp: Boolean, private val allowLanHttp: Boolean,
private val releaseServerUrl: String,
) : ViewModel() { ) : ViewModel() {
@Inject @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() val state: StateFlow<PairingUiState> = mutable.asStateFlow()
private var prefilled = false private var prefilled = false
@@ -110,7 +113,7 @@ class PairingViewModel internal constructor(
code = code?.let(::normalizeInviteCode) ?: s.code, code = code?.let(::normalizeInviteCode) ?: s.code,
adminName = adminName ?: s.adminName, adminName = adminName ?: s.adminName,
serverUrl = server ?: s.serverUrl, 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, code = invite.code,
adminName = invite.adminName ?: s.adminName, adminName = invite.adminName ?: s.adminName,
serverUrl = server ?: s.serverUrl, serverUrl = server ?: s.serverUrl,
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL), serverExpanded = s.serverExpanded || (server != null && server != releaseServerUrl),
error = null, error = null,
) )
} }
@@ -2,9 +2,21 @@ package se.hagstedt.kvitt.feature.onboarding
import java.net.URI import java.net.URI
import java.net.URISyntaxException import java.net.URISyntaxException
import javax.inject.Qualifier
/** The release server, prefilled on S-1 (AC-2.7, SPEC §10.1). */ /** The release server's port: the router forwards external 8443 to the TLS proxy (SPEC §10.1, D-120). */
const val RELEASE_SERVER_URL = "https://icehack.asuscomm.com:8443" 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. */ /** What is wrong with a server address on S-1. */
enum class ServerAddressProblem { enum class ServerAddressProblem {
@@ -24,30 +24,30 @@ class InviteLinkTest {
@Test @Test
fun theAppLinkGivesCodeAndServer() { fun theAppLinkGivesCodeAndServer() {
val parsed = InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA") val parsed = InviteLink.parse("kvitt://invite?server=https%3A%2F%2Fkvitt.example.org%3A8443&code=7K3MQX9D2HPA")
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), parsed) assertEquals(PastedInvite("7K3MQX9D2HPA", TEST_RELEASE_SERVER_URL), parsed)
assertNull(InviteLink.parse("kvitt://other?code=7K3MQX9D2HPA")) assertNull(InviteLink.parse("kvitt://other?code=7K3MQX9D2HPA"))
} }
@Test @Test
fun linksMayNameTheAdmin_andABadNameIsDropped() { fun linksMayNameTheAdmin_andABadNameIsDropped() {
assertEquals( assertEquals(
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Jacob"), PastedInvite("7K3MQX9D2HPA", TEST_RELEASE_SERVER_URL, "Jacob"),
InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA&from=Jacob"), InviteLink.parse("kvitt://invite?server=https%3A%2F%2Fkvitt.example.org%3A8443&code=7K3MQX9D2HPA&from=Jacob"),
) )
assertEquals( assertEquals(
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Åsa Berg"), PastedInvite("7K3MQX9D2HPA", TEST_RELEASE_SERVER_URL, "Åsa Berg"),
InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=%C3%85sa+Berg"), InviteLink.parse("https://kvitt.example.org:8443/i/7K3MQX9D2HPA?from=%C3%85sa+Berg"),
) )
for (bad in listOf("", "%20", "a%0Ab", "A".repeat(41))) { 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 @Test
fun theLandingLinkGivesCodeAndServer() { 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")) 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() val main = MainDispatcherRule()
private fun vm(scenario: FakeScenario = OnboardingScenarios.unpaired, outcome: CallOutcome = CallOutcome.Succeed, allowLanHttp: Boolean = false) = 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 @Test
fun theCodeIsNormalisedWhileTyping() { fun theCodeIsNormalisedWhileTyping() {
@@ -36,7 +36,7 @@ class PairingViewModelTest {
@Test @Test
fun pairIsEnabledWithTwelveCharactersANameAndAWellFormedAddress() { fun pairIsEnabledWithTwelveCharactersANameAndAWellFormedAddress() {
val (_, vm) = vm() val (_, vm) = vm()
assertEquals(RELEASE_SERVER_URL, vm.state.value.serverUrl) assertEquals(TEST_RELEASE_SERVER_URL, vm.state.value.serverUrl)
vm.onCodeChange("7K3MQX9D2HP") vm.onCodeChange("7K3MQX9D2HP")
vm.onNameChange("Anna") vm.onNameChange("Anna")
assertFalse(vm.state.value.canPair) assertFalse(vm.state.value.canPair)
@@ -55,17 +55,17 @@ class PairingViewModelTest {
@Test @Test
fun aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName() { fun aFirstPairingSendsTheNormalisedCodeAndTheTrimmedName() {
val (session, vm) = vm(OnboardingScenarios.unpairedPartner) 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.onNameChange(" Anna ")
vm.pair("Galaxy S26+") 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) assertEquals(PairingDone.ProfileStep, vm.state.value.done)
} }
@Test @Test
fun aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin() { fun aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin() {
val (_, vm) = vm(OnboardingScenarios.unpairedPartner.copy(pairingInviteKind = InviteKind.DEVICE)) 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) assertEquals("Jacob", vm.state.value.adminName)
vm.onNameChange("Anna") vm.onNameChange("Anna")
vm.pair("Galaxy S26+") vm.pair("Galaxy S26+")
@@ -94,10 +94,10 @@ class PairingViewModelTest {
val (_, vm) = vm() val (_, vm) = vm()
vm.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA") vm.prefill("http://192.168.1.210:8080", "7K3MQX9D2HPA")
assertTrue(vm.state.value.serverExpanded) assertTrue(vm.state.value.serverExpanded)
vm.prefill(RELEASE_SERVER_URL, "AAAAAAAAAAAA") vm.prefill(TEST_RELEASE_SERVER_URL, "AAAAAAAAAAAA")
assertEquals("7K3MQX9D2HPA", vm.state.value.code) assertEquals("7K3MQX9D2HPA", vm.state.value.code)
val (_, plain) = vm() val (_, plain) = vm()
plain.prefill(RELEASE_SERVER_URL, "7K3MQX9D2HPA") plain.prefill(TEST_RELEASE_SERVER_URL, "7K3MQX9D2HPA")
assertFalse(plain.state.value.serverExpanded) assertFalse(plain.state.value.serverExpanded)
} }
@@ -169,7 +169,7 @@ class PairingViewModelTest {
@Test @Test
fun pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters() { fun pasteFillsCodeAndServerOrSaysTheCodeHasTwelveCharacters() {
val (_, vm) = vm() 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) assertEquals("7K3MQX9D2HPA", vm.state.value.code)
assertNull(vm.state.value.error) assertNull(vm.state.value.error)
vm.onPaste("hello") vm.onPaste("hello")
@@ -10,7 +10,7 @@ import org.junit.Test
class ServerAddressTest { class ServerAddressTest {
@Test @Test
fun httpsIsAlwaysAccepted() { 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)) assertNull(ServerAddress.check("https://example.org/", allowLanHttp = false))
} }
@@ -34,15 +34,22 @@ class ServerAddressTest {
@Test @Test
fun malformedAddressesAreInvalid() { 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)) assertEquals(it, ServerAddressProblem.Invalid, ServerAddress.check(it, allowLanHttp = true))
assertFalse(it, ServerAddress.isWellFormed(it)) assertFalse(it, ServerAddress.isWellFormed(it))
} }
assertTrue(ServerAddress.isWellFormed("http://example.org")) 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 @Test
fun normaliseTrimsSpacesAndTrailingSlash() { 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.ProfileStepActions
import se.hagstedt.kvitt.feature.onboarding.ProfileStepContent import se.hagstedt.kvitt.feature.onboarding.ProfileStepContent
import se.hagstedt.kvitt.feature.onboarding.ProfileStepViewModel 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.RemovedContent
import se.hagstedt.kvitt.feature.onboarding.WaitingActions import se.hagstedt.kvitt.feature.onboarding.WaitingActions
import se.hagstedt.kvitt.feature.onboarding.WaitingContent 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 CODE = "7K3MQX9D2HPA"
private const val LAN_SERVER = "http://192.168.1.210:8080" 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 * 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 * 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, act: PairingViewModel.() -> Unit,
): () -> @Composable () -> Unit = { ): () -> @Composable () -> Unit = {
val store = FakeRepositories(scenario).store val store = FakeRepositories(scenario).store
val vm = PairingViewModel(ScriptedSessionRepository(store, outcome), allowLanHttp) val vm = PairingViewModel(ScriptedSessionRepository(store, outcome), allowLanHttp, GOLDEN_SERVER)
vm.act() vm.act()
val state = vm.state.value val state = vm.state.value
{ PairingContent(state, PairingActions()) } { PairingContent(state, PairingActions()) }
@@ -64,7 +69,7 @@ object OnboardingStates {
{ WaitingContent(state, WaitingActions()) } { WaitingContent(state, WaitingActions()) }
} }
private fun PairingViewModel.fillFromLink(server: String = RELEASE_SERVER_URL) { private fun PairingViewModel.fillFromLink(server: String = GOLDEN_SERVER) {
prefill(server, CODE) prefill(server, CODE)
onNameChange("Anna") onNameChange("Anna")
} }
@@ -80,7 +85,7 @@ object OnboardingStates {
ScreenState( ScreenState(
"s1_pairing_link_admin", "s1_pairing_link_admin",
render = pairing(OnboardingScenarios.unpaired) { render = pairing(OnboardingScenarios.unpaired) {
prefill(RELEASE_SERVER_URL, CODE, "Jacob") prefill(GOLDEN_SERVER, CODE, "Jacob")
onNameChange("Anna") onNameChange("Anna")
}, },
), ),