Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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>(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()
}
}
28 changes: 20 additions & 8 deletions app/src/main/java/video/typetype/tv/ui/splash/SplashMark.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
Expand Down
22 changes: 22 additions & 0 deletions app/src/main/java/video/typetype/tv/ui/splash/SplashStart.kt
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
7 changes: 2 additions & 5 deletions app/src/main/java/video/typetype/tv/ui/splash/StartupChime.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
18 changes: 16 additions & 2 deletions app/src/main/java/video/typetype/tv/ui/splash/StartupSplash.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -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)
}
Expand Down
23 changes: 23 additions & 0 deletions core/data/src/main/java/video/typetype/tv/data/api/AuthApi.kt
Original file line number Diff line number Diff line change
@@ -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 =
Expand All @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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<DeviceLoginState> = 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<DeviceLoginState>.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
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading