Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions Documentation/LMSDirectory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
# The LMS Directory

A build of this app normally talks to one Open edX site, named in
`config.yaml`. With the LMS Directory on, it instead shows a list of platforms,
lets the learner pick one, re-themes to it and signs in against it.

Off by default. With `ENABLED: false` nothing in this document applies and the
app behaves exactly as it always has.

## Where the list comes from

Two ways, and the config decides which:

```yaml
LMS_DIRECTORY:
ENABLED: true
DIRECTORY_URL: "https://example.com/lms_directory.json" # a document, on the web
DIRECTORY_FILE: ""
```

```yaml
LMS_DIRECTORY:
ENABLED: true
DIRECTORY_URL: ""
DIRECTORY_FILE: "lms_directory.json" # a document, in the app
```

`DIRECTORY_URL` is fetched once, and whatever comes back is the document — the
address can be anything you can serve a file from. If both `DIRECTORY_URL` and
`DIRECTORY_FILE` are set the file wins: a build that ships its own copy has
deliberately opted out of the network, and quietly preferring a remote list would
undo that.

## What a document looks like

One JSON file. This is the whole format:

```json
{
"format": "v1",
"provider": {
"name": "Northwind Education Group",
"tagline": "Five campuses, one app",
"logo": null
},
"include": [
{
"name": "Northwind College",
"description": "Main campus",
"url": "https://learn.northwind.edu",
"logo": "https://cdn.northwind.edu/logo.png",

"id": "1",
"accent_color": "#002545",
"api": {
"feedback_email": "support@northwind.edu"
},
"feature_flags": {
"pre_login_discovery": false,
"unknown_units_mode": "webview"
},
"theme": {
"accent_color_dark": "#4989bf",
"login_background": "https://cdn.northwind.edu/signin.png"
},
"ui_components": {
"course_unit_progress_enabled": true,
"course_dropdown_navigation_enabled": true,
"pre_login_experience_enabled": false
},
"dashboard": { "type": "list" }
}
]
}
```

### Required

| field | what it is |
| --- | --- |
| `format` | `"v1"`. The only version there is. |
| `include[]` | At least one platform. An empty list gives the learner nothing to pick. |
| `name` | Shown in the list and on the sign-in screen. |
| `url` | The Open edX site. Must be `https` in a shipped build. |

### Optional

Everything else, `api` and `id` included. Omit a key and the app uses its own
default, so the smallest useful entry is `name` and `url`. A platform that names
no `id` is identified by its address, which is unique in a directory anyway.
`provider` is optional too; its `name` is shown above the list.

The key names follow the schema the Open edX mobile working group is settling
on, so a file written by hand and a file exported from a registry are the same
shape. Unknown keys are ignored, which is what lets a newer file stay readable
by an older build.

### OAuth

A multi-instance app carries **one** OAuth client id of its own — the one in
`config.yaml` — and each platform registers that id in its own OAuth
Applications table, ideally restricted to the app's redirect scheme. The
directory is not where per-platform credentials live, so `api` can be omitted
entirely and usually should be.

`api` is still read when present: `host_url` for a platform whose API lives at a
different address than the one the learner picked, `oauth_client_id` for a
platform that insists on its own, and `feedback_email` for the support address.
A platform naming its own client id overrides the app's for that platform only.

## Images

Every image field takes either of two things, and the value itself says which:

- something starting with `http://` or `https://` is downloaded;
- anything else is the **name of a file shipped with the app**.

So `"logo": "https://cdn.northwind.edu/logo.png"` is fetched, and
`"logo": "northwind-logo.png"` is read from `assets/` — Coil resolves it as
`file:///android_asset/…` natively. That is what makes a fully offline build possible: put the images next to the document, refer to them
by name, and the app never asks the network for a picture.

## Shipping the document inside the app

1. Put the document and its images in `app/src/main/assets/`.
2. That is all — assets need no registration.

Then set `DIRECTORY_FILE` to the file name and leave `DIRECTORY_URL` empty. The
app now works on a device that has never been online.

## Where to get a document

**Write it by hand.** For a handful of platforms this is the honest answer —
it is one JSON file, and the example above is a working template.

