Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src/main/java/org/openedx/app/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -97,6 +102,9 @@ val appModule = module {
InstanceStore(get()) { bundledInstances }
}
single<InstanceProvider> { get<InstanceStore>() }
single<InstanceCatalogCache> { SharedPrefsInstanceCatalogCache(get()) }
single<InstanceApiService> { OkHttpInstanceApiService(get<Config>().getInstancesCatalogUrl()) }
single { InstanceConfigLoader(get(), get(), { get<Config>().getInstancesConfig() }) }
single { PreferencesManager(get(), get()) }
single<CorePreferences> { get<PreferencesManager>() }
single<ProfilePreferences> { get<PreferencesManager>() }
Expand Down
6 changes: 6 additions & 0 deletions core/src/main/java/org/openedx/core/config/Config.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions core/src/main/java/org/openedx/core/instance/InstanceApiService.kt
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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<String, String> = 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
}
Loading
Loading