From d090c6c850fb0f56327e41014b0728072906bc83 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Mon, 19 Oct 2026 19:58:58 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B --- android/README.md | 8 +++ android/app/build.gradle.kts | 19 ++++++- android/app/src/main/AndroidManifest.xml | 5 +- .../kotlin/se/hagstedt/kvitt/di/AppModule.kt | 6 +++ .../kotlin/se/hagstedt/kvitt/link/AppLinks.kt | 5 +- .../se/hagstedt/kvitt/link/ReleaseServer.kt | 11 ++++ .../se/hagstedt/kvitt/AppViewModelTest.kt | 3 +- .../se/hagstedt/kvitt/link/AppLinksTest.kt | 50 ++++++++++++++----- .../feature/onboarding/PairingViewModel.kt | 15 +++--- .../kvitt/feature/onboarding/ServerAddress.kt | 16 +++++- .../feature/onboarding/InviteLinkTest.kt | 18 +++---- .../onboarding/PairingViewModelTest.kt | 16 +++--- .../feature/onboarding/ServerAddressTest.kt | 13 +++-- .../kvitt/feature/onboarding/TestServer.kt | 8 +++ .../onboarding/screenshot/OnboardingStates.kt | 13 +++-- 15 files changed, 156 insertions(+), 50 deletions(-) create mode 100644 android/app/src/main/kotlin/se/hagstedt/kvitt/link/ReleaseServer.kt create mode 100644 android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/TestServer.kt diff --git a/android/README.md b/android/README.md index 51c7311..81b952a 100644 --- a/android/README.md +++ b/android/README.md @@ -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 `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://:8443/i/` invite links, AC-2.6) and as `RELEASE_SERVER_URL` = `https://: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 | Module | Plugin (build-logic) | Notes | diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index e3f1f5a..f326e50 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -17,6 +17,21 @@ val localProperties = Properties().apply { } 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://: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. 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". versionCode = providers.gradleProperty("kvitt.versionCode").orElse("1").get().toInt() 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 // are left out of the APK. androidResources { 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 { buildConfig = true } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index a173284..f183946 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -34,14 +34,15 @@ - + diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt index 623f795..5a80af7 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/di/AppModule.kt @@ -9,6 +9,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob 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 javax.inject.Qualifier import javax.inject.Singleton @@ -23,6 +25,10 @@ annotation class AppScope object AppModule { @Provides @Singleton @AppScope 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 diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt index 26bd92a..9c2330f 100644 --- a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/AppLinks.kt @@ -26,8 +26,9 @@ sealed interface AppLink { /** * Parses the intents `MainActivity` receives: `kvitt://invite?server=…&code=…[&from=…]` and - * `https://icehack.asuscomm.com:8443/i/[?from=…]` (AC-2.6, both through `:feature:onboarding`'s [InviteLink], the reader - * S-1 uses for pasted links), and the notification taps this app builds ([openIntent]). + * `https://:8443/i/[?from=…]` (AC-2.6; the manifest's intent filter takes its host from the + * `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 { const val ACTION_OPEN = "se.hagstedt.kvitt.action.OPEN" diff --git a/android/app/src/main/kotlin/se/hagstedt/kvitt/link/ReleaseServer.kt b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/ReleaseServer.kt new file mode 100644 index 0000000..4bd2ab0 --- /dev/null +++ b/android/app/src/main/kotlin/se/hagstedt/kvitt/link/ReleaseServer.kt @@ -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://: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) diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt index a257378..47e3383 100644 --- a/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/AppViewModelTest.kt @@ -11,6 +11,7 @@ import se.hagstedt.kvitt.fakes.FakeRepositories import se.hagstedt.kvitt.fakes.FakeScenario import se.hagstedt.kvitt.fakes.FakeScenarios import se.hagstedt.kvitt.link.AppLink +import se.hagstedt.kvitt.link.RELEASE_SERVER_URL import se.hagstedt.kvitt.model.SessionState 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 val link = AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCD2345EFGH", "Jacob") + private val link = AppLink.Invite(RELEASE_SERVER_URL, "ABCD2345EFGH", "Jacob") @Test fun anInviteLinkWhilePaired_isDropped_andNeverPrefillsALaterPairing() { diff --git a/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt index faa9363..6941876 100644 --- a/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt +++ b/android/app/src/test/kotlin/se/hagstedt/kvitt/link/AppLinksTest.kt @@ -4,17 +4,43 @@ import android.app.Application import android.content.Intent import android.net.Uri import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment 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. */ @RunWith(RobolectricTestRunner::class) @Config(sdk = [36], application = Application::class) 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 fun appLinkFillsServerAndCode() { 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 fun appLinkNormalisesTheCode() { // §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 fun landingLinkFillsTheServerFromTheLink() { - val link = AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN") - assertEquals(AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN"), link) + val link = AppLinks.parseUri("$RELEASE_SERVER_URL/i/ABCDEFGHJKMN") + assertEquals(AppLink.Invite(RELEASE_SERVER_URL, "ABCDEFGHJKMN"), link) } @Test fun linksWithTheAdminNameCarryIt_andOldLinksStillParse() { assertEquals( - AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN", "Åsa Berg"), - AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/ABCDEFGHJKMN?from=%C3%85sa%20Berg"), + AppLink.Invite(RELEASE_SERVER_URL, "ABCDEFGHJKMN", "Åsa Berg"), + AppLinks.parseUri("$RELEASE_SERVER_URL/i/ABCDEFGHJKMN?from=%C3%85sa%20Berg"), ) assertEquals( - AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN", "Jacob"), - AppLinks.parseUri("kvitt://invite?server=https%3A%2F%2Ficehack%2Easuscomm%2Ecom%3A8443&code=ABCDEFGHJKMN&from=Jacob"), + AppLink.Invite(RELEASE_SERVER_URL, "ABCDEFGHJKMN", "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 fun otherLinksAreIgnored() { assertNull(AppLinks.parseUri(null)) - assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/apk/latest")) - assertNull(AppLinks.parseUri("https://icehack.asuscomm.com:8443/i/SHORT")) + assertNull(AppLinks.parseUri("$RELEASE_SERVER_URL/apk/latest")) + assertNull(AppLinks.parseUri("$RELEASE_SERVER_URL/i/SHORT")) assertNull(AppLinks.parseUri("kvitt://invite?server=x")) assertNull(AppLinks.parseUri("kvitt://other?code=ABCDEFGHJKMN")) // A bare code is a paste on S-1, not a link. @@ -59,8 +85,8 @@ class AppLinksTest { @Test fun viewIntentIsParsed() { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse("https://icehack.asuscomm.com:8443/i/ABCD-EFGH-JKMN")) - assertEquals(AppLink.Invite("https://icehack.asuscomm.com:8443", "ABCDEFGHJKMN"), AppLinks.parse(intent)) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse("$RELEASE_SERVER_URL/i/ABCD-EFGH-JKMN")) + assertEquals(AppLink.Invite(RELEASE_SERVER_URL, "ABCDEFGHJKMN"), AppLinks.parse(intent)) } @Test diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt index 1fddac2..246572a 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModel.kt @@ -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 = 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, ) } diff --git a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddress.kt b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddress.kt index 6096944..1d7582b 100644 --- a/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddress.kt +++ b/android/feature/onboarding/src/main/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddress.kt @@ -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://: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 { diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt index 594b9d4..4101a98 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/InviteLinkTest.kt @@ -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")) } } diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt index 22818c7..02327a9 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/PairingViewModelTest.kt @@ -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") diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddressTest.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddressTest.kt index 0c21d84..f649925 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddressTest.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/ServerAddressTest.kt @@ -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/ ")) } } diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/TestServer.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/TestServer.kt new file mode 100644 index 0000000..da5d0ea --- /dev/null +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/TestServer.kt @@ -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) diff --git a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt index fc59115..0e1e831 100644 --- a/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt +++ b/android/feature/onboarding/src/test/kotlin/se/hagstedt/kvitt/feature/onboarding/screenshot/OnboardingStates.kt @@ -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") }, ),