diff --git a/app/src/main/java/org/openedx/app/di/AppModule.kt b/app/src/main/java/org/openedx/app/di/AppModule.kt index 267b73432..64dbc6209 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -35,6 +35,10 @@ import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.domain.helper.VideoPreviewHelper +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.InstanceSelectionStorage +import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.SharedPrefsInstanceSelectionStorage import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.DownloadHelper @@ -87,6 +91,12 @@ import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { single { Config(get()) } + single { SharedPrefsInstanceSelectionStorage(get()) } + single { + val bundledInstances by lazy { get().getInstancesConfig() } + InstanceStore(get()) { bundledInstances } + } + single { get() } single { PreferencesManager(get(), get()) } single { get() } single { get() } diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 1f07f43ad..52ed2cfae 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -6,6 +6,7 @@ import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser import org.openedx.core.domain.model.AgreementUrls +import org.openedx.core.instance.InstancesConfig import java.io.InputStreamReader @Suppress("TooManyFunctions") @@ -146,6 +147,11 @@ class Config(context: Context) { return getBoolean(REGISTRATION_ENABLED, true) } + /** The bundled `INSTANCES` catalog, before any remote catalog replaces it. */ + fun getInstancesConfig(): InstancesConfig { + return InstancesConfig.fromAppConfig(configProperties) + } + private fun getExperimentalFeaturesConfig(): ExperimentalFeaturesConfig { return getObjectOrNewInstance(EXPERIMENTAL_FEATURES, ExperimentalFeaturesConfig::class.java) } diff --git a/core/src/main/java/org/openedx/core/instance/Instance.kt b/core/src/main/java/org/openedx/core/instance/Instance.kt new file mode 100644 index 000000000..670b13216 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/Instance.kt @@ -0,0 +1,185 @@ +package org.openedx.core.instance + +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import java.net.URI +import java.text.Normalizer +import java.util.Locale + +/** + * One Open edX platform the app can sign in to: its own host, OAuth client, branding and + * per-platform overrides of the app-level configuration. + * + * Instances are identified by their stable [key], never by their (possibly localized) + * display name or their position in the catalog. + */ +data class Instance( + /** Machine-readable identifier, e.g. "niepd". Falls back to a slugified [name]. */ + val key: String, + val name: String, + /** Localized display name; falls back to the English entry, then to [name]. */ + val instanceName: String, + val color: String, + val oAuthClientId: String, + val apiHostUrl: String, + val ssoUrl: String?, + val ssoFinishedUrl: String?, + val environmentDisplayName: String?, + val isSwitchInstanceLoginEnabled: Boolean, + /** Remote URL, or the name of a bundled drawable. `null` keeps the default logo. */ + val logoUrl: String?, + /** Remote URL, or the name of a bundled drawable. `null` keeps the default header. */ + val headerBackgroundUrl: String?, + /** `null` derives the palette from [color] instead. */ + val themeColors: InstanceThemeColors?, + /** Defaults to [instanceName] when unset, so it is never blank. */ + val platformName: String, + val isRoundedCorners: Boolean, + val buttonCornersRadius: Double, + /** + * The entry exactly as configured, in UPPER_SNAKE_CASE. Instance-level overrides of + * app-level keys (`AGREEMENT_URLS`, `DISCOVERY`, `UI_COMPONENTS`, ...) are read from here. + */ + val config: JsonObject, +) { + + companion object { + const val KEY = "KEY" + const val NAME = "NAME" + const val INSTANCE_NAME = "INSTANCE_NAME" + const val COLOR = "COLOR" + const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" + const val API_HOST_URL = "API_HOST_URL" + const val SSO_URL = "SSO_URL" + const val SSO_FINISHED_URL = "SSO_FINISHED_URL" + const val SSO_URL_SUCCESSFUL_LOGIN = "SSO_URL_SUCCESSFUL_LOGIN" + const val ENVIRONMENT_DISPLAY_NAME = "ENVIRONMENT_DISPLAY_NAME" + const val IS_SWITCH_INSTANCE_LOGIN_ENABLED = "IS_SWITCH_INSTANCE_LOGIN_ENABLED" + const val PLATFORM_NAME = "PLATFORM_NAME" + const val THEME = "THEME" + const val THEME_LIGHT = "LIGHT" + const val THEME_DARK = "DARK" + const val THEME_LOGO_URL = "LOGO_URL" + const val THEME_HEADER_BACKGROUND_URL = "HEADER_BACKGROUND_URL" + const val THEME_ROUNDED_CORNERS_STYLE = "ROUNDED_CORNERS_STYLE" + const val THEME_BUTTON_CORNERS_RADIUS = "BUTTON_CORNERS_RADIUS" + + private const val DEFAULT_COLOR = "#007AFF" + private const val DEFAULT_BUTTON_CORNERS_RADIUS = 8.0 + private const val DEFAULT_LANGUAGE = "en" + + /** + * Parses one `INSTANCES` entry. Returns `null` when a required field (`NAME`, + * `API_HOST_URL`, `OAUTH_CLIENT_ID`) is missing or `API_HOST_URL` isn't an absolute + * URL, so a malformed entry is dropped instead of producing a half-populated instance. + */ + @Suppress("ReturnCount") + fun fromJson( + json: JsonObject, + languageCode: String = Locale.getDefault().language, + ): Instance? { + val name = json.string(NAME)?.takeIf { it.isNotBlank() } ?: return null + val apiHostUrl = json.string(API_HOST_URL)?.takeIf { it.isAbsoluteUrl() } ?: return null + val oAuthClientId = json.string(OAUTH_CLIENT_ID)?.takeIf { it.isNotBlank() } ?: return null + + val key = json.string(KEY)?.takeIf { it.isNotBlank() } ?: slugify(name) + val localizedNames = json.stringMap(INSTANCE_NAME) + val instanceName = localizedNames[languageCode] + ?: localizedNames[DEFAULT_LANGUAGE] + ?: name + + // Logo and header live inside THEME, as siblings of LIGHT/DARK. The palette maps' + // own field names (accent_color, ...) stay lowercase: they are the theme engine's + // contract, shared with iOS, not part of this schema. + val theme = json.obj(THEME) + val themeColors = theme?.let { + InstanceThemeColors( + light = it.stringMap(THEME_LIGHT), + dark = it.stringMap(THEME_DARK), + ) + } + + return Instance( + key = key, + name = name, + instanceName = instanceName, + color = json.nonBlankString(COLOR) ?: DEFAULT_COLOR, + oAuthClientId = oAuthClientId, + apiHostUrl = apiHostUrl, + ssoUrl = json.nonBlankString(SSO_URL), + ssoFinishedUrl = json.nonBlankString(SSO_FINISHED_URL) + ?: json.nonBlankString(SSO_URL_SUCCESSFUL_LOGIN), + environmentDisplayName = json.nonBlankString(ENVIRONMENT_DISPLAY_NAME), + isSwitchInstanceLoginEnabled = json.boolean(IS_SWITCH_INSTANCE_LOGIN_ENABLED) ?: false, + logoUrl = theme?.nonBlankString(THEME_LOGO_URL), + headerBackgroundUrl = theme?.nonBlankString(THEME_HEADER_BACKGROUND_URL), + themeColors = themeColors, + platformName = json.nonBlankString(PLATFORM_NAME) ?: instanceName, + // Absent or non-boolean means rounded, same as the app-level theme default. + isRoundedCorners = theme?.boolean(THEME_ROUNDED_CORNERS_STYLE) != false, + buttonCornersRadius = theme?.double(THEME_BUTTON_CORNERS_RADIUS) + ?: DEFAULT_BUTTON_CORNERS_RADIUS, + config = json.deepCopy(), + ) + } + + internal fun slugify(name: String): String { + return Normalizer.normalize(name.lowercase(Locale.ROOT), Normalizer.Form.NFD) + .replace("\\p{Mn}+".toRegex(), "") + .replace("[^a-z0-9]+".toRegex(), "-") + .trim('-') + } + + private fun String.isAbsoluteUrl(): Boolean { + return try { + val uri = URI(this) + !uri.scheme.isNullOrEmpty() && !uri.host.isNullOrEmpty() + } catch (_: Exception) { + false + } + } + } +} + +/** + * Per-mode color overrides from an instance's `THEME` block. Keys are the shared palette + * field names (`accent_color`, `text_primary`, ...); values are hex strings. + */ +data class InstanceThemeColors( + val light: Map, + val dark: Map, +) + +internal fun JsonObject.primitive(key: String): JsonElement? { + val element = get(key) ?: return null + return if (element.isJsonPrimitive) element else null +} + +internal fun JsonObject.string(key: String): String? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isString) element.asString else null +} + +internal fun JsonObject.nonBlankString(key: String): String? = string(key)?.takeIf { it.isNotBlank() } + +internal fun JsonObject.boolean(key: String): Boolean? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isBoolean) element.asBoolean else null +} + +internal fun JsonObject.double(key: String): Double? { + val element = primitive(key) ?: return null + return if (element.asJsonPrimitive.isNumber) element.asDouble else null +} + +internal fun JsonObject.obj(key: String): JsonObject? { + val element = get(key) ?: return null + return if (element.isJsonObject) element.asJsonObject else null +} + +internal fun JsonObject.stringMap(key: String): Map { + val obj = obj(key) ?: return emptyMap() + return obj.entrySet() + .filter { (_, value) -> value.isJsonPrimitive && value.asJsonPrimitive.isString } + .associate { (entryKey, value) -> entryKey to value.asString } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceProvider.kt b/core/src/main/java/org/openedx/core/instance/InstanceProvider.kt new file mode 100644 index 000000000..12b96dba6 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceProvider.kt @@ -0,0 +1,17 @@ +package org.openedx.core.instance + +import kotlinx.coroutines.flow.StateFlow + +/** + * Read-only, thread-safe access to the selected instance. + */ +interface InstanceProvider { + /** `null` when nothing is selected, e.g. a build with no `INSTANCES` catalog. */ + val currentInstance: Instance? + + /** Emits the selected instance on every change, including deselection. */ + val currentInstanceFlow: StateFlow + + /** Whether the catalog has more than one instance to switch between. */ + val hasMultipleInstances: Boolean +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt b/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt new file mode 100644 index 000000000..91f2b77b3 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt @@ -0,0 +1,29 @@ +package org.openedx.core.instance + +import android.content.Context +import androidx.core.content.edit + +/** + * Where [InstanceStore] persists the key of the selected instance. + */ +interface InstanceSelectionStorage { + var selectedInstanceKey: String? +} + +class SharedPrefsInstanceSelectionStorage(context: Context) : InstanceSelectionStorage { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + // Written synchronously: a selection lost to a process kill right after a switch would + // reopen the app on the previous site. + override var selectedInstanceKey: String? + get() = prefs.getString(KEY_SELECTED_INSTANCE, null) + set(value) = prefs.edit(commit = true) { + if (value == null) remove(KEY_SELECTED_INSTANCE) else putString(KEY_SELECTED_INSTANCE, value) + } + + private companion object { + const val PREFS_NAME = "openedx_instance_store" + const val KEY_SELECTED_INSTANCE = "selected_instance_key" + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceStore.kt b/core/src/main/java/org/openedx/core/instance/InstanceStore.kt new file mode 100644 index 000000000..f2a2b8c2e --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceStore.kt @@ -0,0 +1,74 @@ +package org.openedx.core.instance + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Single source of truth for the instance catalog and the selected instance. + * + * The selection is persisted by key, so it survives restarts and catalog reordering. A + * catalog with exactly one instance selects it automatically: there is no picker to choose + * it through. + */ +class InstanceStore( + private val storage: InstanceSelectionStorage, + private val localInstancesConfig: () -> InstancesConfig = { InstancesConfig() }, +) : InstanceProvider { + + private val lock = Any() + private var remoteInstancesConfig: InstancesConfig? = null + private val _currentInstance = MutableStateFlow(null) + + override val currentInstance: Instance? + get() = _currentInstance.value + + override val currentInstanceFlow: StateFlow = _currentInstance.asStateFlow() + + override val hasMultipleInstances: Boolean + get() = instancesConfig.instances.size > 1 + + /** The catalog in effect: the one handed to [updateInstancesConfig], else the bundled one. */ + val instancesConfig: InstancesConfig + get() = synchronized(lock) { remoteInstancesConfig ?: localInstancesConfig() } + + init { + restoreSelection() + } + + fun instance(withKey: String): Instance? = instancesConfig.instance(withKey) + + /** Replaces the catalog in effect and re-resolves the persisted selection against it. */ + fun updateInstancesConfig(instancesConfig: InstancesConfig) { + synchronized(lock) { remoteInstancesConfig = instancesConfig } + restoreSelection() + } + + /** Selects [instance], or clears the selection with `null`, and persists the choice. */ + fun select(instance: Instance?) { + synchronized(lock) { + storage.selectedInstanceKey = instance?.key + _currentInstance.value = instance + } + // A deselect on a single-instance catalog must not leave nothing selected until the + // next launch. + if (instance == null) { + restoreSelection() + } + } + + fun reset() = select(null) + + private fun restoreSelection() { + val catalog = instancesConfig + val resolved = storage.selectedInstanceKey?.let { catalog.instance(it) } + val onlyInstance = catalog.instances.singleOrNull() + if (resolved == null && onlyInstance != null) { + select(onlyInstance) + return + } + // A saved key that the catalog no longer contains resolves to nothing, but stays saved: + // if a later catalog brings that site back, it is selected again. + _currentInstance.value = resolved + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt b/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt new file mode 100644 index 000000000..f96879b10 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstancesConfig.kt @@ -0,0 +1,51 @@ +package org.openedx.core.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import java.util.Locale + +/** + * The parsed instance catalog: every platform the app can sign in to, in catalog order. + */ +data class InstancesConfig( + val instances: List = emptyList(), +) { + + fun instance(withKey: String): Instance? = instances.firstOrNull { it.key == withKey } + + companion object { + const val INSTANCES = "INSTANCES" + + /** + * Builds the catalog from an `INSTANCES` array. Entries that aren't objects or fail + * [Instance.fromJson] are dropped. + * + * @param fallback values used for the fields an entry omits; the entry always wins. + */ + fun fromJson( + array: JsonArray, + fallback: JsonObject = JsonObject(), + languageCode: String = Locale.getDefault().language, + ): InstancesConfig { + val instances = array.mapNotNull { element -> + if (!element.isJsonObject) return@mapNotNull null + val merged = fallback.deepCopy() + element.asJsonObject.entrySet().forEach { (key, value) -> merged.add(key, value) } + Instance.fromJson(merged, languageCode) + } + return InstancesConfig(instances) + } + + /** The `INSTANCES` array of the bundled `config.json`, or an empty catalog. */ + fun fromAppConfig( + appConfig: JsonObject, + languageCode: String = Locale.getDefault().language, + ): InstancesConfig { + val array = appConfig.get(INSTANCES) + ?.takeIf { it.isJsonArray } + ?.asJsonArray + ?: return InstancesConfig() + return fromJson(array, languageCode = languageCode) + } + } +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt b/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt new file mode 100644 index 000000000..847311bce --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt @@ -0,0 +1,125 @@ +package org.openedx.core.ui.theme + +import androidx.compose.ui.graphics.Color +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Branding of one instance: an accent color plus optional per-field palette overrides for + * light and dark mode, and an optional logo and header image. + * + * Takes primitives rather than the instance model, so the theme layer stays independent of + * how instances are configured. + */ +data class InstanceTheme( + /** Stable instance key. Never a display name, which can be renamed or localized. */ + val key: String, + val accentColorHex: String? = null, + val light: Map = emptyMap(), + val dark: Map = emptyMap(), + /** Remote URL, or the name of a bundled drawable. */ + val logoUrl: String? = null, + /** Remote URL, or the name of a bundled drawable. */ + val headerBackgroundUrl: String? = null, +) { + + /** + * The default palette with this instance's colors layered on top. Only the fields the + * instance sets change; everything else keeps the compiled value. A dark override falls + * back to the light one for the same field. A field set only for dark mode changes dark + * mode alone: light mode keeps the compiled value. + */ + fun applyTo(base: AppColors, isDark: Boolean): AppColors { + val modeAccent = if (isDark) dark[ACCENT_COLOR] ?: light[ACCENT_COLOR] else light[ACCENT_COLOR] + var colors = (modeAccent ?: accentColorHex)?.toColorOrNull()?.let(base::withAccent) ?: base + (light.keys + dark.keys).forEach { field -> + if (field == ACCENT_COLOR) return@forEach + val hex = (if (isDark) dark[field] ?: light[field] else light[field]) ?: return@forEach + val color = hex.toColorOrNull() ?: return@forEach + colors = paletteFields[field]?.invoke(colors, color) ?: colors + } + return colors + } + + companion object { + const val ACCENT_COLOR = "accent_color" + + /** + * Palette field names shared with the iOS app, mapped to the colors they override here. + * Unknown names are ignored rather than treated as errors. + */ + internal val paletteFields: Map AppColors> = mapOf( + "accent_x_color" to { c, v -> c.copy(material3 = c.material3.copy(secondary = v)) }, + "alert" to { c, v -> c.copy(material3 = c.material3.copy(error = v)) }, + "background" to { c, v -> c.copy(material3 = c.material3.copy(background = v)) }, + "card_view_background" to { c, v -> c.copy(cardViewBackground = v) }, + "card_view_stroke" to { c, v -> c.copy(cardViewBorder = v) }, + "certificate_foreground" to { c, v -> c.copy(certificateForeground = v) }, + "text_primary" to { c, v -> c.copy(textPrimary = v) }, + "text_secondary" to { c, v -> c.copy(textSecondary = v) }, + "text_input_background" to { c, v -> c.copy(textFieldBackground = v) }, + "text_input_stroke" to { c, v -> c.copy(textFieldBorder = v) }, + "text_input_text_color" to { c, v -> c.copy(textFieldText = v) }, + "text_input_placeholder_color" to { c, v -> c.copy(textFieldHint = v) }, + "warning" to { c, v -> c.copy(warning = v) }, + "success" to { c, v -> c.copy(successGreen = v) }, + "info_color" to { c, v -> c.copy(info = v) }, + "primary_button_text_color" to { c, v -> c.copy(primaryButtonText = v) }, + "secondary_button_border_color" to { c, v -> c.copy(secondaryButtonBorder = v) }, + "secondary_button_text_color" to { c, v -> c.copy(secondaryButtonBorderedText = v) }, + "past_due_timeline_color" to { c, v -> c.copy(datesSectionBarPastDue = v) }, + "today_timeline_color" to { c, v -> c.copy(datesSectionBarToday = v) }, + "this_week_timeline_color" to { c, v -> c.copy(datesSectionBarThisWeek = v) }, + "next_week_timeline_color" to { c, v -> c.copy(datesSectionBarNextWeek = v) }, + "upcoming_timeline_color" to { c, v -> c.copy(datesSectionBarUpcoming = v) }, + ) + } +} + +/** + * The accent drives every color the default palette derives from `primary`: links, primary + * buttons, bordered button text and the unselected tab content. + */ +internal fun AppColors.withAccent(accent: Color): AppColors = copy( + material3 = material3.copy(primary = accent), + textAccent = accent, + textHyperLink = accent, + infoVariant = accent, + primaryButtonBackground = accent, + primaryButtonBorderedText = accent, + secondaryButtonBackground = accent, + secondaryButtonBorderedText = accent, + tabUnselectedBtnContent = accent, +) + +/** Parses `#RGB`, `#RRGGBB` or `#AARRGGBB`. */ +@Suppress("MagicNumber") +internal fun String.toColorOrNull(): Color? { + val hex = trim().removePrefix("#") + val argb = when (hex.length) { + 3 -> "FF" + hex.map { "$it$it" }.joinToString("") + 6 -> "FF$hex" + 8 -> hex + else -> null + } + return argb + ?.takeIf { value -> value.all { Character.digit(it, 16) >= 0 } } + ?.let { Color(it.toLong(16)) } +} + +/** + * Holds the theme of the selected instance; `null` means the compiled default theme. + * [OpenEdXTheme] observes it, so applying a theme restyles every screen. + */ +object InstanceThemeManager { + + private val _theme = MutableStateFlow(null) + val theme: StateFlow = _theme.asStateFlow() + + fun applyTheme(theme: InstanceTheme?) { + _theme.value = theme + } + + fun reset() = applyTheme(null) +} diff --git a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index ec7997c72..bdeb480f6 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -9,13 +9,16 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.staticCompositionLocalOf internal val LocalAppColors = staticCompositionLocalOf { error("No AppColors provided") } -private val DarkColorPalette = AppColors( +internal val DarkColorPalette = AppColors( material3 = darkColorScheme( primary = dark_primary, onPrimary = dark_onPrimary, @@ -115,7 +118,7 @@ private val DarkColorPalette = AppColors( assignmentCardBorder = dark_assignment_card_border, ) -private val LightColorPalette = AppColors( +internal val LightColorPalette = AppColors( material3 = lightColorScheme( primary = light_primary, onPrimary = light_onPrimary, @@ -223,10 +226,10 @@ val MaterialTheme.appColors: AppColors @OptIn(ExperimentalFoundationApi::class) @Composable fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) { - DarkColorPalette - } else { - LightColorPalette + val instanceTheme by InstanceThemeManager.theme.collectAsState() + val colors = remember(darkTheme, instanceTheme) { + val base = if (darkTheme) DarkColorPalette else LightColorPalette + instanceTheme?.applyTo(base, isDark = darkTheme) ?: base } MaterialTheme( diff --git a/core/src/test/java/org/openedx/core/instance/InstanceStoreTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceStoreTest.kt new file mode 100644 index 000000000..f03a9633a --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstanceStoreTest.kt @@ -0,0 +1,135 @@ +package org.openedx.core.instance + +import com.google.gson.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InstanceStoreTest { + + private class InMemorySelectionStorage(override var selectedInstanceKey: String? = null) : + InstanceSelectionStorage + + private fun instance(key: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key.uppercase()) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val alpha = instance("alpha") + private val beta = instance("beta") + + private fun store( + storage: InstanceSelectionStorage, + vararg instances: Instance, + ) = InstanceStore(storage) { InstancesConfig(instances.toList()) } + + @Test + fun `single instance catalog with no prior selection auto selects it`() { + val storage = InMemorySelectionStorage() + + val store = store(storage, alpha) + + assertEquals(alpha, store.currentInstance) + assertEquals("alpha", storage.selectedInstanceKey) + assertFalse(store.hasMultipleInstances) + } + + @Test + fun `multi instance catalog with no prior selection selects nothing`() { + val store = store(InMemorySelectionStorage(), alpha, beta) + + assertNull(store.currentInstance) + assertTrue(store.hasMultipleInstances) + } + + @Test + fun `persisted selection is restored by key`() { + val store = store(InMemorySelectionStorage("beta"), alpha, beta) + + assertEquals(beta, store.currentInstance) + } + + @Test + fun `single instance catalog replaces a stale saved key`() { + val storage = InMemorySelectionStorage("gone") + + val store = store(storage, alpha) + + assertEquals(alpha, store.currentInstance) + assertEquals("alpha", storage.selectedInstanceKey) + } + + @Test + fun `empty catalog leaves nothing selected`() { + val store = store(InMemorySelectionStorage()) + + assertNull(store.currentInstance) + assertFalse(store.hasMultipleInstances) + } + + @Test + fun `deselecting a single instance catalog selects it again`() { + val store = store(InMemorySelectionStorage(), alpha) + + store.select(null) + + assertEquals(alpha, store.currentInstance) + } + + @Test + fun `deselecting a multi instance catalog stays deselected`() { + val storage = InMemorySelectionStorage() + val store = store(storage, alpha, beta) + store.select(beta) + + store.reset() + + assertNull(store.currentInstance) + assertNull(storage.selectedInstanceKey) + } + + @Test + fun `selection is published to the flow`() { + val store = store(InMemorySelectionStorage(), alpha, beta) + + store.select(alpha) + assertEquals(alpha, store.currentInstanceFlow.value) + store.select(beta) + assertEquals(beta, store.currentInstanceFlow.value) + } + + @Test + fun `a new catalog re-resolves the saved selection`() { + val storage = InMemorySelectionStorage("beta") + val store = store(storage, alpha) + assertEquals(alpha, store.currentInstance) + + storage.selectedInstanceKey = "beta" + store.updateInstancesConfig(InstancesConfig(listOf(beta, alpha))) + + assertEquals(beta, store.currentInstance) + assertEquals(listOf(beta, alpha), store.instancesConfig.instances) + } + + @Test + fun `a site removed from the catalog resolves to nothing but stays saved`() { + val storage = InMemorySelectionStorage() + val store = store(storage, alpha, beta) + store.select(beta) + + store.updateInstancesConfig(InstancesConfig(listOf(alpha, instance("gamma")))) + assertNull(store.currentInstance) + assertEquals("beta", storage.selectedInstanceKey) + + store.updateInstancesConfig(InstancesConfig(listOf(alpha, beta))) + assertEquals(beta, store.currentInstance) + } +} diff --git a/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt b/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt new file mode 100644 index 000000000..75fa89e22 --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt @@ -0,0 +1,183 @@ +package org.openedx.core.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class InstancesConfigTest { + + private fun fixture(): JsonObject { + val stream = requireNotNull(javaClass.classLoader?.getResourceAsStream("instances/bundled_instances.json")) + return stream.reader().use { JsonParser.parseReader(it).asJsonObject } + } + + private fun requiredFields(): JsonObject = JsonObject().apply { + addProperty("KEY", "test-instance") + addProperty("NAME", "Test Instance") + addProperty("OAUTH_CLIENT_ID", "client-id") + addProperty("API_HOST_URL", "https://test-instance.example.com") + } + + @Test + fun `bundled catalog parses every field of a complete entry`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + val instance = requireNotNull(catalog.instance("example-instance")) + + assertEquals("Example Instance", instance.name) + assertEquals("Example Instance", instance.instanceName) + assertEquals("example-oauth-client-id", instance.oAuthClientId) + assertEquals("https://example-instance.example.com", instance.apiHostUrl) + assertEquals( + "https://example-instance.example.com/auth/login/tpa-saml/?auth_entry=login", + instance.ssoUrl + ) + assertEquals( + "https://example-instance.example.com/auth/login/tpa-saml/auth/complete/tpa-saml", + instance.ssoFinishedUrl + ) + assertEquals("Example Instance", instance.environmentDisplayName) + assertTrue(instance.isSwitchInstanceLoginEnabled) + assertEquals("exampleInstanceAppLogo", instance.logoUrl) + assertNull(instance.headerBackgroundUrl) + assertEquals(mapOf("accent_color" to "#3C68FF"), instance.themeColors?.light) + assertEquals(mapOf("accent_color" to "#1C355E"), instance.themeColors?.dark) + assertTrue(instance.config.has("UI_COMPONENTS")) + } + + @Test + fun `malformed entries are dropped instead of half populated`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + + assertEquals(listOf("example-instance", "second-universite"), catalog.instances.map { it.key }) + assertNull(catalog.instance("missing-client")) + } + + @Test + fun `each required field is enforced`() { + listOf("NAME", "API_HOST_URL", "OAUTH_CLIENT_ID").forEach { field -> + val json = requiredFields().apply { remove(field) } + assertNull("missing $field must be rejected", Instance.fromJson(json)) + } + val blankName = requiredFields().apply { addProperty("NAME", " ") } + assertNull(Instance.fromJson(blankName)) + val relativeHost = requiredFields().apply { addProperty("API_HOST_URL", "test-instance.example.com") } + assertNull(Instance.fromJson(relativeHost)) + val numericClient = requiredFields().apply { addProperty("OAUTH_CLIENT_ID", 42) } + assertNull(Instance.fromJson(numericClient)) + } + + @Test + fun `key falls back to a slug of the name`() { + val catalog = InstancesConfig.fromAppConfig(fixture(), languageCode = "en") + + assertNotNull(catalog.instance("second-universite")) + assertEquals("acme-u-of-arts", Instance.slugify(" ACME U. of Arts! ")) + } + + @Test + fun `display name follows the language then English then NAME`() { + assertEquals("اسم المُزود", InstancesConfig.fromAppConfig(fixture(), "ar").instances[0].instanceName) + assertEquals("Example Instance", InstancesConfig.fromAppConfig(fixture(), "uk").instances[0].instanceName) + assertEquals("Second Université", InstancesConfig.fromAppConfig(fixture(), "ar").instances[1].instanceName) + } + + @Test + fun `optional fields use defaults when omitted`() { + val instance = requireNotNull(Instance.fromJson(requiredFields())) + + assertEquals("#007AFF", instance.color) + assertEquals("Test Instance", instance.platformName) + assertNull(instance.ssoUrl) + assertNull(instance.ssoFinishedUrl) + assertNull(instance.themeColors) + assertNull(instance.logoUrl) + assertFalse(instance.isSwitchInstanceLoginEnabled) + assertTrue(instance.isRoundedCorners) + assertEquals(8.0, instance.buttonCornersRadius, 0.0) + } + + @Test + fun `blank optional strings count as unset`() { + val json = requiredFields().apply { + addProperty("SSO_URL", "") + addProperty("SSO_URL_SUCCESSFUL_LOGIN", " ") + addProperty("PLATFORM_NAME", "") + add("THEME", JsonParser.parseString("""{"LOGO_URL": "", "HEADER_BACKGROUND_URL": ""}""")) + } + val instance = requireNotNull(Instance.fromJson(json)) + + assertNull(instance.ssoUrl) + assertNull(instance.ssoFinishedUrl) + assertNull(instance.logoUrl) + assertNull(instance.headerBackgroundUrl) + assertEquals("Test Instance", instance.platformName) + } + + @Test + fun `instance level values win when provided`() { + val json = requiredFields().apply { + addProperty("PLATFORM_NAME", "Custom Platform Name") + addProperty("SSO_FINISHED_URL", "https://test-instance.example.com/finished") + addProperty("SSO_URL_SUCCESSFUL_LOGIN", "https://test-instance.example.com/ignored") + add("THEME", JsonParser.parseString("""{"ROUNDED_CORNERS_STYLE": false, "BUTTON_CORNERS_RADIUS": 4}""")) + } + val instance = requireNotNull(Instance.fromJson(json)) + + assertEquals("Custom Platform Name", instance.platformName) + assertEquals("https://test-instance.example.com/finished", instance.ssoFinishedUrl) + assertFalse(instance.isRoundedCorners) + assertEquals(4.0, instance.buttonCornersRadius, 0.0) + assertEquals(InstanceThemeColors(emptyMap(), emptyMap()), instance.themeColors) + } + + @Test + fun `fallback fills omitted fields and never overrides the entry`() { + val fallback = JsonObject().apply { + addProperty("OAUTH_CLIENT_ID", "fallback-client") + addProperty("FEEDBACK_EMAIL_ADDRESS", "fallback@example.com") + } + val entry = requiredFields().apply { remove("OAUTH_CLIENT_ID") } + val withClient = requiredFields().apply { addProperty("KEY", "with-client") } + val array = JsonArray().apply { + add(entry) + add(withClient) + } + + val catalog = InstancesConfig.fromJson(array, fallback) + + assertEquals("fallback-client", catalog.instance("test-instance")?.oAuthClientId) + assertEquals("client-id", catalog.instance("with-client")?.oAuthClientId) + val feedbackEmail = catalog.instance("with-client")?.config?.get("FEEDBACK_EMAIL_ADDRESS")?.asString + assertEquals("fallback@example.com", feedbackEmail) + assertFalse(fallback.has("KEY")) + } + + @Test + fun `identity is the key, not the position in the catalog`() { + val fixture = fixture() + val reversed = JsonArray().apply { + fixture.getAsJsonArray("INSTANCES").reversed().forEach { add(it) } + } + + val original = InstancesConfig.fromAppConfig(fixture, "en") + val reordered = InstancesConfig.fromJson(reversed, languageCode = "en") + + assertEquals(original.instance("example-instance"), reordered.instance("example-instance")) + assertEquals(original.instance("second-universite"), reordered.instance("second-universite")) + } + + @Test + fun `missing or non array INSTANCES gives an empty catalog`() { + assertTrue(InstancesConfig.fromAppConfig(JsonObject()).instances.isEmpty()) + val notArray = JsonObject().apply { addProperty("INSTANCES", "nope") } + assertTrue(InstancesConfig.fromAppConfig(notArray).instances.isEmpty()) + val junk = JsonObject().apply { add("INSTANCES", JsonParser.parseString("""[1, "two", null]""")) } + assertTrue(InstancesConfig.fromAppConfig(junk).instances.isEmpty()) + } +} diff --git a/core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt b/core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt new file mode 100644 index 000000000..51159b406 --- /dev/null +++ b/core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt @@ -0,0 +1,116 @@ +package org.openedx.core.ui.theme + +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test + +class InstanceThemeTest { + + private val light = LightColorPalette + private val dark = DarkColorPalette + + @Test + fun `no palette derives only the accent colors from the hex`() { + val colors = InstanceTheme(key = "acme", accentColorHex = "#3E578C").applyTo(light, isDark = false) + val accent = Color(0xFF3E578C) + + assertEquals(accent, colors.primary) + assertEquals(accent, colors.textAccent) + assertEquals(accent, colors.textHyperLink) + assertEquals(accent, colors.primaryButtonBackground) + assertEquals(accent, colors.primaryButtonBorderedText) + assertEquals(light.textPrimary, colors.textPrimary) + assertEquals(light.background, colors.background) + } + + @Test + fun `partial palette overrides only the fields it sets`() { + val baseline = InstanceTheme(key = "acme", accentColorHex = "#3E578C").applyTo(light, isDark = false) + val overridden = InstanceTheme( + key = "acme", + accentColorHex = "#3E578C", + light = mapOf("text_primary" to "#18202E"), + ).applyTo(light, isDark = false) + + assertEquals(Color(0xFF18202E), overridden.textPrimary) + assertEquals(baseline.primary, overridden.primary) + assertEquals(baseline.background, overridden.background) + assertEquals(baseline.cardViewBackground, overridden.cardViewBackground) + } + + @Test + fun `palette accent wins over the flat color, per mode`() { + val theme = InstanceTheme( + key = "acme", + accentColorHex = "#007AFF", + light = mapOf("accent_color" to "#3C68FF"), + dark = mapOf("accent_color" to "#1C355E"), + ) + + assertEquals(Color(0xFF3C68FF), theme.applyTo(light, isDark = false).primary) + assertEquals(Color(0xFF1C355E), theme.applyTo(dark, isDark = true).primary) + } + + @Test + fun `missing dark value falls back to the light value`() { + val theme = InstanceTheme(key = "acme", light = mapOf("alert" to "#FF3D71", "accent_color" to "#3C68FF")) + + assertEquals(Color(0xFFFF3D71), theme.applyTo(dark, isDark = true).error) + assertEquals(Color(0xFF3C68FF), theme.applyTo(dark, isDark = true).primary) + } + + @Test + fun `a dark-only value applies in dark mode and leaves light mode alone`() { + val theme = InstanceTheme(key = "acme", dark = mapOf("background" to "#0A0A0A", "text_primary" to "#F5F5F5")) + + assertEquals(Color(0xFF0A0A0A), theme.applyTo(dark, isDark = true).background) + assertEquals(Color(0xFFF5F5F5), theme.applyTo(dark, isDark = true).textPrimary) + assertEquals(light, theme.applyTo(light, isDark = false)) + } + + @Test + fun `light and dark values resolve independently`() { + val theme = InstanceTheme( + key = "acme", + light = mapOf("background" to "#FFFFFF"), + dark = mapOf("background" to "#0A0A0A"), + ) + + assertEquals(Color(0xFFFFFFFF), theme.applyTo(light, isDark = false).background) + assertEquals(Color(0xFF0A0A0A), theme.applyTo(dark, isDark = true).background) + } + + @Test + fun `unknown fields and bad hex values are ignored`() { + val theme = InstanceTheme( + key = "acme", + accentColorHex = "not-a-color", + light = mapOf("not_a_real_field" to "#000000", "text_primary" to "#12"), + ) + + assertEquals(light, theme.applyTo(light, isDark = false)) + } + + @Test + fun `hex parsing accepts short, long and alpha forms`() { + assertEquals(Color(0xFFFF0000), "#F00".toColorOrNull()) + assertEquals(Color(0xFF3C68FF), " 3C68FF ".toColorOrNull()) + assertEquals(Color(0x803C68FF), "#803C68FF".toColorOrNull()) + assertNull("#GG0000".toColorOrNull()) + assertNull("-12345".toColorOrNull()) + assertNull("".toColorOrNull()) + } + + @Test + fun `manager publishes and resets the theme`() { + val theme = InstanceTheme(key = "acme") + + InstanceThemeManager.applyTheme(theme) + assertSame(theme, InstanceThemeManager.theme.value) + + InstanceThemeManager.reset() + assertNull(InstanceThemeManager.theme.value) + } +} diff --git a/core/src/test/resources/instances/bundled_instances.json b/core/src/test/resources/instances/bundled_instances.json new file mode 100644 index 000000000..da32373fb --- /dev/null +++ b/core/src/test/resources/instances/bundled_instances.json @@ -0,0 +1,45 @@ +{ + "API_HOST_URL": "https://example-instance.example.com", + "OAUTH_CLIENT_ID": "example-oauth-client-id", + "INSTANCES": [ + { + "KEY": "example-instance", + "NAME": "Example Instance", + "INSTANCE_NAME": { + "ar": "اسم المُزود", + "en": "Example Instance" + }, + "ENVIRONMENT_DISPLAY_NAME": "Example Instance", + "OAUTH_CLIENT_ID": "example-oauth-client-id", + "API_HOST_URL": "https://example-instance.example.com", + "SSO_URL": "https://example-instance.example.com/auth/login/tpa-saml/?auth_entry=login", + "SSO_URL_SUCCESSFUL_LOGIN": "https://example-instance.example.com/auth/login/tpa-saml/auth/complete/tpa-saml", + "IS_SWITCH_INSTANCE_LOGIN_ENABLED": true, + "THEME": { + "LIGHT": { + "accent_color": "#3C68FF" + }, + "DARK": { + "accent_color": "#1C355E" + }, + "LOGO_URL": "exampleInstanceAppLogo" + }, + "UI_COMPONENTS": { + "COURSE_UNIT_PROGRESS_ENABLED": false, + "LOGIN_REGISTRATION_ENABLED": true, + "SAML_SSO_LOGIN_ENABLED": false, + "SAML_SSO_DEFAULT_LOGIN_BUTTON": false + } + }, + { + "NAME": "Second Université", + "OAUTH_CLIENT_ID": "second-client-id", + "API_HOST_URL": "https://second.example.com:8443" + }, + { + "KEY": "missing-client", + "NAME": "Missing Client", + "API_HOST_URL": "https://missing-client.example.com" + } + ] +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index f2868eb78..97db15191 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -94,3 +94,52 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false + +# Learning sites ("instances") this build can sign in to. One entry: the app behaves like a +# single-site build, with no site picker. Several entries: the learner picks a site and can stay +# signed in to more than one. NAME, API_HOST_URL and OAUTH_CLIENT_ID are required; every other key +# is optional and falls back to the app-level value above. +INSTANCES: + - KEY: 'localhost' + NAME: 'Localhost' + INSTANCE_NAME: + en: 'Localhost' + ENVIRONMENT_DISPLAY_NAME: 'Localhost' + API_HOST_URL: 'http://localhost:8000' + OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' + SSO_URL: '' + SSO_URL_SUCCESSFUL_LOGIN: '' + IS_SWITCH_INSTANCE_LOGIN_ENABLED: false + FEEDBACK_EMAIL_ADDRESS: 'support@example.com' + FAQ_URL: '' + PLATFORM_NAME: 'OpenEdX' + TOKEN_TYPE: 'JWT' + THEME: + LIGHT: + accent_color: '#3C68FF' + DARK: + accent_color: '#3C68FF' + LOGO_URL: '' + HEADER_BACKGROUND_URL: '' + UI_COMPONENTS: + COURSE_DROPDOWN_NAVIGATION_ENABLED: false + COURSE_UNIT_PROGRESS_ENABLED: false + COURSE_DOWNLOAD_QUEUE_SCREEN: false + AGREEMENT_URLS: + PRIVACY_POLICY_URL: '' + COOKIE_POLICY_URL: '' + DATA_SELL_CONSENT_URL: '' + TOS_URL: '' + EULA_URL: '' + SUPPORTED_LANGUAGES: [ ] + DISCOVERY: + TYPE: 'native' + PROGRAM: + TYPE: 'native' + DASHBOARD: + TYPE: 'gallery' + EXPERIMENTAL_FEATURES: + APP_LEVEL_DOWNLOADS: + ENABLED: false + APP_LEVEL_DATES: + ENABLED: false