Show the login link as a QR so nobody races the code
All checks were successful
Build TV app / build (push) Successful in 47s

The identity provider's code lives a minute here, and the screen asked
people to type nine digits into a phone within it. That is the wrong
argument to have: the phone has a camera.

While the login is pending, the QR on the welcome screen points at the
provider's confirmation page with the code already in the URL, instead of at
this TV's own remote. Scanning ends at "confirm"; the digits stay underneath
for whoever prefers them, and the automatic renewal still covers a code that
runs out while somebody walks to the phone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
be-nj
2026-08-26 19:55:58 +02:00
parent 06b31f0b9d
commit 80b4c0c6cd
3 changed files with 33 additions and 4 deletions

View File

@@ -190,6 +190,13 @@ class AppState(
*/ */
var welcomeCodeRenewed by mutableStateOf(false) var welcomeCodeRenewed by mutableStateOf(false)
/**
* The identity provider's confirmation link, complete with the code.
* Shown as a QR while the login is pending: scanning it beats typing
* nine digits against a code that lives a minute.
*/
var welcomeLoginUrl by mutableStateOf("")
/** Plain-language fullscreen error state (issue #13). */ /** Plain-language fullscreen error state (issue #13). */
var appError by mutableStateOf(AppError.NONE) var appError by mutableStateOf(AppError.NONE)
@@ -410,6 +417,7 @@ class AppState(
welcomePhase = WelcomePhase.WAIT_PHONE welcomePhase = WelcomePhase.WAIT_PHONE
welcomeUserCode = "" welcomeUserCode = ""
welcomeCodeRenewed = false welcomeCodeRenewed = false
welcomeLoginUrl = ""
screen = Screen.WELCOME screen = Screen.WELCOME
} }

View File

@@ -210,6 +210,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
repeat(LOGIN_CODE_ROUNDS) { round -> repeat(LOGIN_CODE_ROUNDS) { round ->
val session = state.auth.startDeviceFlow() val session = state.auth.startDeviceFlow()
state.welcomeUserCode = session.userCode state.welcomeUserCode = session.userCode
state.welcomeLoginUrl = session.verificationUriComplete
state.welcomeCodeRenewed = round > 0 state.welcomeCodeRenewed = round > 0
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
server.broadcastLoginLink(session.verificationUriComplete, session.userCode) server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
@@ -242,6 +243,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen") server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen")
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
state.welcomeUserCode = "" state.welcomeUserCode = ""
state.welcomeLoginUrl = ""
state.welcomeCodeRenewed = false state.welcomeCodeRenewed = false
} catch (e: Exception) { } catch (e: Exception) {
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}") android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")

View File

@@ -88,21 +88,40 @@ fun WelcomeScreen(state: AppState) {
.background(Color(0xFFFBFCFD)) .background(Color(0xFFFBFCFD))
.padding(18.dp), .padding(18.dp),
) { ) {
val qr = remember(address) { // While the login is pending the QR points at the
// identity provider's confirmation page instead of at
// this TV: scanning it beats typing nine digits
// against a code that lives a minute.
val target = state.welcomeLoginUrl.ifEmpty {
Pairing.pairingUrl(context, address)
}
val qr = remember(address, target) {
Qr.encode( Qr.encode(
Pairing.pairingUrl(context, address), target,
520, 520,
android.graphics.Color.parseColor("#101216"), android.graphics.Color.parseColor("#101216"),
) )
} }
Image(qr.asImageBitmap(), contentDescription = "Einrichtungs-QR", modifier = Modifier.size(230.dp)) Image(
qr.asImageBitmap(),
contentDescription = if (state.welcomeLoginUrl.isEmpty()) {
"Einrichtungs-QR"
} else {
"Anmelde-QR"
},
modifier = Modifier.size(230.dp),
)
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
Text( Text(
// Both fallbacks in one line: the address for a phone // Both fallbacks in one line: the address for a phone
// whose camera will not scan, and the code the remote // whose camera will not scan, and the code the remote
// asks for when the QR was not what opened it. // asks for when the QR was not what opened it.
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}", if (state.welcomeLoginUrl.isEmpty()) {
"${Pairing.remoteUrl(address)} · Code ${Pairing.code(context)}"
} else {
"Scannen und bestätigen — oder Code ${state.welcomeUserCode} eingeben"
},
color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp, color = CastarrColors.faint, fontFamily = AppFont, fontSize = 13.sp,
) )
} }