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
10 changes: 10 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,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
Expand Down Expand Up @@ -87,6 +91,12 @@ import org.openedx.core.DatabaseManager as IDatabaseManager
val appModule = module {

single { Config(get()) }
single<InstanceSelectionStorage> { SharedPrefsInstanceSelectionStorage(get()) }
single {
val bundledInstances by lazy { get<Config>().getInstancesConfig() }
InstanceStore(get()) { bundledInstances }
}
single<InstanceProvider> { get<InstanceStore>() }
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 @@ -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")
Expand Down Expand Up @@ -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)
}
Expand Down
185 changes: 185 additions & 0 deletions core/src/main/java/org/openedx/core/instance/Instance.kt
Original file line number Diff line number Diff line change
@@ -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<String, String>,
val dark: Map<String, String>,
)

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<String, String> {
val obj = obj(key) ?: return emptyMap()
return obj.entrySet()
.filter { (_, value) -> value.isJsonPrimitive && value.asJsonPrimitive.isString }
.associate { (entryKey, value) -> entryKey to value.asString }
}
17 changes: 17 additions & 0 deletions core/src/main/java/org/openedx/core/instance/InstanceProvider.kt
Original file line number Diff line number Diff line change
@@ -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<Instance?>

/** Whether the catalog has more than one instance to switch between. */
val hasMultipleInstances: Boolean
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
74 changes: 74 additions & 0 deletions core/src/main/java/org/openedx/core/instance/InstanceStore.kt
Original file line number Diff line number Diff line change
@@ -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<Instance?>(null)

override val currentInstance: Instance?
get() = _currentInstance.value

override val currentInstanceFlow: StateFlow<Instance?> = _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
}
}
Loading
Loading