package config import ( "encoding/json" "fmt" "io" "strconv" "strings" ) // RemoteConfig is the deliberately small, presentation-only document offered to TVs. // It must never contain authentication, playback or navigation-routing decisions: an // unavailable document is required to be indistinguishable from an ordinary offline // launch apart from its wording and safe presentation choices. type RemoteConfig struct { SchemaVersion int `json:"schemaVersion"` ConfigVersion int64 `json:"configVersion"` MinimumAppVersion string `json:"minimumAppVersion,omitempty"` MaximumAppVersion string `json:"maximumAppVersion,omitempty"` Copy RemoteConfigCopy `json:"copy"` Features RemoteConfigFeatures `json:"features"` Presentation RemoteConfigPresentation `json:"presentation"` } type RemoteConfigCopy struct { Navigation RemoteConfigNavigationCopy `json:"navigation"` Tagline string `json:"tagline"` } type RemoteConfigNavigationCopy struct { Home string `json:"home"` ForYou string `json:"forYou"` Search string `json:"search"` Movies string `json:"movies"` TVShows string `json:"tvShows"` TVCalendar string `json:"tvCalendar"` Favourites string `json:"favourites"` User string `json:"user"` Settings string `json:"settings"` } type RemoteConfigFeatures struct { ShowNavigationVersion bool `json:"showNavigationVersion"` } type RemoteConfigPresentation struct { NavigationRailExpandedWidthDp int `json:"navigationRailExpandedWidthDp"` NavigationContentShiftDp int `json:"navigationContentShiftDp"` } // DefaultRemoteConfig mirrors the APK's bundled values. Serving it is still useful: it // establishes the schema and ETag contract before an operator chooses an override. func DefaultRemoteConfig() RemoteConfig { return RemoteConfig{ SchemaVersion: 1, ConfigVersion: 1, Copy: RemoteConfigCopy{ Tagline: "Matt’s Android TV client", Navigation: RemoteConfigNavigationCopy{ Home: "Home", ForYou: "For You", Search: "Search", Movies: "Movies", TVShows: "TV Shows", TVCalendar: "TV Calendar", Favourites: "Favourites", User: "User", Settings: "Settings", }, }, Features: RemoteConfigFeatures{ShowNavigationVersion: true}, Presentation: RemoteConfigPresentation{ NavigationRailExpandedWidthDp: 184, NavigationContentShiftDp: 112, }, } } func loadRemoteConfig(raw string) (RemoteConfig, error) { if strings.TrimSpace(raw) == "" { return DefaultRemoteConfig(), nil } var document RemoteConfig decoder := json.NewDecoder(strings.NewReader(raw)) decoder.DisallowUnknownFields() if err := decoder.Decode(&document); err != nil { return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err) } var trailing any if err := decoder.Decode(&trailing); err != io.EOF { return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON must contain exactly one document") } if err := validateRemoteConfig(document); err != nil { return RemoteConfig{}, fmt.Errorf("MEMBY_REMOTE_CONFIG_JSON: %w", err) } return document, nil } func validateRemoteConfig(document RemoteConfig) error { if document.SchemaVersion != 1 { return fmt.Errorf("schemaVersion must be 1") } if document.ConfigVersion < 1 { return fmt.Errorf("configVersion must be positive") } minimum, minimumSet, err := parseRemoteConfigVersion(document.MinimumAppVersion) if err != nil { return fmt.Errorf("minimumAppVersion must be a three-part version") } maximum, maximumSet, err := parseRemoteConfigVersion(document.MaximumAppVersion) if err != nil { return fmt.Errorf("maximumAppVersion must be a three-part version") } if minimumSet && maximumSet && compareRemoteConfigVersions(minimum, maximum) > 0 { return fmt.Errorf("minimumAppVersion must not be newer than maximumAppVersion") } labels := []string{ document.Copy.Tagline, document.Copy.Navigation.Home, document.Copy.Navigation.ForYou, document.Copy.Navigation.Search, document.Copy.Navigation.Movies, document.Copy.Navigation.TVShows, document.Copy.Navigation.TVCalendar, document.Copy.Navigation.Favourites, document.Copy.Navigation.User, document.Copy.Navigation.Settings, } for _, label := range labels { trimmed := strings.TrimSpace(label) if trimmed == "" || len([]rune(trimmed)) > 64 || strings.ContainsAny(trimmed, "\r\n\t") { return fmt.Errorf("copy must be between 1 and 64 characters and contain no control whitespace") } } width := document.Presentation.NavigationRailExpandedWidthDp shift := document.Presentation.NavigationContentShiftDp if width < 160 || width > 240 { return fmt.Errorf("navigationRailExpandedWidthDp must be between 160 and 240") } if shift < 80 || shift > 160 || shift >= width { return fmt.Errorf("navigationContentShiftDp must be between 80 and 160 and less than the rail width") } return nil } func parseRemoteConfigVersion(raw string) ([3]int, bool, error) { var version [3]int if strings.TrimSpace(raw) == "" { return version, false, nil } parts := strings.Split(raw, ".") if len(parts) != len(version) { return version, false, fmt.Errorf("invalid version") } for index, part := range parts { value, err := strconv.Atoi(part) if err != nil || value < 0 { return version, false, fmt.Errorf("invalid version") } version[index] = value } return version, true, nil } func compareRemoteConfigVersions(left, right [3]int) int { for index := range left { if left[index] < right[index] { return -1 } if left[index] > right[index] { return 1 } } return 0 }