- No hardcoded UI strings. Every user-visible string must use
stringResource(R.string.xxx)in composables (orcontext.getString()where a Context is available). Add new strings tovalues/strings.xmland all 17 locale files undervalues-{lang}/strings.xml. This includes button labels, screen titles, error messages, dialog text, and fallback labels. ViewModels must not format user-visible strings directly — pass raw data (e.g. season number asInt) to the composable and format there.
A native Google TV app for BCC Media (app.bcc.media / brunstad.tv): browse and play video content with multi-language audio, multi-profile household support, and deep Google TV integration.
Platform: Google TV / Android TV OS (min SDK 21), Kotlin + Jetpack Compose for TV (androidx.tv).
| Concern | Library | Notes |
|---|---|---|
| Language | Kotlin | |
| UI | Jetpack Compose for TV (androidx.tv) |
D-pad navigation built in |
| Auth | Hand-rolled Device Authorization Flow | OkHttp + OkHttp coroutines; Auth0 SDK not used |
| GraphQL | Apollo Android (com.apollographql.apollo3) |
Codegen from .graphql files |
| Media playback | Media3 ExoPlayer | HLS CMAF, multi-audio |
| Images | Coil | Async image loading, TV-friendly |
| DI | Hilt | Standard Android DI |
| Async | Kotlin Coroutines + Flow |
Auth0 SDK is not used. The login flow is hand-rolled with OkHttp:
- POST to
https://login.bcc.no/oauth/device/code→ getdevice_code,user_code,verification_uri - Display QR code + URL + code on screen
- Poll
https://login.bcc.no/oauth/tokenuntil approved or expired - Store
access_token,refresh_token,id_tokeninEncryptedSharedPreferences
Auth0 domain: login.bcc.no
Client ID: iaDsfutxWw4eoRHHVryW65JHd49kXaP0
Audience: api.bcc.no
Scope: openid church profile country offline_access
Token refresh is also hand-rolled: POST to https://login.bcc.no/oauth/token with grant_type=refresh_token. The Auth0 SDK's claim that it handles this automatically does not apply here.
Token keys are namespaced by userId: profile_{userId}_access_token, profile_{userId}_refresh_token, profile_{userId}_id_token. One-time migration from legacy un-namespaced keys is handled by TokenStore.loadLegacy() / clearLegacy().
Multiple Auth0 accounts can be stored on one device. Key classes:
auth/ProfileStore.kt— stores profile list as JSON + active profile ID in plainSharedPreferences(prefs name:bccmedia_profiles). Uses.commit()(not.apply()) everywhere — synchronous writes are required soActivity.recreate()reads correct state.auth/TokenStore.kt— token storage keyed by userId (see above).auth/AuthRepository.kt—getValidAccessToken()usesprofileStore.activeProfileId. On first login, extracts userId/displayName/initials from JWT and callsprofileStore.saveProfile().data/LanguageRepository.kt— all pref keys are profile-namespaced viakey(base)helper (e.g.profile_{userId}_language). Lazy one-time migration from legacy un-keyed settings. Static companion:profileLanguageKey(userId)used inattachBaseContextbefore Hilt is available.ui/profile/ProfilePickerViewModel.kt—UiState(profiles, activeProfileId),refresh(),switchTo().ui/profile/ProfilePickerScreen.kt— full-screen overlay. Switching callsviewModel.switchTo(profile)thencontext.findActivity()?.recreate().
LocalContext.current inside a Compose Navigation composable is a ContextThemeWrapper, not an Activity. (context as? Activity) always returns null. Use:
fun Context.findActivity(): Activity? {
var ctx = this
while (ctx is ContextWrapper) {
if (ctx is Activity) return ctx
ctx = ctx.baseContext
}
return null
}attachBaseContext runs before Hilt is available, so profile language is read via raw SharedPreferences using static helpers:
val activeId = newBase.getSharedPreferences(ProfileStore.PREFS_NAME, Context.MODE_PRIVATE)
.getString(ProfileStore.KEY_ACTIVE_PROFILE_ID, null)
val langKey = LanguageRepository.profileLanguageKey(activeId)
val lang = newBase.getSharedPreferences("bccmedia_prefs", Context.MODE_PRIVATE)
.getString(langKey, LanguageRepository.DEFAULT) ?: LanguageRepository.DEFAULTSingle endpoint: https://api.brunstad.tv/query
All requests are HTTP POST with Content-Type: application/json. Required headers on every request:
Authorization: Bearer <access_token>
X-Application: bccm-androidtv
X-Session-Id: <timestamp-ms> (generate once per app session)
Accept-Language: <ISO 639-1 code> (e.g. "en", "no")
App bootstrap:
query application {
application {
code
page { code }
searchPage { code }
}
}Home page sections (paginated):
query getPage($code: String!, $first: Int, $offset: Int,
$sectionFirst: Int, $sectionOffset: Int) {
page(code: $code) {
id title code
sections(first: $first, offset: $offset) {
total offset first
items { __typename id title description ...ItemSection }
}
}
}
# Use code: "frontpage" for home screenEpisode detail:
query getEpisode($episodeId: ID!) {
episode(id: $episodeId) {
id uuid title description image
publishDate productionDate duration
number progress locked ageRating
season { id title number show { id type title } }
chapters { id title start }
}
}Stream URL — use UUID, not integer ID:
query getEpisode($ID: ID!) {
episode(id: $ID) {
image
streams { url type audioLanguages subtitleLanguages videoLanguage }
}
}Season episodes:
query getSeasonOnEpisodePage($seasonId: ID!, $firstEpisodes: Int) {
season(id: $seasonId) {
id title image number
episodes(first: $firstEpisodes) {
total
items { id uuid title image publishDate duration number progress description ageRating }
}
}
}Hierarchy: Show → Season → Episode
IDs come in two forms — integer string IDs (e.g. "3003") for most queries, UUIDs (e.g. "645b1fbf-...") required for stream URL queries. The uuid field on episode is the one to use for streams.
Section types on the home page: FeaturedSection, DefaultSection, GridSection, PosterSection, CardSection, ListSection, and others. Each contains SectionItem entries with an item field that can be Episode, Season, Show, Page, Playlist, or Link.
Stream URL: pre-signed AWS CloudFront HLS, valid ~6 hours. No DRM, no token exchange.
CDN: vod2.stream.brunstad.tv
Single HLS manifest contains 18 audio tracks (bg, de, en, es, fi, fr, hr, hu, it, nl, no, pl, pt, ro, ru, sl, ta, tr). Use Media3 ExoPlayer with TrackSelector for runtime audio selection.
Re-fetch the stream URL if the user resumes after more than ~6 hours.
Images: imgix.bcc.media with query params for resizing: ?w=320&h=180&fit=crop&crop=faces
isSystemInDarkTheme()returns false → white background. Fix: always force dark theme inthemes.xmlandTheme.kt, never conditionally check system theme.PlayerViewdefaults toSurfaceViewwhich shows white on older devices. Fix: useapp:surface_type="texture_view"inres/layout/player_view.xmland inflate it.
Each back-stack entry gets its own HiltViewModel instance. Two screens navigating to the same route have separate ViewModels — they don't share state. When returning to HomeScreen from ProfilePickerScreen, HomeScreen's profileViewModel is a different instance than the picker's. Fix: call profileViewModel.refresh() inside the RESUMED lifecycle effect in HomeScreen.
- Do not use
.focusable()before.onFocusChanged+.clickable()— it creates inconsistent focus node behavior. - For a "focus ring" effect, use an outer Box with a primary-colored background that's only visible when focused, wrapping an inner Box with the actual content.
NavRailItemselected+focused states: always checkselected && focusedbeforeselectedalone, otherwise there's no visual difference when a selected item gains focus.
Any write that must be read by Activity.recreate() must use .commit(). The .apply() write is async and the new Activity can read stale state. This affects ProfileStore.activeProfileId and persistProfiles().
The official BCC Media app is open source at github.com/bcc-code/bcc-media-app (Flutter). Use it to understand the content model and GraphQL queries. The GraphQL schema is at github.com/bcc-code/bcc-media-platform under backend/graph/api/schema/.