**Or edit it somewhere.** Any tool that emits the shape above will do, and one
that exists today is <https://openedx-lms.stepanok.com>: a form for adding
platforms and uploading their logos and sign-in artwork, which publishes the
document at a URL and also exports a `.zip` of the document with its image
fields already rewritten to file names, plus the images themselves — the bundle
the offline case needs.

That is somebody's **unofficial** tool. It is not part of Open edX, not
maintained by this project, and nothing here depends on it. The app reads a
document; where the document came from is not its business.
9 changes: 5 additions & 4 deletions app/src/main/java/org/openedx/app/AppActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,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.lmsselection.SiteSelectionFragment
import org.openedx.auth.presentation.logistration.LogistrationFragment
import org.openedx.auth.presentation.signin.SignInFragment
import org.openedx.core.data.storage.CorePreferences
Expand Down Expand Up @@ -158,10 +159,10 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {
if (savedInstanceState == null) {
when {
corePreferencesManager.user == null -> {
val fragment = if (viewModel.isLogistrationEnabled && authCode == null) {
LogistrationFragment()
} else {
SignInFragment.newInstance(null, null)
val fragment = when {
viewModel.isLmsSelectionRequired && authCode == null -> SiteSelectionFragment()
viewModel.isLogistrationEnabled && authCode == null -> LogistrationFragment()
else -> SignInFragment.newInstance(null, null)
}
addFragment(fragment)
}
Expand Down
31 changes: 26 additions & 5 deletions app/src/main/java/org/openedx/app/AppRouter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@ 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.lmsselection.SiteSelectionFragment
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.config.Config
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.lmsdirectory.LmsThemeController
import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter
import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment
import org.openedx.core.presentation.global.webview.SSOWebContentFragment
Expand Down Expand Up @@ -61,7 +65,10 @@ 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 config: Config,
private val corePreferences: CorePreferences,
) :
AuthRouter,
DiscoveryRouter,
DashboardRouter,
Expand Down Expand Up @@ -103,6 +110,10 @@ class AppRouter :
replaceFragmentWithBackStack(fm, LogistrationFragment.newInstance(courseId))
}

override fun navigateToLmsSelection(fm: FragmentManager) {
replaceFragmentWithBackStack(fm, SiteSelectionFragment())
}

override fun navigateToDownloadQueue(fm: FragmentManager, descendants: List<String>) {
replaceFragmentWithBackStack(fm, DownloadQueueFragment.newInstance(descendants))
}
Expand Down Expand Up @@ -406,10 +417,20 @@ class AppRouter :
override fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) {
fm.apply {
clearBackStack(this)
if (isLogistrationEnabled) {
replaceFragment(fm, LogistrationFragment())
} else {
replaceFragment(fm, SignInFragment.newInstance(null, null))
when {
// LMS Directory: after logout the selection is cleared (see
// clearCorePreferences), so when the feature is reachable return to the
// platform picker instead of the stock sign-in — matches the app-launch
// path in AppActivity.setupInitialFragment and the iOS behavior. Reset the
// in-memory accent so the neutral landing isn't tinted by the old LMS.
config.getLMSDirectoryConfig().isReachable &&
corePreferences.selectedBaseUrl.isNullOrBlank() -> {
LmsThemeController.clear()
replaceFragment(fm, SiteSelectionFragment())
}

isLogistrationEnabled -> replaceFragment(fm, LogistrationFragment())
else -> replaceFragment(fm, SignInFragment.newInstance(null, null))
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/org/openedx/app/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ class AppViewModel(

val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled()

/**
* LMS Directory: on first launch (before sign-in) the learner must pick a platform.
* True only when the feature is on and nothing is selected yet.
*/
val isLmsSelectionRequired: Boolean
get() = config.getLMSDirectoryConfig().isReachable && preferencesManager.selectedBaseUrl.isNullOrBlank()

private var logoutHandledAt: Long = 0

val isBranchEnabled get() = config.getBranchConfig().enabled
Expand Down
25 changes: 25 additions & 0 deletions app/src/main/java/org/openedx/app/MainFragment.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package org.openedx.app

import android.content.res.ColorStateList
import android.os.Bundle
import android.view.Menu
import android.view.View
Expand All @@ -8,6 +9,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
Expand All @@ -22,6 +25,7 @@ import org.openedx.app.deeplink.HomeTab
import org.openedx.core.AppUpdateState
import org.openedx.core.AppUpdateState.wasUpgradeDialogClosed
import org.openedx.core.adapter.NavigationFragmentAdapter
import org.openedx.core.lmsdirectory.LmsThemeController
import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment
import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox
import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment
Expand Down Expand Up @@ -84,10 +88,31 @@ class MainFragment : Fragment(R.layout.fragment_main) {
val tabList = createTabList(openTabArg)
addMenuItems(menu, tabList)
setupBottomNavListener(tabList)
applyLmsAccentTint()

requireArguments().remove(ARG_OPEN_TAB)
}

/**
* LMS Directory: the bottom bar is a View-based [BottomNavigationView], so the Compose
* accent theme doesn't reach it — its selected color stays the baked-in stock blue.
* When a platform is selected, tint the checked item with the LMS accent so the tab bar
* matches the rest of the re-themed app (and iOS). Unchecked keeps the stock grey.
*/
private fun applyLmsAccentTint() {
val accent = LmsThemeController.accentColor ?: return
val unchecked = ContextCompat.getColor(requireContext(), org.openedx.core.R.color.unchecked_tab_item)
val tint = ColorStateList(
arrayOf(
intArrayOf(android.R.attr.state_checked),
intArrayOf(-android.R.attr.state_checked),
),
intArrayOf(accent.toArgb(), unchecked),
)
binding.bottomNavView.itemIconTintList = tint
binding.bottomNavView.itemTextColor = tint
}

private fun createTabList(openTabArg: String): List<Pair<Int, () -> Fragment>> {
val learnFragmentFactory = {
LearnFragment.newInstance(
Expand Down
13 changes: 12 additions & 1 deletion app/src/main/java/org/openedx/app/OpenEdXApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,15 @@ import org.openedx.app.di.appModule
import org.openedx.app.di.networkingModule
import org.openedx.app.di.screenModule
import org.openedx.core.config.Config
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.lmsdirectory.LmsThemeController
import org.openedx.core.lmsdirectory.lmsDirectoryModule
import org.openedx.firebase.OEXFirebaseAnalytics

class OpenEdXApp : Application() {

private val config by inject<Config>()
private val corePreferences by inject<CorePreferences>()
private val pluginManager by inject<PluginManager>()

override fun onCreate() {
Expand All @@ -28,9 +32,16 @@ class OpenEdXApp : Application() {
modules(
appModule,
networkingModule,
screenModule
screenModule,
lmsDirectoryModule
)
}
// LMS Directory: re-apply the selected platform's brand color on cold start so
// the whole app is themed before the first screen composes. No-op when off.
if (config.getLMSDirectoryConfig().isReachable) {
LmsThemeController.apply(corePreferences.selectedLmsAccentColor)
LmsThemeController.applyBackground(corePreferences.selectedLmsLoginBackgroundUrl)
}
if (config.getFirebaseConfig().enabled) {
FirebaseApp.initializeApp(this)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package org.openedx.app.data.networking

import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor
import okhttp3.Response
import org.openedx.core.data.storage.CorePreferences

/**
* LMS Directory: routes every API request to the platform the learner selected.
*
* The Retrofit client is built once with the config host, but the selected LMS can
* differ (and is chosen after the client exists). This rewrites each request's
* scheme/host/port to [CorePreferences.selectedBaseUrl] on the fly. No selection
* (feature off, or a stock build) → requests pass through untouched.
*/
class BaseUrlOverrideInterceptor(
private val corePreferences: CorePreferences,
) : Interceptor {

override fun intercept(chain: Interceptor.Chain): Response {
val override = corePreferences.selectedBaseUrl
val original = chain.request()

if (override.isNullOrBlank()) {
return chain.proceed(original)
}

val baseUrl = override.toHttpUrlOrNull()
val originalUrl = original.url

val needsUpdate = baseUrl != null && (
originalUrl.host != baseUrl.host ||
originalUrl.port != baseUrl.port ||
originalUrl.scheme != baseUrl.scheme
)

val requestToProcess = if (needsUpdate && baseUrl != null) {
val updatedUrl = originalUrl.newBuilder()
.scheme(baseUrl.scheme)
.host(baseUrl.host)
.port(baseUrl.port)
.build()
original.newBuilder().url(updatedUrl).build()
} else {
original
}

return chain.proceed(requestToProcess)
}
}
Loading
Loading