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 01/17] 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 02/17] 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) + } +} From 16e15deee1058c60a4dbc519e8b223978c5ceb80 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:57:25 +0300 Subject: [PATCH 03/17] feat: add per-instance theme engine InstanceTheme derives a palette from an instance's accent color and its optional THEME.LIGHT/THEME.DARK overrides: only the fields an instance sets change, the rest keep the compiled default, and a missing dark value falls back to the light one. Palette field names are the ones iOS uses (#675), mapped onto AppColors. OpenEdXTheme observes InstanceThemeManager, which holds the selected instance's theme. Nothing applies a theme yet, so every screen still renders the default palette. --- .../openedx/core/ui/theme/InstanceTheme.kt | 124 ++++++++++++++++++ .../java/org/openedx/core/ui/theme/Theme.kt | 15 ++- .../core/ui/theme/InstanceThemeTest.kt | 107 +++++++++++++++ 3 files changed, 240 insertions(+), 6 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt create mode 100644 core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt 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..c9bb67550 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt @@ -0,0 +1,124 @@ +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. + */ + 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.forEach { (field, lightHex) -> + if (field == ACCENT_COLOR) return@forEach + val hex = if (isDark) dark[field] ?: lightHex else lightHex + 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/ui/theme/InstanceThemeTest.kt b/core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt new file mode 100644 index 000000000..5518bca2e --- /dev/null +++ b/core/src/test/java/org/openedx/core/ui/theme/InstanceThemeTest.kt @@ -0,0 +1,107 @@ +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 `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) + } +} From b8ef84ae1f106778d704ac787215626c73171672 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:00:41 +0300 Subject: [PATCH 04/17] feat: fetch the instance catalog from a remote URL InstanceConfigLoader resolves the catalog the app runs with: the last cached remote catalog, else the bundled INSTANCES list, else nothing. A live fetch with at least one instance replaces that baseline and becomes the cache; a failed, malformed or empty response leaves it untouched. The catalog is fetched from INSTANCES_CATALOG_URL with a plain OkHttp client: no app interceptors, no credentials, a 10 second timeout. Remote snake_case keys are normalized with the same map iOS uses (#676). Nothing calls the loader at launch yet. Unlike iOS, the YAML build configuration stays: the Gradle build already turns it into the bundled config.json the loader reads. --- .../main/java/org/openedx/app/di/AppModule.kt | 8 + .../java/org/openedx/core/config/Config.kt | 6 + .../core/instance/InstanceApiService.kt | 51 ++++ .../core/instance/InstanceConfigLoader.kt | 76 ++++++ .../core/instance/InstancesJsonException.kt | 4 + .../core/instance/InstancesRemoteJson.kt | 77 ++++++ .../core/instance/InstanceConfigLoaderTest.kt | 256 ++++++++++++++++++ .../resources/instances/remote_catalog.json | 34 +++ default_config/dev/config.yaml | 2 + default_config/prod/config.yaml | 2 + default_config/stage/config.yaml | 2 + 11 files changed, 518 insertions(+) create mode 100644 core/src/main/java/org/openedx/core/instance/InstanceApiService.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstancesJsonException.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstancesRemoteJson.kt create mode 100644 core/src/test/java/org/openedx/core/instance/InstanceConfigLoaderTest.kt create mode 100644 core/src/test/resources/instances/remote_catalog.json 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: From a795b264b3b7e6ddbe433f67a2dafb2aa7cabb8b Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:05:02 +0300 Subject: [PATCH 05/17] feat: route API requests to the selected instance InstanceHostInterceptor re-targets requests built against the app's base URL at the selected instance: scheme, host and port change, path and query stay. Other hosts, and every request made with nothing selected, pass through. Each request is tagged with the instance it was sent for. Token refresh calls the selected instance's /oauth2/access_token with its OAuth client. A 401 for a request sent before a site switch neither refreshes nor logs out, and tokens returned after the learner switched are discarded. The access token is only attached to requests for the active site's host. Mirrors iOS #677. --- app/build.gradle | 2 + .../app/data/networking/HeadersInterceptor.kt | 18 +- .../networking/InstanceHostInterceptor.kt | 57 ++++ .../OauthRefreshTokenAuthenticator.kt | 65 +++-- .../org/openedx/app/di/NetworkingModule.kt | 7 +- .../networking/InstanceAwareNetworkingTest.kt | 270 ++++++++++++++++++ 6 files changed, 397 insertions(+), 22 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt create mode 100644 app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt diff --git a/app/build.gradle b/app/build.gradle index f3d047808..8b8f7e9e7 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -150,6 +150,8 @@ dependencies { testImplementation "io.mockk:mockk:$mockk_version" testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" + // The Android stub of org.json returns defaults; the token authenticator parses error bodies with it. + testImplementation "org.json:json:20240303" } private def setupBranchConfigFields(buildType) { diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index baafe5a86..6feeb9e25 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -1,15 +1,18 @@ package org.openedx.app.data.networking +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.Response import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceProvider import org.openedx.core.presentation.global.AppData class HeadersInterceptor( private val appData: AppData, private val config: Config, private val preferencesManager: CorePreferences, + private val instanceProvider: InstanceProvider, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = chain.run { @@ -18,7 +21,8 @@ class HeadersInterceptor( .newBuilder().apply { val token = preferencesManager.accessToken - if (token.isNotEmpty()) { + // The token belongs to the selected site; never send it to another host. + if (token.isNotEmpty() && targetsActiveSite(request().url.toString())) { addHeader("Authorization", "${config.getAccessTokenType()} $token") } @@ -29,4 +33,16 @@ class HeadersInterceptor( }.build() ) } + + private fun targetsActiveSite(url: String): Boolean { + val siteUrl = instanceProvider.currentInstance?.apiHostUrl ?: config.getApiHostURL() + val site = siteUrl.toHttpUrlOrNull() + val request = url.toHttpUrlOrNull() + return when { + // An unparseable configured host can't be compared; keep the previous behavior. + site == null -> true + request == null -> false + else -> request.isSameOrigin(site) + } + } } diff --git a/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt new file mode 100644 index 000000000..8d680ba14 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt @@ -0,0 +1,57 @@ +package org.openedx.app.data.networking + +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.openedx.core.instance.InstanceProvider + +/** + * Which instance a request was sent for, attached as an OkHttp tag when the request starts. + * Token refresh reads it, so a response that arrives after a switch is still handled for the + * site that sent it. + */ +data class RequestInstance(val instanceKey: String?) + +/** + * Re-targets requests built against the app's base URL at the selected instance: scheme, host + * and port change, path and query stay. Requests to any other host (CDNs, third-party SDKs) + * and every request made while no instance is selected pass through untouched. + */ +class InstanceHostInterceptor( + appBaseUrl: String, + private val instanceProvider: InstanceProvider, +) : Interceptor { + + private val appBase: HttpUrl? = appBaseUrl.toHttpUrlOrNull() + + override fun intercept(chain: Interceptor.Chain): Response { + val instance = instanceProvider.currentInstance + val request = chain.request() + val rewritten = rewriteHostIfNeeded(request, instance?.apiHostUrl) + .newBuilder() + .tag(RequestInstance::class.java, RequestInstance(instance?.key)) + .build() + return chain.proceed(rewritten) + } + + internal fun rewriteHostIfNeeded(request: Request, instanceHostUrl: String?): Request { + val target = instanceHostUrl?.toHttpUrlOrNull() + val base = appBase + val url = request.url + if (target == null || base == null || !url.isSameOrigin(base) || url.isSameOrigin(target)) { + return request + } + val newUrl = url.newBuilder() + .scheme(target.scheme) + .host(target.host) + .port(target.port) + .build() + return request.newBuilder().url(newUrl).build() + } +} + +internal fun HttpUrl.isSameOrigin(other: HttpUrl): Boolean { + return scheme == other.scheme && host == other.host && port == other.port +} diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index a60a3a988..4bec459ef 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -23,25 +23,29 @@ import org.openedx.core.ApiConstants.TOKEN_TYPE_JWT import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceProvider import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.TimeUtils import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import java.io.IOException +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit class OauthRefreshTokenAuthenticator( private val config: Config, private val preferencesManager: CorePreferences, private val appNotifier: AppNotifier, + private val instanceProvider: InstanceProvider, + private val okHttpClient: OkHttpClient = defaultRefreshClient(), ) : Authenticator, Interceptor { - private val authApi: AuthApi + private val authApis = ConcurrentHashMap() private var lastTokenRefreshRequestTime = 0L override fun intercept(chain: Interceptor.Chain): Response { - if (isTokenExpired()) { + if (isTokenExpired() && isForActiveInstance(chain.request())) { val response = createUnauthorizedResponse(chain) val request = authenticate(chain.connection()?.route(), response) @@ -50,25 +54,33 @@ class OauthRefreshTokenAuthenticator( return chain.proceed(chain.request()) } - init { - val okHttpClient = OkHttpClient.Builder().apply { - writeTimeout(timeout = 60, TimeUnit.SECONDS) - readTimeout(timeout = 60, TimeUnit.SECONDS) - if (BuildConfig.DEBUG) { - addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) - } - }.build() - authApi = Retrofit.Builder() - .baseUrl(config.getApiHostURL()) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(Gson())) - .build() - .create(AuthApi::class.java) + /** Token endpoint of the selected instance, or of the app-level host when none is selected. */ + private fun authApi(): AuthApi { + val baseUrl = instanceProvider.currentInstance?.apiHostUrl ?: config.getApiHostURL() + return authApis.getOrPut(baseUrl) { + Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(Gson())) + .build() + .create(AuthApi::class.java) + } + } + + /** + * A 401 for a request sent before the learner switched sites says nothing about the site + * that is selected now: refreshing it, or logging out, would act on the wrong session. + */ + private fun isForActiveInstance(request: Request): Boolean { + val sentFor = request.tag(RequestInstance::class.java) ?: return true + return sentFor.instanceKey == instanceProvider.currentInstance?.key } @Suppress("ReturnCount") @Synchronized override fun authenticate(route: Route?, response: Response): Request? { + if (!isForActiveInstance(response.request)) return null + val accessToken = preferencesManager.accessToken val refreshToken = preferencesManager.refreshToken @@ -76,7 +88,7 @@ class OauthRefreshTokenAuthenticator( val errorCode = getErrorCode(response.peekBody(Long.MAX_VALUE).string()) ?: return null - return when (errorCode) { + val retry = when (errorCode) { TOKEN_EXPIRED_ERROR_MESSAGE, JWT_TOKEN_EXPIRED -> { handleTokenExpired(response, refreshToken, accessToken) } @@ -91,6 +103,9 @@ class OauthRefreshTokenAuthenticator( else -> null } + // Re-checked after the refresh: a switch during it must not retry this request with the + // new site's token. + return retry?.takeIf { isForActiveInstance(response.request) } } private fun handleDisabledUser(): Request? { @@ -159,13 +174,17 @@ class OauthRefreshTokenAuthenticator( private fun refreshAccessToken(refreshToken: String): AuthResponse? { var authResponse: AuthResponse? = null if (canRequestTokenRefresh()) { - val response = authApi.refreshAccessToken( + val instance = instanceProvider.currentInstance + val response = authApi().refreshAccessToken( ApiConstants.TOKEN_TYPE_REFRESH, - config.getOAuthClientId(), + instance?.oAuthClientId ?: config.getOAuthClientId(), refreshToken, config.getAccessTokenType() ).execute() authResponse = response.body()?.mapToDomain() + // The learner may have switched sites while the refresh was in flight; these tokens + // belong to the site that issued them, not to the one selected now. + if (instance?.key != instanceProvider.currentInstance?.key) return null if (response.isSuccessful && authResponse != null) { val newAccessToken = authResponse.accessToken ?: "" val newRefreshToken = authResponse.refreshToken ?: "" @@ -244,6 +263,14 @@ class OauthRefreshTokenAuthenticator( companion object { private const val HEADER_AUTHORIZATION = "Authorization" + private fun defaultRefreshClient(): OkHttpClient = OkHttpClient.Builder().apply { + writeTimeout(timeout = 60, TimeUnit.SECONDS) + readTimeout(timeout = 60, TimeUnit.SECONDS) + if (BuildConfig.DEBUG) { + addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) + } + }.build() + private const val TOKEN_EXPIRED_ERROR_MESSAGE = "token_expired" private const val TOKEN_NONEXISTENT_ERROR_MESSAGE = "token_nonexistent" private const val TOKEN_INVALID_GRANT_ERROR_MESSAGE = "invalid_grant" diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index 6360e7fba..412f99a64 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -7,6 +7,7 @@ import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor import org.openedx.app.data.networking.HandleErrorInterceptor import org.openedx.app.data.networking.HeadersInterceptor +import org.openedx.app.data.networking.InstanceHostInterceptor import org.openedx.app.data.networking.OauthRefreshTokenAuthenticator import org.openedx.auth.data.api.AuthApi import org.openedx.core.BuildConfig @@ -22,13 +23,15 @@ import java.util.concurrent.TimeUnit val networkingModule = module { - single { OauthRefreshTokenAuthenticator(get(), get(), get()) } + single { OauthRefreshTokenAuthenticator(get(), get(), get(), get()) } + single { InstanceHostInterceptor(get().getApiHostURL(), get()) } single { OkHttpClient.Builder().apply { writeTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) - addInterceptor(HeadersInterceptor(get(), get(), get())) + addInterceptor(get()) + addInterceptor(HeadersInterceptor(get(), get(), get(), get())) if (BuildConfig.DEBUG) { addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) } diff --git a/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt new file mode 100644 index 000000000..67880402e --- /dev/null +++ b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt @@ -0,0 +1,270 @@ +package org.openedx.app.data.networking + +import com.google.gson.JsonObject +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +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 okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.presentation.global.AppData +import org.openedx.core.system.notifier.app.AppNotifier +import org.openedx.core.system.notifier.app.LogoutEvent + +class InstanceAwareNetworkingTest { + + private class FakeInstanceProvider(instance: Instance?) : InstanceProvider { + private val flow = MutableStateFlow(instance) + override val currentInstance: Instance? get() = flow.value + override val currentInstanceFlow: StateFlow = flow + override val hasMultipleInstances: Boolean = true + fun select(instance: Instance?) { + flow.value = instance + } + } + + private fun instance(key: String, host: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", host) + } + ) + ) + + private val appBase = "https://app.example.com" + private val alpha = instance("alpha", "https://alpha.example.com") + private val beta = instance("beta", "http://beta.example.com:8080") + + /** Runs [interceptor] on [request] and returns the request it forwarded. */ + private fun forward(interceptor: Interceptor, request: Request): Request { + var forwarded: Request? = null + val client = OkHttpClient.Builder() + .addInterceptor(interceptor) + .addInterceptor { chain -> + forwarded = chain.request() + ok(chain.request(), "{}") + } + .build() + client.newCall(request).execute().close() + return requireNotNull(forwarded) + } + + private fun ok(request: Request, body: String, code: Int = 200) = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("status $code") + .body(body.toResponseBody("application/json".toMediaType())) + .build() + + // region InstanceHostInterceptor + + @Test + fun `app base requests are re-targeted at the selected instance, path and query preserved`() { + val interceptor = InstanceHostInterceptor(appBase, FakeInstanceProvider(beta)) + + val forwarded = forward( + interceptor, + Request.Builder().url("https://app.example.com/api/courses/v1/?page=2&size=10").build() + ) + + assertEquals("http://beta.example.com:8080/api/courses/v1/?page=2&size=10", forwarded.url.toString()) + assertEquals(RequestInstance("beta"), forwarded.tag(RequestInstance::class.java)) + } + + @Test + fun `no selected instance passes requests through`() { + val interceptor = InstanceHostInterceptor(appBase, FakeInstanceProvider(null)) + + val forwarded = forward(interceptor, Request.Builder().url("https://app.example.com/api/x").build()) + + assertEquals("https://app.example.com/api/x", forwarded.url.toString()) + assertEquals(RequestInstance(null), forwarded.tag(RequestInstance::class.java)) + } + + @Test + fun `third party hosts are left untouched`() { + val interceptor = InstanceHostInterceptor(appBase, FakeInstanceProvider(alpha)) + + listOf( + "https://cdn.example.net/video.mp4", + "http://app.example.com/api/x", + "https://app.example.com:8443/api/x", + ).forEach { url -> + assertEquals(url, forward(interceptor, Request.Builder().url(url).build()).url.toString()) + } + } + + @Test + fun `requests already built for the instance host stay as they are`() { + val interceptor = InstanceHostInterceptor(appBase, FakeInstanceProvider(alpha)) + val request = Request.Builder().url("https://alpha.example.com/api/x").build() + + assertSame(request, interceptor.rewriteHostIfNeeded(request, alpha.apiHostUrl)) + } + + // endregion + + // region HeadersInterceptor + + private fun headersInterceptor(provider: InstanceProvider): HeadersInterceptor { + val config = mockk { + every { getAccessTokenType() } returns "JWT" + every { getApiHostURL() } returns appBase + } + val preferences = mockk { every { accessToken } returns "token-a" } + val appData = AppData(appName = "app", versionName = "1", applicationId = "org.openedx.app") + return HeadersInterceptor(appData, config, preferences, provider) + } + + @Test + fun `token is sent to the active site only`() { + val interceptor = headersInterceptor(FakeInstanceProvider(alpha)) + + val toSite = forward(interceptor, Request.Builder().url("https://alpha.example.com/api/x").build()) + val elsewhere = forward(interceptor, Request.Builder().url("https://beta.example.com/api/x").build()) + + assertEquals("JWT token-a", toSite.header("Authorization")) + assertNull(elsewhere.header("Authorization")) + } + + @Test + fun `without a selection the app level host gets the token`() { + val interceptor = headersInterceptor(FakeInstanceProvider(null)) + + val forwarded = forward(interceptor, Request.Builder().url("https://app.example.com/api/x").build()) + + assertEquals("JWT token-a", forwarded.header("Authorization")) + } + + // endregion + + // region OauthRefreshTokenAuthenticator + + private class Prefs : CorePreferences by mockk(relaxed = true) { + override var accessToken: String = "old-access" + override var refreshToken: String = "old-refresh" + override var accessTokenExpiresAt: Long = Long.MAX_VALUE + } + + private fun unauthorized(request: Request) = ok(request, """{"detail": "Token has expired."}""", code = 401) + + private fun taggedRequest(url: String, key: String?) = Request.Builder() + .url(url) + .header("Authorization", "JWT old-access") + .tag(RequestInstance::class.java, RequestInstance(key)) + .build() + + @Test + fun `refresh uses the selected instance host and OAuth client`() { + val tokenRequests = mutableListOf() + val refreshClient = OkHttpClient.Builder().addInterceptor { chain -> + tokenRequests += chain.request() + ok( + chain.request(), + """{"access_token": "new-access", "refresh_token": "new-refresh", "expires_in": 3600}""" + ) + }.build() + val prefs = Prefs() + val config = mockk(relaxed = true) { + every { getAccessTokenType() } returns "JWT" + every { getApiHostURL() } returns appBase + every { getOAuthClientId() } returns "app-client" + } + val authenticator = OauthRefreshTokenAuthenticator( + config, + prefs, + mockk(relaxed = true), + FakeInstanceProvider(beta), + refreshClient + ) + + val retry = authenticator.authenticate( + null, + unauthorized(taggedRequest("http://beta.example.com:8080/api/x", "beta")) + ) + + val tokenRequest = tokenRequests.single() + assertEquals("http://beta.example.com:8080/oauth2/access_token/", tokenRequest.url.toString()) + val form = Buffer().also { tokenRequest.body!!.writeTo(it) }.readUtf8() + assertTrue(form, form.contains("client_id=beta-client")) + assertEquals("JWT new-access", retry?.header("Authorization")) + assertEquals("new-access", prefs.accessToken) + } + + @Test + fun `a 401 for a request sent before a switch neither refreshes nor logs out`() { + val refreshClient = OkHttpClient.Builder() + .addInterceptor { throw AssertionError("no refresh expected") } + .build() + val notifier = mockk(relaxed = true) + val prefs = Prefs() + val authenticator = OauthRefreshTokenAuthenticator( + mockk(relaxed = true), + prefs, + notifier, + FakeInstanceProvider(beta), + refreshClient + ) + + val retry = authenticator.authenticate( + null, + unauthorized(taggedRequest("https://alpha.example.com/api/x", "alpha")) + ) + + assertNull(retry) + assertEquals("old-access", prefs.accessToken) + coVerify(exactly = 0) { notifier.send(any()) } + } + + @Test + fun `tokens refreshed while the learner switched sites are discarded`() { + val provider = FakeInstanceProvider(alpha) + val refreshClient = OkHttpClient.Builder().addInterceptor { chain -> + provider.select(beta) + ok( + chain.request(), + """{"access_token": "alpha-new", "refresh_token": "alpha-refresh", "expires_in": 3600}""" + ) + }.build() + val prefs = Prefs() + val authenticator = OauthRefreshTokenAuthenticator( + mockk(relaxed = true) { every { getAccessTokenType() } returns "JWT" }, + prefs, + mockk(relaxed = true), + provider, + refreshClient + ) + + val retry = authenticator.authenticate( + null, + unauthorized(taggedRequest("https://alpha.example.com/api/x", "alpha")) + ) + + assertNull(retry) + assertEquals("old-access", prefs.accessToken) + assertNotNull(provider.currentInstance) + } + + // endregion +} From 4920b7aa152817d40e73a9c4580112c8978e94c0 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:08:41 +0300 Subject: [PATCH 06/17] feat: namespace local storage keys by instance Preferences tied to a learner on one site (tokens, push token, user, profile, server app config, relative dates, calendar sync state) are now stored per instance, so switching sites can't leak one site's session into another. Device-wide settings (video quality, Wi-Fi only downloads, review prompt, What's New) stay shared. StorageNamespace decides where an instance's data lives. The instance whose API host and OAuth client match the app-level configuration keeps the existing unprefixed keys, so an upgraded single-site install stays signed in; that choice is recorded once and never re-derived from a later catalog. Every other instance gets instance..* keys. CorePreferences can now check or clear one instance's session without selecting it, and token refresh saves rotated tokens for the site that issued them even if the learner switched away mid-refresh. Mirrors iOS #678. --- .../OauthRefreshTokenAuthenticator.kt | 11 +- .../app/data/storage/PreferencesManager.kt | 150 +++++++++++------- .../main/java/org/openedx/app/di/AppModule.kt | 16 +- .../networking/InstanceAwareNetworkingTest.kt | 14 +- .../core/data/storage/CorePreferences.kt | 12 ++ .../core/instance/InstanceSelectionStorage.kt | 11 +- .../openedx/core/instance/StorageNamespace.kt | 125 +++++++++++++++ .../core/instance/StorageNamespaceTest.kt | 134 ++++++++++++++++ 8 files changed, 405 insertions(+), 68 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/instance/StorageNamespace.kt create mode 100644 core/src/test/java/org/openedx/core/instance/StorageNamespaceTest.kt diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index 4bec459ef..10bab6a75 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -182,20 +182,19 @@ class OauthRefreshTokenAuthenticator( config.getAccessTokenType() ).execute() authResponse = response.body()?.mapToDomain() - // The learner may have switched sites while the refresh was in flight; these tokens - // belong to the site that issued them, not to the one selected now. - if (instance?.key != instanceProvider.currentInstance?.key) return null if (response.isSuccessful && authResponse != null) { val newAccessToken = authResponse.accessToken ?: "" val newRefreshToken = authResponse.refreshToken ?: "" val newExpireTime = authResponse.getTokenExpiryTime() if (newAccessToken.isNotEmpty() && newRefreshToken.isNotEmpty()) { - preferencesManager.accessToken = newAccessToken - preferencesManager.refreshToken = newRefreshToken - preferencesManager.accessTokenExpiresAt = newExpireTime + // Saved for the site that issued them even if the learner switched away while + // the refresh was in flight: the server has already rotated the refresh token, + // so dropping these would sign that site out. + preferencesManager.saveTokens(instance?.key, newAccessToken, newRefreshToken, newExpireTime) lastTokenRefreshRequestTime = TimeUtils.getCurrentTime() } + if (instance?.key != instanceProvider.currentInstance?.key) return null } else if (response.code() == 400) { // another refresh already in progress Thread.sleep(REFRESH_TOKEN_THREAD_SLEEP) diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 48b0d58a1..87545e3f6 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -24,6 +24,8 @@ import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.CalendarType import org.openedx.core.domain.model.VideoQuality import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.system.CalendarManager import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -43,6 +45,7 @@ private val Context.dataStore: DataStore by preferencesDataStore( class PreferencesManager( private val context: Context, private val appNotifier: AppNotifier, + private val namespaces: StorageNamespaceProvider, ) : CorePreferences, ProfilePreferences, @@ -61,33 +64,46 @@ class PreferencesManager( @Volatile private var isKeyInvalidated = false + /** + * Keys tied to a learner on one site: they are namespaced per instance, so each site keeps + * its own session, profile and calendar state. Everything in [Keys] is device-wide. + */ + @Suppress("TooManyFunctions") + private object InstanceKeys { + fun accessToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("access_token")) + fun refreshToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("refresh_token")) + fun pushToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("push_token")) + fun expiresIn(ns: StorageNamespace) = longPreferencesKey(ns.key("expires_in")) + fun user(ns: StorageNamespace) = stringPreferencesKey(ns.key("user")) + fun account(ns: StorageNamespace) = stringPreferencesKey(ns.key("account")) + fun appConfig(ns: StorageNamespace) = stringPreferencesKey(ns.key("app_config")) + fun isRelativeDatesEnabled(ns: StorageNamespace) = + booleanPreferencesKey(ns.key("is_relative_dates_enabled")) + fun calendarId(ns: StorageNamespace) = longPreferencesKey(ns.key("calendar_id")) + fun calendarUser(ns: StorageNamespace) = stringPreferencesKey(ns.key("calendar_user")) + fun calendarType(ns: StorageNamespace) = stringPreferencesKey(ns.key("calendar_type")) + fun isCalendarSyncEnabled(ns: StorageNamespace) = + booleanPreferencesKey(ns.key("is_calendar_sync_enabled")) + fun isHideInactiveCourses(ns: StorageNamespace) = + booleanPreferencesKey(ns.key("hide_inactive_courses")) + fun calendarSyncDialogShown(ns: StorageNamespace, courseName: String) = + booleanPreferencesKey(ns.key("calendar_sync_dialog_${courseName.replaceSpace("_")}")) + } + + private val ns: StorageNamespace + get() = namespaces.current + private object Keys { - val ACCESS_TOKEN = stringPreferencesKey("access_token") - val REFRESH_TOKEN = stringPreferencesKey("refresh_token") - val PUSH_TOKEN = stringPreferencesKey("push_token") - val EXPIRES_IN = longPreferencesKey("expires_in") - val USER = stringPreferencesKey("user") - val ACCOUNT = stringPreferencesKey("account") val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = booleanPreferencesKey("video_settings_wifi_download_only") val VIDEO_SETTINGS_STREAMING_QUALITY = stringPreferencesKey("video_settings_streaming_quality") val VIDEO_SETTINGS_DOWNLOAD_QUALITY = stringPreferencesKey("video_settings_download_quality") - val APP_CONFIG = stringPreferencesKey("app_config") val CAN_RESET_APP_DIRECTORY = booleanPreferencesKey("reset_app_directory") - val IS_RELATIVE_DATES_ENABLED = booleanPreferencesKey("is_relative_dates_enabled") - val CALENDAR_ID = longPreferencesKey("calendar_id") - val CALENDAR_USER = stringPreferencesKey("calendar_user") - val CALENDAR_TYPE = stringPreferencesKey("calendar_type") - val IS_CALENDAR_SYNC_ENABLED = booleanPreferencesKey("is_calendar_sync_enabled") - val IS_HIDE_INACTIVE_COURSES = booleanPreferencesKey("hide_inactive_courses") val LAST_WHATS_NEW_VERSION = stringPreferencesKey("last_whats_new_version") val LAST_REVIEW_VERSION = stringPreferencesKey("last_review_version") val WAS_POSITIVE_RATED = booleanPreferencesKey("app_was_positive_rated") - - fun calendarSyncDialogShown(courseName: String) = - booleanPreferencesKey("calendar_sync_dialog_${courseName.replaceSpace("_")}") } private fun getValue(key: Preferences.Key, defaultValue: T): T = @@ -121,47 +137,73 @@ class PreferencesManager( } override suspend fun clearCorePreferences() { + clearSession(namespaces.current) + } + + override fun hasSession(instanceKey: String?): Boolean { + val namespace = namespaces.namespaceFor(instanceKey) + return getValue(InstanceKeys.accessToken(namespace), "").isNotEmpty() && + getValue(InstanceKeys.user(namespace), "").isNotEmpty() + } + + override suspend fun clearSession(instanceKey: String?) { + clearSession(namespaces.namespaceFor(instanceKey)) + } + + private suspend fun clearSession(namespace: StorageNamespace) { dataStore.edit { prefs -> - prefs.remove(Keys.ACCESS_TOKEN) - prefs.remove(Keys.REFRESH_TOKEN) - prefs.remove(Keys.PUSH_TOKEN) - prefs.remove(Keys.EXPIRES_IN) - prefs.remove(Keys.USER) - prefs.remove(Keys.ACCOUNT) + prefs.remove(InstanceKeys.accessToken(namespace)) + prefs.remove(InstanceKeys.refreshToken(namespace)) + prefs.remove(InstanceKeys.pushToken(namespace)) + prefs.remove(InstanceKeys.expiresIn(namespace)) + prefs.remove(InstanceKeys.user(namespace)) + prefs.remove(InstanceKeys.account(namespace)) } } + override fun refreshToken(instanceKey: String?): String { + return getEncryptedString(InstanceKeys.refreshToken(namespaces.namespaceFor(instanceKey)), "") + } + + override fun saveTokens(instanceKey: String?, accessToken: String, refreshToken: String, expiresAt: Long) { + val namespace = namespaces.namespaceFor(instanceKey) + setEncryptedString(InstanceKeys.accessToken(namespace), accessToken) + setEncryptedString(InstanceKeys.refreshToken(namespace), refreshToken) + setValue(InstanceKeys.expiresIn(namespace), expiresAt) + } + override suspend fun clearCalendarPreferences() { + val namespace = ns dataStore.edit { prefs -> - prefs.remove(Keys.CALENDAR_ID) - prefs.remove(Keys.CALENDAR_TYPE) - prefs.remove(Keys.IS_CALENDAR_SYNC_ENABLED) - prefs.remove(Keys.IS_HIDE_INACTIVE_COURSES) + prefs.remove(InstanceKeys.calendarId(namespace)) + prefs.remove(InstanceKeys.calendarType(namespace)) + prefs.remove(InstanceKeys.isCalendarSyncEnabled(namespace)) + prefs.remove(InstanceKeys.isHideInactiveCourses(namespace)) } } override var accessToken: String - get() = getEncryptedString(Keys.ACCESS_TOKEN, "") - set(value) = setEncryptedString(Keys.ACCESS_TOKEN, value) + get() = getEncryptedString(InstanceKeys.accessToken(ns), "") + set(value) = setEncryptedString(InstanceKeys.accessToken(ns), value) override var refreshToken: String - get() = getEncryptedString(Keys.REFRESH_TOKEN, "") - set(value) = setEncryptedString(Keys.REFRESH_TOKEN, value) + get() = getEncryptedString(InstanceKeys.refreshToken(ns), "") + set(value) = setEncryptedString(InstanceKeys.refreshToken(ns), value) override var pushToken: String - get() = getEncryptedString(Keys.PUSH_TOKEN, "") - set(value) = setEncryptedString(Keys.PUSH_TOKEN, value) + get() = getEncryptedString(InstanceKeys.pushToken(ns), "") + set(value) = setEncryptedString(InstanceKeys.pushToken(ns), value) override var accessTokenExpiresAt: Long - get() = getValue(Keys.EXPIRES_IN, 0L) - set(value) = setValue(Keys.EXPIRES_IN, value) + get() = getValue(InstanceKeys.expiresIn(ns), 0L) + set(value) = setValue(InstanceKeys.expiresIn(ns), value) override var user: User? get() { - val json = getEncryptedString(Keys.USER, "") + val json = getEncryptedString(InstanceKeys.user(ns), "") return if (json.isEmpty()) null else Gson().fromJson(json, User::class.java) } - set(value) = setEncryptedString(Keys.USER, Gson().toJson(value)) + set(value) = setEncryptedString(InstanceKeys.user(ns), Gson().toJson(value)) override var videoSettings: VideoSettings get() { @@ -188,25 +230,25 @@ class PreferencesManager( override var appConfig: AppConfig get() { - val json = getValue(Keys.APP_CONFIG, Gson().toJson(AppConfig())) + val json = getValue(InstanceKeys.appConfig(ns), Gson().toJson(AppConfig())) return Gson().fromJson(json, AppConfig::class.java) } - set(value) = setValue(Keys.APP_CONFIG, Gson().toJson(value)) + set(value) = setValue(InstanceKeys.appConfig(ns), Gson().toJson(value)) override var canResetAppDirectory: Boolean get() = getValue(Keys.CAN_RESET_APP_DIRECTORY, true) set(value) = setValue(Keys.CAN_RESET_APP_DIRECTORY, value) override var isRelativeDatesEnabled: Boolean - get() = getValue(Keys.IS_RELATIVE_DATES_ENABLED, true) - set(value) = setValue(Keys.IS_RELATIVE_DATES_ENABLED, value) + get() = getValue(InstanceKeys.isRelativeDatesEnabled(ns), true) + set(value) = setValue(InstanceKeys.isRelativeDatesEnabled(ns), value) override var profile: Account? get() { - val json = getEncryptedString(Keys.ACCOUNT, "") + val json = getEncryptedString(InstanceKeys.account(ns), "") return if (json.isEmpty()) null else Gson().fromJson(json, Account::class.java) } - set(value) = setEncryptedString(Keys.ACCOUNT, Gson().toJson(value)) + set(value) = setEncryptedString(InstanceKeys.account(ns), Gson().toJson(value)) override var lastWhatsNewVersion: String get() = getValue(Keys.LAST_WHATS_NEW_VERSION, "") @@ -229,33 +271,33 @@ class PreferencesManager( set(value) = setValue(Keys.WAS_POSITIVE_RATED, value) override var calendarId: Long - get() = getValue(Keys.CALENDAR_ID, CalendarManager.CALENDAR_DOES_NOT_EXIST) - set(value) = setValue(Keys.CALENDAR_ID, value) + get() = getValue(InstanceKeys.calendarId(ns), CalendarManager.CALENDAR_DOES_NOT_EXIST) + set(value) = setValue(InstanceKeys.calendarId(ns), value) override var calendarUser: String - get() = getValue(Keys.CALENDAR_USER, "") - set(value) = setValue(Keys.CALENDAR_USER, value) + get() = getValue(InstanceKeys.calendarUser(ns), "") + set(value) = setValue(InstanceKeys.calendarUser(ns), value) override var calendarType: CalendarType get() { - val stored = getValue(Keys.CALENDAR_TYPE, CalendarType.LOCAL.name) + val stored = getValue(InstanceKeys.calendarType(ns), CalendarType.LOCAL.name) return runCatching { CalendarType.valueOf(stored) }.getOrDefault(CalendarType.LOCAL) } - set(value) = setValue(Keys.CALENDAR_TYPE, value.name) + set(value) = setValue(InstanceKeys.calendarType(ns), value.name) override var isCalendarSyncEnabled: Boolean - get() = getValue(Keys.IS_CALENDAR_SYNC_ENABLED, true) - set(value) = setValue(Keys.IS_CALENDAR_SYNC_ENABLED, value) + get() = getValue(InstanceKeys.isCalendarSyncEnabled(ns), true) + set(value) = setValue(InstanceKeys.isCalendarSyncEnabled(ns), value) override var isHideInactiveCourses: Boolean - get() = getValue(Keys.IS_HIDE_INACTIVE_COURSES, true) - set(value) = setValue(Keys.IS_HIDE_INACTIVE_COURSES, value) + get() = getValue(InstanceKeys.isHideInactiveCourses(ns), true) + set(value) = setValue(InstanceKeys.isHideInactiveCourses(ns), value) override fun setCalendarSyncEventsDialogShown(courseName: String) { - setValue(Keys.calendarSyncDialogShown(courseName), true) + setValue(InstanceKeys.calendarSyncDialogShown(ns, courseName), true) } override fun isCalendarSyncEventsDialogShown(courseName: String): Boolean { - return getValue(Keys.calendarSyncDialogShown(courseName), false) + return getValue(InstanceKeys.calendarSyncDialogShown(ns, courseName), false) } } 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 a713834c1..8bd453326 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -40,10 +40,12 @@ 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.InstanceStorageNamespaces 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.instance.StorageNamespaceProvider import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.DownloadHelper @@ -96,7 +98,17 @@ import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { single { Config(get()) } - single { SharedPrefsInstanceSelectionStorage(get()) } + single { SharedPrefsInstanceSelectionStorage(get()) } + single { get() } + single { + val config = get() + InstanceStorageNamespaces( + instanceStore = get(), + ownerStorage = get(), + appApiHostUrl = config.getApiHostURL(), + appOAuthClientId = config.getOAuthClientId(), + ) + } single { val bundledInstances by lazy { get().getInstancesConfig() } InstanceStore(get()) { bundledInstances } @@ -105,7 +117,7 @@ val appModule = module { single { SharedPrefsInstanceCatalogCache(get()) } single { OkHttpInstanceApiService(get().getInstancesCatalogUrl()) } single { InstanceConfigLoader(get(), get(), { get().getInstancesConfig() }) } - single { PreferencesManager(get(), get()) } + single { PreferencesManager(get(), get(), get()) } single { get() } single { get() } single { get() } diff --git a/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt index 67880402e..a0fc2fdde 100644 --- a/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt +++ b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt @@ -15,7 +15,6 @@ import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import okio.Buffer import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Assert.assertTrue @@ -165,6 +164,11 @@ class InstanceAwareNetworkingTest { override var accessToken: String = "old-access" override var refreshToken: String = "old-refresh" override var accessTokenExpiresAt: Long = Long.MAX_VALUE + val savedTokens = mutableMapOf() + + override fun saveTokens(instanceKey: String?, accessToken: String, refreshToken: String, expiresAt: Long) { + savedTokens[instanceKey] = accessToken + } } private fun unauthorized(request: Request) = ok(request, """{"detail": "Token has expired."}""", code = 401) @@ -209,7 +213,7 @@ class InstanceAwareNetworkingTest { val form = Buffer().also { tokenRequest.body!!.writeTo(it) }.readUtf8() assertTrue(form, form.contains("client_id=beta-client")) assertEquals("JWT new-access", retry?.header("Authorization")) - assertEquals("new-access", prefs.accessToken) + assertEquals(mapOf("beta" to "new-access"), prefs.savedTokens) } @Test @@ -238,7 +242,7 @@ class InstanceAwareNetworkingTest { } @Test - fun `tokens refreshed while the learner switched sites are discarded`() { + fun `tokens refreshed while the learner switched sites are kept for the site that issued them`() { val provider = FakeInstanceProvider(alpha) val refreshClient = OkHttpClient.Builder().addInterceptor { chain -> provider.select(beta) @@ -262,8 +266,8 @@ class InstanceAwareNetworkingTest { ) assertNull(retry) - assertEquals("old-access", prefs.accessToken) - assertNotNull(provider.currentInstance) + assertEquals(mapOf("alpha" to "alpha-new"), prefs.savedTokens) + assertEquals("beta", provider.currentInstance?.key) } // endregion diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 9e42a5273..4b13957bb 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -15,5 +15,17 @@ interface CorePreferences { var canResetAppDirectory: Boolean var isRelativeDatesEnabled: Boolean + /** Clears the selected instance's session: tokens, user and profile. */ suspend fun clearCorePreferences() + + /** Whether [instanceKey]'s site has a stored session, without selecting it. */ + fun hasSession(instanceKey: String?): Boolean + + /** Clears one instance's session without touching the selected one. */ + suspend fun clearSession(instanceKey: String?) + + fun refreshToken(instanceKey: String?): String + + /** Stores refreshed tokens for the site that issued them, whichever site is selected now. */ + fun saveTokens(instanceKey: String?, accessToken: String, refreshToken: String, expiresAt: Long) } diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt b/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt index 91f2b77b3..74f99c72c 100644 --- a/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt +++ b/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt @@ -10,7 +10,9 @@ interface InstanceSelectionStorage { var selectedInstanceKey: String? } -class SharedPrefsInstanceSelectionStorage(context: Context) : InstanceSelectionStorage { +class SharedPrefsInstanceSelectionStorage(context: Context) : + InstanceSelectionStorage, + LegacyNamespaceOwnerStorage { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -22,8 +24,15 @@ class SharedPrefsInstanceSelectionStorage(context: Context) : InstanceSelectionS if (value == null) remove(KEY_SELECTED_INSTANCE) else putString(KEY_SELECTED_INSTANCE, value) } + override var legacyOwnerInstanceKey: String? + get() = prefs.getString(KEY_LEGACY_OWNER, null) + set(value) = prefs.edit(commit = true) { + if (value == null) remove(KEY_LEGACY_OWNER) else putString(KEY_LEGACY_OWNER, value) + } + private companion object { const val PREFS_NAME = "openedx_instance_store" const val KEY_SELECTED_INSTANCE = "selected_instance_key" + const val KEY_LEGACY_OWNER = "legacy_storage_owner_instance_key" } } diff --git a/core/src/main/java/org/openedx/core/instance/StorageNamespace.kt b/core/src/main/java/org/openedx/core/instance/StorageNamespace.kt new file mode 100644 index 000000000..f1d824e0b --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/StorageNamespace.kt @@ -0,0 +1,125 @@ +package org.openedx.core.instance + +import java.net.URI +import java.security.MessageDigest +import java.util.Locale + +/** + * Where one instance's local data lives: preference keys, the database file and the + * downloads folder. + * + * The legacy namespace ([instanceKey] `null`) is the storage a single-site build has always + * used, unprefixed. It belongs to "no instance selected" and to the one instance that is the + * same site as the app-level configuration, so upgrading keeps a signed-in learner's session, + * cached courses and downloads in place. + */ +data class StorageNamespace(val instanceKey: String?) { + + val isLegacy: Boolean get() = instanceKey == null + + /** e.g. `access_token` for the legacy namespace, `instance.acme.access_token` otherwise. */ + fun key(rawKey: String): String = if (instanceKey == null) rawKey else "$PREFIX$instanceKey.$rawKey" + + /** + * A file-system-safe, collision-resistant name segment, or `null` for the legacy namespace. + * The readable part is lossy; the hash keeps two keys that sanitize alike apart. + */ + val fileSegment: String? + get() = instanceKey?.let { key -> + val readable = key.replace(UNSAFE_FILE_CHARS, "_").take(MAX_READABLE_LENGTH) + "${readable}_${sha256(key).take(HASH_LENGTH)}" + } + + companion object { + val LEGACY = StorageNamespace(null) + + private const val PREFIX = "instance." + private const val MAX_READABLE_LENGTH = 32 + private const val HASH_LENGTH = 12 + private val UNSAFE_FILE_CHARS = "[^A-Za-z0-9_-]".toRegex() + + private fun sha256(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8)) + return digest.joinToString("") { "%02x".format(it) } + } + } +} + +/** Resolves instances to the namespace their local data lives under. */ +interface StorageNamespaceProvider { + /** The selected instance's namespace; legacy when nothing is selected. */ + val current: StorageNamespace + + fun namespaceFor(instance: Instance?): StorageNamespace + + /** Resolves by key through the catalog; a key no longer in the catalog keeps its own namespace. */ + fun namespaceFor(instanceKey: String?): StorageNamespace +} + +/** Persists which instance owns the legacy namespace once that has been decided. */ +interface LegacyNamespaceOwnerStorage { + var legacyOwnerInstanceKey: String? +} + +/** + * The instance whose API host and OAuth client are the app-level ones is the site a + * single-site build of this app was signing in to, so it inherits the legacy namespace. That + * decision is recorded the first time it is made and never revisited: a later catalog or + * configuration change can't hand existing data to a different site. + */ +class InstanceStorageNamespaces( + private val instanceStore: InstanceStore, + private val ownerStorage: LegacyNamespaceOwnerStorage, + private val appApiHostUrl: String, + private val appOAuthClientId: String, +) : StorageNamespaceProvider { + + private val lock = Any() + + override val current: StorageNamespace + get() = namespaceFor(instanceStore.currentInstance) + + override fun namespaceFor(instance: Instance?): StorageNamespace { + if (instance == null) return StorageNamespace.LEGACY + val owner = synchronized(lock) { + ownerStorage.legacyOwnerInstanceKey ?: instance.key + .takeIf { isAppLevelSite(instance) } + ?.also { ownerStorage.legacyOwnerInstanceKey = it } + } + return if (owner == instance.key) StorageNamespace.LEGACY else StorageNamespace(instance.key) + } + + override fun namespaceFor(instanceKey: String?): StorageNamespace { + val instance = instanceKey?.let { instanceStore.instance(it) } + val owner = synchronized(lock) { ownerStorage.legacyOwnerInstanceKey } + return when { + instanceKey == null -> StorageNamespace.LEGACY + instance != null -> namespaceFor(instance) + owner == instanceKey -> StorageNamespace.LEGACY + else -> StorageNamespace(instanceKey) + } + } + + private fun isAppLevelSite(instance: Instance): Boolean { + return instance.oAuthClientId == appOAuthClientId && + normalizedOrigin(instance.apiHostUrl) != null && + normalizedOrigin(instance.apiHostUrl) == normalizedOrigin(appApiHostUrl) + } + + private fun normalizedOrigin(url: String): String? = runCatching { + val uri = URI(url.trim()) + val scheme = uri.scheme?.lowercase(Locale.ROOT) ?: return null + val host = uri.host?.lowercase(Locale.ROOT) ?: return null + val port = when { + uri.port != -1 -> uri.port + scheme == "https" -> HTTPS_PORT + else -> HTTP_PORT + } + "$scheme://$host:$port${uri.path.orEmpty().trimEnd('/')}" + }.getOrNull() + + private companion object { + const val HTTPS_PORT = 443 + const val HTTP_PORT = 80 + } +} diff --git a/core/src/test/java/org/openedx/core/instance/StorageNamespaceTest.kt b/core/src/test/java/org/openedx/core/instance/StorageNamespaceTest.kt new file mode 100644 index 000000000..9c84b064e --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/StorageNamespaceTest.kt @@ -0,0 +1,134 @@ +package org.openedx.core.instance + +import com.google.gson.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class StorageNamespaceTest { + + private class InMemoryStorage( + override var selectedInstanceKey: String? = null, + override var legacyOwnerInstanceKey: String? = null, + ) : InstanceSelectionStorage, LegacyNamespaceOwnerStorage + + private fun instance(key: String, host: String, client: String = "$key-client") = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", client) + addProperty("API_HOST_URL", host) + } + ) + ) + + private val appSite = instance("app-site", "https://LMS.example.com/", client = "app-client") + private val other = instance("other", "https://other.example.com") + + private fun namespaces(storage: InMemoryStorage, vararg instances: Instance): InstanceStorageNamespaces { + val store = InstanceStore(storage) { InstancesConfig(instances.toList()) } + return InstanceStorageNamespaces(store, storage, "https://lms.example.com:443", "app-client") + } + + @Test + fun `legacy namespace keeps raw keys and has no file segment`() { + assertEquals("access_token", StorageNamespace.LEGACY.key("access_token")) + assertNull(StorageNamespace.LEGACY.fileSegment) + assertTrue(StorageNamespace.LEGACY.isLegacy) + } + + @Test + fun `instance namespace prefixes keys with the instance key`() { + assertEquals("instance.acme.access_token", StorageNamespace("acme").key("access_token")) + } + + @Test + fun `file segments are safe and keep keys that sanitize alike apart`() { + val slash = requireNotNull(StorageNamespace("a/b").fileSegment) + val colon = requireNotNull(StorageNamespace("a:b").fileSegment) + + assertTrue(slash, slash.matches("[A-Za-z0-9_-]+".toRegex())) + assertTrue(slash.startsWith("a_b_")) + assertNotEquals(slash, colon) + assertEquals(slash, StorageNamespace("a/b").fileSegment) + } + + @Test + fun `nothing selected uses the legacy namespace`() { + val namespaces = namespaces(InMemoryStorage(), appSite, other) + + assertEquals(StorageNamespace.LEGACY, namespaces.current) + assertEquals(StorageNamespace.LEGACY, namespaces.namespaceFor(null as String?)) + } + + @Test + fun `the instance matching the app level host and client inherits the legacy namespace`() { + val storage = InMemoryStorage() + val namespaces = namespaces(storage, appSite, other) + + assertEquals(StorageNamespace.LEGACY, namespaces.namespaceFor(appSite)) + assertEquals(StorageNamespace("other"), namespaces.namespaceFor(other)) + assertEquals("app-site", storage.legacyOwnerInstanceKey) + } + + @Test + fun `same host with a different OAuth client is a different site`() { + val sameHost = instance("same-host", "https://lms.example.com", client = "another-client") + val namespaces = namespaces(InMemoryStorage(), sameHost) + + assertEquals(StorageNamespace("same-host"), namespaces.namespaceFor(sameHost)) + } + + @Test + fun `different port or path is a different site`() { + val port = instance("port", "https://lms.example.com:8443", client = "app-client") + val path = instance("path", "https://lms.example.com/tenant", client = "app-client") + val namespaces = namespaces(InMemoryStorage(), port, path) + + assertEquals(StorageNamespace("port"), namespaces.namespaceFor(port)) + assertEquals(StorageNamespace("path"), namespaces.namespaceFor(path)) + } + + @Test + fun `the recorded owner never changes once decided`() { + val storage = InMemoryStorage(legacyOwnerInstanceKey = "other") + val namespaces = namespaces(storage, appSite, other) + + assertEquals(StorageNamespace.LEGACY, namespaces.namespaceFor(other)) + assertEquals(StorageNamespace("app-site"), namespaces.namespaceFor(appSite)) + assertEquals("other", storage.legacyOwnerInstanceKey) + } + + @Test + fun `catalog order does not decide ownership`() { + val first = namespaces(InMemoryStorage(), other, appSite) + val second = namespaces(InMemoryStorage(), appSite, other) + + assertEquals(first.namespaceFor(appSite), second.namespaceFor(appSite)) + assertEquals(first.namespaceFor(other), second.namespaceFor(other)) + } + + @Test + fun `a key no longer in the catalog keeps its own namespace`() { + val storage = InMemoryStorage(legacyOwnerInstanceKey = "app-site") + val namespaces = namespaces(storage, other) + + assertEquals(StorageNamespace("gone"), namespaces.namespaceFor("gone")) + assertEquals(StorageNamespace.LEGACY, namespaces.namespaceFor("app-site")) + } + + @Test + fun `current follows the selection`() { + val storage = InMemoryStorage() + val store = InstanceStore(storage) { InstancesConfig(listOf(appSite, other)) } + val namespaces = InstanceStorageNamespaces(store, storage, "https://lms.example.com", "app-client") + + store.select(other) + assertEquals(StorageNamespace("other"), namespaces.current) + store.select(appSite) + assertEquals(StorageNamespace.LEGACY, namespaces.current) + } +} From 5406ec938044c94005f4ed28bce24ad20c360a54 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:02:14 +0300 Subject: [PATCH 07/17] fix: keep a token refresh on the site that started it The refresh resolved the selected instance twice: once for the tokens and again for the endpoint, so a switch in between could send one site's refresh token to another. The instance is now resolved once per authenticate call and used for the tokens read, the token endpoint and where the result is saved. The minimum interval between refreshes is tracked per site, so refreshing one site no longer holds back another. --- .../OauthRefreshTokenAuthenticator.kt | 54 +++++++++++-------- .../app/data/storage/PreferencesManager.kt | 4 ++ .../networking/InstanceAwareNetworkingTest.kt | 27 ++++++++++ .../core/data/storage/CorePreferences.kt | 2 + 4 files changed, 66 insertions(+), 21 deletions(-) diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index 10bab6a75..5d8d2c120 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -23,6 +23,7 @@ import org.openedx.core.ApiConstants.TOKEN_TYPE_JWT import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance import org.openedx.core.instance.InstanceProvider import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -42,7 +43,9 @@ class OauthRefreshTokenAuthenticator( ) : Authenticator, Interceptor { private val authApis = ConcurrentHashMap() - private var lastTokenRefreshRequestTime = 0L + + /** Per instance key ("" for none): one site's refresh mustn't hold back another's. */ + private val lastTokenRefreshRequestTimes = ConcurrentHashMap() override fun intercept(chain: Interceptor.Chain): Response { if (isTokenExpired() && isForActiveInstance(chain.request())) { @@ -54,9 +57,9 @@ class OauthRefreshTokenAuthenticator( return chain.proceed(chain.request()) } - /** Token endpoint of the selected instance, or of the app-level host when none is selected. */ - private fun authApi(): AuthApi { - val baseUrl = instanceProvider.currentInstance?.apiHostUrl ?: config.getApiHostURL() + /** Token endpoint of [instance], or of the app-level host when there is none. */ + private fun authApi(instance: Instance?): AuthApi { + val baseUrl = instance?.apiHostUrl ?: config.getApiHostURL() return authApis.getOrPut(baseUrl) { Retrofit.Builder() .baseUrl(baseUrl) @@ -71,18 +74,23 @@ class OauthRefreshTokenAuthenticator( * A 401 for a request sent before the learner switched sites says nothing about the site * that is selected now: refreshing it, or logging out, would act on the wrong session. */ - private fun isForActiveInstance(request: Request): Boolean { + private fun isForActiveInstance(request: Request): Boolean = isFor(request, instanceProvider.currentInstance) + + private fun isFor(request: Request, instance: Instance?): Boolean { val sentFor = request.tag(RequestInstance::class.java) ?: return true - return sentFor.instanceKey == instanceProvider.currentInstance?.key + return sentFor.instanceKey == instance?.key } @Suppress("ReturnCount") @Synchronized override fun authenticate(route: Route?, response: Response): Request? { - if (!isForActiveInstance(response.request)) return null + // Resolved once: the tokens read, the endpoint called and the place the result is saved + // all belong to this site, even if the learner switches while the refresh is in flight. + val instance = instanceProvider.currentInstance + if (!isFor(response.request, instance)) return null - val accessToken = preferencesManager.accessToken - val refreshToken = preferencesManager.refreshToken + val accessToken = preferencesManager.accessToken(instance?.key) + val refreshToken = preferencesManager.refreshToken(instance?.key) if (refreshToken.isEmpty()) return null @@ -90,7 +98,7 @@ class OauthRefreshTokenAuthenticator( val retry = when (errorCode) { TOKEN_EXPIRED_ERROR_MESSAGE, JWT_TOKEN_EXPIRED -> { - handleTokenExpired(response, refreshToken, accessToken) + handleTokenExpired(response, refreshToken, accessToken, instance) } TOKEN_NONEXISTENT_ERROR_MESSAGE, TOKEN_INVALID_GRANT_ERROR_MESSAGE, JWT_INVALID_TOKEN -> { @@ -114,9 +122,14 @@ class OauthRefreshTokenAuthenticator( } // Helper function for handling token expiration logic - private fun handleTokenExpired(response: Response, refreshToken: String, accessToken: String): Request? { + private fun handleTokenExpired( + response: Response, + refreshToken: String, + accessToken: String, + instance: Instance?, + ): Request? { return try { - val newAuth = refreshAccessToken(refreshToken) + val newAuth = refreshAccessToken(refreshToken, instance) if (newAuth != null) { response.request.newBuilder() .header( @@ -125,7 +138,7 @@ class OauthRefreshTokenAuthenticator( ) .build() } else { - val actualToken = preferencesManager.accessToken + val actualToken = preferencesManager.accessToken(instance?.key) if (actualToken != accessToken) { response.request.newBuilder() .header( @@ -165,17 +178,16 @@ class OauthRefreshTokenAuthenticator( return time >= preferencesManager.accessTokenExpiresAt } - private fun canRequestTokenRefresh(): Boolean { - return TimeUtils.getCurrentTime() - lastTokenRefreshRequestTime > - REFRESH_TOKEN_INTERVAL_MINIMUM + private fun canRequestTokenRefresh(instance: Instance?): Boolean { + val last = lastTokenRefreshRequestTimes[instance?.key.orEmpty()] ?: 0L + return TimeUtils.getCurrentTime() - last > REFRESH_TOKEN_INTERVAL_MINIMUM } @Throws(IOException::class) - private fun refreshAccessToken(refreshToken: String): AuthResponse? { + private fun refreshAccessToken(refreshToken: String, instance: Instance?): AuthResponse? { var authResponse: AuthResponse? = null - if (canRequestTokenRefresh()) { - val instance = instanceProvider.currentInstance - val response = authApi().refreshAccessToken( + if (canRequestTokenRefresh(instance)) { + val response = authApi(instance).refreshAccessToken( ApiConstants.TOKEN_TYPE_REFRESH, instance?.oAuthClientId ?: config.getOAuthClientId(), refreshToken, @@ -192,7 +204,7 @@ class OauthRefreshTokenAuthenticator( // the refresh was in flight: the server has already rotated the refresh token, // so dropping these would sign that site out. preferencesManager.saveTokens(instance?.key, newAccessToken, newRefreshToken, newExpireTime) - lastTokenRefreshRequestTime = TimeUtils.getCurrentTime() + lastTokenRefreshRequestTimes[instance?.key.orEmpty()] = TimeUtils.getCurrentTime() } if (instance?.key != instanceProvider.currentInstance?.key) return null } else if (response.code() == 400) { diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 87545e3f6..2650776b2 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -161,6 +161,10 @@ class PreferencesManager( } } + override fun accessToken(instanceKey: String?): String { + return getEncryptedString(InstanceKeys.accessToken(namespaces.namespaceFor(instanceKey)), "") + } + override fun refreshToken(instanceKey: String?): String { return getEncryptedString(InstanceKeys.refreshToken(namespaces.namespaceFor(instanceKey)), "") } diff --git a/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt index a0fc2fdde..3833b6f81 100644 --- a/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt +++ b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt @@ -166,6 +166,10 @@ class InstanceAwareNetworkingTest { override var accessTokenExpiresAt: Long = Long.MAX_VALUE val savedTokens = mutableMapOf() + override fun accessToken(instanceKey: String?) = savedTokens[instanceKey] ?: accessToken + + override fun refreshToken(instanceKey: String?) = refreshToken + override fun saveTokens(instanceKey: String?, accessToken: String, refreshToken: String, expiresAt: Long) { savedTokens[instanceKey] = accessToken } @@ -216,6 +220,29 @@ class InstanceAwareNetworkingTest { assertEquals(mapOf("beta" to "new-access"), prefs.savedTokens) } + @Test + fun `a refresh on one site does not hold back the other site's refresh`() { + val provider = FakeInstanceProvider(alpha) + val tokenHosts = mutableListOf() + val refreshClient = OkHttpClient.Builder().addInterceptor { chain -> + tokenHosts += chain.request().url.host + ok(chain.request(), """{"access_token": "new", "refresh_token": "rotated", "expires_in": 3600}""") + }.build() + val authenticator = OauthRefreshTokenAuthenticator( + mockk(relaxed = true) { every { getAccessTokenType() } returns "JWT" }, + Prefs(), + mockk(relaxed = true), + provider, + refreshClient + ) + + authenticator.authenticate(null, unauthorized(taggedRequest("https://alpha.example.com/api/x", "alpha"))) + provider.select(beta) + authenticator.authenticate(null, unauthorized(taggedRequest("http://beta.example.com:8080/api/x", "beta"))) + + assertEquals(listOf("alpha.example.com", "beta.example.com"), tokenHosts) + } + @Test fun `a 401 for a request sent before a switch neither refreshes nor logs out`() { val refreshClient = OkHttpClient.Builder() diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 4b13957bb..6cd94f8a8 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -24,6 +24,8 @@ interface CorePreferences { /** Clears one instance's session without touching the selected one. */ suspend fun clearSession(instanceKey: String?) + fun accessToken(instanceKey: String?): String + fun refreshToken(instanceKey: String?): String /** Stores refreshed tokens for the site that issued them, whichever site is selected now. */ From 0bd08ebe4b7f3ef6c21ca04d902aedf05f75725e Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:17:20 +0300 Subject: [PATCH 08/17] feat: scope the database and downloads storage by instance Each instance gets a Room database file of its own, so cached courses, offline progress, calendar state and download records can't collide between sites, even when both use the same course and block IDs. The legacy namespace keeps the original OpenEdX_db file, so an upgraded single-site install keeps its data with no schema migration and no destructive fallback. The injected DAOs now forward every call to the selected instance's database, resolved once per call; default @Transaction methods are forwarded to the generated DAO, and flows switch databases after a site switch. Downloaded files go to instances/ under the downloads folder; the legacy namespace keeps the existing location. The download worker carries the site that queued it and writes each file's record to that site's database, so a download that finishes after a switch can't land in the other site. Once its own queue is empty it moves on to the selected site's queue. Progress and failure events reach the UI only for the selected site. Mirrors iOS #679. --- .../app/room/InstanceDatabaseProviderTest.kt | 148 ++++++++++++++ .../main/java/org/openedx/app/di/AppModule.kt | 73 +++---- .../app/room/InstanceDatabaseProvider.kt | 58 ++++++ .../openedx/app/room/InstanceScopedDaos.kt | 180 ++++++++++++++++++ core/build.gradle | 1 + .../core/instance/InstanceDatabases.kt | 12 ++ .../org/openedx/core/module/DownloadWorker.kt | 37 +++- .../core/module/DownloadWorkerController.kt | 5 +- .../core/module/download/DownloadHelper.kt | 21 +- .../DownloadHelperInstanceFolderTest.kt | 49 +++++ 10 files changed, 531 insertions(+), 53 deletions(-) create mode 100644 app/src/androidTest/java/org/openedx/app/room/InstanceDatabaseProviderTest.kt create mode 100644 app/src/main/java/org/openedx/app/room/InstanceDatabaseProvider.kt create mode 100644 app/src/main/java/org/openedx/app/room/InstanceScopedDaos.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstanceDatabases.kt create mode 100644 core/src/test/java/org/openedx/core/module/download/DownloadHelperInstanceFolderTest.kt diff --git a/app/src/androidTest/java/org/openedx/app/room/InstanceDatabaseProviderTest.kt b/app/src/androidTest/java/org/openedx/app/room/InstanceDatabaseProviderTest.kt new file mode 100644 index 000000000..c9bc68304 --- /dev/null +++ b/app/src/androidTest/java/org/openedx/app/room/InstanceDatabaseProviderTest.kt @@ -0,0 +1,148 @@ +package org.openedx.app.room + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.gson.JsonObject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.XBlockProgressData +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider +import org.openedx.core.module.db.DownloadModelEntity +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class InstanceDatabaseProviderTest { + + private val context = ApplicationProvider.getApplicationContext() + private val runId = UUID.randomUUID().toString().take(8) + + private fun instance(key: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val alpha = instance("alpha-$runId") + private val beta = instance("beta-$runId") + + private class Selection(initial: Instance?) : InstanceProvider, StorageNamespaceProvider { + val flow = MutableStateFlow(initial) + override val currentInstance: Instance? get() = flow.value + override val currentInstanceFlow: StateFlow = flow + override val hasMultipleInstances = true + override val current: StorageNamespace get() = namespaceFor(flow.value) + override fun namespaceFor(instance: Instance?) = StorageNamespace(instance?.key) + override fun namespaceFor(instanceKey: String?) = StorageNamespace(instanceKey) + } + + private val selection = Selection(alpha) + private val provider = InstanceDatabaseProvider(context, selection, selection) + + @After + fun tearDown() { + listOf(alpha, beta).forEach { + val name = InstanceDatabaseProvider.databaseName(StorageNamespace(it.key)) + provider.database(StorageNamespace(it.key)).close() + context.deleteDatabase(name) + } + } + + private fun download(id: String, title: String) = DownloadModelEntity( + id = id, + title = title, + courseId = "course-v1:edX+DemoX+Demo_Course", + size = 1L, + path = "/tmp/$id", + url = "https://cdn.example.com/$id.mp4", + type = "VIDEO", + downloadedState = "WAITING", + lastModified = null, + ) + + @Test + fun legacyNamespaceKeepsTheOriginalDatabaseFile() { + assertEquals("OpenEdX_db", InstanceDatabaseProvider.databaseName(StorageNamespace.LEGACY)) + val name = InstanceDatabaseProvider.databaseName(StorageNamespace(alpha.key)) + assertTrue(name, name.startsWith("OpenEdX_db_alpha-$runId")) + } + + @Test + fun sameIdsOnTwoSitesNeverCollide() = runBlocking { + val downloads = InstanceScopedDownloadDao(provider) + + downloads.insertDownloadModel(listOf(download("block-1", "Alpha video"))) + downloads.insertOfflineXBlockProgress( + OfflineXBlockProgress("block-1", "course", XBlockProgressData("https://alpha/handler", "POST", "{}")) + ) + selection.flow.value = beta + downloads.insertDownloadModel(listOf(download("block-1", "Beta video"))) + + assertEquals(listOf("Beta video"), downloads.readAllData().map { it.title }) + assertNull(downloads.getOfflineXBlockProgress("block-1")) + + downloads.clearOfflineProgress() + downloads.removeDownloadModel("block-1") + selection.flow.value = alpha + + assertEquals(listOf("Alpha video"), downloads.readAllData().map { it.title }) + assertEquals("block-1", downloads.getOfflineXBlockProgress("block-1")?.blockId) + } + + @Test + fun flowsFollowTheSelectedSite() = runBlocking { + val downloads = InstanceScopedDownloadDao(provider) + downloads.insertDownloadModel(listOf(download("a", "Alpha"))) + selection.flow.value = beta + downloads.insertDownloadModel(listOf(download("b", "Beta"))) + + assertEquals(listOf("Beta"), downloads.getAllDataFlow().first().map { it.title }) + selection.flow.value = alpha + assertEquals(listOf("Alpha"), downloads.getAllDataFlow().first().map { it.title }) + assertEquals(listOf("Alpha"), downloads.readAllDataByIds(listOf("a", "b")).first().map { it.title }) + } + + @Test + fun pinnedAccessReachesASiteThatIsNotSelected() = runBlocking { + val downloads = InstanceScopedDownloadDao(provider) + downloads.insertDownloadModel(listOf(download("a", "Alpha"))) + selection.flow.value = beta + + val alphaRows = provider.downloadDao(StorageNamespace(alpha.key)).readAllData() + + assertEquals(listOf("Alpha"), alphaRows.map { it.title }) + assertTrue(downloads.readAllData().isEmpty()) + } + + @Test + fun transactionalCleanupOnOneSiteLeavesTheOtherIntact() = runBlocking { + val calendar = InstanceScopedCalendarDao(provider) + calendar.insertCourseCalendarStateEntity(CourseCalendarStateEntity("course", 7, true)) + selection.flow.value = beta + calendar.insertCourseCalendarStateEntity(CourseCalendarStateEntity("course", 9, false)) + + calendar.clearCachedData() + assertTrue(calendar.readAllCourseCalendarState().isEmpty()) + + selection.flow.value = alpha + assertEquals(7, calendar.readCourseCalendarStateById("course")?.checksum) + } +} 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 8bd453326..6bdf62233 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -1,7 +1,6 @@ package org.openedx.app.di import androidx.appcompat.app.AppCompatActivity -import androidx.room.Room import androidx.room.RoomDatabase import com.google.android.play.core.review.ReviewManagerFactory import com.google.gson.Gson @@ -18,8 +17,14 @@ import org.openedx.app.PluginManager import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.room.AppDatabase -import org.openedx.app.room.DATABASE_NAME import org.openedx.app.room.DatabaseManager +import org.openedx.app.room.InstanceDatabaseProvider +import org.openedx.app.room.InstanceScopedCalendarDao +import org.openedx.app.room.InstanceScopedCourseDao +import org.openedx.app.room.InstanceScopedDashboardDao +import org.openedx.app.room.InstanceScopedDatesDao +import org.openedx.app.room.InstanceScopedDiscoveryDao +import org.openedx.app.room.InstanceScopedDownloadDao import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -33,11 +38,13 @@ import org.openedx.core.config.Config import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.data.storage.CourseDao 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.InstanceDatabases import org.openedx.core.instance.InstanceProvider import org.openedx.core.instance.InstanceSelectionStorage import org.openedx.core.instance.InstanceStorageNamespaces @@ -48,6 +55,8 @@ import org.openedx.core.instance.SharedPrefsInstanceSelectionStorage import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager +import org.openedx.core.module.db.CalendarDao +import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.download.DownloadHelper import org.openedx.core.module.download.FileDownloader import org.openedx.core.presentation.CoreAnalytics @@ -73,10 +82,13 @@ import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter import org.openedx.course.utils.ImageProcessor import org.openedx.course.worker.OfflineProgressSyncScheduler +import org.openedx.dashboard.data.DashboardDao import org.openedx.dashboard.presentation.DashboardAnalytics import org.openedx.dashboard.presentation.DashboardRouter +import org.openedx.dates.data.storage.DatesDao import org.openedx.dates.presentation.DatesAnalytics import org.openedx.dates.presentation.DatesRouter +import org.openedx.discovery.data.storage.DiscoveryDao import org.openedx.discovery.presentation.DiscoveryAnalytics import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics @@ -170,56 +182,25 @@ val appModule = module { Dispatchers.IO } - single { - Room.databaseBuilder( - androidApplication(), - AppDatabase::class.java, - DATABASE_NAME - ).fallbackToDestructiveMigration() - .fallbackToDestructiveMigrationOnDowngrade() - .build() - } - - single { - get() - } + single { InstanceDatabaseProvider(androidApplication(), get(), get()) } + single { get() } + // Resolved on every injection: the database belongs to whichever instance is selected. + factory { get().current } + factory { get() } - single { - val room = get() - room.discoveryDao() - } - - single { - val room = get() - room.courseDao() - } - - single { - val room = get() - room.dashboardDao() - } - - single { - val room = get() - room.downloadDao() - } - - single { - val room = get() - room.calendarDao() - } - - single { - val room = get() - room.datesDao() - } + single { InstanceScopedDiscoveryDao(get()) } + single { InstanceScopedCourseDao(get()) } + single { InstanceScopedDashboardDao(get()) } + single { InstanceScopedDownloadDao(get()) } + single { InstanceScopedCalendarDao(get()) } + single { InstanceScopedDatesDao(get()) } single { FileDownloader() } single { - DownloadWorkerController(get(), get(), get()) + DownloadWorkerController(get(), get(), get(), get()) } single { @@ -257,7 +238,7 @@ val appModule = module { factory { VideoPreviewHelper(get(), get()) } factory { FileUtil(get(), get().getString(R.string.app_name)) } - single { DownloadHelper(get(), get()) } + single { DownloadHelper(get(), get(), get()) } factory { OfflineProgressSyncScheduler(get()) } diff --git a/app/src/main/java/org/openedx/app/room/InstanceDatabaseProvider.kt b/app/src/main/java/org/openedx/app/room/InstanceDatabaseProvider.kt new file mode 100644 index 000000000..32e4eeee4 --- /dev/null +++ b/app/src/main/java/org/openedx/app/room/InstanceDatabaseProvider.kt @@ -0,0 +1,58 @@ +package org.openedx.app.room + +import android.content.Context +import androidx.room.Room +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.openedx.core.instance.InstanceDatabases +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider +import org.openedx.core.module.db.DownloadDao + +/** + * One Room database file per instance, so cached courses, offline progress and download + * records can't collide between sites, even when two sites use the same course and block IDs. + * + * The legacy namespace keeps the original `OpenEdX_db` file, so an upgraded single-site install + * keeps its data without a migration. Databases open lazily and stay open for the process + * lifetime: a catalog lists a handful of sites, and closing one could break a flow still + * collecting from it. + */ +class InstanceDatabaseProvider( + private val context: Context, + instanceProvider: InstanceProvider, + private val namespaces: StorageNamespaceProvider, +) : InstanceDatabases { + + private val databases = HashMap() + + /** The selected instance's database. Resolve it per operation; don't hold on to it. */ + val current: AppDatabase + get() = database(namespaces.current) + + /** Emits the selected instance's database, again on every switch. */ + val currentFlow: Flow = instanceProvider.currentInstanceFlow + .map { database(namespaces.namespaceFor(it)) } + .distinctUntilChanged() + + fun database(namespace: StorageNamespace): AppDatabase = synchronized(databases) { + databases.getOrPut(namespace) { build(namespace) } + } + + override fun downloadDao(namespace: StorageNamespace): DownloadDao = database(namespace).downloadDao() + + private fun build(namespace: StorageNamespace): AppDatabase { + return Room.databaseBuilder(context, AppDatabase::class.java, databaseName(namespace)) + .fallbackToDestructiveMigration() + .fallbackToDestructiveMigrationOnDowngrade() + .build() + } + + companion object { + fun databaseName(namespace: StorageNamespace): String { + return namespace.fileSegment?.let { "${DATABASE_NAME}_$it" } ?: DATABASE_NAME + } + } +} diff --git a/app/src/main/java/org/openedx/app/room/InstanceScopedDaos.kt b/app/src/main/java/org/openedx/app/room/InstanceScopedDaos.kt new file mode 100644 index 000000000..277edcf24 --- /dev/null +++ b/app/src/main/java/org/openedx/app/room/InstanceScopedDaos.kt @@ -0,0 +1,180 @@ +package org.openedx.app.room + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest +import org.openedx.core.data.model.room.CourseCalendarEventEntity +import org.openedx.core.data.model.room.CourseCalendarStateEntity +import org.openedx.core.data.model.room.CourseDateEntity +import org.openedx.core.data.model.room.CourseEnrollmentDetailsEntity +import org.openedx.core.data.model.room.CourseProgressEntity +import org.openedx.core.data.model.room.CourseStructureEntity +import org.openedx.core.data.model.room.DownloadCoursePreview +import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.VideoProgressEntity +import org.openedx.core.data.model.room.discovery.EnrolledCourseEntity +import org.openedx.core.data.storage.CourseDao +import org.openedx.core.module.db.CalendarDao +import org.openedx.core.module.db.DownloadDao +import org.openedx.core.module.db.DownloadModelEntity +import org.openedx.dashboard.data.DashboardDao +import org.openedx.dates.data.storage.DatesDao +import org.openedx.discovery.data.model.room.CourseEntity +import org.openedx.discovery.data.storage.DiscoveryDao + +/* + * The DAOs the rest of the app injects. Each call goes to the selected instance's database, + * resolved once per call so one call never spans two databases. Default @Transaction methods + * are forwarded explicitly to the generated DAO, which is what runs them in a transaction. + * Flows follow the selection: they switch to the new database after every site switch. + */ + +class InstanceScopedCourseDao(private val databases: InstanceDatabaseProvider) : CourseDao { + private val dao get() = databases.current.courseDao() + + override suspend fun getCourseStructureById(id: String) = dao.getCourseStructureById(id) + + override suspend fun insertCourseStructureEntity(vararg courseStructureEntity: CourseStructureEntity) = + dao.insertCourseStructureEntity(*courseStructureEntity) + + override suspend fun clearCachedData() = dao.clearCachedData() + + override suspend fun clearCourseStructure() = dao.clearCourseStructure() + + override suspend fun clearVideoProgress() = dao.clearVideoProgress() + + override suspend fun clearEnrollmentCachedData() = dao.clearEnrollmentCachedData() + + override suspend fun clearCourseProgressData() = dao.clearCourseProgressData() + + override suspend fun insertCourseEnrollmentDetailsEntity( + vararg courseEnrollmentDetailsEntity: CourseEnrollmentDetailsEntity + ) = dao.insertCourseEnrollmentDetailsEntity(*courseEnrollmentDetailsEntity) + + override suspend fun getCourseEnrollmentDetailsById(id: String) = dao.getCourseEnrollmentDetailsById(id) + + override suspend fun insertVideoProgressEntity(vararg videoProgressEntity: VideoProgressEntity) = + dao.insertVideoProgressEntity(*videoProgressEntity) + + override suspend fun getVideoProgressByBlockId(blockId: String) = dao.getVideoProgressByBlockId(blockId) + + override suspend fun insertCourseProgressEntity(vararg courseProgressEntity: CourseProgressEntity) = + dao.insertCourseProgressEntity(*courseProgressEntity) + + override suspend fun getCourseProgressById(id: String) = dao.getCourseProgressById(id) +} + +@OptIn(ExperimentalCoroutinesApi::class) +class InstanceScopedDownloadDao(private val databases: InstanceDatabaseProvider) : DownloadDao { + private val dao get() = databases.current.downloadDao() + + override suspend fun removeDownloadModel(id: String) = dao.removeDownloadModel(id) + + override suspend fun insertDownloadModel(downloadModelEntities: List) = + dao.insertDownloadModel(downloadModelEntities) + + override suspend fun updateDownloadModel(downloadModelEntity: DownloadModelEntity) = + dao.updateDownloadModel(downloadModelEntity) + + override fun getAllDataFlow(): Flow> = + databases.currentFlow.flatMapLatest { it.downloadDao().getAllDataFlow() } + + override suspend fun readAllData() = dao.readAllData() + + override fun readAllDataByIds(ids: List): Flow> = + databases.currentFlow.flatMapLatest { it.downloadDao().readAllDataByIds(ids) } + + override suspend fun removeAllDownloadModels(ids: List) = dao.removeAllDownloadModels(ids) + + override suspend fun getDownloadModelsByCourseIds(courseId: String) = dao.getDownloadModelsByCourseIds(courseId) + + override suspend fun insertOfflineXBlockProgress(offlineXBlockProgress: OfflineXBlockProgress) = + dao.insertOfflineXBlockProgress(offlineXBlockProgress) + + override suspend fun getOfflineXBlockProgress(id: String) = dao.getOfflineXBlockProgress(id) + + override suspend fun getAllOfflineXBlockProgress() = dao.getAllOfflineXBlockProgress() + + override suspend fun removeOfflineXBlockProgress(ids: List) = dao.removeOfflineXBlockProgress(ids) + + override suspend fun clearOfflineProgress() = dao.clearOfflineProgress() + + override suspend fun insertDownloadCoursePreview(downloadCoursePreview: List) = + dao.insertDownloadCoursePreview(downloadCoursePreview) + + override fun getDownloadCoursesPreview() = dao.getDownloadCoursesPreview() +} + +class InstanceScopedCalendarDao(private val databases: InstanceDatabaseProvider) : CalendarDao { + private val dao get() = databases.current.calendarDao() + + override suspend fun insertCourseCalendarEntity(vararg courseCalendarEntity: CourseCalendarEventEntity) = + dao.insertCourseCalendarEntity(*courseCalendarEntity) + + override suspend fun deleteCourseCalendarEntitiesById(courseId: String) = + dao.deleteCourseCalendarEntitiesById(courseId) + + override suspend fun readCourseCalendarEventsById(courseId: String) = dao.readCourseCalendarEventsById(courseId) + + override suspend fun readAllCourseCalendarEvents() = dao.readAllCourseCalendarEvents() + + override suspend fun clearCourseCalendarEventsCachedData() = dao.clearCourseCalendarEventsCachedData() + + override suspend fun insertCourseCalendarStateEntity(vararg courseCalendarStateEntity: CourseCalendarStateEntity) = + dao.insertCourseCalendarStateEntity(*courseCalendarStateEntity) + + override suspend fun readCourseCalendarStateById(courseId: String) = dao.readCourseCalendarStateById(courseId) + + override suspend fun readAllCourseCalendarState() = dao.readAllCourseCalendarState() + + override suspend fun clearCourseCalendarStateCachedData() = dao.clearCourseCalendarStateCachedData() + + override suspend fun deleteCourseCalendarStateById(courseId: String) = dao.deleteCourseCalendarStateById(courseId) + + override suspend fun resetChecksums() = dao.resetChecksums() + + override suspend fun updateCourseCalendarStateById( + courseId: String, + checksum: Int?, + isCourseSyncEnabled: Boolean?, + ) = dao.updateCourseCalendarStateById(courseId, checksum, isCourseSyncEnabled) + + override suspend fun clearCachedData() = dao.clearCachedData() +} + +class InstanceScopedDiscoveryDao(private val databases: InstanceDatabaseProvider) : DiscoveryDao { + private val dao get() = databases.current.discoveryDao() + + override suspend fun getCourseById(id: String) = dao.getCourseById(id) + + override suspend fun insertCourseEntity(vararg courseEntity: CourseEntity) = dao.insertCourseEntity(*courseEntity) + + override suspend fun updateCourseEntity(courseEntity: CourseEntity) = dao.updateCourseEntity(courseEntity) + + override suspend fun clearCachedData() = dao.clearCachedData() + + override suspend fun readAllData() = dao.readAllData() +} + +class InstanceScopedDashboardDao(private val databases: InstanceDatabaseProvider) : DashboardDao { + private val dao get() = databases.current.dashboardDao() + + override suspend fun insertEnrolledCourseEntity(vararg courseEntity: EnrolledCourseEntity) = + dao.insertEnrolledCourseEntity(*courseEntity) + + override suspend fun clearCachedData() = dao.clearCachedData() + + override suspend fun readAllData() = dao.readAllData() +} + +class InstanceScopedDatesDao(private val databases: InstanceDatabaseProvider) : DatesDao { + private val dao get() = databases.current.datesDao() + + override suspend fun getCourseDates() = dao.getCourseDates() + + override suspend fun getCourseDates(limit: Int) = dao.getCourseDates(limit) + + override suspend fun insertCourseDates(courseDates: List) = dao.insertCourseDates(courseDates) + + override suspend fun clearCachedData() = dao.clearCachedData() +} diff --git a/core/build.gradle b/core/build.gradle index 19be1f57a..d84b68884 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -124,6 +124,7 @@ dependencies { debugApi "androidx.compose.ui:ui-tooling:$compose_ui_tooling" testImplementation "junit:junit:$junit_version" + testImplementation "io.mockk:mockk:$mockk_version" androidTestImplementation "androidx.test.ext:junit:$test_ext_version" androidTestImplementation "androidx.test.espresso:espresso-core:$espresso_version" } diff --git a/core/src/main/java/org/openedx/core/instance/InstanceDatabases.kt b/core/src/main/java/org/openedx/core/instance/InstanceDatabases.kt new file mode 100644 index 000000000..5bb4c1c31 --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceDatabases.kt @@ -0,0 +1,12 @@ +package org.openedx.core.instance + +import org.openedx.core.module.db.DownloadDao + +/** + * Per-instance database access for work that outlives a screen, such as the download worker: + * it handles each queued file in the database of the site that queued it, whichever site is + * selected by the time the file finishes. + */ +interface InstanceDatabases { + fun downloadDao(namespace: StorageNamespace): DownloadDao +} diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt index f91a19c6a..db90a51c0 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorker.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorker.kt @@ -8,13 +8,18 @@ import android.os.Build import androidx.annotation.RequiresApi import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker +import androidx.work.Data import androidx.work.ForegroundInfo import androidx.work.WorkerParameters +import androidx.work.workDataOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.koin.java.KoinJavaComponent.inject import org.openedx.core.R +import org.openedx.core.instance.InstanceDatabases +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity @@ -30,6 +35,7 @@ import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.DownloadProgressChanged import org.openedx.foundation.utils.FileUtil +import java.io.File class DownloadWorker( val context: Context, @@ -41,7 +47,8 @@ class DownloadWorker( private val notificationBuilder = NotificationCompat.Builder(context, CHANNEL_ID) private val notifier by inject(DownloadNotifier::class.java) - private val downloadDao: DownloadDao by inject(DownloadDao::class.java) + private val databases: InstanceDatabases by inject(InstanceDatabases::class.java) + private val namespaces: StorageNamespaceProvider by inject(StorageNamespaceProvider::class.java) private val downloadHelper: DownloadHelper by inject(DownloadHelper::class.java) private val analytics: DownloadsAnalytics by inject(DownloadsAnalytics::class.java) @@ -52,6 +59,13 @@ class DownloadWorker( private val folder = fileUtil.getExternalAppDir() private var currentDownload: DownloadModel? = null + + /** The site whose queue is being worked through, starting with the one that enqueued us. */ + private var namespace = StorageNamespace(inputData.getString(KEY_INSTANCE_KEY)) + private val downloadDao: DownloadDao + get() = databases.downloadDao(namespace) + private val isForSelectedSite: Boolean + get() = namespace == namespaces.current private var lastUpdateTime = 0L private val fileDownloader by inject(FileDownloader::class.java) @@ -99,8 +113,11 @@ class DownloadWorker( lastUpdateTime = System.currentTimeMillis() currentDownload?.let { - launch { - notifier.send(DownloadProgressChanged(it.id, value, size)) + // Another site can use the same block IDs; only its own screens may see this. + if (isForSelectedSite) { + launch { + notifier.send(DownloadProgressChanged(it.id, value, size)) + } } notificationManager.notify( @@ -140,6 +157,7 @@ class DownloadWorker( ) ) logEvent(DownloadsAnalyticsEvent.DOWNLOAD_STARTED) + File(downloadTask.path).parentFile?.mkdirs() val downloadResult = fileDownloader.download(downloadTask.url, downloadTask.path) when (downloadResult) { DownloadResult.SUCCESS -> { @@ -168,10 +186,16 @@ class DownloadWorker( } newDownload() } else { - if (downloadError.isNotEmpty()) { + if (downloadError.isNotEmpty() && isForSelectedSite) { notifier.send(DownloadFailed(downloadError)) } - return + downloadError.clear() + // Files queued on the site the learner switched to while this queue was running + // would otherwise wait for the next change to their queue. + if (!isForSelectedSite) { + namespace = namespaces.current + newDownload() + } } } @@ -193,6 +217,9 @@ class DownloadWorker( companion object { const val WORKER_TAG = "downloadWorker" + private const val KEY_INSTANCE_KEY = "instance_key" + + fun inputData(namespace: StorageNamespace): Data = workDataOf(KEY_INSTANCE_KEY to namespace.instanceKey) private const val CHANNEL_ID = "download_channel_ID" private const val CHANNEL_NAME = "download_channel_name" diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index 39612ae10..d2e0e8167 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -7,6 +7,7 @@ import androidx.work.WorkManager import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.db.DownloadDao import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadModelEntity @@ -17,7 +18,8 @@ import java.io.File class DownloadWorkerController( context: Context, private val downloadDao: DownloadDao, - private val fileDownloader: FileDownloader + private val fileDownloader: FileDownloader, + private val namespaces: StorageNamespaceProvider, ) { private val workManager = WorkManager.getInstance(context) @@ -34,6 +36,7 @@ class DownloadWorkerController( if (!isWorkScheduled(DownloadWorker.WORKER_TAG)) { val request = OneTimeWorkRequestBuilder() .addTag(DownloadWorker.WORKER_TAG) + .setInputData(DownloadWorker.inputData(namespaces.current)) .build() workManager.enqueue(request) } diff --git a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt index 327d4814e..32981df9a 100644 --- a/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt +++ b/core/src/main/java/org/openedx/core/module/download/DownloadHelper.kt @@ -2,6 +2,7 @@ package org.openedx.core.module.download import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.Block +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.db.DownloadModel import org.openedx.core.module.db.DownloadedState import org.openedx.core.module.db.FileType @@ -13,13 +14,27 @@ import java.io.File class DownloadHelper( private val preferencesManager: CorePreferences, private val fileUtil: FileUtil, + private val namespaces: StorageNamespaceProvider, ) { + /** + * Files are named after their URL, and two sites can serve the same URL (a shared CDN, the + * same video). Each instance therefore downloads into a folder of its own; the legacy + * namespace keeps using [baseFolder] itself, where existing downloads already are. + */ + internal fun instanceFolder(baseFolder: String): String { + val segment = namespaces.current.fileSegment ?: return baseFolder + val folder = File(File(baseFolder, INSTANCES_FOLDER), segment) + folder.mkdirs() + return folder.path + } + fun generateDownloadModelFromBlock( - folder: String, + baseFolder: String, block: Block, courseId: String ): DownloadModel? { + val folder = instanceFolder(baseFolder) return when (val downloadableType = block.downloadableType) { FileType.VIDEO -> { val videoInfo = @@ -92,6 +107,10 @@ class DownloadHelper( } } + private companion object { + const val INSTANCES_FOLDER = "instances" + } + private fun calculateDirectorySize(directory: File): Long { if (!directory.exists()) return 0 diff --git a/core/src/test/java/org/openedx/core/module/download/DownloadHelperInstanceFolderTest.kt b/core/src/test/java/org/openedx/core/module/download/DownloadHelperInstanceFolderTest.kt new file mode 100644 index 000000000..b9f9dae09 --- /dev/null +++ b/core/src/test/java/org/openedx/core/module/download/DownloadHelperInstanceFolderTest.kt @@ -0,0 +1,49 @@ +package org.openedx.core.module.download + +import io.mockk.mockk +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openedx.core.instance.Instance +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider +import java.io.File +import java.nio.file.Files + +class DownloadHelperInstanceFolderTest { + + private val base = Files.createTempDirectory("downloads").toFile() + + private class FixedNamespace(override var current: StorageNamespace) : StorageNamespaceProvider { + override fun namespaceFor(instance: Instance?) = StorageNamespace(instance?.key) + override fun namespaceFor(instanceKey: String?) = StorageNamespace(instanceKey) + } + + private val namespace = FixedNamespace(StorageNamespace.LEGACY) + private val helper = DownloadHelper(mockk(), mockk(), namespace) + + @After + fun tearDown() { + base.deleteRecursively() + } + + @Test + fun `legacy namespace keeps downloading into the base folder`() { + assertEquals(base.path, helper.instanceFolder(base.path)) + } + + @Test + fun `each instance downloads into a folder of its own`() { + namespace.current = StorageNamespace("alpha") + val alpha = helper.instanceFolder(base.path) + namespace.current = StorageNamespace("beta") + val beta = helper.instanceFolder(base.path) + + assertNotEquals(alpha, beta) + assertTrue(File(alpha).isDirectory) + assertTrue(File(beta).isDirectory) + assertEquals(File(base, "instances"), File(alpha).parentFile) + } +} From c9a933fb2d5814a1b14a06aafb14e0d3756de0c5 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:04:36 +0300 Subject: [PATCH 09/17] fix: keep offline progress sync on the site it started on The background sync read and cleared offline progress through the selected site's database, one call at a time, so a site switch in the middle could clear the other site's records and submit the rest to the wrong site. It now works in the database of the site it started on and stops before sending anything once another site is selected. --- .../java/org/openedx/app/di/ScreenModule.kt | 2 +- .../data/repository/CourseRepository.kt | 22 ++++-- .../CourseRepositoryOfflineSyncTest.kt | 72 +++++++++++++++++++ 3 files changed, 90 insertions(+), 6 deletions(-) create mode 100644 course/src/test/java/org/openedx/course/data/repository/CourseRepositoryOfflineSyncTest.kt diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 1799dafc6..2ee5a6284 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -269,7 +269,7 @@ val screenModule = module { factory { CalendarRepository(get(), get(), get()) } factory { CalendarInteractor(get()) } - single { CourseRepository(get(), get(), get(), get(), get()) } + single { CourseRepository(get(), get(), get(), get(), get(), get(), get()) } factory { CourseInteractor(get()) } single { get() } diff --git a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt index 229963b58..b2225952a 100644 --- a/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt +++ b/course/src/main/java/org/openedx/course/data/repository/CourseRepository.kt @@ -19,6 +19,8 @@ import org.openedx.core.domain.model.CourseEnrollmentDetails import org.openedx.core.domain.model.CourseProgress import org.openedx.core.domain.model.CourseStructure import org.openedx.core.exception.NoCachedDataException +import org.openedx.core.instance.InstanceDatabases +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.module.db.DownloadDao import org.openedx.core.system.connection.NetworkConnection import java.net.URLDecoder @@ -38,6 +40,8 @@ class CourseRepository( private val downloadDao: DownloadDao, private val preferencesManager: CorePreferences, private val networkConnection: NetworkConnection, + private val namespaces: StorageNamespaceProvider, + private val databases: InstanceDatabases, ) { // Session tracking - when entering a course, mark that data needs refresh private val needsRefresh = ConcurrentHashMap.newKeySet() @@ -276,10 +280,17 @@ class CourseRepository( suspend fun getXBlockProgress(blockId: String) = downloadDao.getOfflineXBlockProgress(blockId) + /** + * Runs in the background and can outlive a site switch, so it stays with the site it started + * on: progress is read from and cleared in that site's database, and it stops before sending + * anything once another site is selected, since requests go to the selected site. + */ suspend fun submitAllOfflineXBlockProgress() { - val allOfflineXBlockProgress = downloadDao.getAllOfflineXBlockProgress() - allOfflineXBlockProgress.forEach { - submitOfflineXBlockProgress(it.blockId, it.courseId, it.jsonProgress.data) + val namespace = namespaces.current + val siteDao = databases.downloadDao(namespace) + for (progress in siteDao.getAllOfflineXBlockProgress()) { + if (namespaces.current != namespace) return + submitOfflineXBlockProgress(progress.blockId, progress.courseId, progress.jsonProgress.data, siteDao) } } @@ -291,7 +302,8 @@ class CourseRepository( private suspend fun submitOfflineXBlockProgress( blockId: String, courseId: String, - jsonProgressData: String? + jsonProgressData: String?, + siteDao: DownloadDao = downloadDao, ) { if (!jsonProgressData.isNullOrEmpty()) { val parts = mutableListOf() @@ -302,7 +314,7 @@ class CourseRepository( parts.add(MultipartBody.Part.createFormData(key, value)) } api.submitOfflineXBlockProgress(courseId, blockId, parts) - downloadDao.removeOfflineXBlockProgress(listOf(blockId)) + siteDao.removeOfflineXBlockProgress(listOf(blockId)) } } diff --git a/course/src/test/java/org/openedx/course/data/repository/CourseRepositoryOfflineSyncTest.kt b/course/src/test/java/org/openedx/course/data/repository/CourseRepositoryOfflineSyncTest.kt new file mode 100644 index 000000000..684f9b813 --- /dev/null +++ b/course/src/test/java/org/openedx/course/data/repository/CourseRepositoryOfflineSyncTest.kt @@ -0,0 +1,72 @@ +package org.openedx.course.data.repository + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.openedx.core.data.api.CourseApi +import org.openedx.core.data.model.room.OfflineXBlockProgress +import org.openedx.core.data.model.room.XBlockProgressData +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceDatabases +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider +import org.openedx.core.module.db.DownloadDao + +class CourseRepositoryOfflineSyncTest { + + private class Selection(override var current: StorageNamespace) : StorageNamespaceProvider { + override fun namespaceFor(instance: Instance?) = StorageNamespace(instance?.key) + override fun namespaceFor(instanceKey: String?) = StorageNamespace(instanceKey) + } + + private val alpha = StorageNamespace("alpha") + private val beta = StorageNamespace("beta") + private val alphaDao = mockk(relaxed = true) + private val betaDao = mockk(relaxed = true) + private val selectedSiteDao = mockk(relaxed = true) + private val api = mockk(relaxed = true) + private val selection = Selection(alpha) + private val databases = object : InstanceDatabases { + override fun downloadDao(namespace: StorageNamespace) = if (namespace == alpha) alphaDao else betaDao + } + + private fun progress(blockId: String) = + OfflineXBlockProgress(blockId, "course", XBlockProgressData("https://alpha/handler", "POST", "a%3D1")) + + private fun repository() = CourseRepository( + api = api, + courseDao = mockk(relaxed = true), + downloadDao = selectedSiteDao, + preferencesManager = mockk(relaxed = true), + networkConnection = mockk(relaxed = true), + namespaces = selection, + databases = databases, + ) + + @Test + fun `a switch during sync stops it and never touches the new site`() = runTest { + coEvery { alphaDao.getAllOfflineXBlockProgress() } returns listOf(progress("block-1"), progress("block-2")) + coEvery { api.submitOfflineXBlockProgress("course", "block-1", any()) } answers { + selection.current = beta + } + + repository().submitAllOfflineXBlockProgress() + + coVerify { alphaDao.removeOfflineXBlockProgress(listOf("block-1")) } + coVerify(exactly = 0) { api.submitOfflineXBlockProgress(any(), "block-2", any()) } + coVerify(exactly = 0) { betaDao.removeOfflineXBlockProgress(any()) } + coVerify(exactly = 0) { selectedSiteDao.removeOfflineXBlockProgress(any()) } + } + + @Test + fun `without a switch every stored block is submitted and cleared`() = runTest { + coEvery { alphaDao.getAllOfflineXBlockProgress() } returns listOf(progress("block-1"), progress("block-2")) + + repository().submitAllOfflineXBlockProgress() + + coVerify { alphaDao.removeOfflineXBlockProgress(listOf("block-1")) } + coVerify { alphaDao.removeOfflineXBlockProgress(listOf("block-2")) } + } +} From ad5815ff1125da160d791a169aee80137ff4c548 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:20:58 +0300 Subject: [PATCH 10/17] feat: add InstanceSessionManager (switch + logout) and route logout through it InstanceSessionManager separates switching sites from signing out: - switchActiveInstance selects a site and applies its theme; nothing is torn down. - logoutCurrentInstance cancels downloads, unregisters push when wired, clears the site's tokens and user, then clears the selection. Cached courses and downloaded files stay, so signing back in to the same site is fast. - logout(instance) does that for the selected site and only clears the stored session of any other site. Both the Settings logout and the forced logout (401, disabled account) now go through logoutCurrentInstance instead of clearing preferences and every database table. Because logout keeps the cache, a different learner signing in to the same site would see the previous learner's cached courses; sign-in records who used the site last and clears the cached course data when that changes. Mirrors iOS #681. --- .../main/java/org/openedx/app/AppViewModel.kt | 13 +- .../app/data/storage/PreferencesManager.kt | 5 + .../main/java/org/openedx/app/di/AppModule.kt | 10 +- .../java/org/openedx/app/di/ScreenModule.kt | 6 +- .../org/openedx/app/room/DatabaseManager.kt | 20 +-- .../test/java/org/openedx/AppViewModelTest.kt | 22 +-- .../auth/data/repository/AuthRepository.kt | 4 + .../core/data/storage/CorePreferences.kt | 3 + .../core/instance/InstanceSessionManager.kt | 99 +++++++++++ .../core/module/DownloadWorkerController.kt | 14 ++ .../instance/InstanceSessionManagerTest.kt | 154 ++++++++++++++++++ .../data/repository/ProfileRepository.kt | 7 +- 12 files changed, 308 insertions(+), 49 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt create mode 100644 core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index bafddb19b..3c61950eb 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -7,13 +7,10 @@ import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope -import androidx.room.RoomDatabase -import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.openedx.app.deeplink.DeepLink import org.openedx.app.deeplink.DeepLinkRouter import org.openedx.app.system.push.RefreshFirebaseTokenWorker @@ -21,6 +18,7 @@ import org.openedx.app.system.push.SyncFirebaseTokenWorker import org.openedx.core.config.Config import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier @@ -36,9 +34,8 @@ import org.openedx.foundation.utils.FileUtil class AppViewModel( private val config: Config, private val appNotifier: AppNotifier, - private val room: RoomDatabase, private val preferencesManager: CorePreferences, - private val dispatcher: CoroutineDispatcher, + private val sessionManager: InstanceSessionManager, private val analytics: AppAnalytics, private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, @@ -123,10 +120,8 @@ class AppViewModel( if (System.currentTimeMillis() - logoutHandledAt > LOGOUT_EVENT_THRESHOLD) { if (event.isForced) { logoutHandledAt = System.currentTimeMillis() - preferencesManager.clearCorePreferences() - withContext(dispatcher) { - room.clearAllTables() - } + // Signs out of the selected site only; other sites keep their sessions. + sessionManager.logoutCurrentInstance() analytics.logoutEvent(true) _logoutUser.value = Unit } diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 2650776b2..a168546f3 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -77,6 +77,7 @@ class PreferencesManager( fun user(ns: StorageNamespace) = stringPreferencesKey(ns.key("user")) fun account(ns: StorageNamespace) = stringPreferencesKey(ns.key("account")) fun appConfig(ns: StorageNamespace) = stringPreferencesKey(ns.key("app_config")) + fun lastSignedInUsername(ns: StorageNamespace) = stringPreferencesKey(ns.key("last_signed_in_username")) fun isRelativeDatesEnabled(ns: StorageNamespace) = booleanPreferencesKey(ns.key("is_relative_dates_enabled")) fun calendarId(ns: StorageNamespace) = longPreferencesKey(ns.key("calendar_id")) @@ -247,6 +248,10 @@ class PreferencesManager( get() = getValue(InstanceKeys.isRelativeDatesEnabled(ns), true) set(value) = setValue(InstanceKeys.isRelativeDatesEnabled(ns), value) + override var lastSignedInUsername: String + get() = getValue(InstanceKeys.lastSignedInUsername(ns), "") + set(value) = setValue(InstanceKeys.lastSignedInUsername(ns), value) + override var profile: Account? get() { val json = getEncryptedString(InstanceKeys.account(ns), "") 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 6bdf62233..dcf4c7e57 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -1,7 +1,6 @@ package org.openedx.app.di import androidx.appcompat.app.AppCompatActivity -import androidx.room.RoomDatabase import com.google.android.play.core.review.ReviewManagerFactory import com.google.gson.Gson import com.google.gson.GsonBuilder @@ -16,7 +15,6 @@ import org.openedx.app.BuildConfig import org.openedx.app.PluginManager import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter -import org.openedx.app.room.AppDatabase import org.openedx.app.room.DatabaseManager import org.openedx.app.room.InstanceDatabaseProvider import org.openedx.app.room.InstanceScopedCalendarDao @@ -41,12 +39,14 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.data.storage.CourseDao import org.openedx.core.data.storage.InAppReviewPreferences import org.openedx.core.domain.helper.VideoPreviewHelper +import org.openedx.core.instance.DefaultInstanceSessionManager import org.openedx.core.instance.InstanceApiService import org.openedx.core.instance.InstanceCatalogCache import org.openedx.core.instance.InstanceConfigLoader import org.openedx.core.instance.InstanceDatabases import org.openedx.core.instance.InstanceProvider import org.openedx.core.instance.InstanceSelectionStorage +import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.instance.InstanceStorageNamespaces import org.openedx.core.instance.InstanceStore import org.openedx.core.instance.OkHttpInstanceApiService @@ -129,6 +129,7 @@ val appModule = module { single { SharedPrefsInstanceCatalogCache(get()) } single { OkHttpInstanceApiService(get().getInstancesCatalogUrl()) } single { InstanceConfigLoader(get(), get(), { get().getInstancesConfig() }) } + single { DefaultInstanceSessionManager(get(), get(), get(), get()) } single { PreferencesManager(get(), get(), get()) } single { get() } single { get() } @@ -142,7 +143,7 @@ val appModule = module { single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get()) } single { DownloadDialogManager(get(), get(), get(), get()) } - single { DatabaseManager(get(), get(), get(), get()) } + single { DatabaseManager(get()) } single { get() } single { ImageProcessor(get()) } @@ -184,9 +185,6 @@ val appModule = module { single { InstanceDatabaseProvider(androidApplication(), get(), get()) } single { get() } - // Resolved on every injection: the database belongs to whichever instance is selected. - factory { get().current } - factory { get() } single { InstanceScopedDiscoveryDao(get()) } single { InstanceScopedCourseDao(get()) } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 2ee5a6284..1fd6b47b8 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -2,7 +2,6 @@ package org.openedx.app.di import android.content.res.Resources import org.koin.core.module.dsl.viewModel -import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AppViewModel import org.openedx.app.MainViewModel @@ -90,9 +89,8 @@ val screenModule = module { AppViewModel( config = get(), appNotifier = get(), - room = get(), preferencesManager = get(), - dispatcher = get(named("IODispatcher")), + sessionManager = get(), analytics = get(), deepLinkRouter = get(), fileUtil = get(), @@ -103,7 +101,7 @@ val screenModule = module { } viewModel { MainViewModel(get(), get(), get(), get(), get()) } - factory { AuthRepository(get(), get(), get()) } + factory { AuthRepository(get(), get(), get(), get()) } factory { AuthInteractor(get()) } factory { Validator() } diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index 0c3087abf..9f1d592cf 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -4,23 +4,19 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.openedx.core.DatabaseManager -import org.openedx.core.data.storage.CourseDao -import org.openedx.core.module.db.DownloadDao -import org.openedx.dashboard.data.DashboardDao -import org.openedx.discovery.data.storage.DiscoveryDao class DatabaseManager( - private val courseDao: CourseDao, - private val dashboardDao: DashboardDao, - private val downloadDao: DownloadDao, - private val discoveryDao: DiscoveryDao + private val databases: InstanceDatabaseProvider, ) : DatabaseManager { override fun clearTables() { + // Resolved now, not when the coroutine runs: a switch in between must not clear the + // other site's cache. + val database = databases.current CoroutineScope(Dispatchers.IO).launch { - courseDao.clearCachedData() - dashboardDao.clearCachedData() - downloadDao.clearOfflineProgress() - discoveryDao.clearCachedData() + database.courseDao().clearCachedData() + database.dashboardDao().clearCachedData() + database.downloadDao().clearOfflineProgress() + database.discoveryDao().clearCachedData() } } } diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index d661a1c17..c9ae6e978 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -27,10 +27,10 @@ import org.openedx.app.AppAnalytics import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter -import org.openedx.app.room.AppDatabase import org.openedx.core.CoreMocks import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig +import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -47,7 +47,7 @@ class AppViewModelTest { private val config = mockk() private val notifier = mockk() - private val room = mockk() + private val sessionManager = mockk() private val preferencesManager = mockk() private val analytics = mockk() private val fileUtil = mockk() @@ -78,9 +78,8 @@ class AppViewModelTest { val viewModel = AppViewModel( config, notifier, - room, preferencesManager, - dispatcher, + sessionManager, analytics, deepLinkRouter, fileUtil, @@ -103,10 +102,9 @@ class AppViewModelTest { every { notifier.notifier } returns flow { emit(LogoutEvent(true)) } - coEvery { preferencesManager.clearCorePreferences() } returns Unit + coEvery { sessionManager.logoutCurrentInstance() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns CoreMocks.mockUser - every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false every { preferencesManager.pushToken } returns "" @@ -115,9 +113,8 @@ class AppViewModelTest { val viewModel = AppViewModel( config, notifier, - room, preferencesManager, - dispatcher, + sessionManager, analytics, deepLinkRouter, fileUtil, @@ -142,10 +139,9 @@ class AppViewModelTest { emit(LogoutEvent(true)) emit(LogoutEvent(true)) } - coEvery { preferencesManager.clearCorePreferences() } returns Unit + coEvery { sessionManager.logoutCurrentInstance() } returns Unit every { analytics.setUserIdForSession(any()) } returns Unit every { preferencesManager.user } returns CoreMocks.mockUser - every { room.clearAllTables() } returns Unit every { analytics.logoutEvent(true) } returns Unit every { preferencesManager.canResetAppDirectory } returns false every { preferencesManager.pushToken } returns "" @@ -154,9 +150,8 @@ class AppViewModelTest { val viewModel = AppViewModel( config, notifier, - room, preferencesManager, - dispatcher, + sessionManager, analytics, deepLinkRouter, fileUtil, @@ -172,10 +167,9 @@ class AppViewModelTest { advanceUntilIdle() verify(exactly = 1) { analytics.logoutEvent(true) } - coVerify(exactly = 1) { preferencesManager.clearCorePreferences() } + coVerify(exactly = 1) { sessionManager.logoutCurrentInstance() } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { preferencesManager.user } - verify(exactly = 1) { room.clearAllTables() } verify(exactly = 1) { analytics.logoutEvent(true) } } } diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt index 31a869703..3808dfe6b 100644 --- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt +++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt @@ -8,12 +8,14 @@ import org.openedx.core.ApiConstants import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.system.EdxError class AuthRepository( private val config: Config, private val api: AuthApi, private val preferencesManager: CorePreferences, + private val sessionManager: InstanceSessionManager, ) { suspend fun login( @@ -39,6 +41,7 @@ class AuthRepository( } val user = api.getProfile() preferencesManager.user = user + user.username?.let(sessionManager::onSignedIn) } suspend fun socialLogin(token: String?, authType: AuthType) { @@ -78,5 +81,6 @@ class AuthRepository( preferencesManager.accessTokenExpiresAt = getTokenExpiryTime() val user = api.getProfile() preferencesManager.user = user + user.username?.let(sessionManager::onSignedIn) } } diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index 6cd94f8a8..c345b1ae9 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -15,6 +15,9 @@ interface CorePreferences { var canResetAppDirectory: Boolean var isRelativeDatesEnabled: Boolean + /** Who signed in to the selected site last. Survives logout, unlike [user]. */ + var lastSignedInUsername: String + /** Clears the selected instance's session: tokens, user and profile. */ suspend fun clearCorePreferences() diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt new file mode 100644 index 000000000..f8b54d85c --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt @@ -0,0 +1,99 @@ +package org.openedx.core.instance + +import org.openedx.core.DatabaseManager +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.ui.theme.InstanceTheme +import org.openedx.core.ui.theme.InstanceThemeManager + +/** Unregisters this device's push token from the selected instance's backend. */ +interface PushTokenUnregistering { + suspend fun unregisterCurrentPushToken() +} + +interface InstanceSessionManager { + /** + * Makes [instance] the active site. Nothing is torn down: the outgoing site keeps its + * session, cached content and downloads for when the learner comes back. + */ + suspend fun switchActiveInstance(instance: Instance) + + /** + * Signs out of the selected site: cancels downloads, unregisters push, clears its tokens + * and user, then clears the selection. Cached courses and downloaded files stay, so signing + * back in to the same site is fast. + */ + suspend fun logoutCurrentInstance() + + /** + * Signs out of [instance]. The selected site goes through [logoutCurrentInstance]; any other + * site only loses its stored session, and the active one is untouched. + */ + suspend fun logout(instance: Instance) + + /** + * Records who signed in to the selected site. When it isn't the learner who used it last, + * that learner's cached course data is cleared first. + */ + fun onSignedIn(username: String) + + /** + * Applies the selected instance's branding, or the default theme when nothing is selected. + * Restoring a saved selection at launch doesn't go through [switchActiveInstance], so launch + * calls this once the catalog is resolved. + */ + fun applyThemeForCurrentInstance() +} + +class DefaultInstanceSessionManager( + private val instanceStore: InstanceStore, + private val preferences: CorePreferences, + private val downloadWorkerController: DownloadWorkerController, + private val databaseManager: DatabaseManager, + private val pushTokenUnregistrar: PushTokenUnregistering? = null, +) : InstanceSessionManager { + + override suspend fun switchActiveInstance(instance: Instance) { + instanceStore.select(instance) + applyThemeForCurrentInstance() + } + + override suspend fun logoutCurrentInstance() { + downloadWorkerController.cancelUnfinishedDownloads() + // Before the tokens go: unregistering is an authenticated call. + pushTokenUnregistrar?.unregisterCurrentPushToken() + preferences.clearCorePreferences() + // Last, so nothing observes a half signed-out site. + instanceStore.select(null) + applyThemeForCurrentInstance() + } + + override suspend fun logout(instance: Instance) { + if (instanceStore.currentInstance?.key == instance.key) { + logoutCurrentInstance() + } else { + preferences.clearSession(instance.key) + } + } + + override fun onSignedIn(username: String) { + val previous = preferences.lastSignedInUsername + if (previous.isNotEmpty() && previous != username) { + databaseManager.clearTables() + } + preferences.lastSignedInUsername = username + } + + override fun applyThemeForCurrentInstance() { + InstanceThemeManager.applyTheme(instanceStore.currentInstance?.toTheme()) + } +} + +fun Instance.toTheme() = InstanceTheme( + key = key, + accentColorHex = color, + light = themeColors?.light.orEmpty(), + dark = themeColors?.dark.orEmpty(), + logoUrl = logoUrl, + headerBackgroundUrl = headerBackgroundUrl, +) diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index d2e0e8167..c6fcc7b5a 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -89,6 +89,20 @@ class DownloadWorkerController( } } + /** + * Cancels every download the selected site hasn't finished: the active one and the queue. + * Finished files stay. + */ + suspend fun cancelUnfinishedDownloads() { + val unfinished = downloadDao.readAllData() + .map { it.mapToDomain() } + .filter { + it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING + } + .map { it.id } + if (unfinished.isEmpty()) removeModels() else removeModels(unfinished) + } + suspend fun removeModels() { fileDownloader.cancelDownloading() workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) diff --git a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt new file mode 100644 index 000000000..ea2595914 --- /dev/null +++ b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt @@ -0,0 +1,154 @@ +package org.openedx.core.instance + +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.openedx.core.DatabaseManager +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.ui.theme.InstanceThemeManager + +class InstanceSessionManagerTest { + + private class InMemorySelection(override var selectedInstanceKey: String? = null) : InstanceSelectionStorage + + private fun instance(key: String, color: String = "#123456") = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("COLOR", color) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val alpha = instance("alpha", "#3C68FF") + private val beta = instance("beta", "#1C355E") + private val store = InstanceStore(InMemorySelection()) { InstancesConfig(listOf(alpha, beta)) } + private val preferences = mockk(relaxed = true) + private val downloads = mockk(relaxed = true) + private val databaseManager = mockk(relaxed = true) + private val push = mockk(relaxed = true) + + private fun manager(pushTokenUnregistrar: PushTokenUnregistering? = push) = + DefaultInstanceSessionManager(store, preferences, downloads, databaseManager, pushTokenUnregistrar) + + @After + fun tearDown() { + InstanceThemeManager.reset() + } + + @Test + fun `switching selects the site and tears nothing down`() = runBlocking { + manager().switchActiveInstance(beta) + + assertEquals(beta, store.currentInstance) + assertEquals("beta", InstanceThemeManager.theme.value?.key) + coVerify(exactly = 0) { preferences.clearCorePreferences() } + coVerify(exactly = 0) { preferences.clearSession(any()) } + coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } + verify(exactly = 0) { databaseManager.clearTables() } + } + + @Test + fun `logout ends the session in order and keeps cached content`() = runBlocking { + store.select(alpha) + + manager().logoutCurrentInstance() + + coVerifyOrder { + downloads.cancelUnfinishedDownloads() + push.unregisterCurrentPushToken() + preferences.clearCorePreferences() + } + assertNull(store.currentInstance) + assertNull(InstanceThemeManager.theme.value) + verify(exactly = 0) { databaseManager.clearTables() } + } + + @Test + fun `logout completes without push unregistration wired`() = runBlocking { + store.select(alpha) + + manager(pushTokenUnregistrar = null).logoutCurrentInstance() + + coVerify { preferences.clearCorePreferences() } + assertNull(store.currentInstance) + } + + @Test + fun `clearing the selection happens after the session is cleared`() = runBlocking { + store.select(alpha) + var selectedWhileClearing: Instance? = null + coEvery { preferences.clearCorePreferences() } answers { selectedWhileClearing = store.currentInstance } + + manager().logoutCurrentInstance() + + assertEquals(alpha, selectedWhileClearing) + } + + @Test + fun `logging out of another site leaves the active one alone`() = runBlocking { + store.select(alpha) + + manager().logout(beta) + + coVerify { preferences.clearSession("beta") } + coVerify(exactly = 0) { preferences.clearCorePreferences() } + coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } + assertEquals(alpha, store.currentInstance) + } + + @Test + fun `logging out of the active site is a full logout`() = runBlocking { + store.select(beta) + + manager().logout(beta) + + coVerify { preferences.clearCorePreferences() } + assertNull(store.currentInstance) + } + + @Test + fun `a different learner signing in clears the previous learner's cache`() { + every { preferences.lastSignedInUsername } returns "first" + + manager().onSignedIn("second") + + verify { databaseManager.clearTables() } + verify { preferences.lastSignedInUsername = "second" } + } + + @Test + fun `the same learner signing back in keeps the cache`() { + every { preferences.lastSignedInUsername } returns "first" + + manager().onSignedIn("first") + every { preferences.lastSignedInUsername } returns "" + manager().onSignedIn("first") + + verify(exactly = 0) { databaseManager.clearTables() } + } + + @Test + fun `theme follows the selected instance and resets without one`() { + store.select(beta) + manager().applyThemeForCurrentInstance() + assertEquals("#1C355E", InstanceThemeManager.theme.value?.accentColorHex) + + store.select(null) + manager().applyThemeForCurrentInstance() + assertNull(InstanceThemeManager.theme.value) + } +} diff --git a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt index 561d73c05..8afbf12b6 100644 --- a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt +++ b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt @@ -3,9 +3,9 @@ package org.openedx.profile.data.repository import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.asRequestBody import org.openedx.core.ApiConstants -import org.openedx.core.DatabaseManager import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceSessionManager import org.openedx.profile.data.api.ProfileApi import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.domain.model.Account @@ -16,7 +16,7 @@ class ProfileRepository( private val api: ProfileApi, private val profilePreferences: ProfilePreferences, private val corePreferences: CorePreferences, - private val databaseManager: DatabaseManager + private val sessionManager: InstanceSessionManager, ) { suspend fun getAccount(): Account { @@ -61,8 +61,7 @@ class ProfileRepository( ApiConstants.TOKEN_TYPE_REFRESH ) } finally { - corePreferences.clearCorePreferences() - databaseManager.clearTables() + sessionManager.logoutCurrentInstance() } } } From b55e24e3f64bff31b503bc84c1478c9fdeea7503 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:06:19 +0300 Subject: [PATCH 11/17] fix: sign-out and switching stay within one site - Signing out of a site with nothing queued no longer cancels the download worker, which may be working through another site's queue; removing downloads cancels the worker only when one of them was the active download. - Switching sites and signing out clear the web view session cookies, so the next web page fetches the selected site's cookies instead of trusting the previous site's freshness. --- app/src/main/java/org/openedx/app/di/AppModule.kt | 2 +- .../org/openedx/core/instance/InstanceSessionManager.kt | 6 ++++++ .../org/openedx/core/module/DownloadWorkerController.kt | 6 ++++-- .../org/openedx/core/instance/InstanceSessionManagerTest.kt | 6 +++++- 4 files changed, 16 insertions(+), 4 deletions(-) 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 dcf4c7e57..a1c65d28b 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -129,7 +129,7 @@ val appModule = module { single { SharedPrefsInstanceCatalogCache(get()) } single { OkHttpInstanceApiService(get().getInstancesCatalogUrl()) } single { InstanceConfigLoader(get(), get(), { get().getInstancesConfig() }) } - single { DefaultInstanceSessionManager(get(), get(), get(), get()) } + single { DefaultInstanceSessionManager(get(), get(), get(), get(), get()) } single { PreferencesManager(get(), get(), get()) } single { get() } single { get() } diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt index f8b54d85c..0003d4a3f 100644 --- a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt +++ b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt @@ -3,6 +3,7 @@ package org.openedx.core.instance import org.openedx.core.DatabaseManager import org.openedx.core.data.storage.CorePreferences import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.system.AppCookieManager import org.openedx.core.ui.theme.InstanceTheme import org.openedx.core.ui.theme.InstanceThemeManager @@ -50,10 +51,14 @@ class DefaultInstanceSessionManager( private val preferences: CorePreferences, private val downloadWorkerController: DownloadWorkerController, private val databaseManager: DatabaseManager, + private val cookieManager: AppCookieManager, private val pushTokenUnregistrar: PushTokenUnregistering? = null, ) : InstanceSessionManager { override suspend fun switchActiveInstance(instance: Instance) { + // Web content signs in with session cookies fetched for one site; the next page has to + // fetch the new site's. + cookieManager.clearWebViewCookie() instanceStore.select(instance) applyThemeForCurrentInstance() } @@ -63,6 +68,7 @@ class DefaultInstanceSessionManager( // Before the tokens go: unregistering is an authenticated call. pushTokenUnregistrar?.unregisterCurrentPushToken() preferences.clearCorePreferences() + cookieManager.clearWebViewCookie() // Last, so nothing observes a half signed-out site. instanceStore.select(null) applyThemeForCurrentInstance() diff --git a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt index c6fcc7b5a..54a734700 100644 --- a/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt +++ b/core/src/main/java/org/openedx/core/module/DownloadWorkerController.kt @@ -84,7 +84,9 @@ class DownloadWorkerController( updateList() - if (downloadTaskList.isEmpty()) { + // Only when the worker was on one of these: it may be busy with another site's queue, + // which this site has no say over. + if (hasDownloading && downloadTaskList.isEmpty()) { workManager.cancelAllWorkByTag(DownloadWorker.WORKER_TAG) } } @@ -100,7 +102,7 @@ class DownloadWorkerController( it.downloadedState == DownloadedState.WAITING || it.downloadedState == DownloadedState.DOWNLOADING } .map { it.id } - if (unfinished.isEmpty()) removeModels() else removeModels(unfinished) + if (unfinished.isNotEmpty()) removeModels(unfinished) } suspend fun removeModels() { diff --git a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt index ea2595914..aa2e99ec7 100644 --- a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt +++ b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt @@ -15,6 +15,7 @@ import org.junit.Test import org.openedx.core.DatabaseManager import org.openedx.core.data.storage.CorePreferences import org.openedx.core.module.DownloadWorkerController +import org.openedx.core.system.AppCookieManager import org.openedx.core.ui.theme.InstanceThemeManager class InstanceSessionManagerTest { @@ -40,9 +41,10 @@ class InstanceSessionManagerTest { private val downloads = mockk(relaxed = true) private val databaseManager = mockk(relaxed = true) private val push = mockk(relaxed = true) + private val cookies = mockk(relaxed = true) private fun manager(pushTokenUnregistrar: PushTokenUnregistering? = push) = - DefaultInstanceSessionManager(store, preferences, downloads, databaseManager, pushTokenUnregistrar) + DefaultInstanceSessionManager(store, preferences, downloads, databaseManager, cookies, pushTokenUnregistrar) @After fun tearDown() { @@ -55,6 +57,7 @@ class InstanceSessionManagerTest { assertEquals(beta, store.currentInstance) assertEquals("beta", InstanceThemeManager.theme.value?.key) + verify { cookies.clearWebViewCookie() } coVerify(exactly = 0) { preferences.clearCorePreferences() } coVerify(exactly = 0) { preferences.clearSession(any()) } coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } @@ -74,6 +77,7 @@ class InstanceSessionManagerTest { } assertNull(store.currentInstance) assertNull(InstanceThemeManager.theme.value) + verify { cookies.clearWebViewCookie() } verify(exactly = 0) { databaseManager.clearTables() } } From 7f2e6076eca8bd1ee840aa7a685d837eaa387a93 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:04:22 +0300 Subject: [PATCH 12/17] fix: sign-in, sign-out and push registration stay with their site These operations wait on the network, and a site switch while they wait used to send the rest of their work, and their results, to the newly selected site. - Requests can be pinned to a site: a RequestInstance tag set by the caller (Retrofit @Tag) makes InstanceHostInterceptor send the request to that site's host and HeadersInterceptor attach that site's token, whichever site is selected by then. A site no longer in the catalog gets no token. - Sign-in pins its token, exchange and profile requests to the site it started on, writes tokens and user to that site's storage, and stops with InstanceSwitchedException, writing nothing, if another site was selected in the meantime. - Sign-out pins the revoke request and then signs out only the site it started on; if the learner switched meanwhile, the new site keeps its selection and session. Settings no longer cancels every download before signing out. - Push: the device token is device-wide again. PushRegistrations registers it with, and unregisters it from, one site at a time with pinned requests; signing out of any site, selected or not, unregisters that site first. Registration work is unique per site, and the token is rotated after sign-out only once no site is still signed in, since rotating would cut off the others. A new token is registered with every signed-in site. --- .../main/java/org/openedx/app/AppViewModel.kt | 11 +- .../openedx/app/data/api/NotificationsApi.kt | 5 +- .../app/data/networking/HeadersInterceptor.kt | 49 +++++--- .../networking/InstanceHostInterceptor.kt | 18 ++- .../OauthRefreshTokenAuthenticator.kt | 1 + .../app/data/storage/PreferencesManager.kt | 25 ++-- .../main/java/org/openedx/app/di/AppModule.kt | 15 ++- .../org/openedx/app/di/NetworkingModule.kt | 8 +- .../java/org/openedx/app/di/ScreenModule.kt | 7 +- .../org/openedx/app/room/DatabaseManager.kt | 11 +- .../push/OpenEdXFirebaseMessagingService.kt | 6 +- .../app/system/push/PushRegistrations.kt | 51 ++++++++ .../system/push/RefreshFirebaseTokenWorker.kt | 12 +- .../system/push/SyncFirebaseTokenWorker.kt | 27 +++-- .../test/java/org/openedx/AppViewModelTest.kt | 10 ++ .../networking/InstanceAwareNetworkingTest.kt | 47 +++++++- .../app/system/push/PushRegistrationsTest.kt | 99 +++++++++++++++ .../java/org/openedx/auth/data/api/AuthApi.kt | 6 +- .../auth/data/repository/AuthRepository.kt | 69 +++++++---- .../repository/AuthRepositoryInstanceTest.kt | 113 ++++++++++++++++++ .../java/org/openedx/core/DatabaseManager.kt | 4 + .../core/data/storage/CorePreferences.kt | 14 ++- .../core/instance/InstanceSessionManager.kt | 42 ++++--- .../instance/InstanceSwitchedException.kt | 8 ++ .../openedx/core/instance/RequestInstance.kt | 12 ++ .../openedx/core/system/AppCookieManager.kt | 8 ++ .../instance/InstanceSessionManagerTest.kt | 38 ++++-- .../openedx/profile/data/api/ProfileApi.kt | 5 +- .../data/repository/ProfileRepository.kt | 20 +++- .../settings/SettingsViewModel.kt | 3 - .../repository/ProfileRepositoryLogoutTest.kt | 81 +++++++++++++ 31 files changed, 696 insertions(+), 129 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/system/push/PushRegistrations.kt create mode 100644 app/src/test/java/org/openedx/app/system/push/PushRegistrationsTest.kt create mode 100644 auth/src/test/java/org/openedx/auth/data/repository/AuthRepositoryInstanceTest.kt create mode 100644 core/src/main/java/org/openedx/core/instance/InstanceSwitchedException.kt create mode 100644 core/src/main/java/org/openedx/core/instance/RequestInstance.kt create mode 100644 profile/src/test/java/org/openedx/profile/data/repository/ProfileRepositoryLogoutTest.kt diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 3c61950eb..042aa8683 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -13,12 +13,14 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import org.openedx.app.deeplink.DeepLink import org.openedx.app.deeplink.DeepLinkRouter +import org.openedx.app.system.push.PushRegistrations import org.openedx.app.system.push.RefreshFirebaseTokenWorker import org.openedx.app.system.push.SyncFirebaseTokenWorker import org.openedx.core.config.Config import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceStore import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier @@ -36,6 +38,8 @@ class AppViewModel( private val appNotifier: AppNotifier, private val preferencesManager: CorePreferences, private val sessionManager: InstanceSessionManager, + private val instanceStore: InstanceStore, + private val pushRegistrations: PushRegistrations, private val analytics: AppAnalytics, private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, @@ -66,8 +70,8 @@ class AppViewModel( setUserId(user) - if (user != null && preferencesManager.pushToken.isNotEmpty()) { - SyncFirebaseTokenWorker.schedule(context) + if (preferencesManager.pushToken.isNotEmpty()) { + SyncFirebaseTokenWorker.scheduleForSignedInSites(context, pushRegistrations) } if (canResetAppDirectory) { @@ -77,7 +81,8 @@ class AppViewModel( viewModelScope.launch { appNotifier.notifier.collect { event -> if (event is SignInEvent && config.getFirebaseConfig().isCloudMessagingEnabled) { - SyncFirebaseTokenWorker.schedule(context) + // Sign-in completes only on the site that is still selected. + SyncFirebaseTokenWorker.schedule(context, instanceStore.currentInstance?.key) } else if (event is LogoutEvent) { handleLogoutEvent(event) } diff --git a/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt index 9106944c3..0c005e076 100644 --- a/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt +++ b/app/src/main/java/org/openedx/app/data/api/NotificationsApi.kt @@ -1,14 +1,17 @@ package org.openedx.app.data.api +import org.openedx.core.instance.RequestInstance import retrofit2.http.Field import retrofit2.http.FormUrlEncoded import retrofit2.http.POST +import retrofit2.http.Tag interface NotificationsApi { @POST("/api/mobile/v4/notifications/create-token/") @FormUrlEncoded suspend fun syncFirebaseToken( @Field("registration_id") token: String, - @Field("active") active: Boolean = true + @Field("active") active: Boolean, + @Tag instance: RequestInstance, ) } diff --git a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt index 6feeb9e25..e2d03191e 100644 --- a/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/HeadersInterceptor.kt @@ -2,10 +2,13 @@ package org.openedx.app.data.networking import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor +import okhttp3.Request import okhttp3.Response import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.RequestInstance import org.openedx.core.presentation.global.AppData class HeadersInterceptor( @@ -13,36 +16,44 @@ class HeadersInterceptor( private val config: Config, private val preferencesManager: CorePreferences, private val instanceProvider: InstanceProvider, + private val instanceByKey: (String) -> Instance? = { null }, ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response = chain.run { + val request = request() proceed( - request() - .newBuilder().apply { - val token = preferencesManager.accessToken + request.newBuilder().apply { + // The token of the site the request belongs to, and only to that site's host. + val site = request.tag(RequestInstance::class.java) + ?: RequestInstance(instanceProvider.currentInstance?.key) + val token = preferencesManager.accessToken(site.instanceKey) + if (token.isNotEmpty() && request.header(AUTHORIZATION) == null && targets(request, site)) { + addHeader(AUTHORIZATION, "${config.getAccessTokenType()} $token") + } - // The token belongs to the selected site; never send it to another host. - if (token.isNotEmpty() && targetsActiveSite(request().url.toString())) { - addHeader("Authorization", "${config.getAccessTokenType()} $token") - } + addHeader("Accept", "application/json") - addHeader("Accept", "application/json") - - val httpAgent = System.getProperty("http.agent") ?: "" - addHeader("User-Agent", "$httpAgent ${appData.appUserAgent}") - }.build() + val httpAgent = System.getProperty("http.agent") ?: "" + addHeader("User-Agent", "$httpAgent ${appData.appUserAgent}") + }.build() ) } - private fun targetsActiveSite(url: String): Boolean { - val siteUrl = instanceProvider.currentInstance?.apiHostUrl ?: config.getApiHostURL() - val site = siteUrl.toHttpUrlOrNull() - val request = url.toHttpUrlOrNull() + private fun targets(request: Request, site: RequestInstance): Boolean { + val siteUrl = when (val key = site.instanceKey) { + null -> config.getApiHostURL() + // A site no longer in the catalog has no known host: send its token nowhere. + else -> instanceByKey(key)?.apiHostUrl ?: return false + } + val siteHost = siteUrl.toHttpUrlOrNull() return when { // An unparseable configured host can't be compared; keep the previous behavior. - site == null -> true - request == null -> false - else -> request.isSameOrigin(site) + siteHost == null -> true + else -> request.url.isSameOrigin(siteHost) } } + + private companion object { + const val AUTHORIZATION = "Authorization" + } } diff --git a/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt index 8d680ba14..76012b687 100644 --- a/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt +++ b/app/src/main/java/org/openedx/app/data/networking/InstanceHostInterceptor.kt @@ -5,14 +5,9 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.Interceptor import okhttp3.Request import okhttp3.Response +import org.openedx.core.instance.Instance import org.openedx.core.instance.InstanceProvider - -/** - * Which instance a request was sent for, attached as an OkHttp tag when the request starts. - * Token refresh reads it, so a response that arrives after a switch is still handled for the - * site that sent it. - */ -data class RequestInstance(val instanceKey: String?) +import org.openedx.core.instance.RequestInstance /** * Re-targets requests built against the app's base URL at the selected instance: scheme, host @@ -22,16 +17,19 @@ data class RequestInstance(val instanceKey: String?) class InstanceHostInterceptor( appBaseUrl: String, private val instanceProvider: InstanceProvider, + private val instanceByKey: (String) -> Instance? = { null }, ) : Interceptor { private val appBase: HttpUrl? = appBaseUrl.toHttpUrlOrNull() override fun intercept(chain: Interceptor.Chain): Response { - val instance = instanceProvider.currentInstance val request = chain.request() - val rewritten = rewriteHostIfNeeded(request, instance?.apiHostUrl) + // A request pinned to a site by its caller goes there; any other goes to the selected one. + val pinned = request.tag(RequestInstance::class.java) + val target = if (pinned != null) pinned.instanceKey?.let(instanceByKey) else instanceProvider.currentInstance + val rewritten = rewriteHostIfNeeded(request, target?.apiHostUrl) .newBuilder() - .tag(RequestInstance::class.java, RequestInstance(instance?.key)) + .tag(RequestInstance::class.java, pinned ?: RequestInstance(target?.key)) .build() return chain.proceed(rewritten) } diff --git a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt index 5d8d2c120..70e524000 100644 --- a/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt +++ b/app/src/main/java/org/openedx/app/data/networking/OauthRefreshTokenAuthenticator.kt @@ -25,6 +25,7 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.instance.Instance import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.RequestInstance import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent import org.openedx.core.utils.TimeUtils diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index a168546f3..d7ba7c4d6 100644 --- a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt +++ b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt @@ -72,7 +72,6 @@ class PreferencesManager( private object InstanceKeys { fun accessToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("access_token")) fun refreshToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("refresh_token")) - fun pushToken(ns: StorageNamespace) = stringPreferencesKey(ns.key("push_token")) fun expiresIn(ns: StorageNamespace) = longPreferencesKey(ns.key("expires_in")) fun user(ns: StorageNamespace) = stringPreferencesKey(ns.key("user")) fun account(ns: StorageNamespace) = stringPreferencesKey(ns.key("account")) @@ -95,6 +94,7 @@ class PreferencesManager( get() = namespaces.current private object Keys { + val PUSH_TOKEN = stringPreferencesKey("push_token") val VIDEO_SETTINGS_WIFI_DOWNLOAD_ONLY = booleanPreferencesKey("video_settings_wifi_download_only") val VIDEO_SETTINGS_STREAMING_QUALITY = @@ -155,7 +155,6 @@ class PreferencesManager( dataStore.edit { prefs -> prefs.remove(InstanceKeys.accessToken(namespace)) prefs.remove(InstanceKeys.refreshToken(namespace)) - prefs.remove(InstanceKeys.pushToken(namespace)) prefs.remove(InstanceKeys.expiresIn(namespace)) prefs.remove(InstanceKeys.user(namespace)) prefs.remove(InstanceKeys.account(namespace)) @@ -177,6 +176,14 @@ class PreferencesManager( setValue(InstanceKeys.expiresIn(namespace), expiresAt) } + override fun saveAccessToken(instanceKey: String?, accessToken: String) { + setEncryptedString(InstanceKeys.accessToken(namespaces.namespaceFor(instanceKey)), accessToken) + } + + override fun saveUser(instanceKey: String?, user: User?) { + setEncryptedString(InstanceKeys.user(namespaces.namespaceFor(instanceKey)), Gson().toJson(user)) + } + override suspend fun clearCalendarPreferences() { val namespace = ns dataStore.edit { prefs -> @@ -196,8 +203,8 @@ class PreferencesManager( set(value) = setEncryptedString(InstanceKeys.refreshToken(ns), value) override var pushToken: String - get() = getEncryptedString(InstanceKeys.pushToken(ns), "") - set(value) = setEncryptedString(InstanceKeys.pushToken(ns), value) + get() = getEncryptedString(Keys.PUSH_TOKEN, "") + set(value) = setEncryptedString(Keys.PUSH_TOKEN, value) override var accessTokenExpiresAt: Long get() = getValue(InstanceKeys.expiresIn(ns), 0L) @@ -248,9 +255,13 @@ class PreferencesManager( get() = getValue(InstanceKeys.isRelativeDatesEnabled(ns), true) set(value) = setValue(InstanceKeys.isRelativeDatesEnabled(ns), value) - override var lastSignedInUsername: String - get() = getValue(InstanceKeys.lastSignedInUsername(ns), "") - set(value) = setValue(InstanceKeys.lastSignedInUsername(ns), value) + override fun lastSignedInUsername(instanceKey: String?): String { + return getValue(InstanceKeys.lastSignedInUsername(namespaces.namespaceFor(instanceKey)), "") + } + + override fun setLastSignedInUsername(instanceKey: String?, username: String) { + setValue(InstanceKeys.lastSignedInUsername(namespaces.namespaceFor(instanceKey)), username) + } override var profile: Account? get() { 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 a1c65d28b..401334918 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -23,6 +23,7 @@ import org.openedx.app.room.InstanceScopedDashboardDao import org.openedx.app.room.InstanceScopedDatesDao import org.openedx.app.room.InstanceScopedDiscoveryDao import org.openedx.app.room.InstanceScopedDownloadDao +import org.openedx.app.system.push.PushRegistrations import org.openedx.auth.presentation.AgreementProvider import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter @@ -129,7 +130,17 @@ val appModule = module { single { SharedPrefsInstanceCatalogCache(get()) } single { OkHttpInstanceApiService(get().getInstancesCatalogUrl()) } single { InstanceConfigLoader(get(), get(), { get().getInstancesConfig() }) } - single { DefaultInstanceSessionManager(get(), get(), get(), get(), get()) } + single { PushRegistrations(get(), get(), get(), get()) } + single { + DefaultInstanceSessionManager( + get(), + get(), + get(), + get(), + get(), + pushTokenUnregistrar = get() + ) + } single { PreferencesManager(get(), get(), get()) } single { get() } single { get() } @@ -143,7 +154,7 @@ val appModule = module { single { ReviewManagerFactory.create(get()) } single { CalendarManager(get(), get()) } single { DownloadDialogManager(get(), get(), get(), get()) } - single { DatabaseManager(get()) } + single { DatabaseManager(get(), get()) } single { get() } single { ImageProcessor(get()) } diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index 412f99a64..b4ea69c02 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -14,6 +14,7 @@ import org.openedx.core.BuildConfig import org.openedx.core.config.Config import org.openedx.core.data.api.CookiesApi import org.openedx.core.data.api.CourseApi +import org.openedx.core.instance.InstanceStore import org.openedx.discovery.data.api.DiscoveryApi import org.openedx.discussion.data.api.DiscussionApi import org.openedx.profile.data.api.ProfileApi @@ -24,14 +25,17 @@ import java.util.concurrent.TimeUnit val networkingModule = module { single { OauthRefreshTokenAuthenticator(get(), get(), get(), get()) } - single { InstanceHostInterceptor(get().getApiHostURL(), get()) } + single { + val store = get() + InstanceHostInterceptor(get().getApiHostURL(), store) { store.instance(it) } + } single { OkHttpClient.Builder().apply { writeTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) addInterceptor(get()) - addInterceptor(HeadersInterceptor(get(), get(), get(), get())) + addInterceptor(HeadersInterceptor(get(), get(), get(), get()) { get().instance(it) }) if (BuildConfig.DEBUG) { addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 1fd6b47b8..129c5b61c 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -91,6 +91,8 @@ val screenModule = module { appNotifier = get(), preferencesManager = get(), sessionManager = get(), + instanceStore = get(), + pushRegistrations = get(), analytics = get(), deepLinkRouter = get(), fileUtil = get(), @@ -101,7 +103,7 @@ val screenModule = module { } viewModel { MainViewModel(get(), get(), get(), get(), get()) } - factory { AuthRepository(get(), get(), get(), get()) } + factory { AuthRepository(get(), get(), get(), get(), get()) } factory { AuthInteractor(get()) } factory { Validator() } @@ -203,7 +205,7 @@ val screenModule = module { ) } - factory { ProfileRepository(get(), get(), get(), get(), get()) } + factory { ProfileRepository(get(), get(), get(), get(), get(), get()) } factory { ProfileInteractor(get()) } viewModel { ProfileViewModel( @@ -248,7 +250,6 @@ val screenModule = module { get(), get(), get(), - get(), ) } viewModel { diff --git a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt index 9f1d592cf..b1be65c97 100644 --- a/app/src/main/java/org/openedx/app/room/DatabaseManager.kt +++ b/app/src/main/java/org/openedx/app/room/DatabaseManager.kt @@ -4,14 +4,23 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.openedx.core.DatabaseManager +import org.openedx.core.instance.StorageNamespaceProvider class DatabaseManager( private val databases: InstanceDatabaseProvider, + private val namespaces: StorageNamespaceProvider, ) : DatabaseManager { override fun clearTables() { // Resolved now, not when the coroutine runs: a switch in between must not clear the // other site's cache. - val database = databases.current + clear(databases.current) + } + + override fun clearTables(instanceKey: String?) { + clear(databases.database(namespaces.namespaceFor(instanceKey))) + } + + private fun clear(database: AppDatabase) { CoroutineScope(Dispatchers.IO).launch { database.courseDao().clearCachedData() database.dashboardDao().clearCachedData() diff --git a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt index 52caf4de7..339cffb02 100644 --- a/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt +++ b/app/src/main/java/org/openedx/app/system/push/OpenEdXFirebaseMessagingService.kt @@ -20,6 +20,7 @@ import org.openedx.core.data.storage.CorePreferences class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { private val preferences: CorePreferences by inject() + private val registrations: PushRegistrations by inject() private val config: Config by inject() override fun onMessageReceived(message: RemoteMessage) { @@ -37,9 +38,8 @@ class OpenEdXFirebaseMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { super.onNewToken(token) preferences.pushToken = token - if (preferences.user != null) { - SyncFirebaseTokenWorker.schedule(this) - } + // The device token changed: every signed-in site needs the new one. + SyncFirebaseTokenWorker.scheduleForSignedInSites(this, registrations) } private fun handlePushNotification(message: RemoteMessage) { diff --git a/app/src/main/java/org/openedx/app/system/push/PushRegistrations.kt b/app/src/main/java/org/openedx/app/system/push/PushRegistrations.kt new file mode 100644 index 000000000..02402c79a --- /dev/null +++ b/app/src/main/java/org/openedx/app/system/push/PushRegistrations.kt @@ -0,0 +1,51 @@ +package org.openedx.app.system.push + +import org.openedx.app.data.api.NotificationsApi +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.PushTokenUnregistering +import org.openedx.core.instance.RequestInstance + +/** + * The device has one push token; each site the learner is signed in to registers it with its + * own backend. Registering and unregistering are pinned to one site, so they reach that site's + * host with that site's session even if the learner switches sites while they run. + */ +class PushRegistrations( + private val api: NotificationsApi, + private val preferences: CorePreferences, + private val config: Config, + private val instanceStore: InstanceStore, +) : PushTokenUnregistering { + + private val isEnabled: Boolean + get() = config.getFirebaseConfig().isCloudMessagingEnabled + + /** The sites with a session: catalog sites, or the single-site session (`null`) with no catalog. */ + fun signedInSites(): List { + val catalog = instanceStore.instancesConfig.instances + return if (catalog.isEmpty()) { + if (preferences.hasSession(null)) listOf(null) else emptyList() + } else { + catalog.map { it.key }.filter { preferences.hasSession(it) } + } + } + + /** Registers this device's token with one site; does nothing for a site without a session. */ + suspend fun register(instanceKey: String?): Boolean { + val token = preferences.pushToken + if (!isEnabled || token.isEmpty() || !preferences.hasSession(instanceKey)) return false + api.syncFirebaseToken(token, active = true, instance = RequestInstance(instanceKey)) + return true + } + + override suspend fun unregisterPushToken(instanceKey: String?) { + val token = preferences.pushToken + if (!isEnabled || token.isEmpty() || !preferences.hasSession(instanceKey)) return + // Best effort: signing out must not fail because the site is unreachable. + runCatching { + api.syncFirebaseToken(token, active = false, instance = RequestInstance(instanceKey)) + } + } +} diff --git a/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt index 0f37f36e3..cbd8fe1ee 100644 --- a/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt +++ b/app/src/main/java/org/openedx/app/system/push/RefreshFirebaseTokenWorker.kt @@ -12,19 +12,27 @@ import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.openedx.core.data.storage.CorePreferences +/** + * Rotates the device's push token after a sign-out, so a backend that missed the unregistration + * can no longer reach this device. The token is device-wide, so this is device-level work with + * one unique name. It rotates only when no site is still signed in: rotating would cut off the + * sites that still use the token. Any site signed in by the time the new token arrives gets it. + */ class RefreshFirebaseTokenWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params), KoinComponent { private val preferences: CorePreferences by inject() + private val registrations: PushRegistrations by inject() override suspend fun doWork(): Result { - FirebaseMessaging.getInstance().deleteToken().await() + if (registrations.signedInSites().isNotEmpty()) return Result.success() + FirebaseMessaging.getInstance().deleteToken().await() val newPushToken = FirebaseMessaging.getInstance().getToken().await() - preferences.pushToken = newPushToken + SyncFirebaseTokenWorker.scheduleForSignedInSites(applicationContext, registrations) return Result.success() } diff --git a/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt index 6c45a3ab4..953314843 100644 --- a/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt +++ b/app/src/main/java/org/openedx/app/system/push/SyncFirebaseTokenWorker.kt @@ -6,40 +6,43 @@ import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager import androidx.work.WorkerParameters +import androidx.work.workDataOf import org.koin.core.component.KoinComponent import org.koin.core.component.inject -import org.openedx.app.data.api.NotificationsApi -import org.openedx.core.data.storage.CorePreferences +/** Registers the device's push token with one site, the one it was scheduled for. */ class SyncFirebaseTokenWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params), KoinComponent { - private val preferences: CorePreferences by inject() - private val api: NotificationsApi by inject() + private val registrations: PushRegistrations by inject() override suspend fun doWork(): Result { - if (preferences.user != null && preferences.pushToken.isNotEmpty()) { - api.syncFirebaseToken(preferences.pushToken) - - return Result.success() - } - return Result.failure() + val instanceKey = inputData.getString(KEY_INSTANCE_KEY) + registrations.register(instanceKey) + return Result.success() } companion object { private const val WORKER_TAG = "SyncFirebaseTokenWorker" + private const val KEY_INSTANCE_KEY = "instance_key" - fun schedule(context: Context) { + /** One unique work per site: a sync for one site never replaces another site's. */ + fun schedule(context: Context, instanceKey: String?) { val work = OneTimeWorkRequest .Builder(SyncFirebaseTokenWorker::class.java) .addTag(WORKER_TAG) + .setInputData(workDataOf(KEY_INSTANCE_KEY to instanceKey)) .build() WorkManager.getInstance(context).beginUniqueWork( - WORKER_TAG, + "$WORKER_TAG:${instanceKey.orEmpty()}", ExistingWorkPolicy.REPLACE, work ).enqueue() } + + fun scheduleForSignedInSites(context: Context, registrations: PushRegistrations) { + registrations.signedInSites().forEach { schedule(context, it) } + } } } diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index c9ae6e978..c2a914b83 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -27,10 +27,12 @@ import org.openedx.app.AppAnalytics import org.openedx.app.AppViewModel import org.openedx.app.data.storage.PreferencesManager import org.openedx.app.deeplink.DeepLinkRouter +import org.openedx.app.system.push.PushRegistrations import org.openedx.core.CoreMocks import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceStore import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -48,6 +50,8 @@ class AppViewModelTest { private val config = mockk() private val notifier = mockk() private val sessionManager = mockk() + private val instanceStore = mockk(relaxed = true) + private val pushRegistrations = mockk(relaxed = true) private val preferencesManager = mockk() private val analytics = mockk() private val fileUtil = mockk() @@ -80,6 +84,8 @@ class AppViewModelTest { notifier, preferencesManager, sessionManager, + instanceStore, + pushRegistrations, analytics, deepLinkRouter, fileUtil, @@ -115,6 +121,8 @@ class AppViewModelTest { notifier, preferencesManager, sessionManager, + instanceStore, + pushRegistrations, analytics, deepLinkRouter, fileUtil, @@ -152,6 +160,8 @@ class AppViewModelTest { notifier, preferencesManager, sessionManager, + instanceStore, + pushRegistrations, analytics, deepLinkRouter, fileUtil, diff --git a/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt index 3833b6f81..46f0ed0c9 100644 --- a/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt +++ b/app/src/test/java/org/openedx/app/data/networking/InstanceAwareNetworkingTest.kt @@ -23,6 +23,7 @@ import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.instance.Instance import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.RequestInstance import org.openedx.core.presentation.global.AppData import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -126,14 +127,18 @@ class InstanceAwareNetworkingTest { // region HeadersInterceptor + private val catalog = listOf(alpha, beta).associateBy { it.key } + private fun headersInterceptor(provider: InstanceProvider): HeadersInterceptor { val config = mockk { every { getAccessTokenType() } returns "JWT" every { getApiHostURL() } returns appBase } - val preferences = mockk { every { accessToken } returns "token-a" } + val preferences = mockk { + every { accessToken(any()) } answers { "token-${firstArg() ?: "app"}" } + } val appData = AppData(appName = "app", versionName = "1", applicationId = "org.openedx.app") - return HeadersInterceptor(appData, config, preferences, provider) + return HeadersInterceptor(appData, config, preferences, provider) { catalog[it] } } @Test @@ -143,7 +148,7 @@ class InstanceAwareNetworkingTest { val toSite = forward(interceptor, Request.Builder().url("https://alpha.example.com/api/x").build()) val elsewhere = forward(interceptor, Request.Builder().url("https://beta.example.com/api/x").build()) - assertEquals("JWT token-a", toSite.header("Authorization")) + assertEquals("JWT token-alpha", toSite.header("Authorization")) assertNull(elsewhere.header("Authorization")) } @@ -153,7 +158,41 @@ class InstanceAwareNetworkingTest { val forwarded = forward(interceptor, Request.Builder().url("https://app.example.com/api/x").build()) - assertEquals("JWT token-a", forwarded.header("Authorization")) + assertEquals("JWT token-app", forwarded.header("Authorization")) + } + + @Test + fun `a request pinned to a site goes there with that site's token whichever site is selected`() { + val provider = FakeInstanceProvider(beta) + val chain = OkHttpClient.Builder() + .addInterceptor(InstanceHostInterceptor(appBase, provider) { catalog[it] }) + .addInterceptor(headersInterceptor(provider)) + var forwarded: Request? = null + chain.addInterceptor { + forwarded = it.request() + ok(it.request(), "{}") + } + val request = Request.Builder() + .url("https://app.example.com/api/mobile/v4/notifications/create-token/") + .tag(RequestInstance::class.java, RequestInstance("alpha")) + .build() + + chain.build().newCall(request).execute().close() + + assertEquals("alpha.example.com", forwarded?.url?.host) + assertEquals("JWT token-alpha", forwarded?.header("Authorization")) + assertEquals(RequestInstance("alpha"), forwarded?.tag(RequestInstance::class.java)) + } + + @Test + fun `a request pinned to a site no longer in the catalog carries no token`() { + val interceptor = headersInterceptor(FakeInstanceProvider(beta)) + val request = Request.Builder() + .url("https://app.example.com/api/x") + .tag(RequestInstance::class.java, RequestInstance("gone")) + .build() + + assertNull(forward(interceptor, request).header("Authorization")) } // endregion diff --git a/app/src/test/java/org/openedx/app/system/push/PushRegistrationsTest.kt b/app/src/test/java/org/openedx/app/system/push/PushRegistrationsTest.kt new file mode 100644 index 000000000..5a04e642a --- /dev/null +++ b/app/src/test/java/org/openedx/app/system/push/PushRegistrationsTest.kt @@ -0,0 +1,99 @@ +package org.openedx.app.system.push + +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openedx.app.data.api.NotificationsApi +import org.openedx.core.config.Config +import org.openedx.core.config.FirebaseConfig +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceSelectionStorage +import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.InstancesConfig +import org.openedx.core.instance.RequestInstance +import java.io.IOException + +class PushRegistrationsTest { + + private fun site(key: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val alpha = site("alpha") + private val beta = site("beta") + private val api = mockk(relaxed = true) + private val preferences = mockk { + every { pushToken } returns "device-token" + every { hasSession(any()) } answers { firstArg() in setOf("alpha", "beta") } + } + private val config = mockk { + every { getFirebaseConfig() } returns FirebaseConfig(isCloudMessagingEnabled = true) + } + + private fun store(vararg sites: Instance) = InstanceStore( + object : InstanceSelectionStorage { + override var selectedInstanceKey: String? = null + } + ) { InstancesConfig(sites.toList()) } + + @Test + fun `unregistering targets the site it is for, not the selected one`() = runBlocking { + val store = store(alpha, beta) + store.select(beta) + + PushRegistrations(api, preferences, config, store).unregisterPushToken("alpha") + + coVerify { api.syncFirebaseToken("device-token", active = false, instance = RequestInstance("alpha")) } + coVerify(exactly = 0) { api.syncFirebaseToken(any(), any(), RequestInstance("beta")) } + } + + @Test + fun `an unreachable site does not fail the sign-out`() = runBlocking { + coEvery { api.syncFirebaseToken(any(), any(), any()) } throws IOException("offline") + + PushRegistrations(api, preferences, config, store(alpha)).unregisterPushToken("alpha") + } + + @Test + fun `registering is pinned to its site and skipped without a session`() = runBlocking { + val registrations = PushRegistrations(api, preferences, config, store(alpha, beta, site("gamma"))) + + registrations.register("beta") + registrations.register("gamma") + + coVerify { api.syncFirebaseToken("device-token", active = true, instance = RequestInstance("beta")) } + coVerify(exactly = 0) { api.syncFirebaseToken(any(), any(), RequestInstance("gamma")) } + } + + @Test + fun `nothing is sent when cloud messaging is off`() = runBlocking { + every { config.getFirebaseConfig() } returns FirebaseConfig(isCloudMessagingEnabled = false) + + PushRegistrations(api, preferences, config, store(alpha)).unregisterPushToken("alpha") + + coVerify(exactly = 0) { api.syncFirebaseToken(any(), any(), any()) } + } + + @Test + fun `signed in sites come from the catalog, or the single-site session without one`() { + assertEquals( + listOf("alpha", "beta"), + PushRegistrations(api, preferences, config, store(alpha, site("gamma"), beta)).signedInSites() + ) + every { preferences.hasSession(null) } returns true + assertEquals(listOf(null), PushRegistrations(api, preferences, config, store()).signedInSites()) + } +} diff --git a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt index 673168c57..a9cc4f20f 100644 --- a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt +++ b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt @@ -6,6 +6,7 @@ import org.openedx.auth.data.model.RegistrationFields import org.openedx.auth.data.model.ValidationFields import org.openedx.core.ApiConstants import org.openedx.core.data.model.User +import org.openedx.core.instance.RequestInstance import retrofit2.Call import retrofit2.http.Field import retrofit2.http.FieldMap @@ -13,6 +14,7 @@ import retrofit2.http.FormUrlEncoded import retrofit2.http.GET import retrofit2.http.POST import retrofit2.http.Path +import retrofit2.http.Tag interface AuthApi { @@ -24,6 +26,7 @@ interface AuthApi { @Field("token_type") tokenType: String, @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, @Path("auth_type") authType: String, + @Tag instance: RequestInstance, ): AuthResponse @FormUrlEncoded @@ -35,6 +38,7 @@ interface AuthApi { @Field("password") password: String, @Field("token_type") tokenType: String, @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, + @Tag instance: RequestInstance, ): AuthResponse @FormUrlEncoded @@ -59,7 +63,7 @@ interface AuthApi { suspend fun validateRegistrationFields(@FieldMap fields: Map): ValidationFields @GET(ApiConstants.GET_USER_PROFILE) - suspend fun getProfile(): User + suspend fun getProfile(@Tag instance: RequestInstance): User @FormUrlEncoded @POST(ApiConstants.URL_PASSWORD_RESET) diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt index 3808dfe6b..1f807aec5 100644 --- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt +++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt @@ -8,52 +8,64 @@ import org.openedx.core.ApiConstants import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.instance.InstanceProvider import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceSwitchedException +import org.openedx.core.instance.RequestInstance import org.openedx.core.system.EdxError +/** + * Sign-in belongs to the site selected when it starts. Every request is pinned to that site, + * every credential is written to that site's storage, and if the learner selects another site + * before it finishes, it stops with [InstanceSwitchedException] instead of completing. + */ class AuthRepository( private val config: Config, private val api: AuthApi, private val preferencesManager: CorePreferences, private val sessionManager: InstanceSessionManager, + private val instanceProvider: InstanceProvider, ) { suspend fun login( username: String, password: String, ) { + val site = currentSite() api.getAccessToken( - ApiConstants.GRANT_TYPE_PASSWORD, - config.getOAuthClientId(), - username, - password, - config.getAccessTokenType(), + grantType = ApiConstants.GRANT_TYPE_PASSWORD, + clientId = config.getOAuthClientId(), + username = username, + password = password, + tokenType = config.getAccessTokenType(), + instance = site, ) .mapToDomain() - .processAuthResponse() + .processAuthResponse(site) } suspend fun ssoLogin( jwtToken: String ) { - if (preferencesManager.accessToken.isBlank()) { - preferencesManager.accessToken = jwtToken + val site = currentSite() + if (preferencesManager.accessToken(site.instanceKey).isBlank()) { + preferencesManager.saveAccessToken(site.instanceKey, jwtToken) } - val user = api.getProfile() - preferencesManager.user = user - user.username?.let(sessionManager::onSignedIn) + saveProfile(site) } suspend fun socialLogin(token: String?, authType: AuthType) { require(!token.isNullOrBlank()) { "Token is null" } + val site = currentSite() api.exchangeAccessToken( accessToken = token, clientId = config.getOAuthClientId(), tokenType = config.getAccessTokenType(), - authType = authType.postfix + authType = authType.postfix, + instance = site, ) .mapToDomain() - .processAuthResponse() + .processAuthResponse(site) } suspend fun getRegistrationFields(): List { @@ -72,15 +84,32 @@ class AuthRepository( return api.passwordReset(email).success } - private suspend fun AuthResponse.processAuthResponse() { + private fun currentSite() = RequestInstance(instanceProvider.currentInstance?.key) + + private fun ensureStillSelected(site: RequestInstance) { + if (instanceProvider.currentInstance?.key != site.instanceKey) { + throw InstanceSwitchedException(site.instanceKey) + } + } + + private suspend fun AuthResponse.processAuthResponse(site: RequestInstance) { if (error != null) { throw EdxError.UnknownException(error!!) } - preferencesManager.accessToken = accessToken ?: "" - preferencesManager.refreshToken = refreshToken ?: "" - preferencesManager.accessTokenExpiresAt = getTokenExpiryTime() - val user = api.getProfile() - preferencesManager.user = user - user.username?.let(sessionManager::onSignedIn) + ensureStillSelected(site) + preferencesManager.saveTokens( + site.instanceKey, + accessToken.orEmpty(), + refreshToken.orEmpty(), + getTokenExpiryTime(), + ) + saveProfile(site) + } + + private suspend fun saveProfile(site: RequestInstance) { + val user = api.getProfile(site) + ensureStillSelected(site) + preferencesManager.saveUser(site.instanceKey, user) + user.username?.let { sessionManager.onSignedIn(site.instanceKey, it) } } } diff --git a/auth/src/test/java/org/openedx/auth/data/repository/AuthRepositoryInstanceTest.kt b/auth/src/test/java/org/openedx/auth/data/repository/AuthRepositoryInstanceTest.kt new file mode 100644 index 000000000..5b1f7c806 --- /dev/null +++ b/auth/src/test/java/org/openedx/auth/data/repository/AuthRepositoryInstanceTest.kt @@ -0,0 +1,113 @@ +package org.openedx.auth.data.repository + +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.fail +import org.junit.Test +import org.openedx.auth.data.api.AuthApi +import org.openedx.auth.data.model.AuthResponse +import org.openedx.core.config.Config +import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceSwitchedException +import org.openedx.core.instance.RequestInstance + +class AuthRepositoryInstanceTest { + + private class Selection(instance: Instance?) : InstanceProvider { + val flow = MutableStateFlow(instance) + override val currentInstance: Instance? get() = flow.value + override val currentInstanceFlow: StateFlow = flow + override val hasMultipleInstances = true + } + + private fun site(key: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val alpha = site("alpha") + private val beta = site("beta") + private val selection = Selection(alpha) + private val api = mockk() + private val preferences = mockk(relaxed = true) + private val sessionManager = mockk(relaxed = true) + private val config = mockk { + every { getOAuthClientId() } returns "alpha-client" + every { getAccessTokenType() } returns "JWT" + } + private val tokens = AuthResponse("access-a", "JWT", 3600, null, null, "refresh-a") + private val learner = User(1, "learner_a", "a@example.com", "Learner A") + + private fun repository() = AuthRepository(config, api, preferences, sessionManager, selection) + + @Test + fun `sign-in pins every request and write to the site it started on`() = runTest { + coEvery { + api.getAccessToken(any(), any(), any(), any(), any(), any(), RequestInstance("alpha")) + } returns tokens + coEvery { api.getProfile(RequestInstance("alpha")) } returns learner + + repository().login("learner_a", "secret") + + verify { preferences.saveTokens("alpha", "access-a", "refresh-a", any()) } + verify { preferences.saveUser("alpha", learner) } + verify { sessionManager.onSignedIn("alpha", "learner_a") } + } + + @Test + fun `a sign-in answer that arrives after another site was selected writes nothing`() = runTest { + coEvery { api.getAccessToken(any(), any(), any(), any(), any(), any(), any()) } answers { + selection.flow.value = beta + tokens + } + + try { + repository().login("learner_a", "secret") + fail("the sign-in must not complete on another site") + } catch (e: InstanceSwitchedException) { + assertEquals("alpha", e.instanceKey) + } + + verify(exactly = 0) { preferences.saveTokens(any(), any(), any(), any()) } + verify(exactly = 0) { preferences.saveUser(any(), any()) } + verify(exactly = 0) { sessionManager.onSignedIn(any(), any()) } + coVerify(exactly = 0) { api.getProfile(any()) } + } + + @Test + fun `a switch while the profile loads leaves the new site without a user`() = runTest { + coEvery { api.getAccessToken(any(), any(), any(), any(), any(), any(), any()) } returns tokens + coEvery { api.getProfile(RequestInstance("alpha")) } answers { + selection.flow.value = beta + learner + } + + try { + repository().login("learner_a", "secret") + fail("the sign-in must not complete on another site") + } catch (_: InstanceSwitchedException) { + // expected + } + + verify(exactly = 0) { preferences.saveUser(any(), any()) } + verify(exactly = 0) { preferences.saveTokens("beta", any(), any(), any()) } + } +} diff --git a/core/src/main/java/org/openedx/core/DatabaseManager.kt b/core/src/main/java/org/openedx/core/DatabaseManager.kt index d7bc7d025..6be731f33 100644 --- a/core/src/main/java/org/openedx/core/DatabaseManager.kt +++ b/core/src/main/java/org/openedx/core/DatabaseManager.kt @@ -1,5 +1,9 @@ package org.openedx.core interface DatabaseManager { + /** Clears the selected site's cached course data. */ fun clearTables() + + /** Clears one site's cached course data, whichever site is selected. */ + fun clearTables(instanceKey: String?) } diff --git a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt index c345b1ae9..f858c0d99 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -7,6 +7,8 @@ import org.openedx.core.domain.model.VideoSettings interface CorePreferences { var accessToken: String var refreshToken: String + + /** This device's push token. Device-wide: each signed-in site registers the same token. */ var pushToken: String var accessTokenExpiresAt: Long var user: User? @@ -15,9 +17,6 @@ interface CorePreferences { var canResetAppDirectory: Boolean var isRelativeDatesEnabled: Boolean - /** Who signed in to the selected site last. Survives logout, unlike [user]. */ - var lastSignedInUsername: String - /** Clears the selected instance's session: tokens, user and profile. */ suspend fun clearCorePreferences() @@ -33,4 +32,13 @@ interface CorePreferences { /** Stores refreshed tokens for the site that issued them, whichever site is selected now. */ fun saveTokens(instanceKey: String?, accessToken: String, refreshToken: String, expiresAt: Long) + + fun saveAccessToken(instanceKey: String?, accessToken: String) + + fun saveUser(instanceKey: String?, user: User?) + + /** Who signed in to [instanceKey]'s site last. Survives logout, unlike [user]. */ + fun lastSignedInUsername(instanceKey: String?): String + + fun setLastSignedInUsername(instanceKey: String?, username: String) } diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt index 0003d4a3f..83745b7f1 100644 --- a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt +++ b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt @@ -7,9 +7,10 @@ import org.openedx.core.system.AppCookieManager import org.openedx.core.ui.theme.InstanceTheme import org.openedx.core.ui.theme.InstanceThemeManager -/** Unregisters this device's push token from the selected instance's backend. */ +/** Stops one site's backend from sending push notifications to this device. */ interface PushTokenUnregistering { - suspend fun unregisterCurrentPushToken() + /** Needs the site's session, so it runs before the session is cleared. */ + suspend fun unregisterPushToken(instanceKey: String?) } interface InstanceSessionManager { @@ -28,15 +29,21 @@ interface InstanceSessionManager { /** * Signs out of [instance]. The selected site goes through [logoutCurrentInstance]; any other - * site only loses its stored session, and the active one is untouched. + * site has push unregistered and loses its stored session, and the active one is untouched. */ suspend fun logout(instance: Instance) /** - * Records who signed in to the selected site. When it isn't the learner who used it last, - * that learner's cached course data is cleared first. + * [logout] by key, for a sign-out that started on one site and must finish there even if + * the learner switched in between. `null` is the no-instance (single-site) session. */ - fun onSignedIn(username: String) + suspend fun logout(instanceKey: String?) + + /** + * Records who signed in to [instanceKey]'s site. When it isn't the learner who used that site + * last, that learner's cached course data is cleared first. + */ + fun onSignedIn(instanceKey: String?, username: String) /** * Applies the selected instance's branding, or the default theme when nothing is selected. @@ -57,8 +64,8 @@ class DefaultInstanceSessionManager( override suspend fun switchActiveInstance(instance: Instance) { // Web content signs in with session cookies fetched for one site; the next page has to - // fetch the new site's. - cookieManager.clearWebViewCookie() + // fetch the new site's (refetching replaces the old ones). + cookieManager.invalidateSessionCookie() instanceStore.select(instance) applyThemeForCurrentInstance() } @@ -66,7 +73,7 @@ class DefaultInstanceSessionManager( override suspend fun logoutCurrentInstance() { downloadWorkerController.cancelUnfinishedDownloads() // Before the tokens go: unregistering is an authenticated call. - pushTokenUnregistrar?.unregisterCurrentPushToken() + pushTokenUnregistrar?.unregisterPushToken(instanceStore.currentInstance?.key) preferences.clearCorePreferences() cookieManager.clearWebViewCookie() // Last, so nothing observes a half signed-out site. @@ -74,20 +81,23 @@ class DefaultInstanceSessionManager( applyThemeForCurrentInstance() } - override suspend fun logout(instance: Instance) { - if (instanceStore.currentInstance?.key == instance.key) { + override suspend fun logout(instance: Instance) = logout(instance.key) + + override suspend fun logout(instanceKey: String?) { + if (instanceStore.currentInstance?.key == instanceKey) { logoutCurrentInstance() } else { - preferences.clearSession(instance.key) + pushTokenUnregistrar?.unregisterPushToken(instanceKey) + preferences.clearSession(instanceKey) } } - override fun onSignedIn(username: String) { - val previous = preferences.lastSignedInUsername + override fun onSignedIn(instanceKey: String?, username: String) { + val previous = preferences.lastSignedInUsername(instanceKey) if (previous.isNotEmpty() && previous != username) { - databaseManager.clearTables() + databaseManager.clearTables(instanceKey) } - preferences.lastSignedInUsername = username + preferences.setLastSignedInUsername(instanceKey, username) } override fun applyThemeForCurrentInstance() { diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSwitchedException.kt b/core/src/main/java/org/openedx/core/instance/InstanceSwitchedException.kt new file mode 100644 index 000000000..5a0f4870f --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/InstanceSwitchedException.kt @@ -0,0 +1,8 @@ +package org.openedx.core.instance + +/** + * The learner selected another site while an operation that belongs to one site was waiting on + * the network. Its results were kept away from the newly selected site. + */ +class InstanceSwitchedException(val instanceKey: String?) : + IllegalStateException("The selected site changed during an operation for $instanceKey") diff --git a/core/src/main/java/org/openedx/core/instance/RequestInstance.kt b/core/src/main/java/org/openedx/core/instance/RequestInstance.kt new file mode 100644 index 000000000..70288e2fd --- /dev/null +++ b/core/src/main/java/org/openedx/core/instance/RequestInstance.kt @@ -0,0 +1,12 @@ +package org.openedx.core.instance + +/** + * Which instance a request belongs to, as an OkHttp tag. + * + * Requests built through the app's API client are tagged with the selected instance when they + * start. A caller that must stay on one site across suspensions (sign-in, sign-out, push + * registration) passes the tag itself through a Retrofit `@Tag` parameter: the request then goes + * to that site's host with that site's token, whichever site is selected by the time it is sent. + * A `null` key means no instance: the app-level host and the legacy session. + */ +data class RequestInstance(val instanceKey: String?) diff --git a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt index 7df19c627..6f24c701a 100644 --- a/core/src/main/java/org/openedx/core/system/AppCookieManager.kt +++ b/core/src/main/java/org/openedx/core/system/AppCookieManager.kt @@ -36,6 +36,14 @@ class AppCookieManager(private val config: Config, private val api: CookiesApi) authSessionCookieExpiration = -1 } + /** + * Forgets that the session cookies are fresh, so the next web page fetches them again, for + * the site selected then. Cheap enough for the main thread: it doesn't touch the WebView. + */ + fun invalidateSessionCookie() { + authSessionCookieExpiration = -1 + } + fun isSessionCookieMissingOrExpired(): Boolean { return authSessionCookieExpiration < System.currentTimeMillis() } diff --git a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt index aa2e99ec7..a1c749b7b 100644 --- a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt +++ b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt @@ -57,7 +57,7 @@ class InstanceSessionManagerTest { assertEquals(beta, store.currentInstance) assertEquals("beta", InstanceThemeManager.theme.value?.key) - verify { cookies.clearWebViewCookie() } + verify { cookies.invalidateSessionCookie() } coVerify(exactly = 0) { preferences.clearCorePreferences() } coVerify(exactly = 0) { preferences.clearSession(any()) } coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } @@ -72,7 +72,7 @@ class InstanceSessionManagerTest { coVerifyOrder { downloads.cancelUnfinishedDownloads() - push.unregisterCurrentPushToken() + push.unregisterPushToken("alpha") preferences.clearCorePreferences() } assertNull(store.currentInstance) @@ -108,6 +108,7 @@ class InstanceSessionManagerTest { manager().logout(beta) + coVerify { push.unregisterPushToken("beta") } coVerify { preferences.clearSession("beta") } coVerify(exactly = 0) { preferences.clearCorePreferences() } coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } @@ -125,24 +126,37 @@ class InstanceSessionManagerTest { } @Test - fun `a different learner signing in clears the previous learner's cache`() { - every { preferences.lastSignedInUsername } returns "first" + fun `a different learner signing in clears the previous learner's cache on that site`() { + every { preferences.lastSignedInUsername("beta") } returns "first" - manager().onSignedIn("second") + manager().onSignedIn("beta", "second") - verify { databaseManager.clearTables() } - verify { preferences.lastSignedInUsername = "second" } + verify { databaseManager.clearTables("beta") } + verify { preferences.setLastSignedInUsername("beta", "second") } } @Test fun `the same learner signing back in keeps the cache`() { - every { preferences.lastSignedInUsername } returns "first" + every { preferences.lastSignedInUsername("beta") } returns "first" + manager().onSignedIn("beta", "first") + every { preferences.lastSignedInUsername("alpha") } returns "" + manager().onSignedIn("alpha", "first") - manager().onSignedIn("first") - every { preferences.lastSignedInUsername } returns "" - manager().onSignedIn("first") + verify(exactly = 0) { databaseManager.clearTables(any()) } + } - verify(exactly = 0) { databaseManager.clearTables() } + @Test + fun `a sign-out that started on another site finishes there only`() = runBlocking { + store.select(beta) + + manager().logout("alpha") + + coVerifyOrder { + push.unregisterPushToken("alpha") + preferences.clearSession("alpha") + } + coVerify(exactly = 0) { preferences.clearCorePreferences() } + assertEquals(beta, store.currentInstance) } @Test diff --git a/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt b/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt index 1f206ed77..87386decf 100644 --- a/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt +++ b/profile/src/main/java/org/openedx/profile/data/api/ProfileApi.kt @@ -3,6 +3,7 @@ package org.openedx.profile.data.api import okhttp3.RequestBody import okhttp3.ResponseBody import org.openedx.core.ApiConstants +import org.openedx.core.instance.RequestInstance import org.openedx.profile.data.model.Account import retrofit2.Response import retrofit2.http.Body @@ -16,6 +17,7 @@ import retrofit2.http.PATCH import retrofit2.http.POST import retrofit2.http.Path import retrofit2.http.Query +import retrofit2.http.Tag interface ProfileApi { @@ -24,7 +26,8 @@ interface ProfileApi { suspend fun revokeAccessToken( @Field("client_id") clientId: String?, @Field("token") token: String?, - @Field("token_type_hint") tokenTypeHint: String? + @Field("token_type_hint") tokenTypeHint: String?, + @Tag instance: RequestInstance, ): ResponseBody @GET("/api/user/v1/accounts/{username}") diff --git a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt index 8afbf12b6..668052ef5 100644 --- a/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt +++ b/profile/src/main/java/org/openedx/profile/data/repository/ProfileRepository.kt @@ -5,7 +5,9 @@ import okhttp3.RequestBody.Companion.asRequestBody import org.openedx.core.ApiConstants import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceProvider import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.RequestInstance import org.openedx.profile.data.api.ProfileApi import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.domain.model.Account @@ -17,6 +19,7 @@ class ProfileRepository( private val profilePreferences: ProfilePreferences, private val corePreferences: CorePreferences, private val sessionManager: InstanceSessionManager, + private val instanceProvider: InstanceProvider, ) { suspend fun getAccount(): Account { @@ -53,15 +56,24 @@ class ProfileRepository( suspend fun deactivateAccount(password: String) = api.deactivateAccount(password) + /** + * Signs out of the site selected when it starts. The revoke request is pinned to that site, + * and if the learner selects another site while it is in flight, only the original site is + * signed out: the new selection and its session stay. + */ suspend fun logout() { + val siteKey = instanceProvider.currentInstance?.key + val clientId = config.getOAuthClientId() + val refreshToken = corePreferences.refreshToken(siteKey) try { api.revokeAccessToken( - config.getOAuthClientId(), - corePreferences.refreshToken, - ApiConstants.TOKEN_TYPE_REFRESH + clientId, + refreshToken, + ApiConstants.TOKEN_TYPE_REFRESH, + RequestInstance(siteKey), ) } finally { - sessionManager.logoutCurrentInstance() + sessionManager.logout(siteKey) } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 4c418f705..6b453b7dd 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -17,7 +17,6 @@ import org.openedx.core.AppUpdateState import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.config.Config -import org.openedx.core.module.DownloadWorkerController import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.notifier.app.AppNotifier @@ -40,7 +39,6 @@ class SettingsViewModel( private val resourceManager: ResourceManager, private val interactor: ProfileInteractor, private val cookieManager: AppCookieManager, - private val workerController: DownloadWorkerController, private val analytics: ProfileAnalytics, private val profileRouter: ProfileRouter, private val calendarRouter: CalendarRouter, @@ -73,7 +71,6 @@ class SettingsViewModel( logProfileEvent(ProfileAnalyticsEvent.LOGOUT_CLICKED) viewModelScope.launch { try { - workerController.removeModels() withContext(Dispatchers.IO) { interactor.logout() } diff --git a/profile/src/test/java/org/openedx/profile/data/repository/ProfileRepositoryLogoutTest.kt b/profile/src/test/java/org/openedx/profile/data/repository/ProfileRepositoryLogoutTest.kt new file mode 100644 index 000000000..5be927ada --- /dev/null +++ b/profile/src/test/java/org/openedx/profile/data/repository/ProfileRepositoryLogoutTest.kt @@ -0,0 +1,81 @@ +package org.openedx.profile.data.repository + +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.test.runTest +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Test +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceProvider +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.RequestInstance +import org.openedx.profile.data.api.ProfileApi + +class ProfileRepositoryLogoutTest { + + private class Selection(instance: Instance?) : InstanceProvider { + val flow = MutableStateFlow(instance) + override val currentInstance: Instance? get() = flow.value + override val currentInstanceFlow: StateFlow = flow + override val hasMultipleInstances = true + } + + private fun site(key: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val selection = Selection(site("alpha")) + private val api = mockk() + private val preferences = mockk(relaxed = true) { + every { refreshToken("alpha") } returns "refresh-a" + } + private val sessionManager = mockk(relaxed = true) + private val config = mockk { every { getOAuthClientId() } returns "alpha-client" } + + private fun repository() = ProfileRepository( + config, + api, + mockk(relaxed = true), + preferences, + sessionManager, + selection + ) + + @Test + fun `a sign-out that was in flight during a switch signs out only the site it started on`() = runTest { + coEvery { api.revokeAccessToken(any(), any(), any(), any()) } answers { + selection.flow.value = site("beta") + "".toResponseBody() + } + + repository().logout() + + coVerify { api.revokeAccessToken("alpha-client", "refresh-a", any(), RequestInstance("alpha")) } + coVerify { sessionManager.logout("alpha") } + coVerify(exactly = 0) { sessionManager.logoutCurrentInstance() } + coVerify(exactly = 0) { sessionManager.logout("beta") } + } + + @Test + fun `a failed revoke still signs the site out`() = runTest { + coEvery { api.revokeAccessToken(any(), any(), any(), any()) } throws java.io.IOException("offline") + + runCatching { repository().logout() } + + coVerify { sessionManager.logout("alpha") } + } +} From 28a57967d19a7eb3e3064f22e4902ec8354daac4 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:13:54 +0300 Subject: [PATCH 13/17] fix: a sign-out finishes on the site it started on Signing out of the selected site waits on the network to unregister push. If the learner switched sites meanwhile, the rest of the sign-out read the selection again: it cleared the new site's session and reset its selection, cookies and theme. The site is now fixed when the sign-out starts. Only its session is cleared, and the selection, web cookies and theme are reset only while it is still the selected site. --- .../core/instance/InstanceSessionManager.kt | 39 ++++++----- .../instance/InstanceSessionManagerTest.kt | 64 +++++++++++++++++-- 2 files changed, 83 insertions(+), 20 deletions(-) diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt index 83745b7f1..6a91f5a25 100644 --- a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt +++ b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt @@ -21,9 +21,10 @@ interface InstanceSessionManager { suspend fun switchActiveInstance(instance: Instance) /** - * Signs out of the selected site: cancels downloads, unregisters push, clears its tokens - * and user, then clears the selection. Cached courses and downloaded files stay, so signing - * back in to the same site is fast. + * Signs out of the site selected when it is called: cancels downloads, unregisters push, + * clears its tokens and user, then clears the selection unless the learner has switched to + * another site in the meantime. Cached courses and downloaded files stay, so signing back in + * to the same site is fast. */ suspend fun logoutCurrentInstance() @@ -71,24 +72,30 @@ class DefaultInstanceSessionManager( } override suspend fun logoutCurrentInstance() { - downloadWorkerController.cancelUnfinishedDownloads() - // Before the tokens go: unregistering is an authenticated call. - pushTokenUnregistrar?.unregisterPushToken(instanceStore.currentInstance?.key) - preferences.clearCorePreferences() - cookieManager.clearWebViewCookie() - // Last, so nothing observes a half signed-out site. - instanceStore.select(null) - applyThemeForCurrentInstance() + signOut(instanceStore.currentInstance?.key, isSelected = true) } override suspend fun logout(instance: Instance) = logout(instance.key) override suspend fun logout(instanceKey: String?) { - if (instanceStore.currentInstance?.key == instanceKey) { - logoutCurrentInstance() - } else { - pushTokenUnregistrar?.unregisterPushToken(instanceKey) - preferences.clearSession(instanceKey) + signOut(instanceKey, isSelected = instanceStore.currentInstance?.key == instanceKey) + } + + /** + * The site is fixed when the sign-out starts: unregistering push waits on the network, and the + * learner can switch sites meanwhile. Only that site's session is cleared, and the selection, + * web cookies and theme are reset only while it is still the selected site. + */ + private suspend fun signOut(instanceKey: String?, isSelected: Boolean) { + if (isSelected) downloadWorkerController.cancelUnfinishedDownloads() + // Before the tokens go: unregistering is an authenticated call. + pushTokenUnregistrar?.unregisterPushToken(instanceKey) + preferences.clearSession(instanceKey) + if (isSelected && instanceStore.currentInstance?.key == instanceKey) { + cookieManager.clearWebViewCookie() + // Last, so nothing observes a half signed-out site. + instanceStore.select(null) + applyThemeForCurrentInstance() } } diff --git a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt index a1c749b7b..a458a7af1 100644 --- a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt +++ b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt @@ -7,10 +7,14 @@ import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test import org.openedx.core.DatabaseManager import org.openedx.core.data.storage.CorePreferences @@ -73,7 +77,7 @@ class InstanceSessionManagerTest { coVerifyOrder { downloads.cancelUnfinishedDownloads() push.unregisterPushToken("alpha") - preferences.clearCorePreferences() + preferences.clearSession("alpha") } assertNull(store.currentInstance) assertNull(InstanceThemeManager.theme.value) @@ -87,7 +91,7 @@ class InstanceSessionManagerTest { manager(pushTokenUnregistrar = null).logoutCurrentInstance() - coVerify { preferences.clearCorePreferences() } + coVerify { preferences.clearSession("alpha") } assertNull(store.currentInstance) } @@ -95,7 +99,7 @@ class InstanceSessionManagerTest { fun `clearing the selection happens after the session is cleared`() = runBlocking { store.select(alpha) var selectedWhileClearing: Instance? = null - coEvery { preferences.clearCorePreferences() } answers { selectedWhileClearing = store.currentInstance } + coEvery { preferences.clearSession("alpha") } answers { selectedWhileClearing = store.currentInstance } manager().logoutCurrentInstance() @@ -121,10 +125,62 @@ class InstanceSessionManagerTest { manager().logout(beta) - coVerify { preferences.clearCorePreferences() } + coVerify { preferences.clearSession("beta") } assertNull(store.currentInstance) } + @Test + fun `switching sites while push unregisters signs out only the site that started it`() = runBlocking { + val sessions = mutableSetOf("alpha", "beta") + every { preferences.hasSession(any()) } answers { firstArg() in sessions } + coEvery { preferences.clearSession(any()) } answers { sessions.remove(firstArg()) } + val unregisterStarted = CompletableDeferred() + val unregisterDone = CompletableDeferred() + coEvery { push.unregisterPushToken("alpha") } coAnswers { + unregisterStarted.complete(Unit) + unregisterDone.await() + } + val manager = manager() + manager.switchActiveInstance(alpha) + + val logout = launch { manager.logoutCurrentInstance() } + unregisterStarted.await() + manager.switchActiveInstance(beta) + unregisterDone.complete(Unit) + logout.join() + + assertFalse(preferences.hasSession("alpha")) + assertTrue(preferences.hasSession("beta")) + coVerify(exactly = 0) { preferences.clearSession("beta") } + coVerify(exactly = 0) { preferences.clearCorePreferences() } + assertEquals(beta, store.currentInstance) + assertEquals("beta", InstanceThemeManager.theme.value?.key) + verify(exactly = 0) { cookies.clearWebViewCookie() } + } + + @Test + fun `signing out of another site while it becomes selected leaves the selection alone`() = runBlocking { + val unregisterStarted = CompletableDeferred() + val unregisterDone = CompletableDeferred() + coEvery { push.unregisterPushToken("beta") } coAnswers { + unregisterStarted.complete(Unit) + unregisterDone.await() + } + val manager = manager() + manager.switchActiveInstance(alpha) + + val logout = launch { manager.logout(beta) } + unregisterStarted.await() + manager.switchActiveInstance(beta) + unregisterDone.complete(Unit) + logout.join() + + coVerify { preferences.clearSession("beta") } + coVerify(exactly = 0) { downloads.cancelUnfinishedDownloads() } + assertEquals(beta, store.currentInstance) + verify(exactly = 0) { cookies.clearWebViewCookie() } + } + @Test fun `a different learner signing in clears the previous learner's cache on that site`() { every { preferences.lastSignedInUsername("beta") } returns "first" From da8d21bd5315567fc647756dcd1cd8bf83c6253e Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:26:17 +0300 Subject: [PATCH 14/17] feat: app launch sequencing and InstanceAwareConfig The launch now waits for the instance catalog before showing the first screen: the splash screen stays up while InstanceConfigLoader resolves it (a live fetch with a 10 second timeout, falling back to the cached or bundled catalog), then the selected instance's theme is applied and routing proceeds. InstanceStore restores a saved selection synchronously from the cached catalog, so a process restored after death resolves the same site before any request is made. Config is now InstanceAwareConfig: for the keys an instance may override (API host, OAuth client, SSO URLs, token type, feedback email, FAQ, platform name, agreements, discovery, program, dashboard, course UI flags, SSO button title, experimental features and the login flags), a value the instance sets wins and everything else comes from the app-level config. Firebase, Braze, Branch, social providers, the URI scheme and app-wide feature flags stay app-level. Every existing Config consumer becomes instance-aware with no call-site changes. The app-level config stays available under its own Koin qualifier for what must not move with the selection: the Retrofit base URL that InstanceHostInterceptor rewrites, and the legacy storage owner check. Mirrors iOS #682, with the launch now awaiting the catalog as the final iOS behavior documented in #685. --- .../main/java/org/openedx/app/AppActivity.kt | 41 ++-- .../main/java/org/openedx/app/AppViewModel.kt | 26 +++ .../main/java/org/openedx/app/di/AppModule.kt | 29 ++- .../org/openedx/app/di/NetworkingModule.kt | 11 +- .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../test/java/org/openedx/AppViewModelTest.kt | 45 ++++- .../java/org/openedx/core/config/Config.kt | 90 +++++---- .../core/config/InstanceAwareConfig.kt | 100 ++++++++++ .../core/instance/InstanceConfigLoader.kt | 10 +- .../core/config/InstanceAwareConfigTest.kt | 177 ++++++++++++++++++ 10 files changed, 460 insertions(+), 70 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/config/InstanceAwareConfig.kt create mode 100644 core/src/test/java/org/openedx/core/config/InstanceAwareConfigTest.kt diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index f0a71f713..01edfdb59 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -12,10 +12,12 @@ import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withStarted import androidx.window.layout.WindowMetricsCalculator import com.braze.support.toStringMap import io.branch.referral.Branch import io.branch.referral.Branch.BranchUniversalReferralInitListener +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel @@ -92,7 +94,7 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - installSplashScreen() + installSplashScreen().setKeepOnScreenCondition { !viewModel.isInstanceCatalogResolved.value } binding = ActivityAppBinding.inflate(layoutInflater) lifecycle.addObserver(viewModel) viewModel.logAppLaunchEvent() @@ -155,24 +157,31 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { } private fun setupInitialFragment(savedInstanceState: Bundle?) { - if (savedInstanceState == null) { - when { - corePreferencesManager.user == null -> { - val fragment = if (viewModel.isLogistrationEnabled && authCode == null) { - LogistrationFragment() - } else { - SignInFragment.newInstance(null, null) - } - addFragment(fragment) - } + // A recreated activity gets its screens back from the fragment manager. + if (savedInstanceState != null) return + lifecycleScope.launch { + viewModel.isInstanceCatalogResolved.first { it } + lifecycle.withStarted { showInitialFragment() } + } + } - whatsNewManager.shouldShowWhatsNew() -> addFragment(WhatsNewFragment.newInstance()) - else -> addFragment(MainFragment.newInstance()) + private fun showInitialFragment() { + when { + corePreferencesManager.user == null -> { + val fragment = if (viewModel.isLogistrationEnabled && authCode == null) { + LogistrationFragment() + } else { + SignInFragment.newInstance(null, null) + } + addFragment(fragment) } - intent.extras?.takeIf { it.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) }?.let { - handlePushNotification(it) - } + whatsNewManager.shouldShowWhatsNew() -> addFragment(WhatsNewFragment.newInstance()) + else -> addFragment(MainFragment.newInstance()) + } + + intent.extras?.takeIf { it.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) }?.let { + handlePushNotification(it) } } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 042aa8683..32f776201 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -8,8 +8,11 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import org.openedx.app.deeplink.DeepLink import org.openedx.app.deeplink.DeepLinkRouter @@ -19,6 +22,7 @@ import org.openedx.app.system.push.SyncFirebaseTokenWorker import org.openedx.core.config.Config import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.InstanceConfigLoader import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.instance.InstanceStore import org.openedx.core.system.notifier.DownloadFailed @@ -39,6 +43,7 @@ class AppViewModel( private val preferencesManager: CorePreferences, private val sessionManager: InstanceSessionManager, private val instanceStore: InstanceStore, + private val instanceConfigLoader: InstanceConfigLoader, private val pushRegistrations: PushRegistrations, private val analytics: AppAnalytics, private val deepLinkRouter: DeepLinkRouter, @@ -56,8 +61,29 @@ class AppViewModel( val downloadFailedDialog: SharedFlow get() = _downloadFailedDialog.asSharedFlow() + private val _isInstanceCatalogResolved = MutableStateFlow(false) + + /** + * Whether the instance catalog for this launch is in place. The first screen depends on it + * (a site picker, or a site's own sign-in), so the activity routes only once it is. + */ + val isInstanceCatalogResolved: StateFlow = _isInstanceCatalogResolved.asStateFlow() + val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() + init { + viewModelScope.launch { resolveInstanceCatalog() } + } + + private suspend fun resolveInstanceCatalog() { + // Falls back to the cached or bundled catalog when the fetch fails or times out. + instanceStore.updateInstancesConfig(instanceConfigLoader.load()) + // A selection restored from storage doesn't go through a switch, so nothing else + // applies its branding. + sessionManager.applyThemeForCurrentInstance() + _isInstanceCatalogResolved.value = true + } + private var logoutHandledAt: Long = 0 val isBranchEnabled get() = config.getBranchConfig().enabled 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 401334918..87c3964a2 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -1,5 +1,6 @@ package org.openedx.app.di +import android.content.Context import androidx.appcompat.app.AppCompatActivity import com.google.android.play.core.review.ReviewManagerFactory import com.google.gson.Gson @@ -34,6 +35,7 @@ import org.openedx.auth.presentation.sso.OAuthHelper import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.config.Config +import org.openedx.core.config.InstanceAwareConfig import org.openedx.core.data.model.CourseEnrollments import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences @@ -108,28 +110,39 @@ import org.openedx.whatsnew.data.storage.WhatsNewPreferences import org.openedx.whatsnew.presentation.WhatsNewAnalytics import org.openedx.core.DatabaseManager as IDatabaseManager +const val APP_LEVEL_CONFIG = "appLevelConfig" + val appModule = module { - single { Config(get()) } + // The bundled config as shipped. Everything else injects Config, which layers the selected + // instance over it; this one is for what must not move with the selection. + single(named(APP_LEVEL_CONFIG)) { Config(get()) } + single { InstanceAwareConfig(get(), get()) } single { SharedPrefsInstanceSelectionStorage(get()) } single { get() } single { - val config = get() + val appConfig = get(named(APP_LEVEL_CONFIG)) InstanceStorageNamespaces( instanceStore = get(), ownerStorage = get(), - appApiHostUrl = config.getApiHostURL(), - appOAuthClientId = config.getOAuthClientId(), + appApiHostUrl = appConfig.getAppLevelApiHostURL(), + appOAuthClientId = appConfig.getAppLevelOAuthClientId(), ) } single { - val bundledInstances by lazy { get().getInstancesConfig() } - InstanceStore(get()) { bundledInstances } + val loader by lazy { get() } + InstanceStore(get()) { loader.baseline() } } single { get() } single { SharedPrefsInstanceCatalogCache(get()) } - single { OkHttpInstanceApiService(get().getInstancesCatalogUrl()) } - single { InstanceConfigLoader(get(), get(), { get().getInstancesConfig() }) } + single { + OkHttpInstanceApiService(get(named(APP_LEVEL_CONFIG)).getInstancesCatalogUrl()) + } + single { + val appConfig = get(named(APP_LEVEL_CONFIG)) + val bundledInstances by lazy { appConfig.getInstancesConfig() } + InstanceConfigLoader(get(), get(), { bundledInstances }) + } single { PushRegistrations(get(), get(), get(), get()) } single { DefaultInstanceSessionManager( diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index b4ea69c02..9be65092a 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -2,6 +2,7 @@ package org.openedx.app.di import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor @@ -27,7 +28,9 @@ val networkingModule = module { single { OauthRefreshTokenAuthenticator(get(), get(), get(), get()) } single { val store = get() - InstanceHostInterceptor(get().getApiHostURL(), store) { store.instance(it) } + InstanceHostInterceptor(get(named(APP_LEVEL_CONFIG)).getAppLevelApiHostURL(), store) { + store.instance(it) + } } single { @@ -47,9 +50,11 @@ val networkingModule = module { } single { - val config = this.get() + // Built once, against the app-level host; InstanceHostInterceptor re-targets each + // request at the selected instance. + val config = this.get(named(APP_LEVEL_CONFIG)) Retrofit.Builder() - .baseUrl(config.getApiHostURL()) + .baseUrl(config.getAppLevelApiHostURL()) .client(get()) .addConverterFactory(GsonConverterFactory.create(get())) .build() diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 129c5b61c..d7849374d 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -92,6 +92,7 @@ val screenModule = module { preferencesManager = get(), sessionManager = get(), instanceStore = get(), + instanceConfigLoader = get(), pushRegistrations = get(), analytics = get(), deepLinkRouter = get(), diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index c2a914b83..8b09acaf1 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -7,6 +7,7 @@ import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -31,8 +32,10 @@ import org.openedx.app.system.push.PushRegistrations import org.openedx.core.CoreMocks import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig +import org.openedx.core.instance.InstanceConfigLoader import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.InstancesConfig import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -49,8 +52,9 @@ class AppViewModelTest { private val config = mockk() private val notifier = mockk() - private val sessionManager = mockk() + private val sessionManager = mockk(relaxed = true) private val instanceStore = mockk(relaxed = true) + private val instanceConfigLoader = mockk() private val pushRegistrations = mockk(relaxed = true) private val preferencesManager = mockk() private val analytics = mockk() @@ -64,6 +68,7 @@ class AppViewModelTest { fun before() { Dispatchers.setMain(dispatcher) every { downloadNotifier.notifier } returns flow { } + coEvery { instanceConfigLoader.load() } returns InstancesConfig() } @After @@ -85,6 +90,7 @@ class AppViewModelTest { preferencesManager, sessionManager, instanceStore, + instanceConfigLoader, pushRegistrations, analytics, deepLinkRouter, @@ -103,6 +109,41 @@ class AppViewModelTest { verify(exactly = 1) { analytics.setUserIdForSession(any()) } } + @Test + fun `routing waits for the instance catalog`() = runTest { + every { analytics.setUserIdForSession(any()) } returns Unit + every { preferencesManager.user } returns CoreMocks.mockUser + every { notifier.notifier } returns flow { } + every { preferencesManager.canResetAppDirectory } returns false + every { preferencesManager.pushToken } returns "" + val catalog = InstancesConfig() + coEvery { instanceConfigLoader.load() } returns catalog + + val viewModel = AppViewModel( + config, + notifier, + preferencesManager, + sessionManager, + instanceStore, + instanceConfigLoader, + pushRegistrations, + analytics, + deepLinkRouter, + fileUtil, + downloadNotifier, + context, + resourceManager, + ) + assert(!viewModel.isInstanceCatalogResolved.value) + advanceUntilIdle() + + assert(viewModel.isInstanceCatalogResolved.value) + coVerifyOrder { + instanceStore.updateInstancesConfig(catalog) + sessionManager.applyThemeForCurrentInstance() + } + } + @Test fun forceLogout() = runTest { every { notifier.notifier } returns flow { @@ -122,6 +163,7 @@ class AppViewModelTest { preferencesManager, sessionManager, instanceStore, + instanceConfigLoader, pushRegistrations, analytics, deepLinkRouter, @@ -161,6 +203,7 @@ class AppViewModelTest { preferencesManager, sessionManager, instanceStore, + instanceConfigLoader, pushRegistrations, analytics, deepLinkRouter, 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 6982c2854..393347fdb 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -10,16 +10,9 @@ import org.openedx.core.instance.InstancesConfig import java.io.InputStreamReader @Suppress("TooManyFunctions") -class Config(context: Context) { +open class Config(private val configProperties: JsonObject) { - private var configProperties: JsonObject = try { - val inputStream = context.assets.open("config/config.json") - val config = JsonParser.parseReader(InputStreamReader(inputStream)) - config.asJsonObject - } catch (e: Exception) { - e.printStackTrace() - JsonObject() - } + constructor(context: Context) : this(loadBundled(context)) fun getAppId(): String { return getString(APPLICATION_ID, "") @@ -193,42 +186,57 @@ class Config(context: Context) { class ConfigParsingException(cause: Throwable) : Exception(cause) - private fun getObject(key: String): JsonElement? { + /** Looks a key up; [InstanceAwareConfig] layers the selected instance over this. */ + protected open fun getObject(key: String): JsonElement? { return configProperties.get(key) } + /** The app-level `API_HOST_URL`, whichever instance is selected. */ + fun getAppLevelApiHostURL(): String = configProperties.get(API_HOST_URL)?.asString.orEmpty() + + /** The app-level `OAUTH_CLIENT_ID`, whichever instance is selected. */ + fun getAppLevelOAuthClientId(): String = configProperties.get(OAUTH_CLIENT_ID)?.asString.orEmpty() + companion object { - private const val APPLICATION_ID = "APPLICATION_ID" - private const val API_HOST_URL = "API_HOST_URL" - private const val SSO_URL = "SSO_URL" - private const val SSO_FINISHED_URL = "SSO_FINISHED_URL" - private const val SSO_BUTTON_TITLE = "SSO_BUTTON_TITLE" - private const val SAML_SSO_LOGIN_ENABLED = "SAML_SSO_LOGIN_ENABLED" - private const val SAML_SSO_DEFAULT_LOGIN_BUTTON = "SAML_SSO_DEFAULT_LOGIN_BUTTON" - private const val LOGIN_REGISTRATION_ENABLED = "LOGIN_REGISTRATION_ENABLED" - private const val URI_SCHEME = "URI_SCHEME" - private const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" - private const val TOKEN_TYPE = "TOKEN_TYPE" - private const val FAQ_URL = "FAQ_URL" - private const val FEEDBACK_EMAIL_ADDRESS = "FEEDBACK_EMAIL_ADDRESS" - private const val AGREEMENT_URLS = "AGREEMENT_URLS" - private const val WHATS_NEW_ENABLED = "WHATS_NEW_ENABLED" - private const val SOCIAL_AUTH_ENABLED = "SOCIAL_AUTH_ENABLED" - private const val FIREBASE = "FIREBASE" - private const val BRAZE = "BRAZE" - private const val FACEBOOK = "FACEBOOK" - private const val GOOGLE = "GOOGLE" - private const val MICROSOFT = "MICROSOFT" - private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" - private const val REGISTRATION_ENABLED = "REGISTRATION_ENABLED" - private const val DISCOVERY = "DISCOVERY" - private const val PROGRAM = "PROGRAM" - private const val DASHBOARD = "DASHBOARD" - private const val EXPERIMENTAL_FEATURES = "EXPERIMENTAL_FEATURES" - 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" + internal fun loadBundled(context: Context): JsonObject = try { + val inputStream = context.assets.open("config/config.json") + JsonParser.parseReader(InputStreamReader(inputStream)).asJsonObject + } catch (e: Exception) { + e.printStackTrace() + JsonObject() + } + + internal const val APPLICATION_ID = "APPLICATION_ID" + internal const val API_HOST_URL = "API_HOST_URL" + internal const val SSO_URL = "SSO_URL" + internal const val SSO_FINISHED_URL = "SSO_FINISHED_URL" + internal const val SSO_BUTTON_TITLE = "SSO_BUTTON_TITLE" + internal const val SAML_SSO_LOGIN_ENABLED = "SAML_SSO_LOGIN_ENABLED" + internal const val SAML_SSO_DEFAULT_LOGIN_BUTTON = "SAML_SSO_DEFAULT_LOGIN_BUTTON" + internal const val LOGIN_REGISTRATION_ENABLED = "LOGIN_REGISTRATION_ENABLED" + internal const val URI_SCHEME = "URI_SCHEME" + internal const val OAUTH_CLIENT_ID = "OAUTH_CLIENT_ID" + internal const val TOKEN_TYPE = "TOKEN_TYPE" + internal const val FAQ_URL = "FAQ_URL" + internal const val FEEDBACK_EMAIL_ADDRESS = "FEEDBACK_EMAIL_ADDRESS" + internal const val AGREEMENT_URLS = "AGREEMENT_URLS" + internal const val WHATS_NEW_ENABLED = "WHATS_NEW_ENABLED" + internal const val SOCIAL_AUTH_ENABLED = "SOCIAL_AUTH_ENABLED" + internal const val FIREBASE = "FIREBASE" + internal const val BRAZE = "BRAZE" + internal const val FACEBOOK = "FACEBOOK" + internal const val GOOGLE = "GOOGLE" + internal const val MICROSOFT = "MICROSOFT" + internal const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED" + internal const val REGISTRATION_ENABLED = "REGISTRATION_ENABLED" + internal const val DISCOVERY = "DISCOVERY" + internal const val PROGRAM = "PROGRAM" + internal const val DASHBOARD = "DASHBOARD" + internal const val EXPERIMENTAL_FEATURES = "EXPERIMENTAL_FEATURES" + internal const val BRANCH = "BRANCH" + internal const val UI_COMPONENTS = "UI_COMPONENTS" + internal const val PLATFORM_NAME = "PLATFORM_NAME" + internal const val INSTANCES_CATALOG_URL = "INSTANCES_CATALOG_URL" } enum class ViewType { diff --git a/core/src/main/java/org/openedx/core/config/InstanceAwareConfig.kt b/core/src/main/java/org/openedx/core/config/InstanceAwareConfig.kt new file mode 100644 index 000000000..69c626288 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/InstanceAwareConfig.kt @@ -0,0 +1,100 @@ +package org.openedx.core.config + +import android.content.Context +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceProvider + +/** + * The [Config] the app runs with: the selected instance layered over the app-level config. + * + * For the keys an instance may override, a value the instance sets wins; anything it leaves + * out, and every key while no instance is selected, comes from the app-level config. Keys + * outside [INSTANCE_KEYS] (Firebase, Braze, Branch, social sign-in providers, URI scheme, + * feature flags such as What's New) always stay app-level. + */ +open class InstanceAwareConfig( + appConfig: JsonObject, + private val instanceProvider: InstanceProvider, +) : Config(appConfig) { + + constructor(context: Context, instanceProvider: InstanceProvider) : + this(Config.loadBundled(context), instanceProvider) + + override fun getObject(key: String): JsonElement? { + val instance = instanceProvider.currentInstance + return instance?.let { instanceValue(it, key) } ?: super.getObject(key) + } + + private fun instanceValue(instance: Instance, key: String): JsonElement? { + val config = instance.config + return when (key) { + // Resolved through the model, which validates them and applies its defaults. + API_HOST_URL -> JsonPrimitive(instance.apiHostUrl) + OAUTH_CLIENT_ID -> JsonPrimitive(instance.oAuthClientId) + SSO_URL -> instance.ssoUrl?.let(::JsonPrimitive) + SSO_FINISHED_URL -> instance.ssoFinishedUrl?.let(::JsonPrimitive) + PLATFORM_NAME -> JsonPrimitive(instance.platformName) + // The instance schema nests these under UI_COMPONENTS, as iOS does; here they are + // top-level keys. + in UI_COMPONENT_FLAGS -> config.getAsJsonObjectOrNull(UI_COMPONENTS)?.nonBlank(key) + ?: config.nonBlank(key) + UI_COMPONENTS -> config.getAsJsonObjectOrNull(UI_COMPONENTS)?.let { mergedUiComponents(it) } + in INSTANCE_KEYS -> config.nonBlank(key) + else -> null + } + } + + /** Per-field: an instance that sets one course navigation flag keeps the app's others. */ + private fun mergedUiComponents(instanceComponents: JsonObject): JsonObject { + val merged = super.getObject(UI_COMPONENTS) + ?.takeIf { it.isJsonObject } + ?.asJsonObject + ?.deepCopy() + ?: JsonObject() + instanceComponents.entrySet().forEach { (key, value) -> merged.add(key, value) } + return merged + } + + companion object { + private val UI_COMPONENT_FLAGS = setOf( + LOGIN_REGISTRATION_ENABLED, + SAML_SSO_LOGIN_ENABLED, + SAML_SSO_DEFAULT_LOGIN_BUTTON, + ) + + /** App-level keys an instance may override. */ + val INSTANCE_KEYS = setOf( + API_HOST_URL, + OAUTH_CLIENT_ID, + SSO_URL, + SSO_FINISHED_URL, + TOKEN_TYPE, + FEEDBACK_EMAIL_ADDRESS, + FAQ_URL, + PLATFORM_NAME, + AGREEMENT_URLS, + DISCOVERY, + PROGRAM, + DASHBOARD, + UI_COMPONENTS, + SSO_BUTTON_TITLE, + EXPERIMENTAL_FEATURES, + ) + UI_COMPONENT_FLAGS + + private fun JsonObject.getAsJsonObjectOrNull(key: String): JsonObject? = + get(key)?.takeIf { it.isJsonObject }?.asJsonObject + + /** A set value; `null` and blank strings count as unset. */ + private fun JsonObject.nonBlank(key: String): JsonElement? { + val value = get(key) ?: return null + return when { + value.isJsonNull -> null + value.isJsonPrimitive && value.asJsonPrimitive.isString && value.asString.isBlank() -> null + else -> value + } + } + } +} diff --git a/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt b/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt index 73a5f12f9..f7581755d 100644 --- a/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt +++ b/core/src/main/java/org/openedx/core/instance/InstanceConfigLoader.kt @@ -40,8 +40,16 @@ class InstanceConfigLoader( private val logger: (String) -> Unit = { Log.w(TAG, it) }, ) { + /** + * The catalog to run with until [load] finishes: the cached one, else the bundled one. + * Read synchronously at startup, so a selection restored after process death resolves + * against the catalog the learner actually picked it from. + */ + fun baseline(): InstancesConfig = + loadCached() ?: bundledConfig().takeIf { it.instances.isNotEmpty() } ?: InstancesConfig() + suspend fun load(): InstancesConfig { - val baseline = loadCached() ?: bundledConfig().takeIf { it.instances.isNotEmpty() } ?: InstancesConfig() + val baseline = baseline() return try { val json = apiService.fetchCatalog() val fetched = InstancesConfig.fromRemoteJson(json) diff --git a/core/src/test/java/org/openedx/core/config/InstanceAwareConfigTest.kt b/core/src/test/java/org/openedx/core/config/InstanceAwareConfigTest.kt new file mode 100644 index 000000000..98dfe8b19 --- /dev/null +++ b/core/src/test/java/org/openedx/core/config/InstanceAwareConfigTest.kt @@ -0,0 +1,177 @@ +package org.openedx.core.config + +import com.google.gson.JsonParser +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceProvider + +class InstanceAwareConfigTest { + + private class Selection(var instance: Instance?) : InstanceProvider { + override val currentInstance: Instance? get() = instance + override val currentInstanceFlow: StateFlow = MutableStateFlow(null) + override val hasMultipleInstances = true + } + + private val appConfig = JsonParser.parseString( + """ + { + "API_HOST_URL": "https://app.example.com", + "OAUTH_CLIENT_ID": "app-client", + "SSO_URL": "https://app.example.com/sso", + "SSO_FINISHED_URL": "https://app.example.com/sso/done", + "TOKEN_TYPE": "JWT", + "FEEDBACK_EMAIL_ADDRESS": "app@example.com", + "FAQ_URL": "https://app.example.com/faq", + "PLATFORM_NAME": "App Platform", + "URI_SCHEME": "org.openedx.app", + "WHATS_NEW_ENABLED": true, + "SOCIAL_AUTH_ENABLED": true, + "LOGIN_REGISTRATION_ENABLED": true, + "SAML_SSO_LOGIN_ENABLED": false, + "DASHBOARD": { "TYPE": "gallery" }, + "DISCOVERY": { "TYPE": "native" }, + "UI_COMPONENTS": { + "COURSE_DROPDOWN_NAVIGATION_ENABLED": true, + "COURSE_UNIT_PROGRESS_ENABLED": false + }, + "FIREBASE": { "ENABLED": true, "PROJECT_ID": "app-firebase" }, + "EXPERIMENTAL_FEATURES": { "APP_LEVEL_DOWNLOADS": { "ENABLED": false } } + } + """ + ).asJsonObject + + private fun instance(json: String) = requireNotNull(Instance.fromJson(JsonParser.parseString(json).asJsonObject)) + + private val minimal = instance( + """{"KEY": "min", "NAME": "Minimal", "OAUTH_CLIENT_ID": "min-client", + "API_HOST_URL": "https://min.example.com"}""" + ) + + private val full = instance( + """ + { + "KEY": "full", "NAME": "Full", "OAUTH_CLIENT_ID": "full-client", + "API_HOST_URL": "https://full.example.com", + "SSO_URL": "https://full.example.com/sso", + "SSO_URL_SUCCESSFUL_LOGIN": "https://full.example.com/sso/done", + "TOKEN_TYPE": "BEARER", + "FEEDBACK_EMAIL_ADDRESS": "help@full.example.com", + "FAQ_URL": "", + "PLATFORM_NAME": "Full Platform", + "WHATS_NEW_ENABLED": false, + "URI_SCHEME": "evil.scheme", + "FIREBASE": { "ENABLED": false }, + "DASHBOARD": { "TYPE": "list" }, + "UI_COMPONENTS": { + "COURSE_UNIT_PROGRESS_ENABLED": true, + "LOGIN_REGISTRATION_ENABLED": false, + "SAML_SSO_LOGIN_ENABLED": true + }, + "EXPERIMENTAL_FEATURES": { "APP_LEVEL_DOWNLOADS": { "ENABLED": true } } + } + """ + ) + + private fun config(instance: Instance?) = InstanceAwareConfig(appConfig, Selection(instance)) + + @Test + fun `no selection reads the app level config unchanged`() { + val config = config(null) + + assertEquals("https://app.example.com", config.getApiHostURL()) + assertEquals("app-client", config.getOAuthClientId()) + assertEquals("App Platform", config.getPlatformName()) + assertTrue(config.getCourseUIConfig().isCourseDropdownNavigationEnabled) + } + + @Test + fun `instance fields win when set`() { + val config = config(full) + + assertEquals("https://full.example.com", config.getApiHostURL()) + assertEquals("full-client", config.getOAuthClientId()) + assertEquals("https://full.example.com/sso", config.getSSOURL()) + assertEquals("https://full.example.com/sso/done", config.getSSOFinishedURL()) + assertEquals("BEARER", config.getAccessTokenType()) + assertEquals("help@full.example.com", config.getFeedbackEmailAddress()) + assertEquals("Full Platform", config.getPlatformName()) + assertEquals(DashboardConfig.DashboardType.LIST, config.getDashboardConfig().getType()) + assertTrue(config.getDownloadsConfig().isEnabled) + } + + @Test + fun `fields an instance omits fall back to the app level`() { + val config = config(minimal) + + assertEquals("https://min.example.com", config.getApiHostURL()) + assertEquals("app@example.com", config.getFeedbackEmailAddress()) + assertEquals("JWT", config.getAccessTokenType()) + assertEquals("https://app.example.com/sso", config.getSSOURL()) + assertEquals(DashboardConfig.DashboardType.GALLERY, config.getDashboardConfig().getType()) + assertFalse(config.getDownloadsConfig().isEnabled) + } + + @Test + fun `blank instance values count as unset`() { + assertEquals("https://app.example.com/faq", config(full).getFaqUrl()) + } + + @Test + fun `platform name defaults to the instance display name`() { + assertEquals("Minimal", config(minimal).getPlatformName()) + } + + @Test + fun `nested login flags override the matching top level keys`() { + val config = config(full) + + assertFalse(config.isLoginRegistrationEnabled()) + assertTrue(config.isSSOLoginEnabled()) + assertTrue(config(minimal).isLoginRegistrationEnabled()) + assertFalse(config(minimal).isSSOLoginEnabled()) + } + + @Test + fun `course UI flags merge per field`() { + val ui = config(full).getCourseUIConfig() + + assertTrue(ui.isCourseUnitProgressEnabled) + assertTrue(ui.isCourseDropdownNavigationEnabled) + } + + @Test + fun `app level only keys ignore the instance`() { + val config = config(full) + + assertEquals("org.openedx.app", config.getUriScheme()) + assertTrue(config.isWhatsNewEnabled()) + assertTrue(config.getFirebaseConfig().enabled) + assertEquals("app-firebase", config.getFirebaseConfig().projectId) + } + + @Test + fun `values follow the selection as it changes`() { + val selection = Selection(minimal) + val config = InstanceAwareConfig(appConfig, selection) + + assertEquals("https://min.example.com", config.getApiHostURL()) + selection.instance = full + assertEquals("https://full.example.com", config.getApiHostURL()) + selection.instance = null + assertEquals("https://app.example.com", config.getApiHostURL()) + } + + @Test + fun `app level accessors never move with the selection`() { + val config = config(full) + + assertEquals("https://app.example.com", config.getAppLevelApiHostURL()) + assertEquals("app-client", config.getAppLevelOAuthClientId()) + } +} From e1c5d039a516c7da2a6677da5367dc4829a71586 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:57:05 +0300 Subject: [PATCH 15/17] feat: Learning Sites picker (multi-instance switching UI) Presentation layer for multi-instance support: choose, switch and sign out of learning sites, with each site's branding applied app-wide. - Learning Sites screen: the first screen of a multi-site build (Get Started) and, from Settings, where a learner switches sites. It lists the current site, other signed-in sites and the sites to add, with search and per-site log out. A signed-in site opens straight to its dashboard; any other opens its own sign-in. When switching, the previous site's screens are closed before the switch. - Sign In shows the selected site with a Change link. - Settings shows a Current Learning Site card, with Log Out folded into it. - Branding: the site's accent color across Compose screens and the tab bar, and its header background image and logo when configured. - Launch: a saved site without a session, or no site, starts at site selection; logout and forced logout return there. On the first launch after upgrading from a single-site build, the session that build had is opened on its site instead. - A catalog with one site hides all of it: same screens as before. Mirrors iOS #683. --- .../main/java/org/openedx/app/AppActivity.kt | 3 + .../main/java/org/openedx/app/AppRouter.kt | 20 +- .../main/java/org/openedx/app/AppViewModel.kt | 30 ++ .../main/java/org/openedx/app/MainFragment.kt | 20 + .../main/java/org/openedx/app/di/AppModule.kt | 4 +- .../java/org/openedx/app/di/ScreenModule.kt | 20 +- .../test/java/org/openedx/AppViewModelTest.kt | 87 ++++ .../openedx/auth/presentation/AuthRouter.kt | 6 + .../learningsites/LearningSitesFragment.kt | 58 +++ .../learningsites/LearningSitesScreen.kt | 383 ++++++++++++++++++ .../learningsites/LearningSitesViewModel.kt | 142 +++++++ .../presentation/signin/SignInFragment.kt | 3 + .../auth/presentation/signin/SignInUIState.kt | 3 + .../presentation/signin/SignInViewModel.kt | 7 + .../presentation/signin/compose/SignInView.kt | 62 ++- auth/src/main/res/values/strings.xml | 15 + .../LearningSitesViewModelTest.kt | 195 +++++++++ .../signin/SignInViewModelTest.kt | 26 +- .../core/instance/InstanceSelectionStorage.kt | 13 +- .../core/instance/InstanceSessionManager.kt | 4 +- .../org/openedx/core/ui/InstanceBranding.kt | 86 ++++ .../org/openedx/core/ui/LearningSiteRow.kt | 105 +++++ .../openedx/core/ui/theme/InstanceTheme.kt | 9 +- .../instance/InstanceSessionManagerTest.kt | 2 +- .../profile/presentation/ProfileRouter.kt | 3 + .../presentation/settings/SettingsFragment.kt | 125 +++--- .../presentation/settings/SettingsScreenUI.kt | 80 +++- .../presentation/settings/SettingsUIState.kt | 3 + .../settings/SettingsViewModel.kt | 13 +- profile/src/main/res/values/strings.xml | 1 + 30 files changed, 1441 insertions(+), 87 deletions(-) create mode 100644 auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesFragment.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesScreen.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt create mode 100644 auth/src/test/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModelTest.kt create mode 100644 core/src/main/java/org/openedx/core/ui/InstanceBranding.kt create mode 100644 core/src/main/java/org/openedx/core/ui/LearningSiteRow.kt diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index 01edfdb59..f93c74fcf 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -23,6 +23,7 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding import org.openedx.app.deeplink.DeepLink +import org.openedx.auth.presentation.learningsites.LearningSitesFragment import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.data.storage.CorePreferences @@ -167,6 +168,8 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { private fun showInitialFragment() { when { + viewModel.startsWithSiteSelection -> addFragment(LearningSitesFragment.newInstance(isSwitching = false)) + corePreferencesManager.user == null -> { val fragment = if (viewModel.isLogistrationEnabled && authCode == null) { LogistrationFragment() diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index a511dc839..839bb105c 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -5,12 +5,14 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import org.openedx.app.deeplink.HomeTab import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.learningsites.LearningSitesFragment import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType +import org.openedx.core.instance.InstanceProvider import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.SSOWebContentFragment @@ -61,7 +63,9 @@ import org.openedx.profile.presentation.video.VideoSettingsFragment import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment -class AppRouter : +class AppRouter( + private val instanceProvider: InstanceProvider, +) : AuthRouter, DiscoveryRouter, DashboardRouter, @@ -403,10 +407,22 @@ class AppRouter : ) } + override fun navigateToLearningSites(fm: FragmentManager, isSwitching: Boolean) { + if (isSwitching) { + replaceFragmentWithBackStack(fm, LearningSitesFragment.newInstance(isSwitching = true)) + } else { + clearBackStack(fm) + replaceFragment(fm, LearningSitesFragment.newInstance(isSwitching = false)) + } + } + override fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) { fm.apply { clearBackStack(this) - if (isLogistrationEnabled) { + // A multi-site build always starts over at site selection: sign-in belongs to a site. + if (instanceProvider.hasMultipleInstances) { + replaceFragment(fm, LearningSitesFragment.newInstance(isSwitching = false)) + } else if (isLogistrationEnabled) { replaceFragment(fm, LogistrationFragment()) } else { replaceFragment(fm, SignInFragment.newInstance(null, null)) diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index 32f776201..97044bb7a 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -25,6 +25,8 @@ import org.openedx.core.data.storage.CorePreferences import org.openedx.core.instance.InstanceConfigLoader import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.LegacySessionAdoptionStorage +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.system.notifier.DownloadFailed import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier @@ -45,6 +47,8 @@ class AppViewModel( private val instanceStore: InstanceStore, private val instanceConfigLoader: InstanceConfigLoader, private val pushRegistrations: PushRegistrations, + private val namespaces: StorageNamespaceProvider, + private val legacySessionAdoption: LegacySessionAdoptionStorage, private val analytics: AppAnalytics, private val deepLinkRouter: DeepLinkRouter, private val fileUtil: FileUtil, @@ -71,13 +75,39 @@ class AppViewModel( val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() + /** With several sites and none selected, the first screen is site selection. */ + val startsWithSiteSelection get() = instanceStore.hasMultipleInstances && instanceStore.currentInstance == null + init { viewModelScope.launch { resolveInstanceCatalog() } } + /** + * The first launch after upgrading from a single-site build: the learner was signed in to + * the site the app-level configuration points at, and that session now belongs to the + * matching instance. Open it, instead of asking which site to use. Checked once, so signing + * out later still leads to site selection. + */ + private fun adoptLegacySession() { + if (legacySessionAdoption.hasCheckedLegacySession) return + legacySessionAdoption.hasCheckedLegacySession = true + if (!instanceStore.hasMultipleInstances || instanceStore.currentInstance != null) return + instanceStore.instancesConfig.instances + .firstOrNull { namespaces.namespaceFor(it).isLegacy && preferencesManager.hasSession(it.key) } + ?.let(instanceStore::select) + } + private suspend fun resolveInstanceCatalog() { // Falls back to the cached or bundled catalog when the fetch fails or times out. instanceStore.updateInstancesConfig(instanceConfigLoader.load()) + // A saved selection whose session is gone (signed out right before the process died, + // or storage restored from a backup without its keys) would open a site nobody is signed + // in to. With several sites, start over at site selection instead. + val current = instanceStore.currentInstance + if (instanceStore.hasMultipleInstances && current != null && !preferencesManager.hasSession(current.key)) { + instanceStore.select(null) + } + adoptLegacySession() // A selection restored from storage doesn't go through a switch, so nothing else // applies its branding. sessionManager.applyThemeForCurrentInstance() diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 397216b74..97a30a635 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -1,5 +1,7 @@ package org.openedx.app +import android.content.res.ColorStateList +import android.content.res.Configuration import android.os.Bundle import android.view.Menu import android.view.View @@ -8,6 +10,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.forEach import androidx.fragment.app.Fragment @@ -27,12 +31,14 @@ import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.viewBinding import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.ui.theme.InstanceThemeManager import org.openedx.dates.presentation.dates.DatesFragment import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.downloads.presentation.download.DownloadsFragment import org.openedx.learn.presentation.LearnFragment import org.openedx.learn.presentation.LearnTab import org.openedx.profile.presentation.profile.ProfileFragment +import org.openedx.core.R as CoreR class MainFragment : Fragment(R.layout.fragment_main) { @@ -73,7 +79,21 @@ class MainFragment : Fragment(R.layout.fragment_main) { } } + /** The tab bar is a view, outside Compose; the selected tab takes the site's accent too. */ + private fun applyInstanceAccent() { + val isDark = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == + Configuration.UI_MODE_NIGHT_YES + val accent = InstanceThemeManager.theme.value?.accentColor(isDark) ?: return + val tint = ColorStateList( + arrayOf(intArrayOf(android.R.attr.state_checked), intArrayOf()), + intArrayOf(accent.toArgb(), ContextCompat.getColor(requireContext(), CoreR.color.unchecked_tab_item)), + ) + binding.bottomNavView.itemIconTintList = tint + binding.bottomNavView.itemTextColor = tint + } + private fun setupBottomNavigation() { + applyInstanceAccent() val openTabArg = requireArguments().getString(ARG_OPEN_TAB, HomeTab.LEARN.name) val initialMenuId = getInitialMenuId(openTabArg) binding.bottomNavView.selectedItemId = initialMenuId 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 87c3964a2..6526e47a1 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -52,6 +52,7 @@ import org.openedx.core.instance.InstanceSelectionStorage import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.instance.InstanceStorageNamespaces import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.LegacySessionAdoptionStorage import org.openedx.core.instance.OkHttpInstanceApiService import org.openedx.core.instance.SharedPrefsInstanceCatalogCache import org.openedx.core.instance.SharedPrefsInstanceSelectionStorage @@ -120,6 +121,7 @@ val appModule = module { single { InstanceAwareConfig(get(), get()) } single { SharedPrefsInstanceSelectionStorage(get()) } single { get() } + single { get() } single { val appConfig = get(named(APP_LEVEL_CONFIG)) InstanceStorageNamespaces( @@ -187,7 +189,7 @@ val appModule = module { single { DiscoveryNotifier() } single { CalendarNotifier() } - single { AppRouter() } + single { AppRouter(get()) } single { get() } single { get() } single { get() } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index d7849374d..4bf84ac42 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -2,11 +2,13 @@ package org.openedx.app.di import android.content.res.Resources import org.koin.core.module.dsl.viewModel +import org.koin.core.qualifier.named import org.koin.dsl.module import org.openedx.app.AppViewModel import org.openedx.app.MainViewModel import org.openedx.auth.data.repository.AuthRepository import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.learningsites.LearningSitesViewModel import org.openedx.auth.presentation.logistration.LogistrationViewModel import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel @@ -94,6 +96,8 @@ val screenModule = module { instanceStore = get(), instanceConfigLoader = get(), pushRegistrations = get(), + namespaces = get(), + legacySessionAdoption = get(), analytics = get(), deepLinkRouter = get(), fileUtil = get(), @@ -138,7 +142,20 @@ val screenModule = module { courseId, infoType, lang, - get() + get(), + get(), + ) + } + + viewModel { (isSwitching: Boolean) -> + LearningSitesViewModel( + isSwitching = isSwitching, + instanceStore = get(), + preferences = get(), + sessionManager = get(), + router = get(), + config = get(), + dispatcher = get(named("IODispatcher")), ) } @@ -251,6 +268,7 @@ val screenModule = module { get(), get(), get(), + get(), ) } viewModel { diff --git a/app/src/test/java/org/openedx/AppViewModelTest.kt b/app/src/test/java/org/openedx/AppViewModelTest.kt index 8b09acaf1..2ce2758bc 100644 --- a/app/src/test/java/org/openedx/AppViewModelTest.kt +++ b/app/src/test/java/org/openedx/AppViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import com.google.gson.JsonObject import io.mockk.coEvery import io.mockk.coVerify import io.mockk.coVerifyOrder @@ -32,10 +33,15 @@ import org.openedx.app.system.push.PushRegistrations import org.openedx.core.CoreMocks import org.openedx.core.config.Config import org.openedx.core.config.FirebaseConfig +import org.openedx.core.instance.Instance import org.openedx.core.instance.InstanceConfigLoader +import org.openedx.core.instance.InstanceSelectionStorage import org.openedx.core.instance.InstanceSessionManager import org.openedx.core.instance.InstanceStore import org.openedx.core.instance.InstancesConfig +import org.openedx.core.instance.LegacySessionAdoptionStorage +import org.openedx.core.instance.StorageNamespace +import org.openedx.core.instance.StorageNamespaceProvider import org.openedx.core.system.notifier.DownloadNotifier import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -56,6 +62,10 @@ class AppViewModelTest { private val instanceStore = mockk(relaxed = true) private val instanceConfigLoader = mockk() private val pushRegistrations = mockk(relaxed = true) + private val namespaces = mockk(relaxed = true) + private val legacySessionAdoption = object : LegacySessionAdoptionStorage { + override var hasCheckedLegacySession = false + } private val preferencesManager = mockk() private val analytics = mockk() private val fileUtil = mockk() @@ -92,6 +102,8 @@ class AppViewModelTest { instanceStore, instanceConfigLoader, pushRegistrations, + namespaces, + legacySessionAdoption, analytics, deepLinkRouter, fileUtil, @@ -127,6 +139,8 @@ class AppViewModelTest { instanceStore, instanceConfigLoader, pushRegistrations, + namespaces, + legacySessionAdoption, analytics, deepLinkRouter, fileUtil, @@ -144,6 +158,75 @@ class AppViewModelTest { } } + private fun site(key: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", key) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private fun launch(store: InstanceStore): AppViewModel { + every { notifier.notifier } returns flow { } + every { namespaces.namespaceFor(any()) } answers { + val instance = firstArg() + if (instance.key == "legacy") StorageNamespace.LEGACY else StorageNamespace(instance.key) + } + coEvery { instanceConfigLoader.load() } returns store.instancesConfig + return AppViewModel( + config, notifier, preferencesManager, sessionManager, store, instanceConfigLoader, pushRegistrations, + namespaces, legacySessionAdoption, analytics, deepLinkRouter, fileUtil, downloadNotifier, + context, resourceManager, + ) + } + + private fun storeOf(vararg sites: Instance) = InstanceStore( + object : InstanceSelectionStorage { + override var selectedInstanceKey: String? = null + } + ) { InstancesConfig(sites.toList()) } + + @Test + fun `first multi-site launch opens the site an upgraded learner was signed in to`() = runTest { + val legacy = site("legacy") + val store = storeOf(site("other"), legacy) + every { preferencesManager.hasSession(any()) } answers { firstArg() == "legacy" } + + launch(store) + advanceUntilIdle() + + assert(store.currentInstance == legacy) + assert(legacySessionAdoption.hasCheckedLegacySession) + } + + @Test + fun `the legacy session is adopted only on the first launch`() = runTest { + val store = storeOf(site("other"), site("legacy")) + every { preferencesManager.hasSession(any()) } returns true + legacySessionAdoption.hasCheckedLegacySession = true + + launch(store) + advanceUntilIdle() + + assert(store.currentInstance == null) + } + + @Test + fun `a saved site without a session is cleared so launch starts at site selection`() = runTest { + val other = site("other") + val store = storeOf(other, site("legacy")) + store.select(other) + every { preferencesManager.hasSession(any()) } returns false + + launch(store) + advanceUntilIdle() + + assert(store.currentInstance == null) + } + @Test fun forceLogout() = runTest { every { notifier.notifier } returns flow { @@ -165,6 +248,8 @@ class AppViewModelTest { instanceStore, instanceConfigLoader, pushRegistrations, + namespaces, + legacySessionAdoption, analytics, deepLinkRouter, fileUtil, @@ -205,6 +290,8 @@ class AppViewModelTest { instanceStore, instanceConfigLoader, pushRegistrations, + namespaces, + legacySessionAdoption, analytics, deepLinkRouter, fileUtil, diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index ac657271f..958da2017 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -30,4 +30,10 @@ interface AuthRouter { fun navigateToSSOWebContent(fm: FragmentManager, title: String, url: String) fun clearBackStack(fm: FragmentManager) + + /** + * Opens Learning Sites. [isSwitching] pushes it over the current screens, from Settings; + * otherwise it replaces everything and becomes the first screen. + */ + fun navigateToLearningSites(fm: FragmentManager, isSwitching: Boolean) } diff --git a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesFragment.kt new file mode 100644 index 000000000..7a2dc36fe --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesFragment.kt @@ -0,0 +1,58 @@ +package org.openedx.auth.presentation.learningsites + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.foundation.presentation.rememberWindowSize + +class LearningSitesFragment : Fragment() { + + private val viewModel: LearningSitesViewModel by viewModel { + parametersOf(requireArguments().getBoolean(ARG_IS_SWITCHING)) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val state by viewModel.uiState.collectAsState() + LearningSitesScreen( + windowSize = rememberWindowSize(), + state = state, + isSwitching = viewModel.isSwitching, + onBackClick = { parentFragmentManager.popBackStack() }, + onSearchQueryChanged = viewModel::onSearchQueryChanged, + onSiteClick = { viewModel.select(parentFragmentManager, it) }, + onLogOutClick = { viewModel.logOut(parentFragmentManager, it) }, + ) + } + } + } + + override fun onResume() { + super.onResume() + // Back from a site's sign-in, or from signing in elsewhere, the lists may be stale. + viewModel.refresh() + } + + companion object { + private const val ARG_IS_SWITCHING = "is_switching" + + fun newInstance(isSwitching: Boolean) = LearningSitesFragment().apply { + arguments = bundleOf(ARG_IS_SWITCHING to isSwitching) + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesScreen.kt b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesScreen.kt new file mode 100644 index 000000000..e9b71d8dc --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesScreen.kt @@ -0,0 +1,383 @@ +package org.openedx.auth.presentation.learningsites + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Cancel +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.google.gson.JsonObject +import org.openedx.auth.R +import org.openedx.core.instance.Instance +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.InstanceHeaderImage +import org.openedx.core.ui.LearningSiteRow +import org.openedx.core.ui.SearchBar +import org.openedx.core.ui.displayCutoutForLandscape +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.theme.compose.SignInLogoView +import org.openedx.foundation.presentation.WindowSize +import org.openedx.foundation.presentation.WindowType +import org.openedx.foundation.presentation.windowSizeValue +import org.openedx.core.R as coreR + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun LearningSitesScreen( + windowSize: WindowSize, + state: LearningSitesUIState, + isSwitching: Boolean, + onBackClick: () -> Unit, + onSearchQueryChanged: (String) -> Unit, + onSiteClick: (Instance) -> Unit, + onLogOutClick: (Instance) -> Unit, +) { + var siteToLogOut by remember { mutableStateOf(null) } + val contentWidth by remember(windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(Dp.Unspecified, 420.dp), + compact = Modifier.fillMaxWidth(), + ) + ) + } + + Scaffold( + modifier = Modifier + .semantics { testTagsAsResourceId = true } + .fillMaxSize() + .navigationBarsPadding(), + containerColor = MaterialTheme.appColors.background, + contentWindowInsets = WindowInsets(), + ) { paddings -> + InstanceHeaderImage( + fallback = painterResource(id = coreR.drawable.core_top_header), + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.3f), + ) + if (isSwitching) { + Box( + modifier = Modifier + .statusBarsPadding() + .fillMaxWidth(), + contentAlignment = Alignment.CenterStart, + ) { + BackBtn(modifier = Modifier.padding(end = 16.dp), tint = Color.White, onBackClick = onBackClick) + } + } + Column( + modifier = Modifier.padding(paddings), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + SignInLogoView() + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape, + modifier = Modifier.fillMaxSize(), + ) { + Box(contentAlignment = Alignment.TopCenter) { + LazyColumn( + modifier = Modifier + .then(contentWidth) + .displayCutoutForLandscape() + .testTag("lc_learning_sites"), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 28.dp), + ) { + item { Heading(isSwitching) } + state.currentSite?.let { current -> + siteSection( + titleRes = R.string.auth_learning_sites_current_site, + sites = listOf(current), + onSiteClick = onSiteClick, + onLogOutClick = { siteToLogOut = it }, + ) + } + if (state.otherSignedInSites.isNotEmpty()) { + siteSection( + titleRes = R.string.auth_learning_sites_more_sites, + sites = state.otherSignedInSites, + onSiteClick = onSiteClick, + onLogOutClick = { siteToLogOut = it }, + ) + } + addSiteSection( + state = state, + onSearchQueryChanged = onSearchQueryChanged, + onSiteClick = onSiteClick, + ) + } + } + } + } + } + + siteToLogOut?.let { site -> + LogOutDialog( + site = site, + onDismiss = { siteToLogOut = null }, + onConfirm = { + siteToLogOut = null + onLogOutClick(site) + }, + ) + } +} + +@Composable +private fun Heading(isSwitching: Boolean) { + Column(modifier = Modifier.padding(bottom = 8.dp)) { + Text( + modifier = Modifier.testTag("txt_learning_sites_title"), + text = stringResource( + if (isSwitching) R.string.auth_learning_sites_title else R.string.auth_learning_sites_get_started + ), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.displaySmall, + ) + Text( + modifier = Modifier + .testTag("txt_learning_sites_subtitle") + .padding(top = 4.dp), + text = stringResource( + if (isSwitching) { + R.string.auth_learning_sites_subtitle + } else { + R.string.auth_learning_sites_welcome_subtitle + } + ), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall, + ) + } +} + +@Composable +private fun SectionTitle(titleRes: Int) { + Text( + modifier = Modifier.padding(top = 24.dp, bottom = 4.dp), + text = stringResource(titleRes), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleMedium, + ) +} + +private fun LazyListScope.siteSection( + titleRes: Int, + sites: List, + onSiteClick: (Instance) -> Unit, + onLogOutClick: (Instance) -> Unit, +) { + item(key = "title_$titleRes") { SectionTitle(titleRes) } + items(sites, key = { "signed_in_${it.key}" }) { site -> + SiteItem(site = site, onClick = { onSiteClick(site) }) { + IconButton( + modifier = Modifier.testTag("ib_log_out_site"), + onClick = { onLogOutClick(site) }, + ) { + Icon( + imageVector = Icons.Filled.Cancel, + contentDescription = stringResource( + R.string.auth_learning_sites_accessibility_log_out, + site.instanceName + ), + tint = MaterialTheme.appColors.textSecondary, + ) + } + } + } +} + +private fun LazyListScope.addSiteSection( + state: LearningSitesUIState, + onSearchQueryChanged: (String) -> Unit, + onSiteClick: (Instance) -> Unit, +) { + item(key = "title_add") { SectionTitle(R.string.auth_learning_sites_add_site) } + item(key = "search") { + SearchBar( + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp) + .fillMaxWidth(), + searchValue = TextFieldValue(state.searchQuery), + label = stringResource(R.string.auth_learning_sites_search_placeholder), + keyboardActions = {}, + onValueChanged = { onSearchQueryChanged(it.text) }, + onClearValue = { onSearchQueryChanged("") }, + ) + } + if (state.addableSites.isEmpty() && state.searchQuery.isNotBlank()) { + item(key = "empty") { + Text( + modifier = Modifier + .testTag("txt_learning_sites_empty") + .padding(vertical = 12.dp), + text = stringResource(R.string.auth_learning_sites_empty_search), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.bodySmall, + ) + } + } + items(state.addableSites, key = { "addable_${it.key}" }) { site -> + SiteItem(site = site, onClick = { onSiteClick(site) }) + } +} + +@Composable +private fun SiteItem( + site: Instance, + onClick: () -> Unit, + trailing: @Composable () -> Unit = {}, +) { + Column { + LearningSiteRow( + instance = site, + modifier = Modifier + .testTag("btn_select_site") + .clickable(role = Role.Button, onClick = onClick) + .padding(vertical = 4.dp), + trailing = { trailing() }, + ) + HorizontalDivider(color = MaterialTheme.appColors.divider) + } +} + +@Composable +private fun LogOutDialog( + site: Instance, + onDismiss: () -> Unit, + onConfirm: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + containerColor = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.cardShape, + title = { + Text( + text = stringResource(R.string.auth_learning_sites_log_out_title), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + ) + }, + text = { + Text( + text = stringResource(R.string.auth_learning_sites_log_out_message, site.instanceName), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyMedium, + ) + }, + confirmButton = { + TextButton(modifier = Modifier.testTag("btn_confirm_log_out"), onClick = onConfirm) { + Text( + text = stringResource(R.string.auth_learning_sites_log_out_action), + color = MaterialTheme.appColors.error, + style = MaterialTheme.appTypography.labelLarge, + ) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text( + text = stringResource(coreR.string.core_cancel), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge, + ) + } + }, + ) +} + +private fun previewSite(key: String, name: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", name) + addProperty("OAUTH_CLIENT_ID", "client") + addProperty("API_HOST_URL", "https://lms.$key.example.com") + } + ) +) + +@Preview(name = "Switching", widthDp = 375, heightDp = 812) +@Preview(name = "Switching dark", widthDp = 375, heightDp = 812, uiMode = 0x20) +@Composable +private fun LearningSitesScreenPreview() { + OpenEdXTheme { + LearningSitesScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + state = LearningSitesUIState( + currentSite = previewSite("north", "North Valley University"), + otherSignedInSites = listOf(previewSite("arts", "Institute of Arts")), + addableSites = listOf(previewSite("coast", "Coastal College"), previewSite("tech", "Tech Academy")), + ), + isSwitching = true, + onBackClick = {}, + onSearchQueryChanged = {}, + onSiteClick = {}, + onLogOutClick = {}, + ) + } +} + +@Preview(name = "First launch", widthDp = 375, heightDp = 812) +@Composable +private fun LearningSitesFirstLaunchPreview() { + var query by rememberSaveable { mutableStateOf("") } + OpenEdXTheme { + LearningSitesScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + state = LearningSitesUIState( + addableSites = listOf(previewSite("coast", "Coastal College"), previewSite("tech", "Tech Academy")), + searchQuery = query, + ), + isSwitching = false, + onBackClick = {}, + onSearchQueryChanged = { query = it }, + onSiteClick = {}, + onLogOutClick = {}, + ) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt new file mode 100644 index 000000000..fe427faad --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt @@ -0,0 +1,142 @@ +package org.openedx.auth.presentation.learningsites + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceStore +import java.util.Locale + +data class LearningSitesUIState( + /** The selected site, when it has a session. */ + val currentSite: Instance? = null, + /** Other sites the learner is signed in to. */ + val otherSignedInSites: List = emptyList(), + /** Sites without a session, filtered by [searchQuery]. */ + val addableSites: List = emptyList(), + val searchQuery: String = "", +) + +/** + * The Learning Sites screen: the first screen of a multi-site build, and where a signed-in + * learner switches sites or signs out of one ([isSwitching]). + */ +class LearningSitesViewModel( + val isSwitching: Boolean, + private val instanceStore: InstanceStore, + private val preferences: CorePreferences, + private val sessionManager: InstanceSessionManager, + private val router: AuthRouter, + private val config: Config, + private val dispatcher: CoroutineDispatcher, +) : ViewModel() { + + private val _uiState = MutableStateFlow(LearningSitesUIState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var catalog: List = emptyList() + private var signedInKeys: Set = emptySet() + + /** Set while a selection navigates away, so a second tap can't start another one. */ + private var isNavigating = false + + init { + refresh() + } + + /** Re-reads sessions: signing in or out elsewhere changes which list a site belongs to. */ + fun refresh() { + isNavigating = false + viewModelScope.launch { + val sites = instanceStore.instancesConfig.instances + .sortedBy { it.instanceName.lowercase(Locale.getDefault()) } + signedInKeys = withContext(dispatcher) { + sites.filter { preferences.hasSession(it.key) }.map { it.key }.toSet() + } + catalog = sites + publish() + } + } + + fun onSearchQueryChanged(query: String) { + _uiState.update { it.copy(searchQuery = query) } + publish() + } + + /** + * A site with a session opens straight to its dashboard; any other site opens its sign-in. + * When switching, the previous site's screens are closed before the switch, so nothing + * still running on them can act on the new site. + */ + fun select(fragmentManager: FragmentManager, instance: Instance) { + if (isNavigating) return + isNavigating = true + val isSignedIn = instance.key in signedInKeys + if (isSwitching) { + router.clearBackStack(fragmentManager) + fragmentManager.executePendingTransactions() + } + sessionManager.switchActiveInstance(instance) + when { + isSignedIn -> router.navigateToMain(fragmentManager, null, null) + else -> { + if (isSwitching) router.navigateToLearningSites(fragmentManager, isSwitching = false) + if (config.isPreLoginExperienceEnabled()) { + router.navigateToLogistration(fragmentManager, null) + } else { + router.navigateToSignIn(fragmentManager, null, null) + } + } + } + } + + /** + * Signing out of the selected site, while switching, ends that session everywhere and + * starts over on a fresh Learning Sites screen. Any other site just leaves the list of + * signed-in sites. + */ + fun logOut(fragmentManager: FragmentManager, instance: Instance) { + if (isNavigating) return + viewModelScope.launch { + val wasCurrent = instance.key == _uiState.value.currentSite?.key + sessionManager.logout(instance) + if (wasCurrent && isSwitching) { + isNavigating = true + router.navigateToLearningSites(fragmentManager, isSwitching = false) + } else { + refresh() + } + } + } + + private fun publish() { + val current = instanceStore.currentInstance?.takeIf { it.key in signedInKeys } + val query = _uiState.value.searchQuery.trim() + _uiState.update { state -> + state.copy( + currentSite = current, + otherSignedInSites = catalog.filter { it.key in signedInKeys && it.key != current?.key }, + addableSites = catalog + .filter { it.key !in signedInKeys } + .filter { query.isEmpty() || it.matches(query) }, + ) + } + } + + private fun Instance.matches(query: String): Boolean { + return instanceName.contains(query, ignoreCase = true) || + name.contains(query, ignoreCase = true) || + apiHostUrl.contains(query, ignoreCase = true) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index fc72523a8..61a67c1a5 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -67,6 +67,8 @@ class SignInFragment : Fragment() { viewModel.navigateToSignUp(parentFragmentManager) } + AuthEvent.ChangeSiteClick -> viewModel.changeSite(parentFragmentManager) + AuthEvent.BackClick -> { requireActivity().supportFragmentManager.popBackStackImmediate() } @@ -124,4 +126,5 @@ internal sealed interface AuthEvent { object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent + object ChangeSiteClick : AuthEvent } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index f7b56084c..b21593fa7 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -1,6 +1,7 @@ package org.openedx.auth.presentation.signin import org.openedx.core.domain.model.RegistrationField +import org.openedx.core.instance.Instance /** * Data class to store UI state of the SignIn screen @@ -26,4 +27,6 @@ internal data class SignInUIState( val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, + /** The site being signed in to, when the learner chose it among several. */ + val selectedSite: Instance? = null, ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index e7053f2c6..18ad77a90 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -27,6 +27,7 @@ import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.createHonorCodeField +import org.openedx.core.instance.InstanceProvider import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier @@ -57,6 +58,7 @@ class SignInViewModel( val infoType: String?, val authCode: String, currentLang: String, + instanceProvider: InstanceProvider, ) : BaseViewModel(resourceManager) { private val logger = Logger("SignInViewModel") @@ -74,6 +76,7 @@ class SignInViewModel( isLogistrationEnabled = config.isPreLoginExperienceEnabled(), isRegistrationEnabled = config.isRegistrationEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), + selectedSite = instanceProvider.currentInstance?.takeIf { instanceProvider.hasMultipleInstances }, ) ) internal val uiState: StateFlow = _uiState @@ -87,6 +90,10 @@ class SignInViewModel( logSignInScreenEvent() } + fun changeSite(fragmentManager: FragmentManager) { + router.navigateToLearningSites(fragmentManager, isSwitching = false) + } + fun login(username: String, password: String) { logEvent(AuthAnalyticsEvent.USER_SIGN_IN_CLICKED) if (!validator.isEmailOrUserNameValid(username)) { diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index f5b9bc867..8ee1ffcdf 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -2,8 +2,9 @@ package org.openedx.auth.presentation.signin.compose import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES -import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -30,6 +31,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -40,7 +42,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -66,10 +67,13 @@ import org.openedx.auth.presentation.ui.LoginTextField import org.openedx.auth.presentation.ui.PasswordVisibilityIcon import org.openedx.auth.presentation.ui.SocialAuthView import org.openedx.core.extension.TextConverter +import org.openedx.core.instance.Instance import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.HorizontalLine import org.openedx.core.ui.HyperlinkText +import org.openedx.core.ui.InstanceHeaderImage +import org.openedx.core.ui.LearningSiteLogo import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton import org.openedx.core.ui.displayCutoutForLandscape @@ -133,14 +137,12 @@ internal fun LoginScreen( ) } - Image( + InstanceHeaderImage( + fallback = painterResource(id = coreR.drawable.core_top_header), modifier = Modifier .fillMaxWidth() .fillMaxHeight(fraction = 0.3f), - painter = painterResource(id = coreR.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null, ) HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) if (state.isLogistrationEnabled) { @@ -180,6 +182,13 @@ internal fun LoginScreen( .displayCutoutForLandscape() .then(contentPaddings), ) { + state.selectedSite?.let { site -> + SelectedSiteCard( + site = site, + onChangeClick = { onEvent(AuthEvent.ChangeSiteClick) }, + ) + Spacer(modifier = Modifier.height(20.dp)) + } if (state.isLoginRegistrationFormEnabled) { Text( modifier = Modifier.testTag("txt_sign_in_title"), @@ -226,6 +235,47 @@ internal fun LoginScreen( } } +@Composable +private fun SelectedSiteCard( + site: Instance, + onChangeClick: () -> Unit, +) { + Row( + modifier = Modifier + .testTag("selected_site_section") + .fillMaxWidth() + .border(1.dp, MaterialTheme.appColors.textFieldBorder, MaterialTheme.appShapes.textFieldShape) + .padding(start = 12.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + LearningSiteLogo(instance = site, size = 32) + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(id = R.string.auth_selected_site), + color = MaterialTheme.appColors.textSecondary, + style = MaterialTheme.appTypography.labelSmall, + ) + Text( + modifier = Modifier.testTag("txt_selected_site_name"), + text = site.instanceName, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.bodyLarge, + ) + } + TextButton( + modifier = Modifier.testTag("btn_change_site"), + onClick = onChangeClick, + ) { + Text( + text = stringResource(id = R.string.auth_change_site), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge, + ) + } + } +} + @Composable private fun AuthForm( buttonWidth: Modifier, diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 9838dab6b..efaa0954a 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -44,4 +44,19 @@ %2$s]]> Show password Hide password + Learning Sites + Select or add a learning site + Get Started + Welcome! Select your learning site + Current Learning Site + More Learning Sites + Add a Learning Site + Search learning sites + No learning sites match your search + Log Out + Are you sure you want to log out of %s? + Log Out + Log out of %s + Selected Site + Change diff --git a/auth/src/test/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModelTest.kt new file mode 100644 index 000000000..b50d40fe1 --- /dev/null +++ b/auth/src/test/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModelTest.kt @@ -0,0 +1,195 @@ +package org.openedx.auth.presentation.learningsites + +import androidx.fragment.app.FragmentManager +import com.google.gson.JsonObject +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifyOrder +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.instance.Instance +import org.openedx.core.instance.InstanceSelectionStorage +import org.openedx.core.instance.InstanceSessionManager +import org.openedx.core.instance.InstanceStore +import org.openedx.core.instance.InstancesConfig + +@OptIn(ExperimentalCoroutinesApi::class) +class LearningSitesViewModelTest { + + private val dispatcher = UnconfinedTestDispatcher() + + private class InMemorySelection(override var selectedInstanceKey: String? = null) : InstanceSelectionStorage + + private fun site(key: String, name: String) = requireNotNull( + Instance.fromJson( + JsonObject().apply { + addProperty("KEY", key) + addProperty("NAME", name) + addProperty("OAUTH_CLIENT_ID", "$key-client") + addProperty("API_HOST_URL", "https://$key.example.com") + } + ) + ) + + private val zeta = site("zeta", "Zeta University") + private val alpha = site("alpha", "Alpha College") + private val beta = site("beta", "Beta Institute") + private val store = InstanceStore(InMemorySelection()) { InstancesConfig(listOf(zeta, alpha, beta)) } + private val preferences = mockk() + private val sessionManager = mockk(relaxed = true) + private val router = mockk(relaxed = true) + private val config = mockk { every { isPreLoginExperienceEnabled() } returns false } + private val fragmentManager = mockk(relaxed = true) + private var signedIn = setOf() + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + every { preferences.hasSession(any()) } answers { firstArg() in signedIn } + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + private fun viewModel(isSwitching: Boolean) = LearningSitesViewModel( + isSwitching = isSwitching, + instanceStore = store, + preferences = preferences, + sessionManager = sessionManager, + router = router, + config = config, + dispatcher = dispatcher, + ) + + @Test + fun `sites are grouped by session and sorted by name`() = runTest { + signedIn = setOf("zeta", "beta") + store.select(beta) + + val state = viewModel(isSwitching = true).uiState.value + + assertEquals(beta, state.currentSite) + assertEquals(listOf(zeta), state.otherSignedInSites) + assertEquals(listOf(alpha), state.addableSites) + } + + @Test + fun `a selected site without a session is not shown as current`() = runTest { + store.select(alpha) + + val state = viewModel(isSwitching = false).uiState.value + + assertNull(state.currentSite) + assertEquals(listOf(alpha, beta, zeta), state.addableSites) + } + + @Test + fun `search filters addable sites by name or host`() = runTest { + val viewModel = viewModel(isSwitching = false) + + viewModel.onSearchQueryChanged("institute") + assertEquals(listOf(beta), viewModel.uiState.value.addableSites) + viewModel.onSearchQueryChanged("zeta.example") + assertEquals(listOf(zeta), viewModel.uiState.value.addableSites) + viewModel.onSearchQueryChanged("nothing like it") + assertEquals(emptyList(), viewModel.uiState.value.addableSites) + } + + @Test + fun `choosing a signed in site opens its dashboard without signing in again`() = runTest { + signedIn = setOf("alpha", "beta") + store.select(beta) + val viewModel = viewModel(isSwitching = true) + + viewModel.select(fragmentManager, alpha) + + verifyOrder { + router.clearBackStack(fragmentManager) + fragmentManager.executePendingTransactions() + sessionManager.switchActiveInstance(alpha) + router.navigateToMain(fragmentManager, null, null) + } + verify(exactly = 0) { router.navigateToSignIn(any(), any(), any()) } + } + + @Test + fun `choosing a new site on first launch opens its sign in over the list`() = runTest { + val viewModel = viewModel(isSwitching = false) + + viewModel.select(fragmentManager, zeta) + + verify { sessionManager.switchActiveInstance(zeta) } + verify { router.navigateToSignIn(fragmentManager, null, null) } + verify(exactly = 0) { router.clearBackStack(any()) } + } + + @Test + fun `switching to a new site starts over with the list under its sign in`() = runTest { + signedIn = setOf("beta") + store.select(beta) + val viewModel = viewModel(isSwitching = true) + + viewModel.select(fragmentManager, alpha) + + verifyOrder { + router.clearBackStack(fragmentManager) + sessionManager.switchActiveInstance(alpha) + router.navigateToLearningSites(fragmentManager, isSwitching = false) + router.navigateToSignIn(fragmentManager, null, null) + } + } + + @Test + fun `a double tap starts only one navigation`() = runTest { + val viewModel = viewModel(isSwitching = false) + + viewModel.select(fragmentManager, zeta) + viewModel.select(fragmentManager, alpha) + + verify(exactly = 1) { sessionManager.switchActiveInstance(any()) } + } + + @Test + fun `logging out of another site only refreshes the list`() = runTest { + signedIn = setOf("alpha", "beta") + store.select(beta) + coEvery { sessionManager.logout(alpha) } answers { signedIn = setOf("beta") } + val viewModel = viewModel(isSwitching = true) + + viewModel.logOut(fragmentManager, alpha) + + coVerify { sessionManager.logout(alpha) } + verify(exactly = 0) { router.navigateToLearningSites(any(), any()) } + assertEquals(emptyList(), viewModel.uiState.value.otherSignedInSites) + assertEquals(listOf(alpha, zeta), viewModel.uiState.value.addableSites) + } + + @Test + fun `logging out of the current site starts over at site selection`() = runTest { + signedIn = setOf("beta") + store.select(beta) + val viewModel = viewModel(isSwitching = true) + + viewModel.logOut(fragmentManager, beta) + + coVerify { sessionManager.logout(beta) } + verify { router.navigateToLearningSites(fragmentManager, isSwitching = false) } + } +} diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index ce6044eaf..1bc36164b 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -37,6 +37,7 @@ import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.interactor.CalendarInteractor import org.openedx.core.domain.model.CalendarType +import org.openedx.core.instance.InstanceProvider import org.openedx.core.presentation.global.WhatsNewGlobalManager import org.openedx.core.system.EdxError import org.openedx.core.system.notifier.app.AppNotifier @@ -69,6 +70,7 @@ class SignInViewModelTest { private val whatsNewGlobalManager = mockk() private val calendarInteractor = mockk() private val calendarPreferences = mockk() + private val instanceProvider = mockk(relaxed = true) private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -136,7 +138,8 @@ class SignInViewModelTest { calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, configuration = config, - currentLang = "EN" + currentLang = "EN", + instanceProvider = instanceProvider, ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -175,7 +178,8 @@ class SignInViewModelTest { calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, configuration = config, - currentLang = "EN" + currentLang = "EN", + instanceProvider = instanceProvider, ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } @@ -214,7 +218,8 @@ class SignInViewModelTest { calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, configuration = config, - currentLang = "EN" + currentLang = "EN", + instanceProvider = instanceProvider, ) viewModel.login("acc@test.org", "") @@ -252,7 +257,8 @@ class SignInViewModelTest { calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, configuration = config, - currentLang = "EN" + currentLang = "EN", + instanceProvider = instanceProvider, ) viewModel.login("acc@test.org", "ed") @@ -294,7 +300,8 @@ class SignInViewModelTest { calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, configuration = config, - currentLang = "EN" + currentLang = "EN", + instanceProvider = instanceProvider, ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") @@ -337,7 +344,8 @@ class SignInViewModelTest { calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, configuration = config, - currentLang = "EN" + currentLang = "EN", + instanceProvider = instanceProvider, ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") @@ -381,7 +389,8 @@ class SignInViewModelTest { calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, configuration = config, - currentLang = "EN" + currentLang = "EN", + instanceProvider = instanceProvider, ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") @@ -425,7 +434,8 @@ class SignInViewModelTest { calendarInteractor = calendarInteractor, calendarPreferences = calendarPreferences, configuration = config, - currentLang = "EN" + currentLang = "EN", + instanceProvider = instanceProvider, ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt b/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt index 74f99c72c..66f3ce4c2 100644 --- a/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt +++ b/core/src/main/java/org/openedx/core/instance/InstanceSelectionStorage.kt @@ -10,9 +10,15 @@ interface InstanceSelectionStorage { var selectedInstanceKey: String? } +/** Whether the first multi-site launch has already looked for a session from a single-site build. */ +interface LegacySessionAdoptionStorage { + var hasCheckedLegacySession: Boolean +} + class SharedPrefsInstanceSelectionStorage(context: Context) : InstanceSelectionStorage, - LegacyNamespaceOwnerStorage { + LegacyNamespaceOwnerStorage, + LegacySessionAdoptionStorage { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -30,9 +36,14 @@ class SharedPrefsInstanceSelectionStorage(context: Context) : if (value == null) remove(KEY_LEGACY_OWNER) else putString(KEY_LEGACY_OWNER, value) } + override var hasCheckedLegacySession: Boolean + get() = prefs.getBoolean(KEY_CHECKED_LEGACY_SESSION, false) + set(value) = prefs.edit(commit = true) { putBoolean(KEY_CHECKED_LEGACY_SESSION, value) } + private companion object { const val PREFS_NAME = "openedx_instance_store" const val KEY_SELECTED_INSTANCE = "selected_instance_key" const val KEY_LEGACY_OWNER = "legacy_storage_owner_instance_key" + const val KEY_CHECKED_LEGACY_SESSION = "checked_legacy_session" } } diff --git a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt index 6a91f5a25..7127a1f43 100644 --- a/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt +++ b/core/src/main/java/org/openedx/core/instance/InstanceSessionManager.kt @@ -18,7 +18,7 @@ interface InstanceSessionManager { * Makes [instance] the active site. Nothing is torn down: the outgoing site keeps its * session, cached content and downloads for when the learner comes back. */ - suspend fun switchActiveInstance(instance: Instance) + fun switchActiveInstance(instance: Instance) /** * Signs out of the site selected when it is called: cancels downloads, unregisters push, @@ -63,7 +63,7 @@ class DefaultInstanceSessionManager( private val pushTokenUnregistrar: PushTokenUnregistering? = null, ) : InstanceSessionManager { - override suspend fun switchActiveInstance(instance: Instance) { + override fun switchActiveInstance(instance: Instance) { // Web content signs in with session cookies fetched for one site; the next page has to // fetch the new site's (refetching replaces the old ones). cookieManager.invalidateSessionCookie() diff --git a/core/src/main/java/org/openedx/core/ui/InstanceBranding.kt b/core/src/main/java/org/openedx/core/ui/InstanceBranding.kt new file mode 100644 index 000000000..9b86b1125 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/InstanceBranding.kt @@ -0,0 +1,86 @@ +package org.openedx.core.ui + +import android.annotation.SuppressLint +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import coil.compose.AsyncImage +import org.openedx.core.ui.theme.InstanceThemeManager + +/** + * An image an instance may brand: a remote URL, the name of a bundled drawable, or nothing. + * Anything that can't be resolved, including a failed download, shows [fallback], so a broken + * logo URL never leaves an empty space. + */ +@Composable +fun InstanceThemedImage( + source: String?, + fallback: Painter, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.Fit, + contentDescription: String? = null, +) { + val bundledId = rememberBundledDrawable(source) + when { + source != null && (source.startsWith("https://") || source.startsWith("http://")) -> AsyncImage( + model = source, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + placeholder = fallback, + error = fallback, + fallback = fallback, + ) + + bundledId != 0 -> Image( + painter = painterResource(bundledId), + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + + else -> Image( + painter = fallback, + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + ) + } +} + +/** The selected instance's header background, or the app's own header. */ +@Composable +fun InstanceHeaderImage( + fallback: Painter, + modifier: Modifier = Modifier, + contentScale: ContentScale = ContentScale.FillBounds, +) { + val theme by InstanceThemeManager.theme.collectAsState() + InstanceThemedImage( + source = theme?.headerBackgroundUrl, + fallback = fallback, + modifier = modifier, + contentScale = contentScale, + ) +} + +// Instances name bundled logos in configuration, so the resource is looked up by name. +@SuppressLint("DiscouragedApi") +@Composable +private fun rememberBundledDrawable(source: String?): Int { + val context = LocalContext.current + return remember(source) { + if (source.isNullOrBlank() || source.contains("/")) { + 0 + } else { + context.resources.getIdentifier(source, "drawable", context.packageName) + } + } +} diff --git a/core/src/main/java/org/openedx/core/ui/LearningSiteRow.kt b/core/src/main/java/org/openedx/core/ui/LearningSiteRow.kt new file mode 100644 index 000000000..4e564b82b --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/LearningSiteRow.kt @@ -0,0 +1,105 @@ +package org.openedx.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import org.openedx.core.instance.Instance +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import java.net.URI +import java.util.Locale + +/** + * One learning site: its logo, host and name, with an optional trailing control. Used by the + * Learning Sites screen, the sign-in screen and Settings. + */ +@Composable +fun LearningSiteRow( + instance: Instance, + modifier: Modifier = Modifier, + logoSize: Int = 48, + trailing: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 64.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + LearningSiteLogo(instance = instance, size = logoSize) + Column(modifier = Modifier.weight(1f)) { + Text( + text = instance.host, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = instance.instanceName, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + trailing() + } +} + +/** + * The site's own logo; until it loads, or when the site has none, a monogram of its name. + * The app logo is drawn for a dark header and would disappear on a light row. + */ +@Composable +fun LearningSiteLogo(instance: Instance, size: Int) { + Box( + modifier = Modifier + .size(size.dp) + .clip(CircleShape) + .background(MaterialTheme.appColors.background) + .border(1.dp, MaterialTheme.appColors.cardViewBorder, CircleShape), + contentAlignment = Alignment.Center, + ) { + Text( + text = instance.instanceName.firstOrNull { it.isLetterOrDigit() } + ?.uppercase(Locale.getDefault()) + .orEmpty(), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.primary, + ) + if (instance.logoUrl != null) { + InstanceThemedImage( + source = instance.logoUrl, + fallback = ColorPainter(Color.Transparent), + modifier = Modifier.size(size.dp), + ) + } + } +} + +/** `lms.example.com`, with the port when it isn't the default; the configured URL if unparsable. */ +val Instance.host: String + get() = runCatching { + val uri = URI(apiHostUrl) + if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" + }.getOrNull() ?: apiHostUrl 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 index c9bb67550..d3c644549 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/InstanceTheme.kt @@ -30,8 +30,7 @@ data class InstanceTheme( * back to the light one for the same field. */ 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 + var colors = accentColor(isDark)?.let(base::withAccent) ?: base light.forEach { (field, lightHex) -> if (field == ACCENT_COLOR) return@forEach val hex = if (isDark) dark[field] ?: lightHex else lightHex @@ -41,6 +40,12 @@ data class InstanceTheme( return colors } + /** The accent for one mode, for views outside Compose: the dark value falls back to the light one. */ + fun accentColor(isDark: Boolean): Color? { + val modeAccent = if (isDark) dark[ACCENT_COLOR] ?: light[ACCENT_COLOR] else light[ACCENT_COLOR] + return (modeAccent ?: accentColorHex)?.toColorOrNull() + } + companion object { const val ACCENT_COLOR = "accent_color" diff --git a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt index a458a7af1..f44d742f1 100644 --- a/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt +++ b/core/src/test/java/org/openedx/core/instance/InstanceSessionManagerTest.kt @@ -56,7 +56,7 @@ class InstanceSessionManagerTest { } @Test - fun `switching selects the site and tears nothing down`() = runBlocking { + fun `switching selects the site and tears nothing down`() { manager().switchActiveInstance(beta) assertEquals(beta, store.currentInstance) diff --git a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt index e9f67ad48..8bf783b36 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/ProfileRouter.kt @@ -23,4 +23,7 @@ interface ProfileRouter { fun navigateToManageAccount(fm: FragmentManager) fun navigateToCoursesToSync(fm: FragmentManager) + + /** Opens Learning Sites over Settings, to switch sites or sign out of one. */ + fun navigateToLearningSites(fm: FragmentManager, isSwitching: Boolean) } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt index f1eaf0aeb..5a2aac64f 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsFragment.kt @@ -35,65 +35,7 @@ class SettingsFragment : Fragment() { onBackClick = { requireActivity().supportFragmentManager.popBackStack() }, - onAction = { action -> - when (action) { - SettingsScreenAction.AppVersionClick -> { - viewModel.appVersionClickedEvent(requireContext()) - } - - SettingsScreenAction.LogoutClick -> { - viewModel.logout() - } - - SettingsScreenAction.PrivacyPolicyClick -> { - viewModel.privacyPolicyClicked( - requireActivity().supportFragmentManager - ) - } - - SettingsScreenAction.CookiePolicyClick -> { - viewModel.cookiePolicyClicked( - requireActivity().supportFragmentManager - ) - } - - SettingsScreenAction.DataSellClick -> { - viewModel.dataSellClicked( - requireActivity().supportFragmentManager - ) - } - - SettingsScreenAction.FaqClick -> viewModel.faqClicked() - - SettingsScreenAction.SupportClick -> { - viewModel.emailSupportClicked(requireContext()) - } - - SettingsScreenAction.TermsClick -> { - viewModel.termsOfUseClicked( - requireActivity().supportFragmentManager - ) - } - - SettingsScreenAction.VideoSettingsClick -> { - viewModel.videoSettingsClicked( - requireActivity().supportFragmentManager - ) - } - - SettingsScreenAction.ManageAccountClick -> { - viewModel.manageAccountClicked( - requireActivity().supportFragmentManager - ) - } - - SettingsScreenAction.CalendarSettingsClick -> { - viewModel.calendarSettingsClicked( - requireActivity().supportFragmentManager - ) - } - } - } + onAction = ::onAction ) LaunchedEffect(logoutSuccess) { @@ -104,6 +46,70 @@ class SettingsFragment : Fragment() { } } } + + private fun onAction(action: SettingsScreenAction) { + when (action) { + SettingsScreenAction.AppVersionClick -> { + viewModel.appVersionClickedEvent(requireContext()) + } + + SettingsScreenAction.LogoutClick -> { + viewModel.logout() + } + + SettingsScreenAction.PrivacyPolicyClick -> { + viewModel.privacyPolicyClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.CookiePolicyClick -> { + viewModel.cookiePolicyClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.DataSellClick -> { + viewModel.dataSellClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.FaqClick -> viewModel.faqClicked() + + SettingsScreenAction.SupportClick -> { + viewModel.emailSupportClicked(requireContext()) + } + + SettingsScreenAction.TermsClick -> { + viewModel.termsOfUseClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.VideoSettingsClick -> { + viewModel.videoSettingsClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.ManageAccountClick -> { + viewModel.manageAccountClicked( + requireActivity().supportFragmentManager + ) + } + + SettingsScreenAction.LearningSitesClick -> { + viewModel.learningSitesClicked(requireActivity().supportFragmentManager) + } + + SettingsScreenAction.CalendarSettingsClick -> { + viewModel.calendarSettingsClicked( + requireActivity().supportFragmentManager + ) + } + } + } } internal interface SettingsScreenAction { @@ -116,6 +122,7 @@ internal interface SettingsScreenAction { object TermsClick : SettingsScreenAction object SupportClick : SettingsScreenAction object VideoSettingsClick : SettingsScreenAction + object LearningSitesClick : SettingsScreenAction object ManageAccountClick : SettingsScreenAction object CalendarSettingsClick : SettingsScreenAction } diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index 0c049bc68..cd11519da 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -53,7 +54,9 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import org.openedx.core.AppUpdateState import org.openedx.core.R +import org.openedx.core.instance.Instance import org.openedx.core.system.notifier.app.AppUpgradeEvent +import org.openedx.core.ui.LearningSiteRow import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.Toolbar import org.openedx.core.ui.displayCutoutForLandscape @@ -168,6 +171,15 @@ internal fun SettingsScreen( ) { Spacer(Modifier.height(30.dp)) + uiState.learningSite?.let { site -> + LearningSiteSection( + site = site, + onSiteClick = { onAction(SettingsScreenAction.LearningSitesClick) }, + onLogoutClick = { showLogoutDialog = true }, + ) + Spacer(modifier = Modifier.height(24.dp)) + } + ManageAccountSection(onManageAccountClick = { onAction(SettingsScreenAction.ManageAccountClick) }) @@ -192,9 +204,12 @@ internal fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) - LogoutButton( - onClick = { showLogoutDialog = true } - ) + // With several sites, log out lives in the site card above. + if (uiState.learningSite == null) { + LogoutButton( + onClick = { showLogoutDialog = true } + ) + } Spacer(Modifier.height(30.dp)) } @@ -207,6 +222,65 @@ internal fun SettingsScreen( } } +@Composable +private fun LearningSiteSection( + site: Instance, + onSiteClick: () -> Unit, + onLogoutClick: () -> Unit, +) { + Column { + Text( + modifier = Modifier.testTag("txt_current_learning_site"), + text = stringResource(id = profileR.string.profile_current_learning_site), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary + ) + Spacer(modifier = Modifier.height(14.dp)) + Card( + shape = MaterialTheme.appShapes.cardShape, + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.appColors.cardViewBackground) + ) { + Column(Modifier.fillMaxWidth()) { + LearningSiteRow( + instance = site, + modifier = Modifier + .testTag("btn_current_learning_site") + .clickable(onClick = onSiteClick) + .padding(horizontal = 20.dp, vertical = 8.dp), + ) { + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.appColors.onSurface + ) + } + SettingsDivider() + Row( + modifier = Modifier + .testTag("btn_logout") + .fillMaxWidth() + .clickable(onClick = onLogoutClick) + .padding(20.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier.testTag("txt_logout"), + text = stringResource(id = profileR.string.profile_logout), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.error + ) + Icon( + painterResource(id = profileR.drawable.profile_ic_logout), + contentDescription = null, + tint = MaterialTheme.appColors.error + ) + } + } + } + } +} + @Composable private fun SettingsSection( onVideoSettingsClick: () -> Unit, diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt index f2b736d45..d6256702c 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsUIState.kt @@ -1,10 +1,13 @@ package org.openedx.profile.presentation.settings +import org.openedx.core.instance.Instance import org.openedx.profile.domain.model.Configuration sealed class SettingsUIState { data class Data( val configuration: Configuration, + /** The selected site, shown only when there are several to switch between. */ + val learningSite: Instance? = null, ) : SettingsUIState() object Loading : SettingsUIState() diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt index 6b453b7dd..78151b2e4 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsViewModel.kt @@ -17,6 +17,7 @@ import org.openedx.core.AppUpdateState import org.openedx.core.CalendarRouter import org.openedx.core.R import org.openedx.core.config.Config +import org.openedx.core.instance.InstanceProvider import org.openedx.core.presentation.global.AppData import org.openedx.core.system.AppCookieManager import org.openedx.core.system.notifier.app.AppNotifier @@ -44,9 +45,15 @@ class SettingsViewModel( private val calendarRouter: CalendarRouter, private val appNotifier: AppNotifier, private val profileNotifier: ProfileNotifier, + private val instanceProvider: InstanceProvider, ) : BaseViewModel(resourceManager) { - private val _uiState: MutableStateFlow = MutableStateFlow(SettingsUIState.Data(configuration)) + private val _uiState: MutableStateFlow = MutableStateFlow( + SettingsUIState.Data( + configuration = configuration, + learningSite = instanceProvider.currentInstance?.takeIf { instanceProvider.hasMultipleInstances }, + ) + ) internal val uiState: StateFlow = _uiState.asStateFlow() private val _successLogout = MutableSharedFlow() @@ -102,6 +109,10 @@ class SettingsViewModel( } } + fun learningSitesClicked(fragmentManager: FragmentManager) { + profileRouter.navigateToLearningSites(fragmentManager, isSwitching = true) + } + fun videoSettingsClicked(fragmentManager: FragmentManager) { profileRouter.navigateToVideoSettings(fragmentManager) logProfileEvent(ProfileAnalyticsEvent.VIDEO_SETTING_CLICKED) diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index d2ee6951e..e490b2e0f 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -79,5 +79,6 @@ No Google calendars available Select calendar Local calendar + Current Learning Site From 0d5b25b9e5155b95374f70517669dfa045ffbd9d Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:29:46 +0300 Subject: [PATCH 16/17] fix: match the iOS Learning Sites, Sign In and Settings layouts Side-by-side screenshots against openedx-app-ios develop showed where the Android screens had drifted: - Settings: the current site card sits at the bottom, after Support, with Log out folded into it, as on iOS. - Learning Sites: the switching header shows the site's logo (the app logo when it has none) under the back button; the subtitle is secondary text; "No schools match your search" shows whenever nothing is left to add. - Logging out of a site uses the app's own log out dialog, now shared from core, instead of a plain alert. - Sign In shows the back arrow when reached from Learning Sites, and the selected site's logo when it has one. - Site rows show the host without the port, and use the iOS copy for the search placeholder and the empty result. - The Settings header follows the selected site's header background. --- .../learningsites/LearningSitesScreen.kt | 118 ++++++------- .../learningsites/LearningSitesViewModel.kt | 2 +- .../presentation/signin/SignInFragment.kt | 2 + .../presentation/signin/compose/SignInView.kt | 35 +++- auth/src/main/res/values/strings.xml | 5 +- .../org/openedx/core/ui/ComposeExtensions.kt | 11 +- .../org/openedx/core/ui/InstanceBranding.kt | 14 ++ .../org/openedx/core/ui/LearningSiteRow.kt | 15 +- .../java/org/openedx/core/ui/LogoutDialog.kt | 155 ++++++++++++++++++ .../src/main/res/drawable/core_ic_exit.xml | 0 .../src/main/res/drawable/core_ic_logout.xml | 0 .../presentation/settings/SettingsScreenUI.kt | 141 ++-------------- 12 files changed, 296 insertions(+), 202 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/ui/LogoutDialog.kt rename profile/src/main/res/drawable/profile_ic_exit.xml => core/src/main/res/drawable/core_ic_exit.xml (100%) rename profile/src/main/res/drawable/profile_ic_logout.xml => core/src/main/res/drawable/core_ic_logout.xml (100%) diff --git a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesScreen.kt b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesScreen.kt index e9b71d8dc..1f8ae7610 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesScreen.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesScreen.kt @@ -1,15 +1,19 @@ package org.openedx.auth.presentation.learningsites +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn @@ -17,7 +21,6 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Cancel -import androidx.compose.material3.AlertDialog import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -25,7 +28,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -36,6 +38,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -43,6 +46,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -50,8 +54,9 @@ import com.google.gson.JsonObject import org.openedx.auth.R import org.openedx.core.instance.Instance import org.openedx.core.ui.BackBtn -import org.openedx.core.ui.InstanceHeaderImage +import org.openedx.core.ui.InstanceThemedImage import org.openedx.core.ui.LearningSiteRow +import org.openedx.core.ui.LogoutDialog import org.openedx.core.ui.SearchBar import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.theme.OpenEdXTheme @@ -93,8 +98,11 @@ internal fun LearningSitesScreen( containerColor = MaterialTheme.appColors.background, contentWindowInsets = WindowInsets(), ) { paddings -> - InstanceHeaderImage( - fallback = painterResource(id = coreR.drawable.core_top_header), + // The app's own header, as on iOS: a site's branding belongs to its own screens. + Image( + painter = painterResource(id = coreR.drawable.core_top_header), + contentDescription = null, + contentScale = ContentScale.FillBounds, modifier = Modifier .fillMaxWidth() .fillMaxHeight(fraction = 0.3f), @@ -113,7 +121,12 @@ internal fun LearningSitesScreen( modifier = Modifier.padding(paddings), horizontalAlignment = Alignment.CenterHorizontally, ) { - SignInLogoView() + val switchingFrom = state.currentSite + if (isSwitching && switchingFrom != null) { + CurrentSiteHeader(switchingFrom) + } else { + SignInLogoView() + } Surface( color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape, @@ -156,10 +169,11 @@ internal fun LearningSitesScreen( } siteToLogOut?.let { site -> - LogOutDialog( - site = site, - onDismiss = { siteToLogOut = null }, - onConfirm = { + LogoutDialog( + message = stringResource(R.string.auth_learning_sites_log_out_message, site.name), + actionText = stringResource(R.string.auth_learning_sites_log_out_action), + onDismissRequest = { siteToLogOut = null }, + onLogoutClick = { siteToLogOut = null onLogOutClick(site) }, @@ -167,6 +181,39 @@ internal fun LearningSitesScreen( } } +/** The site being switched from: its logo, or the app logo when it has none, and its name. */ +@Composable +private fun CurrentSiteHeader(site: Instance) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.2f) + .testTag("current_site_header"), + contentAlignment = Alignment.Center, + ) { + Row( + // Below the back button rather than beside it, as on iOS. + modifier = Modifier.padding(top = 56.dp, start = 24.dp, end = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + InstanceThemedImage( + source = site.logoUrl, + fallback = painterResource(id = coreR.drawable.core_ic_logo), + modifier = Modifier.size(56.dp), + ) + Text( + modifier = Modifier.testTag("txt_current_site_header_name"), + text = site.name, + color = Color.White, + style = MaterialTheme.appTypography.titleLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + @Composable private fun Heading(isSwitching: Boolean) { Column(modifier = Modifier.padding(bottom = 8.dp)) { @@ -189,7 +236,7 @@ private fun Heading(isSwitching: Boolean) { R.string.auth_learning_sites_welcome_subtitle } ), - color = MaterialTheme.appColors.textPrimary, + color = if (isSwitching) MaterialTheme.appColors.textSecondary else MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.titleSmall, ) } @@ -222,7 +269,7 @@ private fun LazyListScope.siteSection( imageVector = Icons.Filled.Cancel, contentDescription = stringResource( R.string.auth_learning_sites_accessibility_log_out, - site.instanceName + site.name ), tint = MaterialTheme.appColors.textSecondary, ) @@ -249,7 +296,7 @@ private fun LazyListScope.addSiteSection( onClearValue = { onSearchQueryChanged("") }, ) } - if (state.addableSites.isEmpty() && state.searchQuery.isNotBlank()) { + if (state.addableSites.isEmpty()) { item(key = "empty") { Text( modifier = Modifier @@ -285,51 +332,6 @@ private fun SiteItem( } } -@Composable -private fun LogOutDialog( - site: Instance, - onDismiss: () -> Unit, - onConfirm: () -> Unit, -) { - AlertDialog( - onDismissRequest = onDismiss, - containerColor = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.cardShape, - title = { - Text( - text = stringResource(R.string.auth_learning_sites_log_out_title), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleLarge, - ) - }, - text = { - Text( - text = stringResource(R.string.auth_learning_sites_log_out_message, site.instanceName), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.bodyMedium, - ) - }, - confirmButton = { - TextButton(modifier = Modifier.testTag("btn_confirm_log_out"), onClick = onConfirm) { - Text( - text = stringResource(R.string.auth_learning_sites_log_out_action), - color = MaterialTheme.appColors.error, - style = MaterialTheme.appTypography.labelLarge, - ) - } - }, - dismissButton = { - TextButton(onClick = onDismiss) { - Text( - text = stringResource(coreR.string.core_cancel), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge, - ) - } - }, - ) -} - private fun previewSite(key: String, name: String) = requireNotNull( Instance.fromJson( JsonObject().apply { diff --git a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt index fe427faad..f5fadaf72 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt @@ -60,7 +60,7 @@ class LearningSitesViewModel( isNavigating = false viewModelScope.launch { val sites = instanceStore.instancesConfig.instances - .sortedBy { it.instanceName.lowercase(Locale.getDefault()) } + .sortedBy { it.name.lowercase(Locale.getDefault()) } signedInKeys = withContext(dispatcher) { sites.filter { preferences.hasSession(it.key) }.map { it.key }.toSet() } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index 61a67c1a5..80b96ac2d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -80,6 +80,8 @@ class SignInFragment : Fragment() { ) } }, + isBackToSitesShown = state.selectedSite != null && + requireActivity().supportFragmentManager.backStackEntryCount > 0, ) LaunchedEffect(state.loginSuccess) { viewModel.proceedWhatsNew(parentFragmentManager) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index 8ee1ffcdf..6f3edb529 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding @@ -73,6 +74,7 @@ import org.openedx.core.ui.HandleUIMessage import org.openedx.core.ui.HorizontalLine import org.openedx.core.ui.HyperlinkText import org.openedx.core.ui.InstanceHeaderImage +import org.openedx.core.ui.InstanceThemedImage import org.openedx.core.ui.LearningSiteLogo import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.OpenEdXOutlinedButton @@ -96,6 +98,7 @@ internal fun LoginScreen( state: SignInUIState, uiMessage: UIMessage?, onEvent: (AuthEvent) -> Unit, + isBackToSitesShown: Boolean = false, ) { val scrollState = rememberScrollState() val snackbarHostState = remember { SnackbarHostState() } @@ -145,7 +148,8 @@ internal fun LoginScreen( .fillMaxHeight(fraction = 0.3f), ) HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) - if (state.isLogistrationEnabled) { + // As on iOS, a site picked on Learning Sites can be put back from here. + if (state.isLogistrationEnabled || isBackToSitesShown) { Box( modifier = Modifier @@ -165,7 +169,12 @@ internal fun LoginScreen( Modifier.padding(it), horizontalAlignment = Alignment.CenterHorizontally, ) { - SignInLogoView() + val siteLogoUrl = state.selectedSite?.logoUrl + if (siteLogoUrl != null) { + SiteLogoView(siteLogoUrl) + } else { + SignInLogoView() + } Surface( color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape, @@ -235,6 +244,26 @@ internal fun LoginScreen( } } +/** The selected site's logo in place of the app's, sized like the app logo it replaces. */ +@Composable +private fun SiteLogoView(logoUrl: String) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.2f), + contentAlignment = Alignment.Center, + ) { + InstanceThemedImage( + source = logoUrl, + fallback = painterResource(id = coreR.drawable.core_ic_logo), + modifier = Modifier + .padding(top = 20.dp) + .widthIn(max = 189.dp) + .heightIn(max = 89.dp), + ) + } +} + @Composable private fun SelectedSiteCard( site: Instance, @@ -258,7 +287,7 @@ private fun SelectedSiteCard( ) Text( modifier = Modifier.testTag("txt_selected_site_name"), - text = site.instanceName, + text = site.name, color = MaterialTheme.appColors.textPrimary, style = MaterialTheme.appTypography.bodyLarge, ) diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index efaa0954a..397422d3a 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -51,9 +51,8 @@ Current Learning Site More Learning Sites Add a Learning Site - Search learning sites - No learning sites match your search - Log Out + University of Arts & Sciences + No schools match your search Are you sure you want to log out of %s? Log Out Log out of %s diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index 1351662eb..a31058a46 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.Stable import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.mapSaver @@ -39,6 +41,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import org.openedx.core.R import org.openedx.core.presentation.global.InsetHolder +import org.openedx.core.ui.theme.InstanceThemeManager const val KEYBOARD_VISIBILITY_THRESHOLD = 0.15f @@ -171,10 +174,16 @@ fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { return (currentPage - page) + currentPageOffsetFraction } +/** The header behind Settings and its sub-screens: the selected site's, else the app's. */ fun Modifier.settingsHeaderBackground(): Modifier = composed { + val theme by InstanceThemeManager.theme.collectAsState() + val painter = rememberInstanceImagePainter( + source = theme?.headerBackgroundUrl, + fallback = painterResource(id = R.drawable.core_top_header), + ) return@composed this .paint( - painter = painterResource(id = R.drawable.core_top_header), + painter = painter, contentScale = ContentScale.FillWidth, alignment = Alignment.TopCenter ) diff --git a/core/src/main/java/org/openedx/core/ui/InstanceBranding.kt b/core/src/main/java/org/openedx/core/ui/InstanceBranding.kt index 9b86b1125..0a7dce3e9 100644 --- a/core/src/main/java/org/openedx/core/ui/InstanceBranding.kt +++ b/core/src/main/java/org/openedx/core/ui/InstanceBranding.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter import org.openedx.core.ui.theme.InstanceThemeManager /** @@ -55,6 +56,19 @@ fun InstanceThemedImage( } } +/** A painter for an image an instance may brand; see [InstanceThemedImage]. */ +@Composable +fun rememberInstanceImagePainter(source: String?, fallback: Painter): Painter { + val bundledId = rememberBundledDrawable(source) + return when { + source != null && (source.startsWith("https://") || source.startsWith("http://")) -> + rememberAsyncImagePainter(model = source, placeholder = fallback, error = fallback, fallback = fallback) + + bundledId != 0 -> painterResource(bundledId) + else -> fallback + } +} + /** The selected instance's header background, or the app's own header. */ @Composable fun InstanceHeaderImage( diff --git a/core/src/main/java/org/openedx/core/ui/LearningSiteRow.kt b/core/src/main/java/org/openedx/core/ui/LearningSiteRow.kt index 4e564b82b..c42f166aa 100644 --- a/core/src/main/java/org/openedx/core/ui/LearningSiteRow.kt +++ b/core/src/main/java/org/openedx/core/ui/LearningSiteRow.kt @@ -35,13 +35,13 @@ import java.util.Locale fun LearningSiteRow( instance: Instance, modifier: Modifier = Modifier, - logoSize: Int = 48, + logoSize: Int = 50, trailing: @Composable RowScope.() -> Unit = {}, ) { Row( modifier = modifier .fillMaxWidth() - .heightIn(min = 64.dp), + .heightIn(min = 60.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -55,7 +55,7 @@ fun LearningSiteRow( overflow = TextOverflow.Ellipsis, ) Text( - text = instance.instanceName, + text = instance.name, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary, maxLines = 2, @@ -81,7 +81,7 @@ fun LearningSiteLogo(instance: Instance, size: Int) { contentAlignment = Alignment.Center, ) { Text( - text = instance.instanceName.firstOrNull { it.isLetterOrDigit() } + text = instance.name.firstOrNull { it.isLetterOrDigit() } ?.uppercase(Locale.getDefault()) .orEmpty(), style = MaterialTheme.appTypography.titleMedium, @@ -97,9 +97,6 @@ fun LearningSiteLogo(instance: Instance, size: Int) { } } -/** `lms.example.com`, with the port when it isn't the default; the configured URL if unparsable. */ +/** `lms.example.com`, without scheme or port; the configured URL if unparsable. */ val Instance.host: String - get() = runCatching { - val uri = URI(apiHostUrl) - if (uri.port == -1) uri.host else "${uri.host}:${uri.port}" - }.getOrNull() ?: apiHostUrl + get() = runCatching { URI(apiHostUrl).host }.getOrNull() ?: apiHostUrl diff --git a/core/src/main/java/org/openedx/core/ui/LogoutDialog.kt b/core/src/main/java/org/openedx/core/ui/LogoutDialog.kt new file mode 100644 index 000000000..714628181 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/LogoutDialog.kt @@ -0,0 +1,155 @@ +package org.openedx.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTagsAsResourceId +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import org.openedx.core.R +import org.openedx.core.ui.theme.OpenEdXTheme +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography + +/** + * The app's log out confirmation: a door icon, the question, and a warning-coloured button. + * Settings asks about the whole session; Learning Sites asks about one site. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun LogoutDialog( + message: String, + actionText: String, + onDismissRequest: () -> Unit, + onLogoutClick: () -> Unit, +) { + Dialog( + onDismissRequest = onDismissRequest, + content = { + Column( + Modifier + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .background( + MaterialTheme.appColors.background, + MaterialTheme.appShapes.cardShape + ) + .clip(MaterialTheme.appShapes.cardShape) + .border( + 1.dp, + MaterialTheme.appColors.cardViewBorder, + MaterialTheme.appShapes.cardShape + ) + .padding(horizontal = 40.dp, vertical = 36.dp) + .semantics { testTagsAsResourceId = true }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + IconButton( + modifier = Modifier + .testTag("ib_close") + .size(24.dp), + onClick = onDismissRequest + ) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(id = R.string.core_cancel), + tint = MaterialTheme.appColors.primary + ) + } + } + Icon( + modifier = Modifier + .width(88.dp) + .height(85.dp), + painter = painterResource(R.drawable.core_ic_exit), + contentDescription = null, + tint = MaterialTheme.appColors.onBackground + ) + Spacer(Modifier.size(36.dp)) + Text( + modifier = Modifier.testTag("txt_logout_dialog_title"), + text = message, + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleLarge, + textAlign = TextAlign.Center + ) + Spacer(Modifier.size(36.dp)) + OpenEdXButton( + text = actionText, + backgroundColor = MaterialTheme.appColors.warning, + onClick = onLogoutClick, + content = { + Box( + Modifier + .testTag("btn_logout") + .fillMaxWidth(), + contentAlignment = Alignment.CenterEnd + ) { + Text( + modifier = Modifier + .testTag("txt_logout") + .fillMaxWidth(), + text = actionText, + color = MaterialTheme.appColors.textWarning, + style = MaterialTheme.appTypography.labelLarge, + textAlign = TextAlign.Center + ) + Icon( + modifier = Modifier + .testTag("ic_logout"), + painter = painterResource(id = R.drawable.core_ic_logout), + contentDescription = null, + tint = Color.Black + ) + } + } + ) + } + } + ) +} + +@Preview +@Composable +private fun LogoutDialogPreview() { + OpenEdXTheme { + LogoutDialog( + message = "Are you sure you want to log out?", + actionText = "Log Out", + onDismissRequest = {}, + onLogoutClick = {}, + ) + } +} diff --git a/profile/src/main/res/drawable/profile_ic_exit.xml b/core/src/main/res/drawable/core_ic_exit.xml similarity index 100% rename from profile/src/main/res/drawable/profile_ic_exit.xml rename to core/src/main/res/drawable/core_ic_exit.xml diff --git a/profile/src/main/res/drawable/profile_ic_logout.xml b/core/src/main/res/drawable/core_ic_logout.xml similarity index 100% rename from profile/src/main/res/drawable/profile_ic_logout.xml rename to core/src/main/res/drawable/core_ic_logout.xml diff --git a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt index cd11519da..5bdeecef8 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/settings/SettingsScreenUI.kt @@ -2,7 +2,6 @@ package org.openedx.profile.presentation.settings import androidx.compose.foundation.Image import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -15,18 +14,15 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -37,27 +33,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTagsAsResourceId -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog import org.openedx.core.AppUpdateState import org.openedx.core.R import org.openedx.core.instance.Instance import org.openedx.core.system.notifier.app.AppUpgradeEvent import org.openedx.core.ui.LearningSiteRow -import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.LogoutDialog import org.openedx.core.ui.Toolbar import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.settingsHeaderBackground @@ -123,6 +112,8 @@ internal fun SettingsScreen( if (showLogoutDialog) { LogoutDialog( + message = stringResource(id = profileR.string.profile_logout_dialog_body), + actionText = stringResource(id = profileR.string.profile_logout), onDismissRequest = { showLogoutDialog = false }, @@ -171,15 +162,6 @@ internal fun SettingsScreen( ) { Spacer(Modifier.height(30.dp)) - uiState.learningSite?.let { site -> - LearningSiteSection( - site = site, - onSiteClick = { onAction(SettingsScreenAction.LearningSitesClick) }, - onLogoutClick = { showLogoutDialog = true }, - ) - Spacer(modifier = Modifier.height(24.dp)) - } - ManageAccountSection(onManageAccountClick = { onAction(SettingsScreenAction.ManageAccountClick) }) @@ -204,8 +186,15 @@ internal fun SettingsScreen( Spacer(modifier = Modifier.height(24.dp)) - // With several sites, log out lives in the site card above. - if (uiState.learningSite == null) { + // With several sites, log out is folded into the current site's card. + val learningSite = uiState.learningSite + if (learningSite != null) { + LearningSiteSection( + site = learningSite, + onSiteClick = { onAction(SettingsScreenAction.LearningSitesClick) }, + onLogoutClick = { showLogoutDialog = true }, + ) + } else { LogoutButton( onClick = { showLogoutDialog = true } ) @@ -271,7 +260,7 @@ private fun LearningSiteSection( color = MaterialTheme.appColors.error ) Icon( - painterResource(id = profileR.drawable.profile_ic_logout), + painterResource(id = R.drawable.core_ic_logout), contentDescription = null, tint = MaterialTheme.appColors.error ) @@ -431,7 +420,7 @@ private fun LogoutButton(onClick: () -> Unit) { color = MaterialTheme.appColors.error ) Icon( - painterResource(id = profileR.drawable.profile_ic_logout), + painterResource(id = R.drawable.core_ic_logout), contentDescription = null, tint = MaterialTheme.appColors.error ) @@ -439,102 +428,6 @@ private fun LogoutButton(onClick: () -> Unit) { } } -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun LogoutDialog( - onDismissRequest: () -> Unit, - onLogoutClick: () -> Unit, -) { - Dialog( - onDismissRequest = onDismissRequest, - content = { - Column( - Modifier - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .background( - MaterialTheme.appColors.background, - MaterialTheme.appShapes.cardShape - ) - .clip(MaterialTheme.appShapes.cardShape) - .border( - 1.dp, - MaterialTheme.appColors.cardViewBorder, - MaterialTheme.appShapes.cardShape - ) - .padding(horizontal = 40.dp, vertical = 36.dp) - .semantics { testTagsAsResourceId = true }, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier.fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - IconButton( - modifier = Modifier - .testTag("ib_close") - .size(24.dp), - onClick = onDismissRequest - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.core_cancel), - tint = MaterialTheme.appColors.primary - ) - } - } - Icon( - modifier = Modifier - .width(88.dp) - .height(85.dp), - painter = painterResource(profileR.drawable.profile_ic_exit), - contentDescription = null, - tint = MaterialTheme.appColors.onBackground - ) - Spacer(Modifier.size(36.dp)) - Text( - modifier = Modifier.testTag("txt_logout_dialog_title"), - text = stringResource(id = profileR.string.profile_logout_dialog_body), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleLarge, - textAlign = TextAlign.Center - ) - Spacer(Modifier.size(36.dp)) - OpenEdXButton( - text = stringResource(id = profileR.string.profile_logout), - backgroundColor = MaterialTheme.appColors.warning, - onClick = onLogoutClick, - content = { - Box( - Modifier - .testTag("btn_logout") - .fillMaxWidth(), - contentAlignment = Alignment.CenterEnd - ) { - Text( - modifier = Modifier - .testTag("txt_logout") - .fillMaxWidth(), - text = stringResource(id = profileR.string.profile_logout), - color = MaterialTheme.appColors.textWarning, - style = MaterialTheme.appTypography.labelLarge, - textAlign = TextAlign.Center - ) - Icon( - modifier = Modifier - .testTag("ic_logout"), - painter = painterResource(id = profileR.drawable.profile_ic_logout), - contentDescription = null, - tint = Color.Black - ) - } - } - ) - } - } - ) -} - @Composable private fun AppVersionItem( versionName: String, @@ -732,12 +625,6 @@ private fun AppVersionItemUpgradeRequiredPreview() { } } -@Preview -@Composable -private fun LogoutDialogPreview() { - LogoutDialog({}, {}) -} - @Preview @Composable private fun SettingsScreenPreview() { From 78613d90129e44f7366c9d4e60bde2da923945c7 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:14:56 +0300 Subject: [PATCH 17/17] fix: no site can be picked while a sign-out is in progress Signing out of a site waits on the network. A tap on another site in that time switched sites under the sign-out still running. Taps on a site, or on another sign-out, are now ignored until it finishes. --- .../learningsites/LearningSitesViewModel.kt | 14 +++++-- .../LearningSitesViewModelTest.kt | 42 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt index f5fadaf72..b165d4f49 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModel.kt @@ -51,6 +51,9 @@ class LearningSitesViewModel( /** Set while a selection navigates away, so a second tap can't start another one. */ private var isNavigating = false + /** Set while a sign-out waits on the network: switching sites then would race it. */ + private var isSigningOut = false + init { refresh() } @@ -80,7 +83,7 @@ class LearningSitesViewModel( * still running on them can act on the new site. */ fun select(fragmentManager: FragmentManager, instance: Instance) { - if (isNavigating) return + if (isNavigating || isSigningOut) return isNavigating = true val isSignedIn = instance.key in signedInKeys if (isSwitching) { @@ -107,10 +110,15 @@ class LearningSitesViewModel( * signed-in sites. */ fun logOut(fragmentManager: FragmentManager, instance: Instance) { - if (isNavigating) return + if (isNavigating || isSigningOut) return + isSigningOut = true viewModelScope.launch { val wasCurrent = instance.key == _uiState.value.currentSite?.key - sessionManager.logout(instance) + try { + sessionManager.logout(instance) + } finally { + isSigningOut = false + } if (wasCurrent && isSwitching) { isNavigating = true router.navigateToLearningSites(fragmentManager, isSwitching = false) diff --git a/auth/src/test/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModelTest.kt index b50d40fe1..58f7bd2b6 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/learningsites/LearningSitesViewModelTest.kt @@ -8,6 +8,7 @@ import io.mockk.every import io.mockk.mockk import io.mockk.verify import io.mockk.verifyOrder +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -192,4 +193,45 @@ class LearningSitesViewModelTest { coVerify { sessionManager.logout(beta) } verify { router.navigateToLearningSites(fragmentManager, isSwitching = false) } } + + @Test + fun `no site can be picked while a sign-out is still in progress`() = runTest { + signedIn = setOf("alpha", "beta") + store.select(alpha) + val signOutDone = CompletableDeferred() + coEvery { sessionManager.logout(alpha) } coAnswers { + signOutDone.await() + signedIn = setOf("beta") + } + val viewModel = viewModel(isSwitching = true) + + viewModel.logOut(fragmentManager, alpha) + viewModel.select(fragmentManager, beta) + viewModel.logOut(fragmentManager, beta) + + verify(exactly = 0) { sessionManager.switchActiveInstance(any()) } + coVerify(exactly = 0) { sessionManager.logout(beta) } + + signOutDone.complete(Unit) + + verify { router.navigateToLearningSites(fragmentManager, isSwitching = false) } + } + + @Test + fun `sites can be picked again once a sign-out of another site finishes`() = runTest { + signedIn = setOf("alpha", "beta") + store.select(beta) + val signOutDone = CompletableDeferred() + coEvery { sessionManager.logout(alpha) } coAnswers { + signOutDone.await() + signedIn = setOf("beta") + } + val viewModel = viewModel(isSwitching = true) + + viewModel.logOut(fragmentManager, alpha) + signOutDone.complete(Unit) + viewModel.select(fragmentManager, beta) + + verify(exactly = 1) { sessionManager.switchActiveInstance(beta) } + } }