0.2.55 - Remote config/Request fixes
This commit is contained in:
@@ -8,6 +8,7 @@ import com.ponzischeme89.memby.data.PreferencesSync
|
||||
import com.ponzischeme89.memby.data.SettingsStore
|
||||
import com.ponzischeme89.memby.data.ThemeSync
|
||||
import com.ponzischeme89.memby.data.playback.installAudioCapabilityProbe
|
||||
import com.ponzischeme89.memby.data.remoteconfig.RemoteConfigManager
|
||||
import com.ponzischeme89.memby.update.RequiredUpdateGuard
|
||||
|
||||
/**
|
||||
@@ -25,6 +26,8 @@ object ServiceLocator {
|
||||
private set
|
||||
lateinit var maintenance: MaintenanceMonitor
|
||||
private set
|
||||
lateinit var remoteConfig: RemoteConfigManager
|
||||
private set
|
||||
|
||||
/**
|
||||
* Held rather than discarded because it is a long-lived collector, not a service
|
||||
@@ -54,6 +57,9 @@ object ServiceLocator {
|
||||
// Only hands the probe an application context; it does no work until the first
|
||||
// request or playback negotiation asks what this television's receiver accepts.
|
||||
installAudioCapabilityProbe(context)
|
||||
// One tiny synchronous preference read selects an immutable process snapshot.
|
||||
// Network revalidation is delayed and can only affect the next process.
|
||||
remoteConfig = RemoteConfigManager(context.applicationContext)
|
||||
settings = SettingsStore(context.applicationContext)
|
||||
// Started before the repository, so a refusal answering the very first request a
|
||||
// television makes has somewhere to be recorded.
|
||||
@@ -69,5 +75,6 @@ object ServiceLocator {
|
||||
// because the surfaces that obey it — the launcher, the player's Compose islands,
|
||||
// the screensaver's DreamService — are separate roots with no common owner but this.
|
||||
themeSync = ThemeSync(repository, settings, maintenance.theme)
|
||||
remoteConfig.refreshLater()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -872,6 +872,7 @@ class EmbyRepository(private val settings: SettingsStore) {
|
||||
com.ponzischeme89.memby.data.model.GatewayMediaRequest(
|
||||
mediaType = candidate.mediaType,
|
||||
foreignId = candidate.foreignId,
|
||||
title = candidate.title,
|
||||
),
|
||||
).title
|
||||
}
|
||||
|
||||
@@ -446,6 +446,7 @@ data class GatewayRequestLookup(
|
||||
data class GatewayMediaRequest(
|
||||
val mediaType: String,
|
||||
val foreignId: Int,
|
||||
val title: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.ponzischeme89.memby.data.remoteconfig
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.ponzischeme89.memby.BuildConfig
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.remote.HttpStack
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Request
|
||||
|
||||
private const val REMOTE_CONFIG_SCHEMA_VERSION = 1
|
||||
private const val REMOTE_CONFIG_START_DELAY_MS = 5_000L
|
||||
private const val MAX_REMOTE_CONFIG_BYTES = 32L * 1024L
|
||||
private const val PREFERENCES_NAME = "memby_remote_config"
|
||||
private const val DOCUMENT_KEY = "last_known_good"
|
||||
|
||||
/**
|
||||
* Typed, process-stable presentation configuration.
|
||||
*
|
||||
* The active value is chosen once, before any Activity is created. A successful refresh
|
||||
* only replaces the durable last-known-good document; it deliberately does not publish a
|
||||
* Flow or mutable state that could repaint wording under somebody's focus.
|
||||
*/
|
||||
@Serializable
|
||||
data class MembyRemoteConfig(
|
||||
val schemaVersion: Int,
|
||||
val configVersion: Long,
|
||||
val minimumAppVersion: String = "",
|
||||
val maximumAppVersion: String = "",
|
||||
val copy: RemoteCopy,
|
||||
val features: RemoteFeatures,
|
||||
val presentation: RemotePresentation,
|
||||
) {
|
||||
val navigation: NavigationRemoteConfig
|
||||
get() = NavigationRemoteConfig(
|
||||
labels = copy.navigation,
|
||||
tagline = copy.tagline,
|
||||
showVersion = features.showNavigationVersion,
|
||||
expandedWidthDp = presentation.navigationRailExpandedWidthDp,
|
||||
contentShiftDp = presentation.navigationContentShiftDp,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class RemoteCopy(
|
||||
val navigation: NavigationLabels,
|
||||
val tagline: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class NavigationLabels(
|
||||
val home: String,
|
||||
val forYou: String,
|
||||
val search: String,
|
||||
val movies: String,
|
||||
val tvShows: String,
|
||||
val tvCalendar: String,
|
||||
val favourites: String,
|
||||
val user: String,
|
||||
val settings: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RemoteFeatures(
|
||||
val showNavigationVersion: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RemotePresentation(
|
||||
val navigationRailExpandedWidthDp: Int,
|
||||
val navigationContentShiftDp: Int,
|
||||
)
|
||||
|
||||
/** The single UI-facing projection, so Compose never performs stringly typed lookups. */
|
||||
data class NavigationRemoteConfig(
|
||||
val labels: NavigationLabels,
|
||||
val tagline: String,
|
||||
val showVersion: Boolean,
|
||||
val expandedWidthDp: Int,
|
||||
val contentShiftDp: Int,
|
||||
)
|
||||
|
||||
object BundledRemoteConfig {
|
||||
val value = MembyRemoteConfig(
|
||||
schemaVersion = REMOTE_CONFIG_SCHEMA_VERSION,
|
||||
// Bundled data is the floor, not a remotely issued revision. Any valid server
|
||||
// document therefore wins on a later process start.
|
||||
configVersion = 0,
|
||||
copy = RemoteCopy(
|
||||
tagline = "Matt’s Android TV client",
|
||||
navigation = NavigationLabels(
|
||||
home = "Home",
|
||||
forYou = "For You",
|
||||
search = "Search",
|
||||
movies = "Movies",
|
||||
tvShows = "TV Shows",
|
||||
tvCalendar = "TV Calendar",
|
||||
favourites = "Favourites",
|
||||
user = "User",
|
||||
settings = "Settings",
|
||||
),
|
||||
),
|
||||
features = RemoteFeatures(showNavigationVersion = true),
|
||||
presentation = RemotePresentation(
|
||||
navigationRailExpandedWidthDp = 184,
|
||||
navigationContentShiftDp = 112,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class CachedRemoteConfig(
|
||||
val etag: String,
|
||||
val document: MembyRemoteConfig,
|
||||
)
|
||||
|
||||
private val remoteConfigJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns synchronous activation and asynchronous revalidation.
|
||||
*
|
||||
* SharedPreferences gives this one small value an atomic file replacement. The network
|
||||
* path commits only after decoding and validation, on an IO dispatcher, and never mutates
|
||||
* [active]. A killed write therefore leaves either the old complete value or the new one.
|
||||
*/
|
||||
class RemoteConfigManager(context: Context) {
|
||||
private val preferences = context.applicationContext.getSharedPreferences(
|
||||
PREFERENCES_NAME,
|
||||
Context.MODE_PRIVATE,
|
||||
)
|
||||
private val cachedAtStart = preferences.getString(DOCUMENT_KEY, null)
|
||||
?.let(::decodeCachedRemoteConfig)
|
||||
?.takeIf { validateRemoteConfig(it.document, BuildConfig.VERSION_NAME) == null }
|
||||
|
||||
val active: MembyRemoteConfig = cachedAtStart?.document ?: BundledRemoteConfig.value
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Schedules a tiny revalidation after the launcher's critical start-up work. */
|
||||
fun refreshLater() {
|
||||
val gatewayUrl = ServerConfig.gatewayUrl ?: return
|
||||
val cached = cachedAtStart
|
||||
scope.launch {
|
||||
delay(REMOTE_CONFIG_START_DELAY_MS)
|
||||
fetch(gatewayUrl, cached)
|
||||
}
|
||||
}
|
||||
|
||||
private fun fetch(gatewayUrl: String, cached: CachedRemoteConfig?) {
|
||||
val request = Request.Builder()
|
||||
.url(gatewayUrl.trimEnd('/') + "/v1/config")
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Memby-Version", BuildConfig.VERSION_NAME)
|
||||
.header("X-Memby-Config-Schema", REMOTE_CONFIG_SCHEMA_VERSION.toString())
|
||||
.apply { cached?.etag?.takeIf(String::isNotBlank)?.let { header("If-None-Match", it) } }
|
||||
.build()
|
||||
val client = HttpStack.base.newBuilder()
|
||||
// This request is speculative and affects only a later process. Give it less
|
||||
// patience than any screen-facing call and never retry it in this session.
|
||||
.connectTimeout(2, TimeUnit.SECONDS)
|
||||
.readTimeout(2, TimeUnit.SECONDS)
|
||||
.callTimeout(3, TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(false)
|
||||
.build()
|
||||
runCatching {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.code == 304) return
|
||||
if (!response.isSuccessful) return
|
||||
val body = response.body ?: return
|
||||
if (body.contentLength() > MAX_REMOTE_CONFIG_BYTES) return
|
||||
val source = body.source()
|
||||
source.request(MAX_REMOTE_CONFIG_BYTES + 1)
|
||||
if (source.buffer.size > MAX_REMOTE_CONFIG_BYTES) return
|
||||
val raw = source.readUtf8()
|
||||
val document = decodeRemoteConfig(raw) ?: return
|
||||
if (validateRemoteConfig(document, BuildConfig.VERSION_NAME) != null) return
|
||||
val heldVersion = cached?.document?.configVersion ?: 0
|
||||
if (document.configVersion <= heldVersion) return
|
||||
val etag = response.header("ETag")?.takeIf {
|
||||
it.isNotBlank() && it.length <= 128 && !it.contains('\n') && !it.contains('\r')
|
||||
} ?: return
|
||||
val encoded = remoteConfigJson.encodeToString(CachedRemoteConfig(etag, document))
|
||||
if (!preferences.edit().putString(DOCUMENT_KEY, encoded).commit()) {
|
||||
Log.w("MembyRemoteConfig", "Could not persist remote configuration")
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
// A failed speculative refresh is ordinary offline behaviour. The active
|
||||
// bundled/cached document remains complete and no screen needs to know.
|
||||
Log.d("MembyRemoteConfig", "Remote configuration refresh skipped", it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun decodeRemoteConfig(raw: String): MembyRemoteConfig? = runCatching {
|
||||
remoteConfigJson.decodeFromString<MembyRemoteConfig>(raw)
|
||||
}.getOrNull()
|
||||
|
||||
private fun decodeCachedRemoteConfig(raw: String): CachedRemoteConfig? = runCatching {
|
||||
remoteConfigJson.decodeFromString<CachedRemoteConfig>(raw)
|
||||
}.getOrNull()
|
||||
|
||||
/** Returns null only when the whole document is safe to activate. */
|
||||
internal fun validateRemoteConfig(document: MembyRemoteConfig, appVersion: String): String? {
|
||||
if (document.schemaVersion != REMOTE_CONFIG_SCHEMA_VERSION) return "unsupported schema"
|
||||
if (document.configVersion < 1) return "invalid configuration version"
|
||||
val current = semanticVersion(appVersion) ?: return "invalid app version"
|
||||
document.minimumAppVersion.takeIf(String::isNotBlank)?.let { minimum ->
|
||||
val parsed = semanticVersion(minimum) ?: return "invalid minimum app version"
|
||||
if (current < parsed) return "app is older than the configuration minimum"
|
||||
}
|
||||
document.maximumAppVersion.takeIf(String::isNotBlank)?.let { maximum ->
|
||||
val parsed = semanticVersion(maximum) ?: return "invalid maximum app version"
|
||||
if (current > parsed) return "app is newer than the configuration maximum"
|
||||
}
|
||||
val copy = document.copy
|
||||
val labels = listOf(
|
||||
copy.tagline,
|
||||
copy.navigation.home,
|
||||
copy.navigation.forYou,
|
||||
copy.navigation.search,
|
||||
copy.navigation.movies,
|
||||
copy.navigation.tvShows,
|
||||
copy.navigation.tvCalendar,
|
||||
copy.navigation.favourites,
|
||||
copy.navigation.user,
|
||||
copy.navigation.settings,
|
||||
)
|
||||
if (labels.any { it.isBlank() || it != it.trim() || it.length > 64 || it.any(Char::isISOControl) }) {
|
||||
return "invalid copy"
|
||||
}
|
||||
val width = document.presentation.navigationRailExpandedWidthDp
|
||||
val shift = document.presentation.navigationContentShiftDp
|
||||
if (width !in 160..240 || shift !in 80..160 || shift >= width) {
|
||||
return "unsafe presentation values"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private data class SemanticVersion(val major: Int, val minor: Int, val patch: Int) :
|
||||
Comparable<SemanticVersion> {
|
||||
override fun compareTo(other: SemanticVersion): Int =
|
||||
compareValuesBy(this, other, SemanticVersion::major, SemanticVersion::minor, SemanticVersion::patch)
|
||||
}
|
||||
|
||||
private fun semanticVersion(raw: String): SemanticVersion? {
|
||||
val parts = raw.split('.')
|
||||
if (parts.size != 3) return null
|
||||
val numbers = parts.map { it.toIntOrNull() ?: return null }
|
||||
if (numbers.any { it < 0 }) return null
|
||||
return SemanticVersion(numbers[0], numbers[1], numbers[2])
|
||||
}
|
||||
@@ -140,6 +140,9 @@ import com.ponzischeme89.memby.ui.theme.MembyQuietText
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurface
|
||||
import com.ponzischeme89.memby.ui.theme.MembySurfaceRaised
|
||||
import com.ponzischeme89.memby.ui.theme.ValueSeparator
|
||||
import com.ponzischeme89.memby.data.remoteconfig.BundledRemoteConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationLabels
|
||||
import com.ponzischeme89.memby.data.remoteconfig.NavigationRemoteConfig
|
||||
import java.util.Locale
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -236,6 +239,7 @@ private fun homeRowVisual(row: HomeBrowseRow): HomeRowVisual = when {
|
||||
|
||||
@Composable
|
||||
fun TvNavigationRail(
|
||||
config: NavigationRemoteConfig = BundledRemoteConfig.value.navigation,
|
||||
selected: BrowseDestination,
|
||||
focusDestination: BrowseDestination = selected,
|
||||
expanded: Boolean,
|
||||
@@ -269,7 +273,7 @@ fun TvNavigationRail(
|
||||
}
|
||||
}
|
||||
val railWidth = animateDpAsState(
|
||||
targetValue = if (expanded) TvRailExpandedWidth else TvRailCollapsedWidth,
|
||||
targetValue = if (expanded) config.expandedWidthDp.dp else TvRailCollapsedWidth,
|
||||
animationSpec = tween(160),
|
||||
label = "navigation-rail-width",
|
||||
)
|
||||
@@ -339,7 +343,7 @@ fun TvNavigationRail(
|
||||
maxLines = 1,
|
||||
)
|
||||
Text(
|
||||
"Matt’s Android TV client",
|
||||
config.tagline,
|
||||
color = QuietText,
|
||||
fontSize = 9.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
@@ -364,7 +368,7 @@ fun TvNavigationRail(
|
||||
label = if (destination == BrowseDestination.PROFILES) {
|
||||
profileDestinationLabel(activeUsername)
|
||||
} else {
|
||||
destination.label
|
||||
configuredNavigationLabel(destination, config.labels)
|
||||
},
|
||||
selected = destination == selected,
|
||||
expanded = expanded,
|
||||
@@ -389,18 +393,35 @@ fun TvNavigationRail(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
|
||||
color = QuietText,
|
||||
fontSize = if (expanded) 10.sp else 8.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
|
||||
)
|
||||
if (config.showVersion) {
|
||||
Text(
|
||||
if (expanded) "Version ${BuildConfig.VERSION_NAME}" else "v${BuildConfig.VERSION_NAME}",
|
||||
color = QuietText,
|
||||
fontSize = if (expanded) 10.sp else 8.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.padding(horizontal = if (expanded) 8.dp else 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun configuredNavigationLabel(
|
||||
destination: BrowseDestination,
|
||||
labels: NavigationLabels,
|
||||
): String = when (destination) {
|
||||
BrowseDestination.HOME -> labels.home
|
||||
BrowseDestination.FOR_YOU -> labels.forYou
|
||||
BrowseDestination.SEARCH -> labels.search
|
||||
BrowseDestination.MOVIES -> labels.movies
|
||||
BrowseDestination.SHOWS -> labels.tvShows
|
||||
BrowseDestination.CALENDAR -> labels.tvCalendar
|
||||
BrowseDestination.FAVORITES -> labels.favourites
|
||||
BrowseDestination.PROFILES -> labels.user
|
||||
BrowseDestination.SETTINGS -> labels.settings
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserSwitcherOverlay(
|
||||
profiles: List<EmbyProfile>,
|
||||
|
||||
@@ -389,14 +389,9 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
metadataJob = viewModelScope.launch(Dispatchers.IO) {
|
||||
delay(FOCUS_METADATA_DEBOUNCE_MS)
|
||||
coroutineScope {
|
||||
// Resolution is tiny compared with video buffering and makes the later
|
||||
// Play click a memory lookup. The repository single-flights requests,
|
||||
// so focus and click can never duplicate the gateway call.
|
||||
if (item.membyPlayable) {
|
||||
// The row owns live UserData. Prefetching the metadata-cache copy used
|
||||
// to turn a 5% resume point into zero after details had been visited.
|
||||
launch { runCatching { repository.prefetchPlayable(focused) } }
|
||||
}
|
||||
// Do not negotiate playback on focus. Emby creates a playback session as
|
||||
// part of PlaybackInfo, so warming a stream here made merely browsing a
|
||||
// shelf appear in server history as something the viewer had played.
|
||||
// Warm the explanation and franchise siblings while the card is already
|
||||
// focused, so opening Details does not add a reason line a frame later.
|
||||
if (!item.isSchedule && (item.isMovie || item.isSeries)) {
|
||||
@@ -423,7 +418,7 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() {
|
||||
* The two requests a detail page still opened cold, warmed while the card is focused.
|
||||
*
|
||||
* Everything else the page needs is already in hand by the time it opens — the item
|
||||
* record, its "why you might enjoy it" and its playable URL are all warmed above — but
|
||||
* record and its "why you might enjoy it" are warmed above — but
|
||||
* the episode list and the trailer were not, so a series page opened with an empty
|
||||
* Episodes pane, no progress, no next episode and no estimated finish, and every page
|
||||
* opened with its trailer button missing until the network answered. Continue Watching
|
||||
|
||||
@@ -131,6 +131,7 @@ import com.ponzischeme89.memby.data.model.RecommendationOnboarding
|
||||
import com.ponzischeme89.memby.data.model.UserNotification
|
||||
import com.ponzischeme89.memby.data.model.RecommendationPerson
|
||||
import com.ponzischeme89.memby.data.ServerConfig
|
||||
import com.ponzischeme89.memby.data.remoteconfig.MembyRemoteConfig
|
||||
import com.ponzischeme89.memby.ui.alerts.MyAlertsPage
|
||||
import com.ponzischeme89.memby.ui.detail.AiringNotice
|
||||
import com.ponzischeme89.memby.ui.detail.airingNoticeFor
|
||||
@@ -246,8 +247,11 @@ private val LazyListStateMapSaver = listSaver<MutableMap<String, LazyListState>,
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
// Captured before composition and never observed as state: a downloaded document
|
||||
// is for the next process, never a label change under the viewer's focus.
|
||||
val remoteConfig = ServiceLocator.remoteConfig.active
|
||||
setContent {
|
||||
MembyTheme { AppRoot(onCloseSettings = ::finish) }
|
||||
MembyTheme { AppRoot(remoteConfig = remoteConfig, onCloseSettings = ::finish) }
|
||||
}
|
||||
PerformanceMonitor.start(this)
|
||||
}
|
||||
@@ -298,7 +302,7 @@ private const val UPDATE_REQUIRED_FAST_ATTEMPTS = 4
|
||||
private const val ROW_FOCUS_MOVE_TIMEOUT_MS = 1_200L
|
||||
|
||||
@Composable
|
||||
private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
private fun AppRoot(remoteConfig: MembyRemoteConfig, onCloseSettings: () -> Unit) {
|
||||
val repo = ServiceLocator.repository
|
||||
val context = LocalContext.current
|
||||
// This client intentionally has no token provider and no dependency on the active
|
||||
@@ -643,7 +647,7 @@ private fun AppRoot(onCloseSettings: () -> Unit) {
|
||||
if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings()
|
||||
}
|
||||
key(loaded.userId, loaded.serverUrl) {
|
||||
HomeScreen(settings = loaded)
|
||||
HomeScreen(settings = loaded, remoteConfig = remoteConfig)
|
||||
}
|
||||
// Snow, bats or blossom for the few days a year a season is on, over the
|
||||
// launcher and nowhere else. Not over playback — a film is the one thing
|
||||
@@ -1887,6 +1891,7 @@ private fun ProfileTile(
|
||||
@Composable
|
||||
private fun HomeScreen(
|
||||
settings: Settings,
|
||||
remoteConfig: MembyRemoteConfig,
|
||||
) {
|
||||
val repo = ServiceLocator.repository
|
||||
val context = LocalContext.current
|
||||
@@ -2347,13 +2352,14 @@ private fun HomeScreen(
|
||||
}
|
||||
|
||||
val contentShift by animateDpAsState(
|
||||
targetValue = if (navigationExpanded) TvRailContentShift else 0.dp,
|
||||
targetValue = if (navigationExpanded) remoteConfig.navigation.contentShiftDp.dp else 0.dp,
|
||||
animationSpec = tween(150),
|
||||
label = "navigation-content-shift",
|
||||
)
|
||||
Box(Modifier.fillMaxSize().background(MembySurface)) {
|
||||
Row(Modifier.fillMaxSize()) {
|
||||
TvNavigationRail(
|
||||
config = remoteConfig.navigation,
|
||||
selected = selectedDestination,
|
||||
focusDestination = railFocusDestination,
|
||||
expanded = navigationExpanded,
|
||||
|
||||
@@ -316,7 +316,7 @@ internal fun MyShowDetailsOverlay(
|
||||
)
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(show.title, color = Color.White, fontSize = 30.sp, fontWeight = FontWeight.Bold)
|
||||
StatusLine("Sonarr", show.sonarrStatus)
|
||||
StatusLine("Series", show.sonarrStatus)
|
||||
StatusLine("Next episode", formatMyShowDate(show.nextEpisode))
|
||||
StatusLine("Series status", show.lifecycle)
|
||||
Row(
|
||||
|
||||
@@ -156,7 +156,7 @@ internal fun CalendarContent(
|
||||
state.errorMessage.orEmpty(), "Try again", onRetry,
|
||||
)
|
||||
!state.calendar.available -> AgendaNotice(
|
||||
"The TV calendar is not available. It needs the Memby gateway with Sonarr configured.",
|
||||
"The TV calendar is not available. It needs the Memby gateway with a series service configured.",
|
||||
)
|
||||
week == null -> AgendaNotice("Nothing scheduled.")
|
||||
else -> {
|
||||
|
||||
@@ -1029,7 +1029,7 @@ private fun RequestOptions(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text("Request something new", color = Heading, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"Search Radarr and Sonarr, then choose the exact movie or series you want added.",
|
||||
"Search for movies and series, then choose the exact title you want added.",
|
||||
color = Muted,
|
||||
fontSize = 13.sp,
|
||||
maxLines = 2,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.ponzischeme89.memby.data.remoteconfig
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class MembyRemoteConfigTest {
|
||||
@Test
|
||||
fun bundledDefaultsAreCompleteButNotARemoteRevision() {
|
||||
val bundled = BundledRemoteConfig.value
|
||||
|
||||
assertEquals(0, bundled.configVersion)
|
||||
assertEquals("Favourites", bundled.navigation.labels.favourites)
|
||||
assertEquals(184, bundled.navigation.expandedWidthDp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun validCompatibleDocumentDecodes() {
|
||||
val document = decodeRemoteConfig(validDocument)!!
|
||||
|
||||
assertNull(validateRemoteConfig(document, "0.2.54"))
|
||||
assertEquals("Discover", document.navigation.labels.search)
|
||||
assertEquals(false, document.navigation.showVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun incompatibleSchemaAndAppVersionsAreRejected() {
|
||||
val document = decodeRemoteConfig(validDocument)!!
|
||||
|
||||
assertNotNull(validateRemoteConfig(document.copy(schemaVersion = 2), "0.2.54"))
|
||||
assertNotNull(validateRemoteConfig(document, "0.2.53"))
|
||||
assertNotNull(
|
||||
validateRemoteConfig(
|
||||
document.copy(minimumAppVersion = "", maximumAppVersion = "0.2.53"),
|
||||
"0.2.54",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedCopyAndUnsafePresentationAreRejected() {
|
||||
val document = decodeRemoteConfig(validDocument)!!
|
||||
|
||||
assertNotNull(
|
||||
validateRemoteConfig(
|
||||
document.copy(copy = document.copy.copy(tagline = "\nNot safe")),
|
||||
"0.2.54",
|
||||
),
|
||||
)
|
||||
assertNotNull(
|
||||
validateRemoteConfig(
|
||||
document.copy(
|
||||
presentation = document.presentation.copy(
|
||||
navigationRailExpandedWidthDp = 500,
|
||||
),
|
||||
),
|
||||
"0.2.54",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val validDocument = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"configVersion": 7,
|
||||
"minimumAppVersion": "0.2.54",
|
||||
"copy": {
|
||||
"navigation": {
|
||||
"home": "Home",
|
||||
"forYou": "For You",
|
||||
"search": "Discover",
|
||||
"movies": "Films",
|
||||
"tvShows": "TV Shows",
|
||||
"tvCalendar": "TV Calendar",
|
||||
"favourites": "Favourites",
|
||||
"user": "User",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"tagline": "Your library, made personal"
|
||||
},
|
||||
"features": { "showNavigationVersion": false },
|
||||
"presentation": {
|
||||
"navigationRailExpandedWidthDp": 196,
|
||||
"navigationContentShiftDp": 120
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user