From 9ac168779543ed087799ca84959b28e14159a612 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 24 Oct 2026 18:18:23 +0200 Subject: [PATCH 1/6] chore: release 0.2.0 --- app/build.gradle.kts | 2 +- release-notes/v0.2.0.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 release-notes/v0.2.0.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a9ae1bd..f8e289f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } // The release workflow reads this line: "x.y.z-beta.n" is published from the beta branch, "x.y.z" from main. -val appVersion = "0.2.0-beta.1" +val appVersion = "0.2.0" // The version code follows from the name, so each release installs over the one before, and a beta sits under the // stable release that ends it: 0.3.0-beta.2 is 30002 and 0.3.0 is 30099. diff --git a/release-notes/v0.2.0.md b/release-notes/v0.2.0.md new file mode 100644 index 0000000..c093cdc --- /dev/null +++ b/release-notes/v0.2.0.md @@ -0,0 +1,15 @@ +TypeType TV now updates itself. + +- When a new version is published, an Update button appears in the top bar. It shows what the new version + brings, then downloads it, checks its checksum, and hands it to the Android installer. +- Settings > Updates checks right away, switches between the stable and beta channels, and turns off the check + made when the app opens. +- The check asks GitHub, where TypeType TV is published, when the app opens and every few hours while it stays open. + +Versions before this one cannot update themselves: install this one by hand once, and the next ones are offered +in the app. + +Download `TypeType-TV-v0.2.0.apk` below; the `.sha256` file lets you check the download. + +Betas are published as prereleases. A beta installs over the stable release, and the next stable release installs +over the beta. From d032c689b116c45bd8eec1dce01958ac29ac8ae5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 29 Oct 2026 06:00:56 +0100 Subject: [PATCH 2/6] perf: keep the startup intro smooth while the first screen loads --- .../video/typetype/tv/ui/splash/SplashMark.kt | 28 +++++++++++++------ .../typetype/tv/ui/splash/SplashStart.kt | 22 +++++++++++++++ .../typetype/tv/ui/splash/SplashWordmark.kt | 6 ++-- .../typetype/tv/ui/splash/StartupChime.kt | 7 ++--- .../typetype/tv/ui/splash/StartupSplash.kt | 18 ++++++++++-- 5 files changed, 64 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/video/typetype/tv/ui/splash/SplashStart.kt diff --git a/app/src/main/java/video/typetype/tv/ui/splash/SplashMark.kt b/app/src/main/java/video/typetype/tv/ui/splash/SplashMark.kt index 1de50d3..8b00a02 100644 --- a/app/src/main/java/video/typetype/tv/ui/splash/SplashMark.kt +++ b/app/src/main/java/video/typetype/tv/ui/splash/SplashMark.kt @@ -47,7 +47,8 @@ internal class SplashMark { arcTo(Rect(17f, 17f, 21f, 21f), 0f, 90f, false) lineTo(16.2f, 21f) } - private val measures = listOf(antennas, divider, screen).map { path -> PathMeasure().apply { setPath(path, false) } } + private val strokes = listOf(antennas, divider, screen) + private val measures = strokes.map { path -> PathMeasure().apply { setPath(path, false) } } private val lowerButton = Offset(18.7f, 15.6f) private val upperButton = Offset(18.7f, 11.4f) @@ -80,16 +81,27 @@ internal class SplashMark { val step = progress.toInt().coerceAtMost(moves.size) val fraction = progress - step revealed.fill(0f) - moves.take(step).forEach { move -> if (move is Move.Trace) revealed[move.stroke] = move.to } + for (index in 0 until step) { + val move = moves[index] + if (move is Move.Trace) revealed[move.stroke] = move.to + } val current = moves.getOrNull(step) if (current is Move.Trace) revealed[current.stroke] = current.from + (current.to - current.from) * fraction - measures.forEachIndexed { index, measure -> - if (revealed[index] <= 0f) return@forEachIndexed - segment.reset() - measure.getSegment(0f, measure.length * revealed[index], segment, true) - drawPath(segment, color, alpha, LINE, blendMode = blendMode) + for (index in measures.indices) { + val shown = revealed[index] + if (shown <= 0f) continue + // A finished stroke is drawn from its own path, which the renderer keeps tessellated between frames. + val path = if (shown >= 1f) { + strokes[index] + } else { + segment.reset() + measures[index].getSegment(0f, measures[index].length * shown, segment, true) + segment + } + drawPath(path, color, alpha, LINE, blendMode = blendMode) } - moves.take(step).forEach { move -> + for (index in 0 until step) { + val move = moves[index] if (move is Move.Jump && move.becomesDot) drawCircle(color, BUTTON_RADIUS, move.to, alpha, blendMode = blendMode) } if (current != null) drawCursor(current, fraction, progress, appear, color, alpha, blendMode) diff --git a/app/src/main/java/video/typetype/tv/ui/splash/SplashStart.kt b/app/src/main/java/video/typetype/tv/ui/splash/SplashStart.kt new file mode 100644 index 0000000..9fa5a0c --- /dev/null +++ b/app/src/main/java/video/typetype/tv/ui/splash/SplashStart.kt @@ -0,0 +1,22 @@ +package video.typetype.tv.ui.splash + +import androidx.compose.runtime.withFrameNanos +import kotlinx.coroutines.withTimeoutOrNull + +private const val STEADY_FRAMES = 8 +private const val STEADY_GAP_NANOS = 25_000_000L +private const val MAX_WAIT_MILLIS = 1_500L + +// The app composes its first screen behind the intro while it starts, which drops frames for a moment. The intro +// waits on its dark first frame until frames come at a steady rate again, so no hop is lost in that moment. +internal suspend fun awaitSteadyFrames() { + withTimeoutOrNull(MAX_WAIT_MILLIS) { + var previous = withFrameNanos { it } + var steady = 0 + while (steady < STEADY_FRAMES) { + val now = withFrameNanos { it } + steady = if (now - previous <= STEADY_GAP_NANOS) steady + 1 else 0 + previous = now + } + } +} diff --git a/app/src/main/java/video/typetype/tv/ui/splash/SplashWordmark.kt b/app/src/main/java/video/typetype/tv/ui/splash/SplashWordmark.kt index e288e87..24bd353 100644 --- a/app/src/main/java/video/typetype/tv/ui/splash/SplashWordmark.kt +++ b/app/src/main/java/video/typetype/tv/ui/splash/SplashWordmark.kt @@ -58,11 +58,13 @@ internal class SplashWordmark { for (piece in pieces) { val part = left.coerceIn(0f, piece.length) when (piece) { - is Ink -> if (part > 0f) { + is Ink -> if (part >= piece.length) { + drawPath(piece.path, color, style = LINE) + } else if (part > 0f) { segment.reset() piece.measure.getSegment(0f, part, segment, true) drawPath(segment, color, style = LINE) - if (part < piece.length) pen = piece.measure.getPosition(part) + pen = piece.measure.getPosition(part) } is Glide -> if (part > 0f && part < piece.length) pen = lerp(piece.from, piece.to, part / piece.length) } diff --git a/app/src/main/java/video/typetype/tv/ui/splash/StartupChime.kt b/app/src/main/java/video/typetype/tv/ui/splash/StartupChime.kt index 377437d..79c9235 100644 --- a/app/src/main/java/video/typetype/tv/ui/splash/StartupChime.kt +++ b/app/src/main/java/video/typetype/tv/ui/splash/StartupChime.kt @@ -9,11 +9,8 @@ import video.typetype.tv.resources.R import java.io.IOException object StartupChime { - suspend fun play(context: Context) { - val player = withContext(Dispatchers.IO) { prepared(context) } ?: return - player.setOnCompletionListener { it.release() } - player.start() - } + suspend fun prepare(context: Context): MediaPlayer? = + withContext(Dispatchers.IO) { prepared(context) }?.apply { setOnCompletionListener { it.release() } } private fun prepared(context: Context): MediaPlayer? { val player = MediaPlayer() diff --git a/app/src/main/java/video/typetype/tv/ui/splash/StartupSplash.kt b/app/src/main/java/video/typetype/tv/ui/splash/StartupSplash.kt index 943d98b..8bc2b9e 100644 --- a/app/src/main/java/video/typetype/tv/ui/splash/StartupSplash.kt +++ b/app/src/main/java/video/typetype/tv/ui/splash/StartupSplash.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.lerp import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.drawscope.scale import androidx.compose.ui.graphics.drawscope.translate @@ -26,9 +27,11 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import kotlinx.coroutines.async import video.typetype.tv.resources.R import video.typetype.tv.theme.Palette import video.typetype.tv.ui.util.requestFocusWhenReady +import kotlin.coroutines.cancellation.CancellationException private val TraceSize = 250.dp private val FinalSize = 104.dp @@ -56,7 +59,15 @@ fun StartupSplash(onFinished: () -> Unit, modifier: Modifier = Modifier, short: LaunchedEffect(Unit) { // The picture waits for the sound to be ready: each hop of the mark lands on one of its notes. The short // intro plays silently. - if (!short) StartupChime.play(context) + val steady = async { awaitSteadyFrames() } + val chime = if (short) null else StartupChime.prepare(context) + try { + steady.await() + } catch (cancelled: CancellationException) { + chime?.release() + throw cancelled + } + chime?.start() val left = (SplashTimeline.TOTAL - start).toInt() clock.animateTo(SplashTimeline.TOTAL.toFloat(), tween(left, easing = LinearEasing)) onFinished() @@ -74,7 +85,10 @@ fun StartupSplash(onFinished: () -> Unit, modifier: Modifier = Modifier, short: .focusRequester(focus) .focusable() .semantics { contentDescription = description } - .graphicsLayer { alpha = SplashTimeline.exitAlpha(clock.value) }, + .graphicsLayer { + alpha = SplashTimeline.exitAlpha(clock.value) + compositingStrategy = CompositingStrategy.ModulateAlpha + }, ) { drawSplashFrame(clock.value, mark, wordmark) } From c9a334b16c8acba1d04bb9c6a723a727f0316dae Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 29 Oct 2026 15:01:34 +0100 Subject: [PATCH 3/6] feat: wait for a phone to approve the TV sign-in --- .../video/typetype/tv/data/api/AuthApi.kt | 23 ++++ .../typetype/tv/data/dto/DeviceLoginDto.kt | 20 ++++ .../tv/data/session/DeviceLoginFlow.kt | 107 ++++++++++++++++++ .../typetype/tv/data/session/DevicePoll.kt | 18 +++ .../tv/data/session/SessionManager.kt | 2 + .../tv/data/api/DeviceLoginApiTest.kt | 56 +++++++++ .../tv/data/session/DeviceLoginFlowTest.kt | 105 +++++++++++++++++ 7 files changed, 331 insertions(+) create mode 100644 core/data/src/main/java/video/typetype/tv/data/dto/DeviceLoginDto.kt create mode 100644 core/data/src/main/java/video/typetype/tv/data/session/DeviceLoginFlow.kt create mode 100644 core/data/src/main/java/video/typetype/tv/data/session/DevicePoll.kt create mode 100644 core/data/src/test/java/video/typetype/tv/data/api/DeviceLoginApiTest.kt create mode 100644 core/data/src/test/java/video/typetype/tv/data/session/DeviceLoginFlowTest.kt diff --git a/core/data/src/main/java/video/typetype/tv/data/api/AuthApi.kt b/core/data/src/main/java/video/typetype/tv/data/api/AuthApi.kt index dca2ecf..2932cc3 100644 --- a/core/data/src/main/java/video/typetype/tv/data/api/AuthApi.kt +++ b/core/data/src/main/java/video/typetype/tv/data/api/AuthApi.kt @@ -1,12 +1,18 @@ package video.typetype.tv.data.api import kotlinx.serialization.encodeToString +import video.typetype.tv.data.dto.DeviceStartRequest +import video.typetype.tv.data.dto.DeviceStartResponse +import video.typetype.tv.data.dto.DeviceTokenReply +import video.typetype.tv.data.dto.DeviceTokenRequest import video.typetype.tv.data.dto.GuestResponse import video.typetype.tv.data.dto.LoginRequest import video.typetype.tv.data.dto.RefreshRequest import video.typetype.tv.data.dto.RegisterRequest import video.typetype.tv.data.dto.SessionResponse import video.typetype.tv.data.net.ApiClient +import video.typetype.tv.data.session.DevicePoll +import video.typetype.tv.data.session.devicePoll class AuthApi(private val client: ApiClient) { suspend fun login(email: String, password: String): String = @@ -28,6 +34,23 @@ class AuthApi(private val client: ApiClient) { suspend fun refresh(): String = client.post("auth/refresh", client.json.encodeToString(RefreshRequest()), SessionResponse.serializer()).accessToken + suspend fun deviceStart(deviceName: String): DeviceStartResponse = + client.post( + "auth/device/start", + client.json.encodeToString(DeviceStartRequest(deviceName)), + DeviceStartResponse.serializer(), + ) + + suspend fun deviceToken(deviceCode: String): DevicePoll { + val (status, reply) = client.postAllowing( + "auth/device/token", + client.json.encodeToString(DeviceTokenRequest(deviceCode)), + DeviceTokenReply.serializer(), + accepted = setOf(200, 400), + ) + return devicePoll(status, reply.accessToken, reply.code) + } + suspend fun logout() { client.send("POST", "auth/logout") } diff --git a/core/data/src/main/java/video/typetype/tv/data/dto/DeviceLoginDto.kt b/core/data/src/main/java/video/typetype/tv/data/dto/DeviceLoginDto.kt new file mode 100644 index 0000000..c2b12b8 --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/dto/DeviceLoginDto.kt @@ -0,0 +1,20 @@ +package video.typetype.tv.data.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class DeviceStartRequest(val deviceName: String) + +@Serializable +data class DeviceStartResponse( + val deviceCode: String, + val userCode: String, + val expiresIn: Long, + val interval: Long, +) + +@Serializable +data class DeviceTokenRequest(val deviceCode: String) + +@Serializable +data class DeviceTokenReply(val accessToken: String? = null, val code: String? = null) diff --git a/core/data/src/main/java/video/typetype/tv/data/session/DeviceLoginFlow.kt b/core/data/src/main/java/video/typetype/tv/data/session/DeviceLoginFlow.kt new file mode 100644 index 0000000..11be5cd --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/session/DeviceLoginFlow.kt @@ -0,0 +1,107 @@ +package video.typetype.tv.data.session + +import java.io.IOException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import okhttp3.HttpUrl +import video.typetype.tv.data.dto.DeviceStartResponse +import video.typetype.tv.data.net.ApiException +import video.typetype.tv.data.net.FailureCategory + +sealed interface DeviceLoginState { + data object Starting : DeviceLoginState + data class Showing(val userCode: String, val link: HttpUrl, val scanned: Boolean) : DeviceLoginState + data object Refused : DeviceLoginState + data object Unavailable : DeviceLoginState + data class Approved(val accessToken: String) : DeviceLoginState +} + +class DeviceLoginFlow( + private val apiBase: HttpUrl, + private val start: suspend () -> DeviceStartResponse, + private val poll: suspend (String) -> DevicePoll, + private val now: () -> Long = { System.nanoTime() / 1_000_000 }, + private val wait: suspend (Long) -> Unit = { delay(it) }, +) { + fun states(): Flow = flow { + var failures = 0 + while (true) { + emit(DeviceLoginState.Starting) + val started = try { + start() + } catch (failure: IOException) { + if (failure.unsupported()) { + emit(DeviceLoginState.Unavailable) + return@flow + } + failures++ + wait(retryDelay(failures)) + continue + } + failures = 0 + if (!showUntilDone(started)) return@flow + } + }.distinctUntilChanged() + + private suspend fun FlowCollector.showUntilDone(started: DeviceStartResponse): Boolean { + val link = deviceLink(apiBase, started.userCode) + var interval = started.interval.coerceAtLeast(1) * 1000 + val renewAt = now() + (started.expiresIn * 1000 - RENEW_MARGIN_MS).coerceAtLeast(interval) + emit(DeviceLoginState.Showing(started.userCode, link, scanned = false)) + while (now() < renewAt) { + wait(interval) + val result = try { + poll(started.deviceCode) + } catch (failure: IOException) { + if (failure.unsupported()) { + emit(DeviceLoginState.Unavailable) + return false + } + continue + } + when (result) { + is DevicePoll.Approved -> { + emit(DeviceLoginState.Approved(result.accessToken)) + return false + } + is DevicePoll.Pending -> emit(DeviceLoginState.Showing(started.userCode, link, result.scanned)) + DevicePoll.SlowDown -> interval += SLOW_DOWN_STEP_MS + DevicePoll.Expired -> return true + DevicePoll.Denied -> { + emit(DeviceLoginState.Refused) + wait(REFUSED_PAUSE_MS) + return true + } + } + } + return true + } + + private fun IOException.unsupported(): Boolean = + (this as? ApiException)?.failure?.category.let { it == FailureCategory.NotFound || it == FailureCategory.Forbidden } + + private fun retryDelay(failures: Int): Long = (RETRY_BASE_MS shl (failures - 1).coerceAtMost(3)).coerceAtMost(RETRY_MAX_MS) + + private companion object { + const val RENEW_MARGIN_MS = 15_000L + const val SLOW_DOWN_STEP_MS = 5_000L + const val REFUSED_PAUSE_MS = 3_000L + const val RETRY_BASE_MS = 4_000L + const val RETRY_MAX_MS = 30_000L + } +} + +fun deviceLink(apiBase: HttpUrl, userCode: String): HttpUrl { + val segments = apiBase.pathSegments.filter(String::isNotEmpty).let { if (it.lastOrNull() == "api") it.dropLast(1) else it } + val builder = HttpUrl.Builder().scheme(apiBase.scheme).host(apiBase.host).port(apiBase.port) + segments.forEach(builder::addPathSegment) + return builder.addPathSegment("link").addQueryParameter("code", userCode).build() +} + +fun HttpUrl.deviceLinkLabel(): String { + val defaultPort = port == HttpUrl.defaultPort(scheme) + return host + (if (defaultPort) "" else ":$port") + encodedPath +} diff --git a/core/data/src/main/java/video/typetype/tv/data/session/DevicePoll.kt b/core/data/src/main/java/video/typetype/tv/data/session/DevicePoll.kt new file mode 100644 index 0000000..5715cec --- /dev/null +++ b/core/data/src/main/java/video/typetype/tv/data/session/DevicePoll.kt @@ -0,0 +1,18 @@ +package video.typetype.tv.data.session + +sealed interface DevicePoll { + data class Approved(val accessToken: String) : DevicePoll + data class Pending(val scanned: Boolean) : DevicePoll + data object SlowDown : DevicePoll + data object Expired : DevicePoll + data object Denied : DevicePoll +} + +internal fun devicePoll(status: Int, accessToken: String?, code: String?): DevicePoll = when { + status == 200 && accessToken != null -> DevicePoll.Approved(accessToken) + code == "authorization_pending" -> DevicePoll.Pending(scanned = false) + code == "authorization_scanned" -> DevicePoll.Pending(scanned = true) + code == "slow_down" -> DevicePoll.SlowDown + code == "access_denied" -> DevicePoll.Denied + else -> DevicePoll.Expired +} diff --git a/core/data/src/main/java/video/typetype/tv/data/session/SessionManager.kt b/core/data/src/main/java/video/typetype/tv/data/session/SessionManager.kt index 5e67161..985256a 100644 --- a/core/data/src/main/java/video/typetype/tv/data/session/SessionManager.kt +++ b/core/data/src/main/java/video/typetype/tv/data/session/SessionManager.kt @@ -170,6 +170,8 @@ class SessionManager(private val store: SecretStore) : AccessTokens, TokenRefres suspend fun guest() = authenticate { it.auth.guest() } + suspend fun signInWithDevice(accessToken: String) = authenticate { accessToken } + suspend fun signOut() { val current = backend ?: return try { diff --git a/core/data/src/test/java/video/typetype/tv/data/api/DeviceLoginApiTest.kt b/core/data/src/test/java/video/typetype/tv/data/api/DeviceLoginApiTest.kt new file mode 100644 index 0000000..a5504de --- /dev/null +++ b/core/data/src/test/java/video/typetype/tv/data/api/DeviceLoginApiTest.kt @@ -0,0 +1,56 @@ +package video.typetype.tv.data.api + +import kotlinx.coroutines.runBlocking +import okhttp3.OkHttpClient +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import video.typetype.tv.data.net.ApiClient +import video.typetype.tv.data.net.RetryPolicy +import video.typetype.tv.data.session.DevicePoll + +class DeviceLoginApiTest { + private lateinit var server: MockWebServer + private lateinit var auth: AuthApi + + @Before + fun start() { + server = MockWebServer().apply { start() } + auth = AuthApi(ApiClient(server.url("/api/"), OkHttpClient(), retry = RetryPolicy.NONE)) + } + + @After + fun stop() = server.shutdown() + + private fun reply(status: Int, body: String) = server.enqueue(MockResponse().setResponseCode(status).setBody(body)) + + @Test + fun startSendsTheDeviceName() = runBlocking { + reply(200, """{"deviceCode":"d","userCode":"KQ4M-7TZD","expiresIn":600,"interval":2}""") + val started = auth.deviceStart("TypeType TV · Box") + assertEquals("KQ4M-7TZD", started.userCode) + val request = server.takeRequest() + assertEquals("/api/auth/device/start", request.path) + assertEquals("""{"deviceName":"TypeType TV · Box"}""", request.body.readUtf8()) + } + + @Test + fun everyServerAnswerMapsToAState() = runBlocking { + val cases = listOf( + (400 to """{"error":"x","code":"authorization_pending"}""") to DevicePoll.Pending(false), + (400 to """{"error":"x","code":"authorization_scanned"}""") to DevicePoll.Pending(true), + (400 to """{"error":"x","code":"slow_down"}""") to DevicePoll.SlowDown, + (400 to """{"error":"x","code":"access_denied"}""") to DevicePoll.Denied, + (400 to """{"error":"x","code":"expired_token"}""") to DevicePoll.Expired, + (200 to """{"accessToken":"jwt"}""") to DevicePoll.Approved("jwt"), + ) + for ((response, expected) in cases) { + reply(response.first, response.second) + assertEquals(expected, auth.deviceToken("secret")) + assertEquals("""{"deviceCode":"secret"}""", server.takeRequest().body.readUtf8()) + } + } +} diff --git a/core/data/src/test/java/video/typetype/tv/data/session/DeviceLoginFlowTest.kt b/core/data/src/test/java/video/typetype/tv/data/session/DeviceLoginFlowTest.kt new file mode 100644 index 0000000..0541e7c --- /dev/null +++ b/core/data/src/test/java/video/typetype/tv/data/session/DeviceLoginFlowTest.kt @@ -0,0 +1,105 @@ +package video.typetype.tv.data.session + +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import video.typetype.tv.data.dto.DeviceStartResponse +import video.typetype.tv.data.net.ApiException +import video.typetype.tv.data.net.ApiFailure +import video.typetype.tv.data.net.FailureCategory +import java.io.IOException + +class DeviceLoginFlowTest { + private var clock = 0L + private val waits = mutableListOf() + private var starts = 0 + + private fun flow( + expiresIn: Long = 600, + start: suspend () -> DeviceStartResponse = { started(expiresIn) }, + poll: suspend (String) -> DevicePoll, + ) = DeviceLoginFlow( + apiBase = "https://tv.example/api/".toHttpUrl(), + start = start, + poll = poll, + now = { clock }, + wait = { waits += it; clock += it }, + ) + + private fun started(expiresIn: Long = 600): DeviceStartResponse { + starts++ + return DeviceStartResponse("device-$starts", "CODE-000$starts", expiresIn, 2) + } + + private fun failure(category: FailureCategory) = ApiException(ApiFailure(category, null, null, "x", null)) + + @Test + fun approvalHandsOverTheTokenAfterTheCodeWasScanned() = runBlocking { + val replies = ArrayDeque(listOf(DevicePoll.Pending(false), DevicePoll.Pending(true), DevicePoll.Approved("token"))) + val states = flow { replies.removeFirst() }.states().toList() + val link = "https://tv.example/link?code=CODE-0001".toHttpUrl() + assertEquals( + listOf( + DeviceLoginState.Starting, + DeviceLoginState.Showing("CODE-0001", link, scanned = false), + DeviceLoginState.Showing("CODE-0001", link, scanned = true), + DeviceLoginState.Approved("token"), + ), + states, + ) + assertEquals(listOf(2_000L, 2_000L, 2_000L), waits) + } + + @Test + fun slowDownStretchesTheInterval() = runBlocking { + val replies = ArrayDeque(listOf(DevicePoll.SlowDown, DevicePoll.Approved("token"))) + flow { replies.removeFirst() }.states().toList() + assertEquals(listOf(2_000L, 7_000L), waits) + } + + @Test + fun anExpiredOrRefusedCodeIsReplacedByAFreshOne() = runBlocking { + val replies = ArrayDeque(listOf(DevicePoll.Expired, DevicePoll.Denied, DevicePoll.Approved("token"))) + val states = flow { replies.removeFirst() }.states().toList() + assertEquals(3, starts) + assertTrue(DeviceLoginState.Refused in states) + assertEquals(DeviceLoginState.Approved("token"), states.last()) + } + + @Test + fun theCodeIsRenewedBeforeItExpires() = runBlocking { + var polls = 0 + val states = flow(expiresIn = 21) { if (++polls < 5) DevicePoll.Pending(false) else DevicePoll.Approved("token") }.states().toList() + assertEquals(2, starts) + assertTrue(states.any { it is DeviceLoginState.Showing && it.userCode == "CODE-0002" }) + } + + @Test + fun anOldOrClosedServerLeavesOnlyTheKeyboard() = runBlocking { + for (category in listOf(FailureCategory.NotFound, FailureCategory.Forbidden)) { + val states = flow(start = { throw failure(category) }) { error("no poll") }.states().toList() + assertEquals(listOf(DeviceLoginState.Starting, DeviceLoginState.Unavailable), states) + } + } + + @Test + fun aNetworkFailureIsRetriedWithoutGivingUp() = runBlocking { + var attempts = 0 + val states = flow(start = { if (++attempts < 3) throw IOException("offline") else started() }) { + DevicePoll.Approved("token") + }.states().toList() + assertEquals(listOf(4_000L, 8_000L, 2_000L), waits) + assertEquals(DeviceLoginState.Approved("token"), states.last()) + } + + @Test + fun theLinkFollowsTheInstanceAddress() { + assertEquals("https://tv.example/link?code=AB", deviceLink("https://tv.example/api/".toHttpUrl(), "AB").toString()) + assertEquals("http://10.0.0.2:8080/tt/link?code=AB", deviceLink("http://10.0.0.2:8080/tt/api/".toHttpUrl(), "AB").toString()) + assertEquals("tv.example/link", deviceLink("https://tv.example/api/".toHttpUrl(), "AB").deviceLinkLabel()) + assertEquals("10.0.0.2:8080/tt/link", deviceLink("http://10.0.0.2:8080/tt/api/".toHttpUrl(), "AB").deviceLinkLabel()) + } +} From 650abc38213874f6cb1d3936fafeb3ee6f7ef734 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 29 Oct 2026 15:01:34 +0100 Subject: [PATCH 4/6] feat: show a QR code to sign the TV in from a phone --- .../typetype/tv/ui/DeviceLoginPanelTest.kt | 51 +++++++++++++ .../resources/src/main/res/values/strings.xml | 11 +++ feature/auth/build.gradle.kts | 2 + .../typetype/tv/ui/auth/DeviceLoginPanel.kt | 75 +++++++++++++++++++ .../typetype/tv/ui/auth/DeviceLoginStates.kt | 31 ++++++++ .../java/video/typetype/tv/ui/auth/QrCode.kt | 58 ++++++++++++++ gradle/libs.versions.toml | 2 + 7 files changed, 230 insertions(+) create mode 100644 app/src/androidTest/java/video/typetype/tv/ui/DeviceLoginPanelTest.kt create mode 100644 feature/auth/src/main/java/video/typetype/tv/ui/auth/DeviceLoginPanel.kt create mode 100644 feature/auth/src/main/java/video/typetype/tv/ui/auth/DeviceLoginStates.kt create mode 100644 feature/auth/src/main/java/video/typetype/tv/ui/auth/QrCode.kt diff --git a/app/src/androidTest/java/video/typetype/tv/ui/DeviceLoginPanelTest.kt b/app/src/androidTest/java/video/typetype/tv/ui/DeviceLoginPanelTest.kt new file mode 100644 index 0000000..bf65a49 --- /dev/null +++ b/app/src/androidTest/java/video/typetype/tv/ui/DeviceLoginPanelTest.kt @@ -0,0 +1,51 @@ +package video.typetype.tv.ui + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.junit4.accessibility.enableAccessibilityChecks +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import video.typetype.tv.data.session.DeviceLoginState +import video.typetype.tv.ui.auth.DeviceLoginPanel + +class DeviceLoginPanelTest { + @get:Rule + val rule = createComposeRule() + + private val showing = DeviceLoginState.Showing( + userCode = "KQ4M-7TZD", + link = "https://tv.example/link?code=KQ4M-7TZD".toHttpUrl(), + scanned = false, + ) + + @Before + fun enable() { + rule.enableAccessibilityChecks() + } + + @Test + fun theCodeCanBeScannedOrTyped() { + rule.setContent { DeviceLoginPanel(showing) } + rule.onNodeWithContentDescription("QR code to sign in to this TV from your phone").assertIsDisplayed() + rule.onNodeWithText("Or open tv.example/link on your phone and enter").assertIsDisplayed() + rule.onNodeWithContentDescription("Code K Q 4 M 7 T Z D").assertIsDisplayed() + } + + @Test + fun theTvFollowsWhatHappensOnThePhone() { + var state by mutableStateOf(DeviceLoginState.Starting) + rule.setContent { DeviceLoginPanel(state) } + rule.onNodeWithText("Preparing a code…").assertIsDisplayed() + state = showing.copy(scanned = true) + rule.onNodeWithText("Code scanned. Confirm on your phone.").assertIsDisplayed() + state = DeviceLoginState.Refused + rule.onNodeWithText("Refused on the phone. Here is a new code.").assertIsDisplayed() + } +} diff --git a/core/resources/src/main/res/values/strings.xml b/core/resources/src/main/res/values/strings.xml index 40268b6..228f410 100644 --- a/core/resources/src/main/res/values/strings.xml +++ b/core/resources/src/main/res/values/strings.xml @@ -183,6 +183,17 @@ Something went wrong. Try again. Fill in every field first. Your session expired. Sign in again. + No typing: scan the code and confirm on your phone. + Scan with your phone\'s camera + Or open %1$s on your phone and enter + Preparing a code… + Code scanned. Confirm on your phone. + Refused on the phone. Here is a new code. + QR code to sign in to this TV from your phone + Code %1$s + Use a password + Use a QR code + TypeType TV · %1$s Connecting to your server Checking your session. The server is not answering. The app keeps trying and continues as soon as it is back. diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index 4523235..87f7d8b 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -8,6 +8,8 @@ android { dependencies { implementation(project(":core:ui")) + implementation(libs.zxing.core) + implementation(libs.androidx.lifecycle.runtime.compose) testImplementation(project(":core:testing")) testImplementation(libs.junit) diff --git a/feature/auth/src/main/java/video/typetype/tv/ui/auth/DeviceLoginPanel.kt b/feature/auth/src/main/java/video/typetype/tv/ui/auth/DeviceLoginPanel.kt new file mode 100644 index 0000000..4f3d9e7 --- /dev/null +++ b/feature/auth/src/main/java/video/typetype/tv/ui/auth/DeviceLoginPanel.kt @@ -0,0 +1,75 @@ +package video.typetype.tv.ui.auth + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import video.typetype.tv.data.session.DeviceLoginState +import video.typetype.tv.data.session.deviceLinkLabel +import video.typetype.tv.resources.R +import video.typetype.tv.theme.Palette +import video.typetype.tv.theme.Shapes +import video.typetype.tv.theme.Type +import video.typetype.tv.ui.components.TextLabel + +private val QR_SIZE = 232.dp +private val CodeStyle = Type.Display.copy(fontFamily = FontFamily.Monospace, letterSpacing = 4.sp) + +@Composable +fun DeviceLoginPanel(state: DeviceLoginState, modifier: Modifier = Modifier) { + val showing = state as? DeviceLoginState.Showing + Row(modifier, horizontalArrangement = Arrangement.spacedBy(28.dp), verticalAlignment = Alignment.CenterVertically) { + if (showing != null) { + QrCode(showing.link.toString(), stringResource(R.string.login_qr_description), QR_SIZE) + } else { + Box(Modifier.size(QR_SIZE).clip(Shapes.Panel).background(Palette.SurfaceRaised)) + } + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + TextLabel(stringResource(R.string.login_qr_scan), Type.Title, maxLines = 2) + if (showing == null) { + TextLabel(stringResource(R.string.login_qr_preparing), Type.Body, maxLines = 2) + } else { + val link = showing.link.newBuilder().query(null).build().deviceLinkLabel() + TextLabel(stringResource(R.string.login_qr_or_visit, link), Type.Body, maxLines = 3) + val spelled = showing.userCode.filter(Char::isLetterOrDigit).toList().joinToString(" ") + val codeDescription = stringResource(R.string.login_qr_code_description, spelled) + TextLabel( + showing.userCode, + CodeStyle, + maxLines = 1, + modifier = Modifier.clearAndSetSemantics { contentDescription = codeDescription }, + ) + } + val refused = state is DeviceLoginState.Refused + val status = when { + refused -> stringResource(R.string.login_qr_refused) + showing?.scanned == true -> stringResource(R.string.login_qr_scanned) + else -> null + } + if (status != null) { + TextLabel( + status, + Type.Body, + maxLines = 2, + color = { if (refused) Palette.AccentSoft else Palette.SuccessGreen }, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + } + } + } +} diff --git a/feature/auth/src/main/java/video/typetype/tv/ui/auth/DeviceLoginStates.kt b/feature/auth/src/main/java/video/typetype/tv/ui/auth/DeviceLoginStates.kt new file mode 100644 index 0000000..98aba62 --- /dev/null +++ b/feature/auth/src/main/java/video/typetype/tv/ui/auth/DeviceLoginStates.kt @@ -0,0 +1,31 @@ +package video.typetype.tv.ui.auth + +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.repeatOnLifecycle +import video.typetype.tv.data.session.Backend +import video.typetype.tv.data.session.DeviceLoginFlow +import video.typetype.tv.data.session.DeviceLoginState +import video.typetype.tv.resources.R + +@Composable +fun rememberDeviceLogin(backend: Backend, active: Boolean): DeviceLoginState { + val lifecycle = LocalLifecycleOwner.current.lifecycle + val deviceName = stringResource(R.string.login_device_name, Build.MODEL.orEmpty().ifBlank { Build.DEVICE }) + val state by produceState(DeviceLoginState.Starting, backend, active) { + if (!active) return@produceState + lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + DeviceLoginFlow( + apiBase = backend.apiBase, + start = { backend.auth.deviceStart(deviceName) }, + poll = backend.auth::deviceToken, + ).states().collect { value = it } + } + } + return state +} diff --git a/feature/auth/src/main/java/video/typetype/tv/ui/auth/QrCode.kt b/feature/auth/src/main/java/video/typetype/tv/ui/auth/QrCode.kt new file mode 100644 index 0000000..830a3f3 --- /dev/null +++ b/feature/auth/src/main/java/video/typetype/tv/ui/auth/QrCode.kt @@ -0,0 +1,58 @@ +package video.typetype.tv.ui.auth + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.scale +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel +import video.typetype.tv.theme.Shapes + +private class QrModules(val count: Int, val path: Path) + +private fun encode(content: String): QrModules { + val hints = mapOf(EncodeHintType.MARGIN to 0, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M) + val matrix = QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 0, 0, hints) + val path = Path() + for (y in 0 until matrix.height) { + for (x in 0 until matrix.width) { + if (matrix[x, y]) path.addRect(Rect(x.toFloat(), y.toFloat(), x + 1f, y + 1f)) + } + } + return QrModules(matrix.width, path) +} + +@Composable +fun QrCode(content: String, description: String, size: Dp, modifier: Modifier = Modifier) { + val modules = remember(content) { encode(content) } + Box( + modifier + .size(size) + .clip(Shapes.Panel) + .background(Color.White) + .padding(size / 14) + .semantics { contentDescription = description }, + ) { + Canvas(Modifier.size(size - size / 7)) { + scale(this.size.width / modules.count, pivot = Offset.Zero) { + drawPath(modules.path, Color.Black) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b3c5efc..be1e4da 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,8 +14,10 @@ kotlinxSerialization = "1.11.0" tink = "1.23.0" kotlinxCoroutines = "1.10.2" extJunit = "1.3.0" +zxing = "3.5.4" [libraries] +zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" } androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" } From 50f3ee7e434cc6d62e0c59800e9bcd62cb00e86e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 29 Oct 2026 15:01:34 +0100 Subject: [PATCH 5/6] feat: open the login screen on the QR code --- .../video/typetype/tv/ui/auth/AuthScaffold.kt | 4 +- .../video/typetype/tv/ui/auth/LoginScreen.kt | 115 +++++++----------- .../typetype/tv/ui/auth/PasswordLoginForm.kt | 115 ++++++++++++++++++ 3 files changed, 164 insertions(+), 70 deletions(-) create mode 100644 feature/auth/src/main/java/video/typetype/tv/ui/auth/PasswordLoginForm.kt diff --git a/feature/auth/src/main/java/video/typetype/tv/ui/auth/AuthScaffold.kt b/feature/auth/src/main/java/video/typetype/tv/ui/auth/AuthScaffold.kt index 32fea51..1c92926 100644 --- a/feature/auth/src/main/java/video/typetype/tv/ui/auth/AuthScaffold.kt +++ b/feature/auth/src/main/java/video/typetype/tv/ui/auth/AuthScaffold.kt @@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import video.typetype.tv.ui.brand.BrandLogo import video.typetype.tv.theme.Palette @@ -26,12 +27,13 @@ fun AuthScaffold( title: String, subtitle: String, modifier: Modifier = Modifier, + width: Dp = 520.dp, content: @Composable () -> Unit, ) { Box(modifier.fillMaxSize()) { InkBackground() Column( - modifier = Modifier.align(Alignment.Center).width(520.dp), + modifier = Modifier.align(Alignment.Center).width(width), horizontalAlignment = Alignment.Start, ) { BrandLogo() diff --git a/feature/auth/src/main/java/video/typetype/tv/ui/auth/LoginScreen.kt b/feature/auth/src/main/java/video/typetype/tv/ui/auth/LoginScreen.kt index 000e689..924cf3e 100644 --- a/feature/auth/src/main/java/video/typetype/tv/ui/auth/LoginScreen.kt +++ b/feature/auth/src/main/java/video/typetype/tv/ui/auth/LoginScreen.kt @@ -1,15 +1,13 @@ package video.typetype.tv.ui.auth -import video.typetype.tv.ui.components.MessageText -import video.typetype.tv.ui.util.UiMessage import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Login -import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.material.icons.rounded.Password +import androidx.compose.material.icons.rounded.QrCode2 import androidx.compose.material.icons.rounded.SwapHoriz import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -20,23 +18,21 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusProperties import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch -import video.typetype.tv.resources.R import video.typetype.tv.data.net.ApiException import video.typetype.tv.data.session.Backend +import video.typetype.tv.data.session.DeviceLoginState import video.typetype.tv.data.session.SessionManager import video.typetype.tv.data.session.SignOutReason +import video.typetype.tv.resources.R +import video.typetype.tv.ui.components.MessageText import video.typetype.tv.ui.components.PillButton import video.typetype.tv.ui.util.LocalIntroPlaying +import video.typetype.tv.ui.util.UiMessage import video.typetype.tv.ui.util.requestFocusWhenReady @Composable @@ -47,20 +43,19 @@ fun LoginScreen( onChangeInstance: () -> Unit, modifier: Modifier = Modifier, ) { - var email by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - var name by rememberSaveable { mutableStateOf("") } var registering by rememberSaveable { mutableStateOf(false) } + var usePassword by rememberSaveable { mutableStateOf(false) } + var qrUnavailable by remember { mutableStateOf(false) } var busy by remember { mutableStateOf(false) } var message by remember { mutableStateOf(notice?.let { UiMessage(R.string.login_notice_expired) }) } val scope = rememberCoroutineScope() - val focus = LocalFocusManager.current val first = remember { FocusRequester() } - val submitButton = remember { FocusRequester() } + val showQr = !usePassword && !qrUnavailable + val deviceLogin = rememberDeviceLogin(backend, active = showQr) val introPlaying = LocalIntroPlaying.current - LaunchedEffect(registering, introPlaying) { if (!introPlaying) first.requestFocusWhenReady() } + LaunchedEffect(registering, showQr, introPlaying) { if (!introPlaying) first.requestFocusWhenReady() } fun attempt(block: suspend () -> Unit) { if (busy) return @@ -76,73 +71,55 @@ fun LoginScreen( } } - fun submit() { - val missing = email.isBlank() || password.isBlank() || (registering && name.isBlank()) - if (missing) { - message = UiMessage(R.string.login_error_missing) - return - } - attempt { - if (registering) session.register(email.trim(), password, name.trim()) else session.login(email.trim(), password) + LaunchedEffect(deviceLogin) { + when (deviceLogin) { + is DeviceLoginState.Approved -> attempt { session.signInWithDevice(deviceLogin.accessToken) } + DeviceLoginState.Unavailable -> qrUnavailable = true + else -> Unit } } AuthScaffold( title = stringResource(R.string.login_title, backend.instance.name.ifBlank { backend.apiBase.host }), - subtitle = stringResource(if (registering) R.string.login_subtitle_register else R.string.login_subtitle_sign_in), + subtitle = stringResource( + when { + showQr -> R.string.login_subtitle_qr + registering -> R.string.login_subtitle_register + else -> R.string.login_subtitle_sign_in + }, + ), modifier = modifier, + width = if (showQr) 720.dp else 520.dp, ) { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - if (registering) { - TvTextField( - value = name, - onValueChange = { name = it }, - label = stringResource(R.string.login_name), - modifier = Modifier.focusRequester(first), - onImeAction = { focus.moveFocus(FocusDirection.Down) }, + if (showQr) { + DeviceLoginPanel(deviceLogin) + Spacer(Modifier.height(8.dp)) + } else { + PasswordLoginForm( + backend = backend, + session = session, + registering = registering, + onRegisteringChange = { registering = it }, + busy = busy, + attempt = ::attempt, + onMessage = { message = it }, + first = first, ) } - TvTextField( - value = email, - onValueChange = { email = it }, - label = stringResource(R.string.login_email), - modifier = if (registering) Modifier else Modifier.focusRequester(first), - keyboardType = KeyboardType.Email, - onImeAction = { focus.moveFocus(FocusDirection.Down) }, - ) - TvTextField( - value = password, - onValueChange = { password = it }, - label = stringResource(R.string.login_password), - password = true, - imeAction = ImeAction.Go, - onImeAction = ::submit, - modifier = Modifier.focusProperties { down = submitButton }, - ) - Spacer(Modifier.height(4.dp)) Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { - PillButton( - label = stringResource( - when { - busy -> R.string.login_busy - registering -> R.string.login_register - else -> R.string.login_sign_in - }, - ), - icon = Icons.Rounded.Login, - onClick = ::submit, - modifier = Modifier.focusRequester(submitButton), - emphasized = true, - ) - if (backend.instance.registrationAllowed) { + if (!qrUnavailable) { PillButton( - label = stringResource(if (registering) R.string.login_back_to_sign_in else R.string.login_create_account), - icon = Icons.Rounded.PersonAdd, - onClick = { registering = !registering; message = null }, + label = stringResource(if (showQr) R.string.login_use_password else R.string.login_use_qr), + icon = if (showQr) Icons.Rounded.Password else Icons.Rounded.QrCode2, + onClick = { + usePassword = showQr + message = null + }, + modifier = if (showQr) Modifier.focusRequester(first) else Modifier, + emphasized = showQr, ) } - } - Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { if (backend.instance.guestAllowed) { PillButton( label = stringResource(R.string.login_continue_guest), diff --git a/feature/auth/src/main/java/video/typetype/tv/ui/auth/PasswordLoginForm.kt b/feature/auth/src/main/java/video/typetype/tv/ui/auth/PasswordLoginForm.kt new file mode 100644 index 0000000..4b2844a --- /dev/null +++ b/feature/auth/src/main/java/video/typetype/tv/ui/auth/PasswordLoginForm.kt @@ -0,0 +1,115 @@ +package video.typetype.tv.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Login +import androidx.compose.material.icons.rounded.PersonAdd +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusDirection +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusProperties +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import video.typetype.tv.data.session.Backend +import video.typetype.tv.data.session.SessionManager +import video.typetype.tv.resources.R +import video.typetype.tv.ui.components.PillButton +import video.typetype.tv.ui.util.UiMessage + +@Composable +fun PasswordLoginForm( + backend: Backend, + session: SessionManager, + registering: Boolean, + onRegisteringChange: (Boolean) -> Unit, + busy: Boolean, + attempt: (suspend () -> Unit) -> Unit, + onMessage: (UiMessage?) -> Unit, + first: FocusRequester, +) { + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var name by rememberSaveable { mutableStateOf("") } + val focus = LocalFocusManager.current + val submitButton = remember { FocusRequester() } + + fun submit() { + val missing = email.isBlank() || password.isBlank() || (registering && name.isBlank()) + if (missing) { + onMessage(UiMessage(R.string.login_error_missing)) + return + } + attempt { + if (registering) session.register(email.trim(), password, name.trim()) else session.login(email.trim(), password) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + if (registering) { + TvTextField( + value = name, + onValueChange = { name = it }, + label = stringResource(R.string.login_name), + modifier = Modifier.focusRequester(first), + onImeAction = { focus.moveFocus(FocusDirection.Down) }, + ) + } + TvTextField( + value = email, + onValueChange = { email = it }, + label = stringResource(R.string.login_email), + modifier = if (registering) Modifier else Modifier.focusRequester(first), + keyboardType = KeyboardType.Email, + onImeAction = { focus.moveFocus(FocusDirection.Down) }, + ) + TvTextField( + value = password, + onValueChange = { password = it }, + label = stringResource(R.string.login_password), + password = true, + imeAction = ImeAction.Go, + onImeAction = ::submit, + modifier = Modifier.focusProperties { down = submitButton }, + ) + Spacer(Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { + PillButton( + label = stringResource( + when { + busy -> R.string.login_busy + registering -> R.string.login_register + else -> R.string.login_sign_in + }, + ), + icon = Icons.Rounded.Login, + onClick = ::submit, + modifier = Modifier.focusRequester(submitButton), + emphasized = true, + ) + if (backend.instance.registrationAllowed) { + PillButton( + label = stringResource(if (registering) R.string.login_back_to_sign_in else R.string.login_create_account), + icon = Icons.Rounded.PersonAdd, + onClick = { + onRegisteringChange(!registering) + onMessage(null) + }, + ) + } + } + } +} From 105776b338aa6642db641aaa0e3a6736220bd2fc Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 30 Oct 2026 13:42:48 +0100 Subject: [PATCH 6/6] chore: release 0.3.0-beta.1 --- app/build.gradle.kts | 2 +- release-notes/v0.3.0-beta.1.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 release-notes/v0.3.0-beta.1.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f8e289f..318c47f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } // The release workflow reads this line: "x.y.z-beta.n" is published from the beta branch, "x.y.z" from main. -val appVersion = "0.2.0" +val appVersion = "0.3.0-beta.1" // The version code follows from the name, so each release installs over the one before, and a beta sits under the // stable release that ends it: 0.3.0-beta.2 is 30002 and 0.3.0 is 30099. diff --git a/release-notes/v0.3.0-beta.1.md b/release-notes/v0.3.0-beta.1.md new file mode 100644 index 0000000..6dc8d0c --- /dev/null +++ b/release-notes/v0.3.0-beta.1.md @@ -0,0 +1,12 @@ +Sign in to TypeType TV from your phone. + +- The sign-in screen now opens on a QR code. Scan it with your phone, sign in to your instance there if needed, + and confirm: the TV signs in without typing anything with the remote. +- You can also open the address shown under the code on your phone and type the code by hand. +- Use a password switches back to the usual form. On instances that do not offer QR sign-in yet, or where the + admin turned it off, the TV shows the password form directly. +- The startup intro stays smooth while the first screen loads. + +QR sign-in needs TypeType 1.13.0 or later on the instance. + +Download `TypeType-TV-v0.3.0-beta.1.apk` below; the `.sha256` file lets you check the download.