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:
@@ -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 |
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+9
-6
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-2
@@ -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 {
|
||||||
|
|||||||
+9
-9
@@ -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"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -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
-3
@@ -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/ "))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+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.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")
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user