M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)
Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite; X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3); OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
+31
-12
@@ -6,13 +6,16 @@ import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import java.net.URLDecoder
|
||||
|
||||
/** What "Klistra in" found on the clipboard: a normalised 12-character code and, from a link, the server. */
|
||||
data class PastedInvite(val code: String, val serverUrl: String?)
|
||||
/**
|
||||
* What "Klistra in" found on the clipboard: a normalised 12-character code and, from a link, the server and the
|
||||
* inviting admin's display name ([adminName], the optional `from` parameter of E-12's link; older links have none).
|
||||
*/
|
||||
data class PastedInvite(val code: String, val serverUrl: String?, val adminName: String? = null)
|
||||
|
||||
/**
|
||||
* Reads pasted text on S-1 (design-spec S-1 "Klistra in"): an invite link `kvitt://invite?server=…&code=…`, a landing
|
||||
* link `https://<server>/i/<CODE>` (AC-2.5, AC-2.6), or a bare code in any spelling §8.3 normalises. Anything else is
|
||||
* null. Opening the app from a link is `:app`'s; this only reads the clipboard.
|
||||
* Reads pasted text on S-1 (design-spec S-1 "Klistra in") and the links `:app` opens: an invite link
|
||||
* `kvitt://invite?server=…&code=…[&from=…]`, a landing link `https://<server>/i/<CODE>[?from=…]` (AC-2.5, AC-2.6), or a
|
||||
* bare code in any spelling §8.3 normalises. Anything else is null.
|
||||
*/
|
||||
object InviteLink {
|
||||
fun parse(text: String): PastedInvite? {
|
||||
@@ -31,13 +34,9 @@ object InviteLink {
|
||||
|
||||
private fun fromAppLink(uri: URI): PastedInvite? {
|
||||
if (uri.schemeSpecificPart?.startsWith("//invite") != true) return null
|
||||
val query = uri.rawQuery ?: return null
|
||||
val params = query.split('&').mapNotNull { pair ->
|
||||
val key = pair.substringBefore('=', "")
|
||||
if (key.isEmpty()) null else key to URLDecoder.decode(pair.substringAfter('='), Charsets.UTF_8)
|
||||
}.toMap()
|
||||
val params = params(uri.rawQuery ?: return null)
|
||||
val code = params["code"]?.let(::code) ?: return null
|
||||
return PastedInvite(code, params["server"]?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() })
|
||||
return PastedInvite(code, params["server"]?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() }, adminName(params[FROM]))
|
||||
}
|
||||
|
||||
private fun fromLandingLink(uri: URI): PastedInvite? {
|
||||
@@ -46,9 +45,29 @@ object InviteLink {
|
||||
if (segments.size != 2 || segments[0] != "i") return null
|
||||
val code = code(segments[1]) ?: return null
|
||||
val port = if (uri.port == -1) "" else ":${uri.port}"
|
||||
return PastedInvite(code, "${uri.scheme.lowercase()}://${uri.host}$port")
|
||||
return PastedInvite(code, "${uri.scheme.lowercase()}://${uri.host}$port", adminName(uri.rawQuery?.let(::params)?.get(FROM)))
|
||||
}
|
||||
|
||||
/** The first value of each key; a value that is not valid percent-encoding is dropped. */
|
||||
private fun params(query: String): Map<String, String> = query.split('&').mapNotNull { pair ->
|
||||
val key = pair.substringBefore('=', "")
|
||||
val value = try {
|
||||
URLDecoder.decode(pair.substringAfter('='), Charsets.UTF_8)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
if (key.isEmpty() || value == null) null else key to value
|
||||
}.distinctBy { it.first }.toMap()
|
||||
|
||||
/** A display name of §9.6 (1 to 40 code points after trimming, no control characters), else null. */
|
||||
private fun adminName(value: String?): String? {
|
||||
val name = value?.trim() ?: return null
|
||||
return name.takeIf { it.codePointCount(0, it.length) in 1..NAME_MAX && it.none(Char::isISOControl) }
|
||||
}
|
||||
|
||||
private const val FROM = "from"
|
||||
private const val NAME_MAX = 40
|
||||
|
||||
/** The normalised code when [text] normalises to exactly 12 alphabet characters (§8.3), else null. */
|
||||
private fun code(text: String): String? {
|
||||
val stripped = text.uppercase().filter { it != '-' && !it.isWhitespace() }
|
||||
|
||||
+9
-10
@@ -62,8 +62,8 @@ data class PairingActions(
|
||||
)
|
||||
|
||||
/**
|
||||
* S-1 Pairing (design-spec S-1). [initialServerUrl] and [initialCode] come from an invite link that `:app` parsed
|
||||
* (AC-2.6); [onPaired] receives where to go next (S-2 for a partner, AC-2.10; else Home).
|
||||
* S-1 Pairing (design-spec S-1). [initialServerUrl], [initialCode] and [initialAdminName] come from an invite link that
|
||||
* `:app` parsed (AC-2.6); [onPaired] receives where to go next (S-2 after a partner invite, AC-2.10; else Home).
|
||||
*/
|
||||
@Composable
|
||||
fun PairingScreen(
|
||||
@@ -71,13 +71,14 @@ fun PairingScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
initialServerUrl: String? = null,
|
||||
initialCode: String? = null,
|
||||
initialAdminName: String? = null,
|
||||
viewModel: PairingViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val clipboard = LocalClipboard.current
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
LaunchedEffect(initialServerUrl, initialCode) { viewModel.prefill(initialServerUrl, initialCode) }
|
||||
LaunchedEffect(initialServerUrl, initialCode, initialAdminName) { viewModel.prefill(initialServerUrl, initialCode, initialAdminName) }
|
||||
LaunchedEffect(state.done) {
|
||||
state.done?.let {
|
||||
onPaired(it)
|
||||
@@ -181,7 +182,7 @@ fun PairingContent(
|
||||
readOnly = state.pairing,
|
||||
)
|
||||
}
|
||||
HelpLine()
|
||||
HelpLine(state.adminName)
|
||||
val serverError = when (state.error) {
|
||||
PairingError.Network -> stringResource(R.string.onboarding_error_network)
|
||||
PairingError.HttpsRequired -> stringResource(R.string.onboarding_error_https)
|
||||
@@ -245,19 +246,17 @@ private fun Brand(mode: PairingMode) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the code comes from. The admin's name is not known before pairing (an invite link carries only server and
|
||||
* code, AC-2.6), so the generic line is used (`onboarding_code_help_generic`; spec gap in the M7a report).
|
||||
*/
|
||||
/** Where the code comes from: from the admin named in the invite link, else the generic line (AC-2.6). */
|
||||
@Composable
|
||||
private fun HelpLine() {
|
||||
private fun HelpLine(adminName: String?) {
|
||||
val muted = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(horizontal = KvittTheme.spacing.s2),
|
||||
horizontalArrangement = Arrangement.spacedBy(KvittTheme.spacing.s3),
|
||||
) {
|
||||
KvittIconImage(KvittIcon.Info, null, size = KvittDimens.helpIcon, tint = muted)
|
||||
Text(stringResource(R.string.onboarding_code_help_generic), style = MaterialTheme.typography.bodyMedium, color = muted)
|
||||
val text = adminName?.let { stringResource(R.string.onboarding_code_help, it) } ?: stringResource(R.string.onboarding_code_help_generic)
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium, color = muted)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+11
-5
@@ -11,7 +11,7 @@ import kotlinx.coroutines.launch
|
||||
import se.hagstedt.kvitt.design.component.INVITE_LENGTH
|
||||
import se.hagstedt.kvitt.design.component.normalizeInviteCode
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.Role
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import javax.inject.Inject
|
||||
@@ -38,17 +38,21 @@ enum class PairingError {
|
||||
|
||||
/** Where S-1 goes after a successful pairing. */
|
||||
enum class PairingDone {
|
||||
/** A `member` on a first pairing confirms the profile first (AC-2.10). */
|
||||
/** A `partner` pairing confirms the profile first (AC-2.10); a device invite does not, even for the member. */
|
||||
ProfileStep,
|
||||
|
||||
/** Everyone else goes to Home (its loading state until the first sync ends). */
|
||||
Home,
|
||||
}
|
||||
|
||||
/** The state of S-1. [code] is normalised (§8.3), at most 12 characters, without hyphens. */
|
||||
/**
|
||||
* The state of S-1. [code] is normalised (§8.3), at most 12 characters, without hyphens. [adminName] is the inviting
|
||||
* admin's name from the link, for the help line (`onboarding_code_help`, AC-2.6); null without one.
|
||||
*/
|
||||
data class PairingUiState(
|
||||
val mode: PairingMode = PairingMode.First,
|
||||
val code: String = "",
|
||||
val adminName: String? = null,
|
||||
val name: String = "",
|
||||
val serverUrl: String = RELEASE_SERVER_URL,
|
||||
val serverExpanded: Boolean = false,
|
||||
@@ -99,13 +103,14 @@ class PairingViewModel internal constructor(
|
||||
* Fills the fields from an invite link once (AC-2.6); `:app` parses the intent. The server row opens expanded when
|
||||
* the link names a server other than the release default (design-spec S-1).
|
||||
*/
|
||||
fun prefill(serverUrl: String?, code: String?) {
|
||||
fun prefill(serverUrl: String?, code: String?, adminName: String? = null) {
|
||||
if (prefilled || (serverUrl == null && code == null)) return
|
||||
prefilled = true
|
||||
mutable.update { s ->
|
||||
val server = serverUrl?.let(ServerAddress::normalise)?.takeIf { it.isNotEmpty() }
|
||||
s.copy(
|
||||
code = code?.let(::normalizeInviteCode) ?: s.code,
|
||||
adminName = adminName ?: s.adminName,
|
||||
serverUrl = server ?: s.serverUrl,
|
||||
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
|
||||
)
|
||||
@@ -134,6 +139,7 @@ class PairingViewModel internal constructor(
|
||||
val server = invite.serverUrl
|
||||
s.copy(
|
||||
code = invite.code,
|
||||
adminName = invite.adminName ?: s.adminName,
|
||||
serverUrl = server ?: s.serverUrl,
|
||||
serverExpanded = s.serverExpanded || (server != null && server != RELEASE_SERVER_URL),
|
||||
error = null,
|
||||
@@ -159,7 +165,7 @@ class PairingViewModel internal constructor(
|
||||
when (result) {
|
||||
is OnlineResult.Ok -> current.copy(
|
||||
pairing = false,
|
||||
done = if (s.mode == PairingMode.First && result.value.role == Role.MEMBER) PairingDone.ProfileStep else PairingDone.Home,
|
||||
done = if (result.value.inviteKind == InviteKind.PARTNER) PairingDone.ProfileStep else PairingDone.Home,
|
||||
)
|
||||
OnlineResult.Offline -> current.copy(pairing = false, error = PairingError.Network, serverExpanded = true)
|
||||
is OnlineResult.Failed -> {
|
||||
|
||||
+15
@@ -29,6 +29,21 @@ class InviteLinkTest {
|
||||
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"),
|
||||
)
|
||||
assertEquals(
|
||||
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Åsa Berg"),
|
||||
InviteLink.parse("https://icehack.asuscomm.com: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"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theLandingLinkGivesCodeAndServer() {
|
||||
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA"))
|
||||
|
||||
+11
@@ -18,6 +18,7 @@ import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
|
||||
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
|
||||
import se.hagstedt.kvitt.fakes.onboarding.PairCall
|
||||
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PairingViewModelTest {
|
||||
@@ -68,6 +69,16 @@ class PairingViewModelTest {
|
||||
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")
|
||||
assertEquals("Jacob", vm.state.value.adminName)
|
||||
vm.onNameChange("Anna")
|
||||
vm.pair("Galaxy S26+")
|
||||
assertEquals(PairingDone.Home, vm.state.value.done)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theAdminGoesHomeAndTheAgainVariantSendsNoName() {
|
||||
val (_, admin) = vm(OnboardingScenarios.unpaired)
|
||||
|
||||
+8
@@ -76,6 +76,14 @@ object OnboardingStates {
|
||||
ScreenState("s1_pairing_empty", render = pairing(OnboardingScenarios.unpaired) {}),
|
||||
ScreenState("s1_pairing", largeFont = true, render = pairing(OnboardingScenarios.unpaired) { fillFromLink() }),
|
||||
ScreenState("s1_pairing_link_lan", render = pairing(OnboardingScenarios.unpaired, allowLanHttp = true) { fillFromLink(LAN_SERVER) }),
|
||||
// AC-2.6: a link that names the inviting admin fills the help line with the name.
|
||||
ScreenState(
|
||||
"s1_pairing_link_admin",
|
||||
render = pairing(OnboardingScenarios.unpaired) {
|
||||
prefill(RELEASE_SERVER_URL, CODE, "Jacob")
|
||||
onNameChange("Anna")
|
||||
},
|
||||
),
|
||||
ScreenState("s1_pairing_loading", render = pairing(OnboardingScenarios.unpaired, CallOutcome.Hang) { fillFromLink(); pair("Galaxy S26") }),
|
||||
ScreenState(
|
||||
"s1_pairing_error",
|
||||
|
||||
Reference in New Issue
Block a user