Work through the 1.0 milestone: focus, backend tests, remote, login code
All checks were successful
Build TV app / build (push) Successful in 53s
All checks were successful
Build TV app / build (push) Successful in 53s
#20 Each settings column is a focus group now. The middle column is shorter than the left, so "down" past its last row used to search wider and land back in the left column — "Erweitert" was only reachable by going right, down, left. #22 The whole backend path had no coverage, which is why an afternoon against the real instance found three bugs. Parsing moved out of DispatcharrRepository into DispatcharrJson and is covered by twelve tests built from the shapes that instance actually returns: pagination, channels without a uuid or hidden from output, effective vs plain names and logos, favourites without the key, inactive output profiles, and all three EPG envelopes the fork has shipped. Android's stub org.json throws in unit tests, so the real implementation is on the test classpath. What no test can reach is written down in tests/dispatcharr-checkliste.md. #24 The remote's channel tab was only ever read in source. Driven through the DevTools protocol against the real backend it lists 503 channels with logos and EPG, the search filters, the club chip switches view, and a star survives the round trip to the backend and back — set and unset. It also showed the phone still spelling out "BL: Bayer Leverkusen - VfB Stuttgart, tipico Topspiel der Woche, 16. Spieltag" where the TV shortens to the fixture; the payload shortens now too. #23 The TV says "Der alte Code war abgelaufen — neuer Code 566071643" instead of swapping the digits without a word. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,9 @@ dependencies {
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
implementation("io.coil-kt:coil-compose:2.7.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
// Android ships a stub org.json for unit tests that throws on every call;
|
||||
// the real one lets the backend parsing be tested without a device.
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
|
||||
// Full JDK for javac via toolchain (host may only have a JRE); resolved by
|
||||
|
||||
@@ -182,6 +182,14 @@ class AppState(
|
||||
var welcomePhase by mutableStateOf(WelcomePhase.WAIT_PHONE)
|
||||
var welcomeUserCode by mutableStateOf("")
|
||||
|
||||
/**
|
||||
* True once the login code has been replaced because the old one ran out.
|
||||
* The identity provider hands out short-lived codes; swapping the digits
|
||||
* on screen without a word leaves whoever is typing them wondering why
|
||||
* the phone says no.
|
||||
*/
|
||||
var welcomeCodeRenewed by mutableStateOf(false)
|
||||
|
||||
/** Plain-language fullscreen error state (issue #13). */
|
||||
var appError by mutableStateOf(AppError.NONE)
|
||||
|
||||
@@ -401,6 +409,7 @@ class AppState(
|
||||
appError = AppError.NONE
|
||||
welcomePhase = WelcomePhase.WAIT_PHONE
|
||||
welcomeUserCode = ""
|
||||
welcomeCodeRenewed = false
|
||||
screen = Screen.WELCOME
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,14 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
||||
.put("channel", channel.name)
|
||||
.put("url", channel.url)
|
||||
.put("group", channel.group)
|
||||
.put("title", hit.programme.title)
|
||||
// Same shortening as on the TV: the phone showed
|
||||
// "BL: Bayer Leverkusen - VfB Stuttgart, tipico
|
||||
// Topspiel der Woche, 16. Spieltag" in full.
|
||||
.put(
|
||||
"title",
|
||||
dev.castarr.tv.data.TeamFilters.fixture(hit.programme.title)
|
||||
?: hit.programme.title,
|
||||
)
|
||||
.put("start", hit.programme.start)
|
||||
.put("stop", hit.programme.stop)
|
||||
.put("further", hit.further)
|
||||
@@ -201,6 +208,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
||||
repeat(LOGIN_CODE_ROUNDS) { round ->
|
||||
val session = state.auth.startDeviceFlow()
|
||||
state.welcomeUserCode = session.userCode
|
||||
state.welcomeCodeRenewed = round > 0
|
||||
state.welcomePhase = AppState.WelcomePhase.WAIT_LOGIN
|
||||
server.broadcastLoginLink(session.verificationUriComplete, session.userCode)
|
||||
if (round > 0) {
|
||||
@@ -232,6 +240,7 @@ class MainActivity : ComponentActivity(), ControlServer.Listener {
|
||||
server.broadcastToast("Anmeldung abgebrochen — bitte erneut versuchen")
|
||||
state.welcomePhase = AppState.WelcomePhase.WAIT_PHONE
|
||||
state.welcomeUserCode = ""
|
||||
state.welcomeCodeRenewed = false
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("Onboarding", "configure failed: ${e.javaClass.simpleName}")
|
||||
server.broadcastToast("Server nicht erreichbar oder ohne Anmeldung")
|
||||
|
||||
119
app/src/main/java/dev/castarr/tv/data/DispatcharrJson.kt
Normal file
119
app/src/main/java/dev/castarr/tv/data/DispatcharrJson.kt
Normal file
@@ -0,0 +1,119 @@
|
||||
package dev.castarr.tv.data
|
||||
|
||||
import dev.castarr.tv.playlist.Channel
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Everything the Dispatcharr backend sends, turned into the app's own types.
|
||||
*
|
||||
* Split out of [DispatcharrRepository] so the parsing can be tested without a
|
||||
* server: the whole backend path had no coverage, and the first run against a
|
||||
* real instance turned up three bugs in an afternoon.
|
||||
*/
|
||||
object DispatcharrJson {
|
||||
|
||||
/** `{"results": [...]}` from the paginated API, or a bare array. */
|
||||
fun paginatedResults(body: String): JSONArray =
|
||||
runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body)
|
||||
|
||||
/** Next page URL of a paginated response, or null on the last one. */
|
||||
fun nextPage(body: String): String? =
|
||||
runCatching { JSONObject(body).optString("next") }.getOrNull()
|
||||
?.takeIf { it.isNotEmpty() && it != "null" }
|
||||
|
||||
fun parseGroups(body: String): Map<Int, String> {
|
||||
val results = paginatedResults(body)
|
||||
return (0 until results.length()).associate {
|
||||
val obj = results.getJSONObject(it)
|
||||
obj.getInt("id") to obj.optString("name")
|
||||
}
|
||||
}
|
||||
|
||||
/** `{"channels": [1, 2]}` — the ids the signed-in user starred. */
|
||||
fun parseFavorites(body: String): Set<Int> {
|
||||
val ids = runCatching { JSONObject(body).optJSONArray("channels") }.getOrNull() ?: JSONArray()
|
||||
return (0 until ids.length()).map { ids.getInt(it) }.toSet()
|
||||
}
|
||||
|
||||
/** Output profile names; inactive ones are not offered. */
|
||||
fun parseProfiles(body: String): List<String> {
|
||||
val results = paginatedResults(body)
|
||||
return (0 until results.length()).mapNotNull {
|
||||
val obj = results.getJSONObject(it)
|
||||
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One page of channels.
|
||||
*
|
||||
* A channel without a uuid cannot be played (the proxy resolves by uuid,
|
||||
* integer ids give a 404), and one hidden from output is not meant to
|
||||
* show up at all — both are dropped.
|
||||
*/
|
||||
fun parseChannels(
|
||||
body: String,
|
||||
groups: Map<Int, String>,
|
||||
streamUrl: (uuid: String) -> String,
|
||||
logoUrl: (id: Int) -> String,
|
||||
): List<Channel> {
|
||||
val results = paginatedResults(body)
|
||||
val list = mutableListOf<Channel>()
|
||||
for (i in 0 until results.length()) {
|
||||
val obj = results.getJSONObject(i)
|
||||
val uuid = obj.optString("uuid")
|
||||
if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue
|
||||
list.add(
|
||||
Channel(
|
||||
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
|
||||
url = streamUrl(uuid),
|
||||
group = groups[obj.optInt("channel_group_id")].orEmpty(),
|
||||
logo = (obj.optInt("effective_logo_id").takeIf { it > 0 }
|
||||
?: obj.optInt("logo_id").takeIf { it > 0 })
|
||||
?.let(logoUrl)
|
||||
.orEmpty(),
|
||||
tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
|
||||
backendId = obj.getInt("id"),
|
||||
streamKey = uuid,
|
||||
)
|
||||
)
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
/**
|
||||
* The EPG grid, keyed by tvg-id. The envelope is `{"data": [...]}`, but
|
||||
* `results` and a bare array are tolerated — the fork has shipped all
|
||||
* three at some point.
|
||||
*/
|
||||
fun parseEpgGrid(body: String): Map<String, List<Programme>> {
|
||||
val root = runCatching { JSONObject(body) }.getOrNull()
|
||||
val results = root?.optJSONArray("data")
|
||||
?: root?.optJSONArray("results")
|
||||
?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") }
|
||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val map = HashMap<String, MutableList<Programme>>()
|
||||
for (i in 0 until results.length()) {
|
||||
val obj = results.getJSONObject(i)
|
||||
val tvgId = obj.optString("tvg_id")
|
||||
if (tvgId.isEmpty()) continue
|
||||
val start = parseIso(obj.optString("start_time"), format)
|
||||
val stop = parseIso(obj.optString("end_time"), format)
|
||||
if (start == 0L || stop <= start) continue
|
||||
map.getOrPut(tvgId) { mutableListOf() }
|
||||
.add(Programme(start, stop, obj.optString("title")))
|
||||
}
|
||||
map.values.forEach { it.sortBy(Programme::start) }
|
||||
return map
|
||||
}
|
||||
|
||||
private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching {
|
||||
format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L
|
||||
}.getOrDefault(0L)
|
||||
}
|
||||
@@ -11,12 +11,8 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Channel source backed by the Dispatcharr fork's Bearer API: channels,
|
||||
@@ -124,45 +120,23 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
||||
channels.value = channels.value.map { it.copy(url = streamUrl(it.streamKey)) }
|
||||
}
|
||||
|
||||
private suspend fun fetchGroups(token: String): Map<Int, String> {
|
||||
val body = request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
|
||||
val results = paginatedResults(body)
|
||||
return (0 until results.length()).associate {
|
||||
val obj = results.getJSONObject(it)
|
||||
obj.getInt("id") to obj.optString("name")
|
||||
}
|
||||
}
|
||||
private suspend fun fetchGroups(token: String): Map<Int, String> =
|
||||
DispatcharrJson.parseGroups(
|
||||
request("${auth.serverUrl}/api/channels/groups/?page_size=1000", "GET", token)
|
||||
)
|
||||
|
||||
private suspend fun fetchChannels(token: String, groups: Map<Int, String>): List<Channel> {
|
||||
val list = mutableListOf<Channel>()
|
||||
var url: String? = "${auth.serverUrl}/api/channels/channels/?page_size=500"
|
||||
while (url != null && list.size < 10_000) {
|
||||
val body = request(url, "GET", token)
|
||||
val json = runCatching { JSONObject(body) }.getOrNull()
|
||||
val results = json?.optJSONArray("results") ?: JSONArray(body)
|
||||
for (i in 0 until results.length()) {
|
||||
val obj = results.getJSONObject(i)
|
||||
val id = obj.getInt("id")
|
||||
val uuid = obj.optString("uuid")
|
||||
if (uuid.isEmpty() || obj.optBoolean("hidden_from_output")) continue
|
||||
list.add(
|
||||
Channel(
|
||||
name = obj.optString("effective_name").ifEmpty { obj.optString("name") },
|
||||
url = streamUrl(uuid),
|
||||
group = groups[obj.optInt("channel_group_id")].orEmpty(),
|
||||
// The list serializer only carries logo ids; the
|
||||
// cache endpoint serves the image without auth.
|
||||
logo = (obj.optInt("effective_logo_id").takeIf { it > 0 }
|
||||
?: obj.optInt("logo_id").takeIf { it > 0 })
|
||||
?.let { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" }
|
||||
.orEmpty(),
|
||||
tvgId = obj.optString("effective_tvg_id").ifEmpty { obj.optString("tvg_id") },
|
||||
backendId = id,
|
||||
streamKey = uuid,
|
||||
)
|
||||
)
|
||||
}
|
||||
url = json?.optString("next")?.takeIf { it.isNotEmpty() && it != "null" }
|
||||
list += DispatcharrJson.parseChannels(
|
||||
body = body,
|
||||
groups = groups,
|
||||
streamUrl = ::streamUrl,
|
||||
logoUrl = { "${auth.serverUrl.trimEnd('/')}/api/channels/logos/$it/cache/" },
|
||||
)
|
||||
url = DispatcharrJson.nextPage(body)
|
||||
}
|
||||
list.sortBy { it.name.lowercase() }
|
||||
return list
|
||||
@@ -170,54 +144,22 @@ class DispatcharrRepository(context: Context, private val auth: DeviceAuth) {
|
||||
|
||||
private suspend fun refreshFavorites(token: String) {
|
||||
val body = request("${auth.serverUrl}/api/channels/favorites/", "GET", token)
|
||||
val ids = JSONObject(body).optJSONArray("channels") ?: JSONArray()
|
||||
favorites.value = (0 until ids.length()).map { ids.getInt(it) }.toSet()
|
||||
favorites.value = DispatcharrJson.parseFavorites(body)
|
||||
}
|
||||
|
||||
private suspend fun refreshProfiles(token: String) {
|
||||
val body = request("${auth.serverUrl}/api/core/outputprofiles/", "GET", token)
|
||||
val results = paginatedResults(body)
|
||||
profiles.value = (0 until results.length()).mapNotNull {
|
||||
val obj = results.getJSONObject(it)
|
||||
if (obj.optBoolean("is_active", true)) obj.optString("name") else null
|
||||
}
|
||||
profiles.value = DispatcharrJson.parseProfiles(body)
|
||||
}
|
||||
|
||||
private suspend fun refreshEpg(token: String) {
|
||||
status.value = "loading_epg"
|
||||
val body = request("${auth.serverUrl}/api/epg/grid/", "GET", token)
|
||||
// Envelope is {"data": [...]}; tolerate bare arrays and "results" too.
|
||||
val root = runCatching { JSONObject(body) }.getOrNull()
|
||||
val results = root?.optJSONArray("data")
|
||||
?: root?.optJSONArray("results")
|
||||
?: runCatching { JSONArray(body) }.getOrElse { error("unexpected EPG envelope") }
|
||||
val format = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val map = HashMap<String, MutableList<Programme>>()
|
||||
for (i in 0 until results.length()) {
|
||||
val obj = results.getJSONObject(i)
|
||||
val tvgId = obj.optString("tvg_id")
|
||||
if (tvgId.isEmpty()) continue
|
||||
val start = parseIso(obj.optString("start_time"), format)
|
||||
val stop = parseIso(obj.optString("end_time"), format)
|
||||
if (start == 0L || stop <= start) continue
|
||||
map.getOrPut(tvgId) { mutableListOf() }
|
||||
.add(Programme(start, stop, obj.optString("title")))
|
||||
}
|
||||
map.values.forEach { it.sortBy(Programme::start) }
|
||||
programmesByTvgId = map
|
||||
programmesByTvgId = DispatcharrJson.parseEpgGrid(body)
|
||||
epgUpdatedAt.value = System.currentTimeMillis()
|
||||
status.value = ""
|
||||
}
|
||||
|
||||
private fun parseIso(raw: String, format: SimpleDateFormat): Long = runCatching {
|
||||
format.parse(raw.substringBefore(".").substringBefore("+").removeSuffix("Z"))?.time ?: 0L
|
||||
}.getOrDefault(0L)
|
||||
|
||||
private fun paginatedResults(body: String): JSONArray =
|
||||
runCatching { JSONObject(body).optJSONArray("results") }.getOrNull() ?: JSONArray(body)
|
||||
|
||||
private suspend fun request(url: String, method: String, token: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
|
||||
@@ -28,6 +28,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.foundation.focusGroup
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
@@ -80,7 +81,9 @@ fun SettingsScreen(state: AppState) {
|
||||
// club menus arrived, and the ten-foot rule (CONTEXT.md) allows no
|
||||
// scrolling outside the channel list.
|
||||
Column(
|
||||
Modifier.weight(1f),
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.focusGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SettingsCard("Konto") {
|
||||
@@ -203,7 +206,9 @@ fun SettingsScreen(state: AppState) {
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
Column(
|
||||
Modifier.weight(1f),
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.focusGroup(),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
SettingsCard("App") {
|
||||
@@ -259,7 +264,11 @@ fun SettingsScreen(state: AppState) {
|
||||
|
||||
Spacer(Modifier.width(16.dp))
|
||||
|
||||
Column(Modifier.weight(1f)) {
|
||||
Column(
|
||||
Modifier
|
||||
.weight(1f)
|
||||
.focusGroup()
|
||||
) {
|
||||
SettingsCard("Handy-Fernbedienung") {
|
||||
val address = remember(pairingEpoch) { Pairing.lanAddress() }
|
||||
if (!state.remoteAvailable) {
|
||||
|
||||
@@ -61,8 +61,18 @@ fun WelcomeScreen(state: AppState) {
|
||||
AppState.WelcomePhase.WAIT_PHONE -> Unit
|
||||
AppState.WelcomePhase.WAIT_URL -> Status("Handy verbunden — warte auf den Server…")
|
||||
AppState.WelcomePhase.WAIT_LOGIN -> Status(
|
||||
if (state.welcomeUserCode.isEmpty()) "Warte auf die Anmeldung am Handy…"
|
||||
else "Anmeldung am Handy bestätigen · Code ${state.welcomeUserCode}"
|
||||
when {
|
||||
state.welcomeUserCode.isEmpty() ->
|
||||
"Warte auf die Anmeldung am Handy…"
|
||||
// Saying so beats swapping the digits in silence
|
||||
// while somebody is typing them.
|
||||
state.welcomeCodeRenewed ->
|
||||
"Der alte Code war abgelaufen — neuer Code " +
|
||||
state.welcomeUserCode
|
||||
else ->
|
||||
"Anmeldung am Handy bestätigen · Code " +
|
||||
state.welcomeUserCode
|
||||
}
|
||||
)
|
||||
AppState.WelcomePhase.DONE -> Status("Angemeldet! Lade Sender…")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user