From efacd2ef740c6fe2a23ad6734fee36aa1b8aa566 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:53:26 +0300 Subject: [PATCH 1/2] feat: add instance domain model and static config Introduces Instance and InstancesConfig: what one Open edX platform is and how it is parsed from the INSTANCES block of the bundled config. Nothing reads it yet. Required fields are NAME, API_HOST_URL and OAUTH_CLIENT_ID; a malformed entry is dropped. KEY falls back to a slug of NAME, INSTANCE_NAME is localized with an English fallback, and PLATFORM_NAME defaults to the display name. The schema matches iOS (#672). --- .../org/openedx/core/instance/Instance.kt | 185 ++++++++++++++++++ .../openedx/core/instance/InstancesConfig.kt | 51 +++++ .../core/instance/InstancesConfigTest.kt | 183 +++++++++++++++++ .../instances/bundled_instances.json | 45 +++++ default_config/dev/config.yaml | 49 +++++ 5 files changed, 513 insertions(+) create mode 100644 core/src/main/java/org/openedx/core/instance/Instance.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstancesConfig.kt create mode 100644 core/src/test/java/org/openedx/core/instance/InstancesConfigTest.kt create mode 100644 core/src/test/resources/instances/bundled_instances.json 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/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/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/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 From b15a3e7d7fc5a7a6332cbf94d9312576ee87b48a Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:55:02 +0300 Subject: [PATCH 2/2] feat: add instance selection store (InstanceStore) InstanceStore holds the instance catalog and the selected instance, persists the selection by key and publishes it as a StateFlow. A single-instance catalog selects its only instance automatically. Registered in Koin as InstanceStore and InstanceProvider; nothing consumes it yet. Mirrors iOS #673. --- .../main/java/org/openedx/app/di/AppModule.kt | 10 ++ .../java/org/openedx/core/config/Config.kt | 6 + .../openedx/core/instance/InstanceProvider.kt | 17 +++ .../core/instance/InstanceSelectionStorage.kt | 29 ++++ .../openedx/core/instance/InstanceStore.kt | 74 ++++++++++ .../core/instance/InstanceStoreTest.kt | 135 ++++++++++++++++++ 6 files changed, 271 insertions(+) create mode 100644 core/src/main/java/org/openedx/core/instance/InstanceProvider.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstanceStore.kt create mode 100644 core/src/test/java/org/openedx/core/instance/InstanceStoreTest.kt 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/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/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) + } +}