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 64dbc6209..a713834c1 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -35,9 +35,14 @@ 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.InstanceApiService +import org.openedx.core.instance.InstanceCatalogCache +import org.openedx.core.instance.InstanceConfigLoader import org.openedx.core.instance.InstanceProvider import org.openedx.core.instance.InstanceSelectionStorage import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.OkHttpInstanceApiService +import org.openedx.core.instance.SharedPrefsInstanceCatalogCache import org.openedx.core.instance.SharedPrefsInstanceSelectionStorage import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager @@ -97,6 +102,9 @@ val appModule = module { InstanceStore(get()) { bundledInstances } } single { get() } + single { SharedPrefsInstanceCatalogCache(get()) } + single { OkHttpInstanceApiService(get().getInstancesCatalogUrl()) } + single { InstanceConfigLoader(get(), get(), { get().getInstancesConfig() }) } 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 52ed2cfae..6982c2854 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -147,6 +147,11 @@ class Config(context: Context) { return getBoolean(REGISTRATION_ENABLED, true) } + /** Where the remote instance catalog is fetched from; empty means bundled catalog only. */ + fun getInstancesCatalogUrl(): String { + return getString(INSTANCES_CATALOG_URL, "") + } + /** The bundled `INSTANCES` catalog, before any remote catalog replaces it. */ fun getInstancesConfig(): InstancesConfig { return InstancesConfig.fromAppConfig(configProperties) @@ -223,6 +228,7 @@ class Config(context: Context) { private const val BRANCH = "BRANCH" private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" + private const val INSTANCES_CATALOG_URL = "INSTANCES_CATALOG_URL" } enum class ViewType { diff --git a/core/src/main/java/org/openedx/core/instance/InstanceApiService.kt b/core/src/main/java/org/openedx/core/instance/InstanceApiService.kt new file mode 100644 index 000000000..a97f7a28a --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceApiService.kt @@ -0,0 +1,51 @@ +package org.openedx.core.instance + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.CacheControl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.concurrent.TimeUnit + +interface InstanceApiService { + /** The catalog's raw JSON. Throws when the request fails or no catalog URL is configured. */ + suspend fun fetchCatalog(): String +} + +class InstanceCatalogNotConfiguredException : IOException("INSTANCES_CATALOG_URL is not configured") + +class InstanceCatalogHttpException(val code: Int) : IOException("Catalog request failed with HTTP $code") + +/** + * Fetches the remote catalog with a plain client of its own. It is a fixed, anonymous endpoint, + * so it deliberately bypasses the app's API client and never carries credentials. + */ +class OkHttpInstanceApiService( + private val catalogUrl: String?, + private val client: OkHttpClient = defaultClient(), +) : InstanceApiService { + + override suspend fun fetchCatalog(): String = withContext(Dispatchers.IO) { + val url = catalogUrl?.takeIf { it.isNotBlank() } ?: throw InstanceCatalogNotConfiguredException() + val request = Request.Builder() + .url(url) + .cacheControl(CacheControl.FORCE_NETWORK) + .header("Accept", "application/json") + .build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) throw InstanceCatalogHttpException(response.code) + response.body?.string() ?: throw InstanceCatalogHttpException(response.code) + } + } + + companion object { + private const val TIMEOUT_SECONDS = 10L + + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .callTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt b/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt new file mode 100644 index 000000000..73a5f12f9 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt @@ -0,0 +1,76 @@ +package org.openedx.core.instance + +import android.content.Context +import android.util.Log +import androidx.core.content.edit +import kotlinx.coroutines.CancellationException + +/** Keeps the last remote catalog that replaced the baseline. */ +interface InstanceCatalogCache { + var cachedCatalogJson: String? +} + +class SharedPrefsInstanceCatalogCache(context: Context) : InstanceCatalogCache { + + private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + override var cachedCatalogJson: String? + get() = prefs.getString(KEY_CATALOG_JSON, null) + set(value) = prefs.edit { putString(KEY_CATALOG_JSON, value) } + + private companion object { + const val PREFS_NAME = "openedx_instance_catalog" + const val KEY_CATALOG_JSON = "cached_catalog_json" + } +} + +/** + * Decides which catalog the app runs with. + * + * The baseline is the last catalog fetched successfully, else the bundled one, else empty. + * Every launch tries a live fetch: a response with at least one instance replaces the baseline + * and becomes the new cache. A failed fetch, or one that returns no instances, leaves the + * baseline untouched, so a reachable but empty endpoint can never wipe a good cache, and an + * offline launch shows the catalog the learner saw last time. + */ +class InstanceConfigLoader( + private val apiService: InstanceApiService, + private val cache: InstanceCatalogCache, + private val bundledConfig: () -> InstancesConfig, + private val logger: (String) -> Unit = { Log.w(TAG, it) }, +) { + + suspend fun load(): InstancesConfig { + val baseline = loadCached() ?: bundledConfig().takeIf { it.instances.isNotEmpty() } ?: InstancesConfig() + return try { + val json = apiService.fetchCatalog() + val fetched = InstancesConfig.fromRemoteJson(json) + if (fetched.instances.isEmpty()) { + baseline + } else { + cache.cachedCatalogJson = json + fetched + } + } catch (e: CancellationException) { + throw e + } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { + // No configured URL is the normal single-catalog setup, not a failure worth logging. + if (e !is InstanceCatalogNotConfiguredException) { + logger("Live catalog fetch failed (${e.message}), using the baseline") + } + baseline + } + } + + /** `null` if nothing was cached, or if the cached payload no longer parses. */ + private fun loadCached(): InstancesConfig? { + val json = cache.cachedCatalogJson ?: return null + return runCatching { InstancesConfig.fromRemoteJson(json) } + .getOrNull() + ?.takeIf { it.instances.isNotEmpty() } + } + + private companion object { + const val TAG = "InstanceConfigLoader" + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstancesJsonException.kt b/core/src/main/java/org/openedx/core/instance/InstancesJsonException.kt new file mode 100644 index 000000000..5b925856c --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstancesJsonException.kt @@ -0,0 +1,4 @@ +package org.openedx.core.instance + +/** The payload is neither a bare instance array nor an object wrapping one. */ +class InstancesJsonException(message: String, cause: Throwable? = null) : Exception(message, cause) diff --git a/core/src/main/java/org/openedx/core/instance/InstancesRemoteJson.kt b/core/src/main/java/org/openedx/core/instance/InstancesRemoteJson.kt new file mode 100644 index 000000000..b03cc1ae5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstancesRemoteJson.kt @@ -0,0 +1,77 @@ +package org.openedx.core.instance + +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonParseException +import com.google.gson.JsonParser +import java.util.Locale + +/** + * Remote catalogs are sent in snake_case. These are the keys renamed to the bundled schema's + * UPPER_SNAKE_CASE before parsing; every other key passes through unchanged, so a remote field + * outside this map has to be sent in UPPER_SNAKE_CASE already. Shared with iOS. + */ +internal val remoteToConfigKeyMap: Map = mapOf( + "key" to "KEY", + "name" to "NAME", + "color" to "COLOR", + "instance_name" to "INSTANCE_NAME", + "oauth_client_id" to "OAUTH_CLIENT_ID", + "api_host_url" to "API_HOST_URL", + "sso_url" to "SSO_URL", + "sso_url_successful_login" to "SSO_URL_SUCCESSFUL_LOGIN", + "environment_display_name" to "ENVIRONMENT_DISPLAY_NAME", + "is_switch_instance_login_enabled" to "IS_SWITCH_INSTANCE_LOGIN_ENABLED", + "ui_components" to "UI_COMPONENTS", + "course_dropdown_navigation_enabled" to "COURSE_DROPDOWN_NAVIGATION_ENABLED", + "course_unit_progress_enabled" to "COURSE_UNIT_PROGRESS_ENABLED", + "login_registration_enabled" to "LOGIN_REGISTRATION_ENABLED", + "saml_sso_login_enabled" to "SAML_SSO_LOGIN_ENABLED", + "saml_sso_default_login_button" to "SAML_SSO_DEFAULT_LOGIN_BUTTON", + "theme" to "THEME", + "light" to "LIGHT", + "dark" to "DARK", + "logo_url" to "LOGO_URL", + "header_background_url" to "HEADER_BACKGROUND_URL", +) + +/** Renames mapped keys at every nesting depth; unmapped keys and all values stay as they are. */ +internal fun JsonObject.normalizedRemoteKeys(): JsonObject { + val result = JsonObject() + entrySet().forEach { (key, value) -> + val mappedKey = remoteToConfigKeyMap[key.lowercase(Locale.ROOT)] ?: key + result.add(mappedKey, if (value.isJsonObject) value.asJsonObject.normalizedRemoteKeys() else value) + } + return result +} + +/** + * Parses a remote catalog: a bare array, or an object with the array under `instances` or + * `INSTANCES`. Keys are normalized, then entries go through the same rules as the bundled + * catalog. + */ +fun InstancesConfig.Companion.fromRemoteJson( + json: String, + languageCode: String = Locale.getDefault().language, +): InstancesConfig { + val root: JsonElement = try { + JsonParser.parseString(json) + } catch (e: JsonParseException) { + throw InstancesJsonException("Catalog is not valid JSON", e) + } + val array = root.instancesArray() ?: throw InstancesJsonException("Catalog has no instances array") + val normalized = JsonArray().apply { + array.forEach { add(if (it.isJsonObject) it.asJsonObject.normalizedRemoteKeys() else it) } + } + return fromJson(normalized, languageCode = languageCode) +} + +private fun JsonElement.instancesArray(): JsonArray? = when { + isJsonArray -> asJsonArray + isJsonObject -> (asJsonObject.get(InstancesConfig.INSTANCES) ?: asJsonObject.get("instances")) + ?.takeIf { it.isJsonArray } + ?.asJsonArray + + else -> null +} diff --git a/core/src/test/java/org/openedx/core/instance/InstanceConfigLoaderTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceConfigLoaderTest.kt new file mode 100644 index 000000000..6a0d88579 --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstanceConfigLoaderTest.kt @@ -0,0 +1,256 @@ +package org.openedx.core.instance + +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test +import java.io.IOException + +class InstanceConfigLoaderTest { + + private fun resource(name: String): String { + val stream = requireNotNull(javaClass.classLoader?.getResourceAsStream("instances/$name")) + return stream.reader().use { it.readText() } + } + + private class FakeApi(private val result: () -> String) : InstanceApiService { + var calls = 0 + override suspend fun fetchCatalog(): String { + calls++ + return result() + } + } + + private class InMemoryCache(override var cachedCatalogJson: String? = null) : InstanceCatalogCache + + private val bundled = InstancesConfig.fromAppConfig( + com.google.gson.JsonParser.parseString( + """{"INSTANCES": [{"KEY": "bundled", "NAME": "Bundled", "OAUTH_CLIENT_ID": "c", + "API_HOST_URL": "https://bundled.example.com"}]}""" + ).asJsonObject + ) + + private val onlineCatalog = """[{"key": "online", "name": "Online", "oauth_client_id": "c", + "api_host_url": "https://online.example.com"}]""" + + private fun loader(api: InstanceApiService, cache: InstanceCatalogCache, bundledConfig: InstancesConfig = bundled) = + InstanceConfigLoader(api, cache, { bundledConfig }, logger = {}) + + // region Remote JSON + + @Test + fun `remote snake_case keys are normalized at every depth`() { + val catalog = InstancesConfig.fromRemoteJson(resource("remote_catalog.json"), languageCode = "en") + val instance = requireNotNull(catalog.instance("niepd")) + + assertEquals("NIEPD", instance.name) + assertEquals("National Institute", instance.instanceName) + assertEquals("#1C355E", instance.color) + assertEquals("niepd-client", instance.oAuthClientId) + assertEquals("https://lms.niepd.example.com", instance.apiHostUrl) + assertEquals("https://lms.niepd.example.com/auth/complete/tpa-saml", instance.ssoFinishedUrl) + assertTrue(instance.isSwitchInstanceLoginEnabled) + assertEquals("https://cdn.example.com/niepd/logo.png", instance.logoUrl) + assertEquals("https://cdn.example.com/niepd/header.png", instance.headerBackgroundUrl) + val uiComponents = instance.config.getAsJsonObject("UI_COMPONENTS") + assertTrue(uiComponents.get("COURSE_UNIT_PROGRESS_ENABLED").asBoolean) + assertFalse(uiComponents.get("LOGIN_REGISTRATION_ENABLED").asBoolean) + } + + @Test + fun `palette field names inside THEME are left as they are`() { + val instance = InstancesConfig.fromRemoteJson(resource("remote_catalog.json")).instances.single() + + assertEquals(mapOf("accent_color" to "#3C68FF", "text_primary" to "#18202E"), instance.themeColors?.light) + assertEquals(mapOf("accent_color" to "#1C355E"), instance.themeColors?.dark) + } + + @Test + fun `unmapped remote keys pass through unchanged`() { + val instance = InstancesConfig.fromRemoteJson(resource("remote_catalog.json")).instances.single() + + assertEquals("help@niepd.example.com", instance.config.get("FEEDBACK_EMAIL_ADDRESS").asString) + assertEquals( + "ignored-because-unmapped@example.com", + instance.config.get("feedback_email_address").asString + ) + } + + @Test + fun `bare and wrapped arrays are both accepted`() { + val entry = """{"key": "a", "name": "A", "oauth_client_id": "c", "api_host_url": "https://a.example.com"}""" + + listOf("[$entry]", """{"instances": [$entry]}""", """{"INSTANCES": [$entry]}""").forEach { json -> + assertEquals(listOf("a"), InstancesConfig.fromRemoteJson(json).instances.map { it.key }) + } + } + + @Test + fun `unrecognized shapes are rejected`() { + listOf("""{"sites": []}""", "\"text\"", "{not json").forEach { json -> + try { + InstancesConfig.fromRemoteJson(json) + fail("$json should be rejected") + } catch (_: InstancesJsonException) { + // expected + } + } + } + + @Test + fun `remote entries follow the same required field rules`() { + val catalog = InstancesConfig.fromRemoteJson(resource("remote_catalog.json")) + + assertNull(catalog.instance("broken")) + } + + // endregion + + // region Loader + + @Test + fun `non empty live catalog replaces the baseline and is cached`() = runBlocking { + val cache = InMemoryCache() + + val result = loader(FakeApi { onlineCatalog }, cache).load() + + assertEquals(listOf("online"), result.instances.map { it.key }) + assertEquals(onlineCatalog, cache.cachedCatalogJson) + } + + @Test + fun `failed fetch falls back to the cache before the bundled catalog`() = runBlocking { + val cache = InMemoryCache(onlineCatalog) + + val result = loader(FakeApi { throw IOException("offline") }, cache).load() + + assertEquals(listOf("online"), result.instances.map { it.key }) + } + + @Test + fun `failed fetch without a cache uses the bundled catalog`() = runBlocking { + val result = loader(FakeApi { throw IOException("timeout") }, InMemoryCache()).load() + + assertEquals(listOf("bundled"), result.instances.map { it.key }) + } + + @Test + fun `failed fetch with nothing cached or bundled gives an empty catalog`() = runBlocking { + val result = loader(FakeApi { throw IOException("offline") }, InMemoryCache(), InstancesConfig()).load() + + assertTrue(result.instances.isEmpty()) + } + + @Test + fun `reachable but empty catalog never wipes a good cache`() = runBlocking { + val cache = InMemoryCache(onlineCatalog) + + listOf("[]", """{"instances": []}""", """[{"key": "x"}]""").forEach { empty -> + val result = loader(FakeApi { empty }, cache).load() + assertEquals(listOf("online"), result.instances.map { it.key }) + } + assertEquals(onlineCatalog, cache.cachedCatalogJson) + } + + @Test + fun `malformed response keeps the baseline`() = runBlocking { + val cache = InMemoryCache(onlineCatalog) + + val result = loader(FakeApi { "502" }, cache).load() + + assertEquals(listOf("online"), result.instances.map { it.key }) + assertEquals(onlineCatalog, cache.cachedCatalogJson) + } + + @Test + fun `unparseable cache is ignored`() = runBlocking { + val result = loader(FakeApi { throw IOException("offline") }, InMemoryCache("{broken")).load() + + assertEquals(listOf("bundled"), result.instances.map { it.key }) + } + + @Test + fun `no configured URL uses the baseline without a network call`() = runBlocking { + val service = OkHttpInstanceApiService( + catalogUrl = "", + client = OkHttpClient.Builder() + .addInterceptor { throw AssertionError("no request expected") } + .build() + ) + + val result = loader(service, InMemoryCache()).load() + + assertEquals(listOf("bundled"), result.instances.map { it.key }) + } + + // endregion + + // region Service + + private fun capturingClient(code: Int, body: String, captured: MutableList) = OkHttpClient.Builder() + .addInterceptor( + Interceptor { chain -> + captured += chain.request() + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("status $code") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + } + ) + .build() + + @Test + fun `catalog request is anonymous and bypasses caches`() = runBlocking { + val captured = mutableListOf() + val service = OkHttpInstanceApiService( + catalogUrl = "https://catalog.example.com/instances.json", + client = capturingClient(200, onlineCatalog, captured), + ) + + assertEquals(onlineCatalog, service.fetchCatalog()) + val request = captured.single() + assertEquals("https://catalog.example.com/instances.json", request.url.toString()) + assertNull(request.header("Authorization")) + assertNull(request.header("Cookie")) + assertTrue(request.cacheControl.noCache) + } + + @Test + fun `non 2xx responses are errors`() = runBlocking { + val service = OkHttpInstanceApiService( + catalogUrl = "https://catalog.example.com/instances.json", + client = capturingClient(503, "{}", mutableListOf()), + ) + + try { + service.fetchCatalog() + fail("503 must throw") + } catch (e: InstanceCatalogHttpException) { + assertEquals(503, e.code) + } + } + + @Test + fun `default client times out after ten seconds`() { + val client = OkHttpInstanceApiService.defaultClient() + + assertEquals(10_000, client.callTimeoutMillis) + assertTrue(client.interceptors.isEmpty()) + assertTrue(client.networkInterceptors.isEmpty()) + } + + // endregion +} diff --git a/core/src/test/resources/instances/remote_catalog.json b/core/src/test/resources/instances/remote_catalog.json new file mode 100644 index 000000000..c9bab44a3 --- /dev/null +++ b/core/src/test/resources/instances/remote_catalog.json @@ -0,0 +1,34 @@ +{ + "instances": [ + { + "key": "niepd", + "name": "NIEPD", + "instance_name": { "en": "National Institute", "ar": "المعهد الوطني" }, + "color": "#1C355E", + "oauth_client_id": "niepd-client", + "api_host_url": "https://lms.niepd.example.com", + "sso_url": "https://lms.niepd.example.com/auth/login/tpa-saml/?auth_entry=login", + "sso_url_successful_login": "https://lms.niepd.example.com/auth/complete/tpa-saml", + "environment_display_name": "NIEPD", + "is_switch_instance_login_enabled": true, + "ui_components": { + "course_unit_progress_enabled": true, + "login_registration_enabled": false, + "saml_sso_login_enabled": true + }, + "theme": { + "light": { "accent_color": "#3C68FF", "text_primary": "#18202E" }, + "dark": { "accent_color": "#1C355E" }, + "logo_url": "https://cdn.example.com/niepd/logo.png", + "header_background_url": "https://cdn.example.com/niepd/header.png" + }, + "FEEDBACK_EMAIL_ADDRESS": "help@niepd.example.com", + "feedback_email_address": "ignored-because-unmapped@example.com" + }, + { + "key": "broken", + "name": "Broken entry without a client", + "api_host_url": "https://broken.example.com" + } + ] +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 97db15191..2101f01d7 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -5,6 +5,8 @@ URI_SCHEME: '' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' FAQ_URL: '' OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' +# URL of the remote learning site catalog. Leave empty to use only the bundled INSTANCES list. +INSTANCES_CATALOG_URL: '' # Keep empty to hide setting AGREEMENT_URLS: diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f2868eb78..2f5e900d7 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -5,6 +5,8 @@ URI_SCHEME: '' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' FAQ_URL: '' OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' +# URL of the remote learning site catalog. Leave empty to use only the bundled INSTANCES list. +INSTANCES_CATALOG_URL: '' # Keep empty to hide setting AGREEMENT_URLS: diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index f2868eb78..2f5e900d7 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -5,6 +5,8 @@ URI_SCHEME: '' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' FAQ_URL: '' OAUTH_CLIENT_ID: 'OAUTH_CLIENT_ID' +# URL of the remote learning site catalog. Leave empty to use only the bundled INSTANCES list. +INSTANCES_CATALOG_URL: '' # Keep empty to hide setting AGREEMENT_URLS: