diff --git a/Documentation/LMSDirectory.md b/Documentation/LMSDirectory.md new file mode 100644 index 000000000..fd13c75f1 --- /dev/null +++ b/Documentation/LMSDirectory.md @@ -0,0 +1,178 @@ +# 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 + +Three 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: "" + DIRECTORY_MODE: "" +``` + +```yaml +LMS_DIRECTORY: + ENABLED: true + DIRECTORY_URL: "" + DIRECTORY_FILE: "lms_directory.json" # a document, in the app + DIRECTORY_MODE: "" +``` + +```yaml +LMS_DIRECTORY: + ENABLED: true + DIRECTORY_URL: "https://registry.example.com" # a live registry + DIRECTORY_FILE: "" + DIRECTORY_MODE: "" +``` + +**The address is what decides.** A `DIRECTORY_URL` ending in `.json` is read as a +*document*: one file, fetched once, that already contains every platform and its +branding. Anything else is treated as the base URL of a *service* that answers +`/api/v1/directory`. 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. + +`DIRECTORY_MODE` is `""`, `"search"` or `"curated"`, and only means anything for +a live registry: it overrides what the server would otherwise say. A document is +always a fixed list, so the key is ignored for one. + +## What a document looks like + +One JSON file. This is the whole format: + +```json +{ + "version": 1, + "provider": { + "name": "Northwind Education Group", + "tagline": "Five campuses, one app", + "logo_url": null + }, + "platforms": [ + { + "id": "1", + "title": "Northwind College", + "description": "The main campus, offering undergraduate programmes.", + "short_description": "Main campus", + "base_url": "https://learn.northwind.edu", + "logo_url": "https://cdn.northwind.edu/logo.png", + "accent_color": "#002545", + "visibility": "public", + "featured": false, + "api": { + "host_url": "https://learn.northwind.edu", + "feedback_email": "support@northwind.edu", + "oauth_client_id": "PASTE_THE_MOBILE_OAUTH_CLIENT_ID" + }, + "feature_flags": { + "pre_login_discovery": false, + "unknown_units_mode": "webview" + }, + "theme": { + "accent_color_dark": "#4989bf", + "login_background_url": "https://cdn.northwind.edu/signin.png", + "logo_upload_url": null + }, + "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 | +| --- | --- | +| `version` | `1`. The only version there is. | +| `platforms[]` | At least one. An empty list gives the learner nothing to pick. | +| `id` | Unique within the file. A string, even when it looks like a number. | +| `title` | Shown in the list and on the sign-in screen. | +| `description` / `short_description` | Long and one-line blurbs. | +| `base_url` | The Open edX site. Must be `https` in a shipped build. | +| `api.host_url` | Usually the same as `base_url`. | +| `api.oauth_client_id` | The site's **mobile** OAuth client id. Sign-in fails without the right one. | +| `api.feedback_email` | May be `""`. | + +### Optional + +Everything else. Omit a key and the app uses its own default, so the smallest +useful entry is `id`, `title`, `description`, `short_description`, `base_url` +and `api`. `provider` is optional too; its `name` is shown above the list. + +`visibility` and `featured` come from the registry's own model and are ignored +when reading a document — every platform in the file is shown. + +## 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_url": "https://cdn.northwind.edu/logo.png"` is fetched, and +`"logo_url": "northwind-logo.png"` is looked up in `assets/` (Coil reads `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 generate one.** Any tool that emits the shape above will do. One that +exists today is the LMS Registry at — a web +app where you add platforms through a form, upload their logos and sign-in +artwork, and it publishes the document at `/p//directory.json`. It also +exports a `.zip` holding the document with its image fields already rewritten to +file names plus every image beside it, which is exactly the bundle the offline +case needs. + +Be clear about what that is: an **unofficial, experimental tool**, not part of +Open edX, not maintained by this project, and not required by this app. Its +public source lives in the archived +[openedx-unsupported/openedx-mobile-site-registry](https://github.com/openedx-unsupported/openedx-mobile-site-registry). + +Nothing in this app knows about that tool, or any other. It reads a document; +where the document came from is not its business. + +## The live-registry alternative + +Pointing `DIRECTORY_URL` at a service instead makes the app ask it for the list, +which is what a public catalog of many unrelated platforms needs — search, and +platforms appearing without a new app release. A service must answer: + +- `GET /api/v1/config` — `{"directory_mode": "search"|"curated", "provider_name": …}` +- `GET /api/v1/directory` — `{"items": [ … ]}`, the list +- `GET /api/v1/directory/{id}` — one platform, the same shape as a `platforms[]` + entry above + +`GET /api/v1/directory/{id}` and a document's `platforms[]` entries are the same +shape on purpose: a client that reads one already reads the other. + +Reporting a platform ("Report this LMS" in the profile) needs a live registry to +post to, and is offered only when the registry says it is in `search` mode — an +open catalog is the only place where a stranger can list something nobody has +vouched for. A build reading a document never shows it. diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index f0a71f713..9593c83a5 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -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.LmsLandingFragment import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.data.storage.CorePreferences @@ -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 -> LmsLandingFragment() + viewModel.isLogistrationEnabled && authCode == null -> LogistrationFragment() + else -> SignInFragment.newInstance(null, null) } addFragment(fragment) } diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index a511dc839..9bbd47d41 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -5,12 +5,17 @@ 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.LmsLandingFragment +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 @@ -61,7 +66,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, @@ -103,6 +111,10 @@ class AppRouter : replaceFragmentWithBackStack(fm, LogistrationFragment.newInstance(courseId)) } + override fun navigateToLmsSelection(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, SiteSelectionFragment()) + } + override fun navigateToDownloadQueue(fm: FragmentManager, descendants: List) { replaceFragmentWithBackStack(fm, DownloadQueueFragment.newInstance(descendants)) } @@ -406,10 +418,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, LmsLandingFragment()) + } + + 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 bafddb19b..74939976c 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -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 diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 397216b74..c96697a57 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -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 @@ -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 @@ -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 @@ -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 Fragment>> { val learnFragmentFactory = { LearnFragment.newInstance( diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 6524cde5d..8d218fb02 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -6,6 +6,10 @@ import com.braze.configuration.BrazeConfig import com.braze.ui.BrazeDeeplinkHandler import com.google.firebase.FirebaseApp import io.branch.referral.Branch +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.koin.android.ext.koin.androidContext import org.koin.core.context.startKoin @@ -14,12 +18,20 @@ 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.LmsDirectoryMode +import org.openedx.core.lmsdirectory.LmsDirectoryRepository +import org.openedx.core.lmsdirectory.LmsDirectoryState +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() + private val corePreferences by inject() private val pluginManager by inject() + private val directoryRepository by inject() override fun onCreate() { super.onCreate() @@ -28,9 +40,22 @@ 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) { + // Anything remembered about a directory this build no longer reads is + // dropped here, before a screen can act on it. + LmsDirectoryState.reconcile(config.getLMSDirectoryConfig(), corePreferences) + LmsThemeController.apply(corePreferences.selectedLmsAccentColor) + LmsThemeController.applyBackground(corePreferences.selectedLmsLoginBackgroundUrl) + refreshDirectoryMode() + } else { + LmsDirectoryState.clear(corePreferences) + } if (config.getFirebaseConfig().enabled) { FirebaseApp.initializeApp(this) } @@ -64,6 +89,27 @@ class OpenEdXApp : Application() { initPlugins() } + /** + * Ask the registry what kind of catalog it is, on every launch. + * + * The platform picker asks this too, but a learner who has already chosen a + * platform never sees the picker again — so without this, a registry that + * switched between an open catalog and a curated one would go unnoticed for + * the life of the install. + */ + private fun refreshDirectoryMode() { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + LmsDirectoryState.refresh(config.getLMSDirectoryConfig(), corePreferences) { + val answer = directoryRepository.fetchConfigOrNull() + when { + answer == null -> LmsDirectoryMode.UNKNOWN + answer.isCurated -> LmsDirectoryMode.CURATED + else -> LmsDirectoryMode.SEARCH + } + } + } + } + private fun initPlugins() { if (config.getFirebaseConfig().enabled) { pluginManager.addPlugin(OEXFirebaseAnalytics(context = this)) diff --git a/app/src/main/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptor.kt new file mode 100644 index 000000000..301bfb8e3 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptor.kt @@ -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) + } +} 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..fcff40133 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 @@ -9,6 +9,7 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.google.gson.Gson +import com.google.gson.reflect.TypeToken import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -24,6 +25,7 @@ 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.lmsdirectory.LmsHistoryEntry import org.openedx.core.system.CalendarManager import org.openedx.core.system.notifier.app.AppNotifier import org.openedx.core.system.notifier.app.LogoutEvent @@ -56,6 +58,8 @@ class PreferencesManager( private val encryption = DataStoreEncryption() + private val gson = Gson() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @Volatile @@ -85,6 +89,20 @@ class PreferencesManager( 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") + val SELECTED_BASE_URL = stringPreferencesKey("selected_base_url") + val SELECTED_LMS_ACCENT_COLOR = stringPreferencesKey("selected_lms_accent_color") + val SELECTED_OAUTH_CLIENT_ID = stringPreferencesKey("selected_oauth_client_id") + val SELECTED_FEEDBACK_EMAIL = stringPreferencesKey("selected_feedback_email") + val SELECTED_LMS_LOGO_URL = stringPreferencesKey("selected_lms_logo_url") + val SELECTED_LMS_LOGIN_BACKGROUND = + stringPreferencesKey("selected_lms_login_background") + val SELECTED_LMS_TITLE = stringPreferencesKey("selected_lms_title") + val LMS_DIRECTORY_MODE = stringPreferencesKey("lms_directory_mode") + + /** Written by builds before LMS_DIRECTORY_MODE. Only ever removed. */ + val legacyLmsDirectoryCurated = booleanPreferencesKey("lms_directory_curated") + val LMS_DIRECTORY_SOURCE_KEY = stringPreferencesKey("lms_directory_source_key") + val LMS_HISTORY = stringPreferencesKey("lms_history") fun calendarSyncDialogShown(courseName: String) = booleanPreferencesKey("calendar_sync_dialog_${courseName.replaceSpace("_")}") @@ -128,6 +146,16 @@ class PreferencesManager( prefs.remove(Keys.EXPIRES_IN) prefs.remove(Keys.USER) prefs.remove(Keys.ACCOUNT) + // LMS Directory: drop the selected platform on logout so the app returns to + // the platform picker (matches iOS) instead of silently reusing the previous + // LMS's host/branding for the next user. No-op for single-tenant builds. + prefs.remove(Keys.SELECTED_BASE_URL) + prefs.remove(Keys.SELECTED_LMS_ACCENT_COLOR) + prefs.remove(Keys.SELECTED_OAUTH_CLIENT_ID) + prefs.remove(Keys.SELECTED_FEEDBACK_EMAIL) + prefs.remove(Keys.SELECTED_LMS_LOGO_URL) + prefs.remove(Keys.SELECTED_LMS_LOGIN_BACKGROUND) + prefs.remove(Keys.SELECTED_LMS_TITLE) } } @@ -201,6 +229,59 @@ class PreferencesManager( get() = getValue(Keys.IS_RELATIVE_DATES_ENABLED, true) set(value) = setValue(Keys.IS_RELATIVE_DATES_ENABLED, value) + override var selectedBaseUrl: String? + get() = getValue(Keys.SELECTED_BASE_URL, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_BASE_URL, value.orEmpty()) + + override var selectedLmsAccentColor: String? + get() = getValue(Keys.SELECTED_LMS_ACCENT_COLOR, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_ACCENT_COLOR, value.orEmpty()) + + override var selectedOAuthClientId: String? + get() = getValue(Keys.SELECTED_OAUTH_CLIENT_ID, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_OAUTH_CLIENT_ID, value.orEmpty()) + + override var selectedFeedbackEmail: String? + get() = getValue(Keys.SELECTED_FEEDBACK_EMAIL, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_FEEDBACK_EMAIL, value.orEmpty()) + + override var selectedLmsLogoUrl: String? + get() = getValue(Keys.SELECTED_LMS_LOGO_URL, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_LOGO_URL, value.orEmpty()) + + override var selectedLmsLoginBackgroundUrl: String? + get() = getValue(Keys.SELECTED_LMS_LOGIN_BACKGROUND, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_LOGIN_BACKGROUND, value.orEmpty()) + + override var selectedLmsTitle: String? + get() = getValue(Keys.SELECTED_LMS_TITLE, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_TITLE, value.orEmpty()) + + override var lmsDirectoryMode: String + get() = getValue(Keys.LMS_DIRECTORY_MODE, "") + set(value) { + setValue(Keys.LMS_DIRECTORY_MODE, value) + // This replaced a boolean that named no source. Drop it the first + // time an upgraded install records a real answer, so nothing is left + // behind that a later reader could mistake for one. + runBlocking(Dispatchers.IO) { dataStore.edit { it.remove(Keys.legacyLmsDirectoryCurated) } } + } + + override var lmsDirectorySourceKey: String + get() = getValue(Keys.LMS_DIRECTORY_SOURCE_KEY, "") + set(value) = setValue(Keys.LMS_DIRECTORY_SOURCE_KEY, value) + + override var lmsHistory: List + get() { + val json = getValue(Keys.LMS_HISTORY, "") + if (json.isEmpty()) return emptyList() + return runCatching { + val type = object : TypeToken>() {}.type + gson.fromJson>(json, type) ?: emptyList() + }.getOrDefault(emptyList()) + } + set(value) = setValue(Keys.LMS_HISTORY, gson.toJson(value)) + override var profile: Account? get() { val json = getEncryptedString(Keys.ACCOUNT, "") 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..fcee60ab9 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -86,7 +86,7 @@ import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { - single { Config(get()) } + single { Config(context = get(), corePreferences = get()) } single { PreferencesManager(get(), get()) } single { get() } single { get() } @@ -120,7 +120,7 @@ val appModule = module { single { DiscoveryNotifier() } single { CalendarNotifier() } - single { AppRouter() } + single { AppRouter(get(), get()) } single { get() } single { get() } single { 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 6360e7fba..6c09ac913 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -5,6 +5,7 @@ import okhttp3.logging.HttpLoggingInterceptor import org.koin.dsl.module import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor +import org.openedx.app.data.networking.BaseUrlOverrideInterceptor import org.openedx.app.data.networking.HandleErrorInterceptor import org.openedx.app.data.networking.HeadersInterceptor import org.openedx.app.data.networking.OauthRefreshTokenAuthenticator @@ -29,6 +30,8 @@ val networkingModule = module { writeTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) addInterceptor(HeadersInterceptor(get(), get(), get())) + // LMS Directory: redirect requests to the selected platform (no-op when off). + addInterceptor(BaseUrlOverrideInterceptor(get())) 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 1799dafc6..5e5951797 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -8,6 +8,7 @@ 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.lmsselection.SiteSelectionViewModel import org.openedx.auth.presentation.logistration.LogistrationViewModel import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel @@ -80,6 +81,7 @@ import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel import org.openedx.profile.presentation.profile.ProfileViewModel +import org.openedx.profile.presentation.reportlms.ReportLmsViewModel import org.openedx.profile.presentation.settings.SettingsViewModel import org.openedx.profile.presentation.video.VideoSettingsViewModel import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel @@ -107,6 +109,8 @@ val screenModule = module { factory { AuthInteractor(get()) } factory { Validator() } + viewModel { SiteSelectionViewModel(get(), get(), get(), get()) } + viewModel { (courseId: String) -> LogistrationViewModel( courseId, @@ -213,6 +217,8 @@ val screenModule = module { resourceManager = get(), notifier = get(), analytics = get(), + config = get(), + corePreferences = get(), profileRouter = get(), ) } @@ -226,6 +232,7 @@ val screenModule = module { account ) } + viewModel { ReportLmsViewModel(get(), get(), get()) } viewModel { VideoSettingsViewModel(get(), get(), get(), get(), get()) } viewModel { (qualityType: String) -> VideoQualityViewModel( diff --git a/app/src/test/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptorTest.kt b/app/src/test/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptorTest.kt new file mode 100644 index 000000000..66392b2f1 --- /dev/null +++ b/app/src/test/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptorTest.kt @@ -0,0 +1,63 @@ +package org.openedx.app.data.networking + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openedx.core.data.storage.CorePreferences + +/** + * Regression coverage for the LMS Directory login fix: with a platform selected, + * every request must be routed to that host (the Retrofit client is built once, + * before selection). No selection → the request is untouched. + */ +class BaseUrlOverrideInterceptorTest { + + private val corePreferences = mockk() + + private fun proceededRequest(selected: String?, requestUrl: String): Request { + every { corePreferences.selectedBaseUrl } returns selected + val original = Request.Builder().url(requestUrl).build() + val captured = slot() + val chain = mockk() + every { chain.request() } returns original + every { chain.proceed(capture(captured)) } returns mockk(relaxed = true) + + BaseUrlOverrideInterceptor(corePreferences).intercept(chain) + return captured.captured + } + + @Test + fun `rewrites host to the selected LMS`() { + val request = proceededRequest( + selected = "https://sandbox.openedx.org/", + requestUrl = "http://localhost:8000/oauth2/access_token", + ) + assertEquals("sandbox.openedx.org", request.url.host) + assertEquals("https", request.url.scheme) + assertEquals("/oauth2/access_token", request.url.encodedPath) + } + + @Test + fun `passes request through when nothing is selected`() { + val request = proceededRequest( + selected = null, + requestUrl = "http://localhost:8000/oauth2/access_token", + ) + assertEquals("localhost", request.url.host) + assertEquals(8000, request.url.port) + } + + @Test + fun `passes request through when selection is blank`() { + val request = proceededRequest( + selected = "", + requestUrl = "https://config-host.example.com/api/v1/x", + ) + assertEquals("config-host.example.com", request.url.host) + } +} diff --git a/app/src/test/java/org/openedx/app/lmsdirectory/LmsDetailDtoTest.kt b/app/src/test/java/org/openedx/app/lmsdirectory/LmsDetailDtoTest.kt new file mode 100644 index 000000000..3f009e393 --- /dev/null +++ b/app/src/test/java/org/openedx/app/lmsdirectory/LmsDetailDtoTest.kt @@ -0,0 +1,63 @@ +package org.openedx.app.lmsdirectory + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.openedx.core.lmsdirectory.LmsDetailDto + +/** + * The catalog summary can't log you in — only the detail carries the per-LMS OAuth + * client id and feedback email. This verifies the mapping the selection flow relies on. + */ +class LmsDetailDtoTest { + + @Test + fun `maps api fields to domain`() { + val detail = LmsDetailDto( + id = "4", + title = "Sandbox Env", + baseUrl = "https://sandbox.openedx.org", + logoUrl = "https://cdn.example.com/logo.png", + accentColor = "#6a2e7b", + api = LmsDetailDto.ApiDto( + hostUrl = "https://sandbox.openedx.org", + oauthClientId = "android", + feedbackEmail = "team@example.com", + ), + ).toDomain() + + assertEquals("android", detail.oauthClientId) + assertEquals("team@example.com", detail.feedbackEmail) + assertEquals("https://sandbox.openedx.org", detail.baseUrl) + assertEquals("#6a2e7b", detail.accentColor) + assertEquals("https://cdn.example.com/logo.png", detail.logoUrl) + } + + @Test + fun `blank api values fall back to null and base_url`() { + val detail = LmsDetailDto( + id = "1", + title = "Fallback", + baseUrl = "https://fallback.example.com", + api = LmsDetailDto.ApiDto(hostUrl = "", oauthClientId = "", feedbackEmail = null), + ).toDomain() + + // Blank host_url → the top-level base_url is used. + assertEquals("https://fallback.example.com", detail.baseUrl) + assertNull(detail.oauthClientId) + assertNull(detail.feedbackEmail) + } + + @Test + fun `null api yields base_url and null credentials`() { + val detail = LmsDetailDto( + id = "2", + title = "No API block", + baseUrl = "https://noapi.example.com", + api = null, + ).toDomain() + + assertEquals("https://noapi.example.com", detail.baseUrl) + assertNull(detail.oauthClientId) + } +} diff --git a/auth/build.gradle b/auth/build.gradle index 3bd660c15..39646d102 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -74,6 +74,9 @@ dependencies { implementation("io.opentelemetry:opentelemetry-api:$opentelemetry_version") implementation("io.opentelemetry:opentelemetry-context:$opentelemetry_version") + // LMS Directory: QR sign-in scanner + implementation "com.journeyapps:zxing-android-embedded:$zxing_embedded_version" + testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml new file mode 100644 index 000000000..4b63cf14d --- /dev/null +++ b/auth/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + 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..65b40a3a4 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -15,6 +15,9 @@ interface AuthRouter { fun navigateToLogistration(fm: FragmentManager, courseId: String?) + /** LMS Directory: open the "Find my LMS" browse/search screen. */ + fun navigateToLmsSelection(fm: FragmentManager) + fun navigateToSignUp(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToRestorePassword(fm: FragmentManager) diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt new file mode 100644 index 000000000..3fd16d56c --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt @@ -0,0 +1,107 @@ +package org.openedx.auth.presentation.lmsselection + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.auth.R +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.config.Config +import org.openedx.core.ui.theme.OpenEdXTheme + +/** + * LMS Directory landing shown before sign-in when the feature is on and no platform + * has been chosen yet. Offers browse/search or QR sign-in. Reuses + * [SiteSelectionViewModel] for the QR path (validate + select + re-theme). + */ +class LmsLandingFragment : Fragment() { + + private val viewModel: SiteSelectionViewModel by viewModel() + private val router: AuthRouter by inject() + private val config: Config by inject() + + private val scanLauncher = registerForActivityResult(ScanContract()) { result -> + result.contents?.let { viewModel.onUrlScanned(it) } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val state by viewModel.uiState.collectAsState() + LaunchedEffect(Unit) { + viewModel.actions.collect { action -> + when (action) { + is SiteSelectionViewModel.SiteSelectionAction.Success -> + continueAfterSelection(action.preLoginDiscovery) + } + } + } + if (state.isCurated) { + // Curated / company mode: the registry serves a fixed list of platforms, + // so skip the "Choose your learning platform" intro and show the list + // straight away (matches iOS). No back button — this is the entry point. + SiteSelectionScreen( + state = state, + showBack = false, + callbacks = SiteSelectionCallbacks( + onBack = {}, + onQrClick = { launchQrScanner() }, + onSubmitManual = viewModel::onSubmitManual, + onQueryChanged = viewModel::onQueryChanged, + onCatalogItemSelected = viewModel::onCatalogItemSelected, + onCleanHistory = viewModel::onCleanHistory, + onHistoryItemSelected = viewModel::onHistoryItemSelected, + ), + ) + } else { + // Open (search) mode: tapping "Find my LMS" opens search; "Sign in with + // QR code" opens the camera scanner directly (no instructions screen). + LmsLandingScreen( + onFindClick = { router.navigateToLmsSelection(requireActivity().supportFragmentManager) }, + onQrClick = { launchQrScanner() }, + ) + } + } + } + } + + private fun launchQrScanner() { + val options = ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setPrompt(getString(R.string.auth_lms_qr_prompt)) + .setBeepEnabled(false) + .setOrientationLocked(false) + .setCaptureActivity(LmsQrScannerActivity::class.java) + scanLauncher.launch(options) + } + + private fun continueAfterSelection(preLoginDiscovery: Boolean) { + val fm = requireActivity().supportFragmentManager + when { + // The selected LMS is configured to start on the course Discovery screen — + // open it (native or webview per config) instead of sign-in, matching iOS. + preLoginDiscovery -> if (config.getDiscoveryConfig().isViewTypeWebView()) { + router.navigateToWebDiscoverCourses(fm, querySearch = "") + } else { + router.navigateToNativeDiscoverCourses(fm, querySearch = "") + } + + config.isPreLoginExperienceEnabled() -> router.navigateToLogistration(fm, courseId = null) + else -> router.navigateToSignIn(fm, courseId = null, infoType = null) + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt new file mode 100644 index 000000000..ebaf94296 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt @@ -0,0 +1,105 @@ +package org.openedx.auth.presentation.lmsselection + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +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.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.openedx.auth.R +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.R as coreR + +// Weight of the gap between the welcome block and the action buttons; biases the +// logo/title group toward the upper third of the screen (mirrors the iOS landing). +private const val CONTENT_TO_ACTIONS_WEIGHT = 1.6f + +/** + * The LMS Directory entry point: welcome the learner and offer two ways in — + * browse/search the catalog ("Find my LMS") or scan a platform's QR code. + */ +@Composable +internal fun LmsLandingScreen( + onFindClick: () -> Unit, + onQrClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = coreR.drawable.core_ic_logo), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.appColors.primary), + modifier = Modifier.height(48.dp), + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.auth_lms_welcome_title), + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.auth_lms_welcome_subtitle), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.weight(CONTENT_TO_ACTIONS_WEIGHT)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.auth_lms_find_button), + onClick = onFindClick, + ) + TextButton(onClick = onQrClick) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.QrCodeScanner, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(id = R.string.auth_lms_qr_button), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + ) + } + } + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsQrScannerActivity.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsQrScannerActivity.kt new file mode 100644 index 000000000..2dd2d14eb --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsQrScannerActivity.kt @@ -0,0 +1,62 @@ +package org.openedx.auth.presentation.lmsselection + +import android.app.Activity +import android.os.Bundle +import android.view.View +import com.journeyapps.barcodescanner.CaptureManager +import com.journeyapps.barcodescanner.DecoratedBarcodeView +import org.openedx.auth.R + +/** + * Full-screen QR scanner for the LMS Directory "Sign in with QR code" flow. + * + * Wraps zxing's [DecoratedBarcodeView] — which draws the framing viewfinder (the scan + * rectangle) — and adds a Close button so the learner can back out of the full-screen + * camera without relying on the system Back gesture. The scanned contents are returned + * to the caller through ScanContract, exactly like the default zxing CaptureActivity. + */ +class LmsQrScannerActivity : Activity() { + + private lateinit var capture: CaptureManager + private lateinit var barcodeScannerView: DecoratedBarcodeView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.auth_qr_scanner) + barcodeScannerView = findViewById(R.id.barcode_scanner) + capture = CaptureManager(this, barcodeScannerView) + capture.initializeFromIntent(intent, savedInstanceState) + capture.decode() + findViewById(R.id.qr_close).setOnClickListener { finish() } + } + + override fun onResume() { + super.onResume() + capture.onResume() + } + + override fun onPause() { + super.onPause() + capture.onPause() + } + + override fun onDestroy() { + super.onDestroy() + capture.onDestroy() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + capture.onSaveInstanceState(outState) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + // Forward to CaptureManager so the camera starts once permission is granted. + capture.onRequestPermissionsResult(requestCode, permissions, grantResults) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt new file mode 100644 index 000000000..b15280bd0 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt @@ -0,0 +1,15 @@ +package org.openedx.auth.presentation.lmsselection + +import org.openedx.core.lmsdirectory.LmsHistoryEntry +import org.openedx.core.lmsdirectory.LmsSummary + +/** UI callbacks for [SiteSelectionScreen]. */ +class SiteSelectionCallbacks( + val onBack: () -> Unit, + val onQrClick: () -> Unit, + val onSubmitManual: () -> Unit, + val onQueryChanged: (String) -> Unit, + val onCatalogItemSelected: (LmsSummary) -> Unit, + val onCleanHistory: () -> Unit, + val onHistoryItemSelected: (LmsHistoryEntry) -> Unit, +) diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt new file mode 100644 index 000000000..3fabf2081 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt @@ -0,0 +1,97 @@ +package org.openedx.auth.presentation.lmsselection + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.auth.R +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.config.Config +import org.openedx.core.ui.theme.OpenEdXTheme + +/** + * "Find my LMS" — browse or search the registry catalog (or type a URL). Picking a + * platform re-themes the app to it and continues to the normal sign-in flow. The + * search field's QR button opens the camera scanner directly, same as the landing. + */ +class SiteSelectionFragment : Fragment() { + + private val viewModel: SiteSelectionViewModel by viewModel() + private val router: AuthRouter by inject() + private val config: Config by inject() + + private val scanLauncher = registerForActivityResult(ScanContract()) { result -> + result.contents?.let { viewModel.onUrlScanned(it) } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actions.collect { action -> + when (action) { + is SiteSelectionViewModel.SiteSelectionAction.Success -> + continueAfterSelection(action.preLoginDiscovery) + } + } + } + + // The QR button opens the camera scanner directly (no instructions screen). + SiteSelectionScreen( + state = state, + callbacks = SiteSelectionCallbacks( + onBack = { requireActivity().supportFragmentManager.popBackStack() }, + onQrClick = { launchQrScanner() }, + onSubmitManual = viewModel::onSubmitManual, + onQueryChanged = viewModel::onQueryChanged, + onCatalogItemSelected = viewModel::onCatalogItemSelected, + onCleanHistory = viewModel::onCleanHistory, + onHistoryItemSelected = viewModel::onHistoryItemSelected, + ) + ) + } + } + } + + private fun launchQrScanner() { + val options = ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setPrompt(getString(R.string.auth_lms_qr_prompt)) + .setBeepEnabled(false) + .setOrientationLocked(false) + .setCaptureActivity(LmsQrScannerActivity::class.java) + scanLauncher.launch(options) + } + + private fun continueAfterSelection(preLoginDiscovery: Boolean) { + val fm = requireActivity().supportFragmentManager + when { + // The selected LMS is configured to start on the course Discovery screen — + // open it (native or webview per config) instead of sign-in, matching iOS. + preLoginDiscovery -> if (config.getDiscoveryConfig().isViewTypeWebView()) { + router.navigateToWebDiscoverCourses(fm, querySearch = "") + } else { + router.navigateToNativeDiscoverCourses(fm, querySearch = "") + } + + config.isPreLoginExperienceEnabled() -> router.navigateToLogistration(fm, courseId = null) + else -> router.navigateToSignIn(fm, courseId = null, infoType = null) + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt new file mode 100644 index 000000000..93222463d --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt @@ -0,0 +1,393 @@ +package org.openedx.auth.presentation.lmsselection + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +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.Row +import androidx.compose.foundation.layout.Spacer +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.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +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.QrCodeScanner +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.auth.R +import org.openedx.core.lmsdirectory.LmsHistoryEntry +import org.openedx.core.lmsdirectory.LmsImageSource +import org.openedx.core.lmsdirectory.LmsSummary +import org.openedx.core.lmsdirectory.LmsThemeController +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography + +@Composable +internal fun SiteSelectionScreen( + state: SiteSelectionUIState, + callbacks: SiteSelectionCallbacks, + // Hidden when this screen IS the entry point (curated mode landing) — there is + // nothing to go back to. Shown when pushed from the "Find my LMS" landing button. + showBack: Boolean = true, +) { + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + containerColor = MaterialTheme.appColors.background, + topBar = { + Surface(color = MaterialTheme.appColors.background) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + if (showBack) { + BackBtn( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 8.dp), + tint = MaterialTheme.appColors.textPrimary, + ) { callbacks.onBack() } + } + Text( + text = stringResource( + id = if (state.isCurated) { + R.string.auth_lms_curated_title + } else { + R.string.auth_lms_choose_title + } + ), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + ) + } + } + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 24.dp, vertical = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + val context = LocalContext.current + LaunchedEffect(state.imageReferences) { + // Decoding these now is the whole reason the branded sign-in appears + // whole instead of assembling itself after the platform is tapped. + LmsImageSource.prefetch(context, state.imageReferences) + } + + if (!state.isCurated) { + SearchField(state, callbacks) + } + CatalogContent(state, callbacks) + } + } +} + +@Composable +private fun SearchField(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.query, + onValueChange = { callbacks.onQueryChanged(it) }, + singleLine = true, + placeholder = { + Text( + text = stringResource(id = R.string.auth_lms_search_hint), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textFieldHint, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = MaterialTheme.appColors.textFieldText, + ) + }, + trailingIcon = { + IconButton(onClick = { callbacks.onQrClick() }) { + Icon( + imageVector = Icons.Filled.QrCodeScanner, + contentDescription = stringResource(id = R.string.auth_lms_qr_button), + tint = MaterialTheme.appColors.primary, + ) + } + }, + textStyle = MaterialTheme.appTypography.bodyLarge, + shape = MaterialTheme.appShapes.textFieldShape, + colors = directoryTextFieldColors(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search, keyboardType = KeyboardType.Uri), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + focusManager.clearFocus() + callbacks.onSubmitManual() + } + ), + ) +} + +@Composable +private fun CatalogContent(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { + when (val catalog = state.catalog) { + // Curated mode has no search/history — the org's fixed list loads straight away, + // so an idle state just means "still loading the list". + is CatalogState.Idle -> when { + state.isCurated -> LoadingRow() + state.history.isNotEmpty() -> HistorySection(history = state.history, callbacks = callbacks) + else -> PlaceholderText(stringResource(id = R.string.auth_lms_start_typing)) + } + is CatalogState.Loading -> LoadingRow() + is CatalogState.Empty -> PlaceholderText( + stringResource( + id = if (state.isCurated) R.string.auth_lms_curated_empty else R.string.auth_lms_no_results + ) + ) + is CatalogState.Error -> Text( + text = catalog.message, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.error, + ) + is CatalogState.Loaded -> Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // "Results" is a search concept — in curated mode the list is just the platforms. + if (!state.isCurated) { + SectionHeader(text = stringResource(id = R.string.auth_lms_results)) + } + state.results.forEach { item -> + CatalogRow(item = item, onSelect = { callbacks.onCatalogItemSelected(item) }) + } + } + } +} + +/** + * "History" section shown when the search field is empty: a header with a + * "Clean history" action, then the recently selected platforms rendered with the + * same row visual as search results. Mirrors iOS `historySection`. + */ +@Composable +private fun HistorySection(history: List, callbacks: SiteSelectionCallbacks) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + SectionHeader(text = stringResource(id = R.string.auth_lms_history)) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(id = R.string.auth_lms_clean_history), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.primary, + modifier = Modifier.clickable { callbacks.onCleanHistory() }, + ) + } + history.forEach { entry -> + CatalogRow(entry = entry, onSelect = { callbacks.onHistoryItemSelected(entry) }) + } + } +} + +@Composable +private fun LoadingRow() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(vertical = 8.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary, + ) + PlaceholderText(stringResource(id = R.string.auth_lms_searching)) + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary, + ) +} + +@Composable +private fun CatalogRow(item: LmsSummary, onSelect: () -> Unit) { + CatalogRow( + title = item.title, + shortDescription = item.shortDescription, + baseUrl = item.baseUrl, + logoUrl = item.logoUrl, + accentColor = item.accentColor, + onSelect = onSelect, + ) +} + +@Composable +private fun CatalogRow(entry: LmsHistoryEntry, onSelect: () -> Unit) { + CatalogRow( + title = entry.title, + shortDescription = entry.shortDescription, + baseUrl = entry.baseUrl, + logoUrl = entry.logoUrl, + accentColor = entry.accentColor, + onSelect = onSelect, + ) +} + +@Composable +private fun CatalogRow( + title: String, + shortDescription: String, + baseUrl: String, + logoUrl: String?, + accentColor: String?, + onSelect: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect() }, + shape = MaterialTheme.appShapes.textFieldShape, + color = MaterialTheme.appColors.background, + border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LmsRowLogo(logoUrl = logoUrl, title = title, accentColor = accentColor) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + maxLines = 1, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + ) + if (shortDescription.isNotBlank()) { + Text( + text = shortDescription, + maxLines = 1, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textSecondary, + ) + } + Text( + text = hostOf(baseUrl), + maxLines = 1, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textSecondary, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + ) + } + } +} + +/** + * Platform logo for a catalog row. Loads the LMS's logo when available; otherwise + * falls back to a colored initial badge tinted with the platform's accent color — + * mirroring the iOS directory rows. + */ +@Composable +private fun LmsRowLogo(logoUrl: String?, title: String, accentColor: String?) { + val logoModifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(10.dp)) + val logoModel = LmsImageSource.model(logoUrl) + if (logoModel != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(logoModel) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = logoModifier, + ) + } else { + val accent = LmsThemeController.parseHexColor(accentColor) ?: MaterialTheme.appColors.primary + Box( + modifier = logoModifier.background(accent.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = title.trim().take(1).uppercase(), + style = MaterialTheme.appTypography.titleMedium, + color = accent, + ) + } + } +} + +@Composable +private fun directoryTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.background, + unfocusedContainerColor = MaterialTheme.appColors.background, + focusedBorderColor = MaterialTheme.appColors.textFieldBorder, + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + cursorColor = MaterialTheme.appColors.primary, +) + +@Composable +private fun PlaceholderText(text: String) { + Text( + text = text, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + modifier = Modifier.padding(vertical = 8.dp), + ) +} + +private fun hostOf(url: String): String { + return url.removePrefix("https://").removePrefix("http://").trimEnd('/') +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt new file mode 100644 index 000000000..8d370ea15 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt @@ -0,0 +1,36 @@ +package org.openedx.auth.presentation.lmsselection + +import org.openedx.core.lmsdirectory.LmsHistoryEntry +import org.openedx.core.lmsdirectory.LmsSummary + +data class SiteSelectionUIState( + val inputUrl: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null, + + // Registry catalog (search / curated browse) + val isCurated: Boolean = false, + val providerName: String = "", + val query: String = "", + val catalog: CatalogState = CatalogState.Idle, + val results: List = emptyList(), + + // Recently selected platforms, shown when the search field is empty. + val history: List = emptyList(), + + /** + * Every image the directory will ask for. Populated only when the list came + * from a document, which is the only source that knows them before a platform + * is picked. The screen warms them so the branded sign-in does not assemble + * itself in front of the learner. + */ + val imageReferences: List = emptyList(), +) + +sealed interface CatalogState { + data object Idle : CatalogState + data object Loading : CatalogState + data object Loaded : CatalogState + data object Empty : CatalogState + data class Error(val message: String) : CatalogState +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt new file mode 100644 index 000000000..0e0b39463 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt @@ -0,0 +1,407 @@ +package org.openedx.auth.presentation.lmsselection + +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +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.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import org.openedx.auth.R +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.lmsdirectory.LmsDirectoryMode +import org.openedx.core.lmsdirectory.LmsDirectoryRepository +import org.openedx.core.lmsdirectory.LmsDirectoryState +import org.openedx.core.lmsdirectory.LmsHistoryEntry +import org.openedx.core.lmsdirectory.LmsSummary +import org.openedx.core.lmsdirectory.LmsThemeController +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Drives the LMS Directory selection screen: search or browse the registry catalog, + * or enter an LMS URL by hand. Picking a platform persists it as the app's host, + * re-themes the app to its brand color, then signals the fragment to continue to + * sign-in. + */ +class SiteSelectionViewModel( + private val corePreferences: CorePreferences, + private val resourceManager: ResourceManager, + private val config: Config, + private val directoryRepository: LmsDirectoryRepository, +) : BaseViewModel(resourceManager) { + + private val validationClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(VALIDATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(VALIDATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + private val _uiState = MutableStateFlow( + SiteSelectionUIState( + inputUrl = corePreferences.selectedBaseUrl?.trimEnd('/') ?: "", + history = corePreferences.lmsHistory, + ) + ) + val uiState: StateFlow = _uiState + + private val _actions = MutableSharedFlow() + val actions: SharedFlow = _actions.asSharedFlow() + + private var searchJob: Job? = null + + init { + seedCuratedFromConfig() + loadCatalogConfig() + } + + // region Catalog + + /** + * A document is a fixed list, so a build reading one is curated before any + * work happens. Seeding it here means the generic landing never flashes on a + * cold start while the config round trip is still in flight. + */ + private fun seedCuratedFromConfig() { + val directory = config.getLMSDirectoryConfig() + if (directory.configuredMode == LmsDirectoryMode.CURATED) { + LmsDirectoryState.remember(LmsDirectoryMode.CURATED, directory, corePreferences) + _uiState.update { it.copy(isCurated = true) } + } + } + + private fun loadCatalogConfig() { + viewModelScope.launch { + val directory = config.getLMSDirectoryConfig() + val answer = directoryRepository.fetchConfigOrNull() + // What the screen shows and what the app records are different + // questions. Unreachable, the picker still has to draw something and + // a search box is the safe choice; but nothing is recorded, because a + // network error says nothing about what kind of catalog this is. + val mode = directory.configuredMode + ?: answer?.let { if (it.isCurated) LmsDirectoryMode.CURATED else LmsDirectoryMode.SEARCH } + mode?.let { LmsDirectoryState.remember(it, directory, corePreferences) } + val curated = mode == LmsDirectoryMode.CURATED + _uiState.update { + it.copy(isCurated = curated, providerName = answer?.providerName.orEmpty()) + } + if (curated) { + loadFeatured() + } + // Only a document can answer this before a platform is picked. + directoryRepository.imageReferences().takeIf { it.isNotEmpty() }?.let { refs -> + _uiState.update { it.copy(imageReferences = refs) } + } + } + } + + private fun loadFeatured() { + _uiState.update { it.copy(catalog = CatalogState.Loading) } + viewModelScope.launch { + directoryRepository.fetchFeatured() + .onSuccess { items -> applyResults(items) } + .onFailure { showCatalogError() } + } + } + + private fun showCatalogError() { + val message = resourceManager.getString(R.string.auth_lms_error_catalog_failed) + _uiState.update { it.copy(catalog = CatalogState.Error(message)) } + } + + fun onQueryChanged(value: String) { + // The single search field doubles as URL entry (iOS parity): keep inputUrl in + // sync so submitting the field (IME "search") can connect to a typed-in URL. + _uiState.update { it.copy(query = value, inputUrl = value, errorMessage = null) } + searchJob?.cancel() + val trimmed = value.trim() + if (trimmed.isEmpty()) { + _uiState.update { it.copy(catalog = CatalogState.Idle, results = emptyList()) } + return + } + _uiState.update { it.copy(catalog = CatalogState.Loading) } + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MS) + directoryRepository.search(trimmed) + .onSuccess { items -> applyResults(items) } + .onFailure { showCatalogError() } + } + } + + private fun applyResults(items: List) { + _uiState.update { + it.copy( + results = items, + catalog = if (items.isEmpty()) CatalogState.Empty else CatalogState.Loaded, + ) + } + } + + fun onCatalogItemSelected(item: LmsSummary) { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + // Fetch the full record: the catalog summary has no OAuth client id, and + // sign-in needs the platform's own registered mobile client to work. + val detail = directoryRepository.fetchDetail(item.id).getOrNull() + val normalized = normalizeUrl(detail?.baseUrl ?: item.baseUrl) + if (normalized == null) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = resourceManager.getString(R.string.auth_lms_error_invalid_url), + ) + } + return@launch + } + selectLms( + baseUrl = normalized.newBuilder().encodedPath("/").build().toString(), + accentColor = detail?.accentColor ?: item.accentColor, + oauthClientId = detail?.oauthClientId, + feedbackEmail = detail?.feedbackEmail, + logoUrl = detail?.logoUrl ?: item.logoUrl, + title = detail?.title ?: item.title, + shortDescription = detail?.shortDescription ?: item.shortDescription, + loginBackgroundUrl = detail?.loginBackgroundUrl, + preLoginDiscovery = detail?.preLoginDiscovery ?: false, + ) + _actions.emit(SiteSelectionAction.Success(detail?.preLoginDiscovery ?: false)) + } + } + + /** + * Select an LMS from a scanned QR (the code holds the platform's base URL). Resolve + * the host against the registry so a scanned platform gets its full branding + OAuth + * client and routes to sign-in or pre-login Discovery per its settings — exactly like + * tapping it in the catalog. If the host isn't registered, fall back to validating the + * URL directly and continue to sign-in. + */ + fun onUrlScanned(rawUrl: String) { + val normalized = normalizeUrl(rawUrl) ?: run { + _uiState.update { + it.copy(errorMessage = resourceManager.getString(R.string.auth_lms_error_invalid_url)) + } + return + } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val match = directoryRepository.search(normalized.host).getOrNull() + ?.firstOrNull { it.baseUrl.toHttpUrlOrNull()?.host.equals(normalized.host, ignoreCase = true) } + if (match != null) { + val detail = directoryRepository.fetchDetail(match.id).getOrNull() + val resolved = normalizeUrl(detail?.baseUrl ?: match.baseUrl) ?: normalized + selectLms( + baseUrl = resolved.newBuilder().encodedPath("/").build().toString(), + accentColor = detail?.accentColor ?: match.accentColor, + oauthClientId = detail?.oauthClientId, + feedbackEmail = detail?.feedbackEmail, + logoUrl = detail?.logoUrl ?: match.logoUrl, + title = detail?.title ?: match.title, + shortDescription = detail?.shortDescription ?: match.shortDescription, + loginBackgroundUrl = detail?.loginBackgroundUrl, + preLoginDiscovery = detail?.preLoginDiscovery ?: false, + ) + _actions.emit(SiteSelectionAction.Success(detail?.preLoginDiscovery ?: false)) + return@launch + } + // Not in the registry — validate the URL directly, then continue to sign-in. + val confirmed = withContext(Dispatchers.IO) { confirmBaseUrl(normalized) } + if (confirmed) { + selectLms(normalized.newBuilder().encodedPath("/").build().toString(), accentColor = null) + _actions.emit(SiteSelectionAction.Success(preLoginDiscovery = false)) + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = resourceManager.getString(R.string.auth_lms_error_unable_confirm_url), + ) + } + } + } + } + + /** Clear the persisted directory history and drop it from the UI. */ + fun onCleanHistory() { + corePreferences.lmsHistory = emptyList() + _uiState.update { it.copy(history = emptyList()) } + } + + /** + * Re-select a platform straight from history. Its details were already validated + * when first added, so there's no network round-trip — commit and continue. + */ + fun onHistoryItemSelected(entry: LmsHistoryEntry) { + viewModelScope.launch { + selectLms( + baseUrl = entry.baseUrl, + accentColor = entry.accentColor, + oauthClientId = entry.oauthClientId, + feedbackEmail = entry.feedbackEmail, + logoUrl = entry.logoUrl, + title = entry.title, + shortDescription = entry.shortDescription, + loginBackgroundUrl = entry.loginBackgroundUrl, + preLoginDiscovery = entry.preLoginDiscovery, + ) + _actions.emit(SiteSelectionAction.Success(entry.preLoginDiscovery)) + } + } + + // endregion + + // region Manual URL entry (fallback when the registry is unreachable) + + fun onInputChanged(value: String) { + _uiState.update { it.copy(inputUrl = value, errorMessage = null) } + } + + fun onSubmitManual() { + val normalized = normalizeUrl(_uiState.value.inputUrl) + if (normalized == null) { + _uiState.update { + it.copy(errorMessage = resourceManager.getString(R.string.auth_lms_error_invalid_url)) + } + return + } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val confirmed = withContext(Dispatchers.IO) { confirmBaseUrl(normalized) } + if (confirmed) { + selectLms(normalized.newBuilder().encodedPath("/").build().toString(), accentColor = null) + _actions.emit(SiteSelectionAction.Success(preLoginDiscovery = false)) + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = resourceManager.getString(R.string.auth_lms_error_unable_confirm_url), + ) + } + } + } + } + + /** + * Commit the chosen platform: persist host, OAuth client, feedback, branding, then + * re-theme immediately. Manual/QR entry passes only the URL, clearing the per-LMS + * OAuth override so sign-in falls back to the config client for unknown hosts. + */ + private fun selectLms( + baseUrl: String, + accentColor: String?, + oauthClientId: String? = null, + feedbackEmail: String? = null, + logoUrl: String? = null, + title: String? = null, + shortDescription: String = "", + loginBackgroundUrl: String? = null, + preLoginDiscovery: Boolean = false, + ) { + corePreferences.selectedBaseUrl = baseUrl + corePreferences.selectedLmsAccentColor = accentColor + corePreferences.selectedOAuthClientId = oauthClientId + corePreferences.selectedFeedbackEmail = feedbackEmail + corePreferences.selectedLmsLogoUrl = logoUrl + corePreferences.selectedLmsTitle = title + corePreferences.selectedLmsLoginBackgroundUrl = loginBackgroundUrl + LmsThemeController.apply(accentColor) + LmsThemeController.applyBackground(loginBackgroundUrl) + rememberInHistory( + LmsHistoryEntry( + baseUrl = baseUrl, + title = title.orEmpty(), + shortDescription = shortDescription, + logoUrl = logoUrl, + accentColor = accentColor, + oauthClientId = oauthClientId, + feedbackEmail = feedbackEmail, + loginBackgroundUrl = loginBackgroundUrl, + preLoginDiscovery = preLoginDiscovery, + ) + ) + } + + /** + * Prepend [entry] to the persisted history as the most recent, deduping by base URL + * (case- and trailing-slash-insensitive) and capping the list. Mirrors iOS. + */ + private fun rememberInHistory(entry: LmsHistoryEntry) { + val key = historyKey(entry.baseUrl) + val deduped = corePreferences.lmsHistory.filterNot { historyKey(it.baseUrl) == key } + val updated = (listOf(entry) + deduped).take(HISTORY_LIMIT) + corePreferences.lmsHistory = updated + _uiState.update { it.copy(history = updated) } + } + + private fun historyKey(url: String) = url.trimEnd('/').lowercase() + + private fun normalizeUrl(text: String): HttpUrl? { + val trimmed = text.trim() + if (trimmed.isEmpty()) { + return null + } + val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "https://$trimmed" + } + val candidate = withScheme.toHttpUrlOrNull() + return when { + candidate == null || candidate.host.isEmpty() -> null + else -> candidate.newBuilder() + .encodedPath("/") + .query(null) + .fragment(null) + .build() + } + } + + @Suppress("MagicNumber") + private fun confirmBaseUrl(base: HttpUrl): Boolean { + return try { + val registrationRequest = Request.Builder() + .get() + .url(base.newBuilder().addPathSegments(REGISTRATION_PATH).build()) + .build() + val registrationSuccessful = validationClient.newCall(registrationRequest).execute() + .use { response -> response.isSuccessful } + + if (registrationSuccessful) { + true + } else { + val oauthRequest = Request.Builder() + .get() + .url(base.newBuilder().addPathSegments(OAUTH_PATH).build()) + .build() + validationClient.newCall(oauthRequest).execute() + .use { response -> response.code in 200..399 } + } + } catch (_: IOException) { + false + } + } + + // endregion + + sealed interface SiteSelectionAction { + /** [preLoginDiscovery] true -> open the pre-login Discovery catalog instead of sign-in. */ + data class Success(val preLoginDiscovery: Boolean) : SiteSelectionAction + } + + private companion object { + const val REGISTRATION_PATH = "user_api/v1/account/registration/" + const val OAUTH_PATH = "oauth2/login/" + const val SEARCH_DEBOUNCE_MS = 400L + const val VALIDATION_TIMEOUT_SECONDS = 20L + const val HISTORY_LIMIT = 10 + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index beebf4eaa..a3cbb0c9e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -4,7 +4,6 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,7 +40,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.ComposeView import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy @@ -64,6 +62,7 @@ import org.openedx.core.R import org.openedx.core.presentation.global.appupgrade.AppUpgradeRequiredScreen import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.LmsHeaderImage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset @@ -186,13 +185,10 @@ private fun RestorePasswordScreen( ) } - Image( + LmsHeaderImage( modifier = Modifier .fillMaxWidth() - .height(200.dp), - painter = painterResource(id = R.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null + .height(200.dp) ) HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) 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..2921d0e1f 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 @@ -71,6 +71,12 @@ class SignInFragment : Fragment() { requireActivity().supportFragmentManager.popBackStackImmediate() } + AuthEvent.ChangeLmsClick -> { + viewModel.navigateToLmsSelection( + requireActivity().supportFragmentManager + ) + } + is AuthEvent.OpenLink -> viewModel.openLink( parentFragmentManager, event.links, @@ -124,4 +130,5 @@ internal sealed interface AuthEvent { object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent + object ChangeLmsClick : 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..95337b51c 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 @@ -26,4 +26,9 @@ internal data class SignInUIState( val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, + // LMS Directory: branding of the platform the learner picked (null when the + // feature is off or nothing selected — sign-in then shows the app's own logo). + val selectedLmsTitle: String? = null, + val selectedLmsLogoUrl: String? = null, + val selectedLmsLoginBackgroundUrl: String? = 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 395cf7ec5..b73e2563f 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 @@ -73,6 +73,21 @@ class SignInViewModel( isLogistrationEnabled = config.isPreLoginExperienceEnabled(), isRegistrationEnabled = config.isRegistrationEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), + selectedLmsTitle = if (config.getLMSDirectoryConfig().isReachable) { + preferencesManager.selectedLmsTitle + } else { + null + }, + selectedLmsLogoUrl = if (config.getLMSDirectoryConfig().isReachable) { + preferencesManager.selectedLmsLogoUrl + } else { + null + }, + selectedLmsLoginBackgroundUrl = if (config.getLMSDirectoryConfig().isReachable) { + preferencesManager.selectedLmsLoginBackgroundUrl + } else { + null + }, ) ) internal val uiState: StateFlow = _uiState @@ -202,6 +217,10 @@ class SignInViewModel( logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) } + fun navigateToLmsSelection(parentFragmentManager: FragmentManager) { + router.navigateToLmsSelection(parentFragmentManager) + } + fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { router.navigateToRestorePassword(parentFragmentManager) logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED) 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..80b1c7123 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,10 @@ 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.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,6 +15,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 @@ -41,6 +44,7 @@ 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.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -59,6 +63,8 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest import org.openedx.auth.R import org.openedx.auth.presentation.signin.AuthEvent import org.openedx.auth.presentation.signin.SignInUIState @@ -133,15 +139,30 @@ internal fun LoginScreen( ) } - Image( - modifier = - Modifier + // LMS Directory: brand the header background with the selected platform's own + // login background image, falling back to the default gradient header. + if (!state.selectedLmsLoginBackgroundUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(state.selectedLmsLoginBackgroundUrl) + .crossfade(true) + .build(), + modifier = Modifier .fillMaxWidth() .fillMaxHeight(fraction = 0.3f), - painter = painterResource(id = coreR.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null, - ) + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + } else { + Image( + 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) { Box( @@ -163,7 +184,29 @@ internal fun LoginScreen( Modifier.padding(it), horizontalAlignment = Alignment.CenterHorizontally, ) { - SignInLogoView() + // LMS Directory: brand the header with the selected platform's logo. + if (!state.selectedLmsLogoUrl.isNullOrBlank()) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.2f), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(state.selectedLmsLogoUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(top = 20.dp, start = 24.dp, end = 24.dp) + .heightIn(max = 80.dp) + ) + } + } else { + SignInLogoView() + } Surface( color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape, @@ -197,7 +240,13 @@ internal fun LoginScreen( style = MaterialTheme.appTypography.titleSmall, ) } - + if (!state.selectedLmsTitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(16.dp)) + SelectedLmsBanner( + title = state.selectedLmsTitle, + onChange = { onEvent(AuthEvent.ChangeLmsClick) }, + ) + } Spacer(modifier = Modifier.height(24.dp)) AuthForm( buttonWidth, @@ -533,3 +582,51 @@ private fun SignInScreenTabletPreview() { ) } } + +@Composable +private fun SelectedLmsBanner( + title: String, + onChange: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .testTag("selected_lms_container"), + shape = MaterialTheme.appShapes.textFieldShape, + color = MaterialTheme.appColors.background, + border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(id = R.string.auth_lms_selected_label), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textSecondary, + ) + Text( + text = title, + maxLines = 1, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + ) + } + Text( + modifier = Modifier + .noRippleClickable { onChange() } + .padding(start = 12.dp) + .testTag("change_lms_button"), + text = stringResource(id = R.string.auth_lms_change), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + ) + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 5354a081a..1ca3026c8 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -3,7 +3,6 @@ package org.openedx.auth.presentation.signup.compose import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -45,12 +44,10 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController 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 @@ -73,6 +70,7 @@ import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.LmsHeaderImage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.SheetContent import org.openedx.core.ui.displayCutoutForLandscape @@ -234,13 +232,10 @@ internal fun SignUpView( } } - Image( + LmsHeaderImage( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(fraction = 0.3f), - painter = painterResource(id = coreR.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null + .fillMaxHeight(fraction = 0.3f) ) HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) Column( diff --git a/auth/src/main/res/drawable/auth_ic_qr_close.xml b/auth/src/main/res/drawable/auth_ic_qr_close.xml new file mode 100644 index 000000000..d2364c84c --- /dev/null +++ b/auth/src/main/res/drawable/auth_ic_qr_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/auth/src/main/res/drawable/auth_qr_close_bg.xml b/auth/src/main/res/drawable/auth_qr_close_bg.xml new file mode 100644 index 000000000..587c07c72 --- /dev/null +++ b/auth/src/main/res/drawable/auth_qr_close_bg.xml @@ -0,0 +1,4 @@ + + + diff --git a/auth/src/main/res/layout/auth_qr_scanner.xml b/auth/src/main/res/layout/auth_qr_scanner.xml new file mode 100644 index 000000000..80be0bbae --- /dev/null +++ b/auth/src/main/res/layout/auth_qr_scanner.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 77401c27f..49310ddeb 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -43,4 +43,27 @@ %2$s]]> Show password Hide password + + + Choose your learning platform + Connect to any LMS in our library to explore courses or continue learning. + Find my LMS + Sign in with QR code + Point your camera at the QR code shown on your platform. + Close + Find your LMS + Choose your platform + No platforms are available yet. + Enter LMS URL or name + Start typing to find your platform. + Results + History + Clean history + Searching… + No matching platforms found. + Couldn\'t load the catalog. Check your connection and try again. + Enter a valid platform address. + We couldn\'t reach that platform. Check the address and try again. + Selected LMS + Change diff --git a/auth/src/test/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModelTest.kt new file mode 100644 index 000000000..f5df63e7f --- /dev/null +++ b/auth/src/test/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModelTest.kt @@ -0,0 +1,110 @@ +package org.openedx.auth.presentation.lmsselection + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +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.assertTrue +import org.junit.Before +import org.junit.Test +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.lmsdirectory.DirectoryConfig +import org.openedx.core.lmsdirectory.LmsDetail +import org.openedx.core.lmsdirectory.LmsDirectoryRepository +import org.openedx.core.lmsdirectory.LmsSummary +import org.openedx.core.lmsdirectory.LmsThemeController +import org.openedx.foundation.system.ResourceManager + +/** + * Covers the QR path: scanning a platform's URL resolves it against the registry and + * emits the routing signal (sign-in vs pre-login Discovery) that matches the LMS's + * settings — the behavior that replaced the old "prefill the search box" QR flow. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SiteSelectionViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val corePreferences = mockk(relaxed = true) + private val resourceManager = mockk(relaxed = true) + private val config = mockk(relaxed = true) + private val repository = mockk(relaxed = true) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + coEvery { repository.fetchConfig() } returns DirectoryConfig.SEARCH_DEFAULT + } + + @After + fun tearDown() { + Dispatchers.resetMain() + LmsThemeController.clear() + } + + @Test + fun `onUrlScanned resolves registry detail and routes to discovery`() = runTest(dispatcher) { + val actions = givenScan(preLoginDiscovery = true) + assertEquals(1, actions.size) + val success = actions.first() as SiteSelectionViewModel.SiteSelectionAction.Success + assertTrue("Discovery LMS must route to pre-login Discovery", success.preLoginDiscovery) + // The scanned platform got its full record + brand, not a bare URL. + coVerify { repository.fetchDetail("4") } + } + + @Test + fun `onUrlScanned resolves registry detail and routes to sign-in`() = runTest(dispatcher) { + val actions = givenScan(preLoginDiscovery = false) + assertEquals(1, actions.size) + val success = actions.first() as SiteSelectionViewModel.SiteSelectionAction.Success + assertTrue("Non-discovery LMS must route to sign-in", !success.preLoginDiscovery) + } + + private fun kotlinx.coroutines.test.TestScope.givenScan( + preLoginDiscovery: Boolean, + ): List { + val summary = LmsSummary( + id = "4", + title = "Sandbox Env", + shortDescription = "", + baseUrl = "https://sandbox.openedx.org", + logoUrl = null, + accentColor = "#6a2e7b", + ) + val detail = LmsDetail( + id = "4", + title = "Sandbox Env", + shortDescription = "", + baseUrl = "https://sandbox.openedx.org", + logoUrl = null, + accentColor = "#6a2e7b", + oauthClientId = "client-id", + feedbackEmail = null, + loginBackgroundUrl = null, + preLoginDiscovery = preLoginDiscovery, + ) + coEvery { repository.search("sandbox.openedx.org") } returns Result.success(listOf(summary)) + coEvery { repository.fetchDetail("4") } returns Result.success(detail) + + val viewModel = SiteSelectionViewModel(corePreferences, resourceManager, config, repository) + val actions = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.actions.toList(actions) + } + + viewModel.onUrlScanned("https://sandbox.openedx.org") + advanceUntilIdle() + return actions + } +} 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..0ee2a1a9f 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 @@ -32,6 +32,7 @@ import org.openedx.core.Validator import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig +import org.openedx.core.config.LMSDirectoryConfig import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences @@ -91,6 +92,7 @@ class SignInViewModelTest { every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(true) } returns null every { config.isPreLoginExperienceEnabled() } returns false + every { config.getLMSDirectoryConfig() } returns LMSDirectoryConfig() every { config.isSocialAuthEnabled() } returns false every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() diff --git a/build.gradle b/build.gradle index 889599361..450edc9e6 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,9 @@ buildscript { // OpenTelemetry versions opentelemetry_version = '1.53.0' + // LMS Directory QR scanner + zxing_embedded_version = '4.3.0' + // Testing versions compose_ui_tooling = '1.7.8' mockk_version = '1.14.5' diff --git a/core/build.gradle b/core/build.gradle index 19be1f57a..67fc46cf7 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 "org.jetbrains.kotlinx:kotlinx-coroutines-test:$kotlinx_coroutines_test_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/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 1f07f43ad..f81ede613 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -5,11 +5,15 @@ import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AgreementUrls import java.io.InputStreamReader @Suppress("TooManyFunctions") -class Config(context: Context) { +class Config( + context: Context, + private val corePreferences: CorePreferences? = null, +) { private var configProperties: JsonObject = try { val inputStream = context.assets.open("config/config.json") @@ -24,10 +28,25 @@ class Config(context: Context) { return getString(APPLICATION_ID, "") } + /** + * The LMS the app talks to. With the LMS Directory feature on and a platform + * picked, that selection wins over the baked-in host; otherwise the config value + * is used. Off (default) → always the config value, i.e. stock behaviour. + */ fun getApiHostURL(): String { + if (getLMSDirectoryConfig().isReachable) { + val selected = corePreferences?.selectedBaseUrl + if (!selected.isNullOrBlank()) { + return selected + } + } return getString(API_HOST_URL) } + fun getLMSDirectoryConfig(): LMSDirectoryConfig { + return getObjectOrNewInstance(LMS_DIRECTORY, LMSDirectoryConfig::class.java) + } + fun getSSOURL(): String { return getString(SSO_URL, "") } @@ -40,6 +59,14 @@ class Config(context: Context) { } fun getOAuthClientId(): String { + // LMS Directory: sign in with the selected platform's own registered mobile + // OAuth client. Off (default) or no selection → the config value. + if (getLMSDirectoryConfig().isReachable) { + val selected = corePreferences?.selectedOAuthClientId + if (!selected.isNullOrBlank()) { + return selected + } + } return getString(OAUTH_CLIENT_ID) } @@ -52,6 +79,12 @@ class Config(context: Context) { } fun getFeedbackEmailAddress(): String { + if (getLMSDirectoryConfig().isReachable) { + val selected = corePreferences?.selectedFeedbackEmail + if (!selected.isNullOrBlank()) { + return selected + } + } return getString(FEEDBACK_EMAIL_ADDRESS) } @@ -217,6 +250,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 LMS_DIRECTORY = "LMS_DIRECTORY" } enum class ViewType { diff --git a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt new file mode 100644 index 000000000..96d49973f --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt @@ -0,0 +1,126 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName +import org.openedx.core.lmsdirectory.LmsDirectoryMode + +/** + * Feature flag for the multi-tenant LMS Directory. + * + * When [enabled], the app can browse the Open edX platforms published by a site + * registry ([directoryUrl]), re-theme to the one the learner picks, and sign in + * against it. Off by default — the app then behaves as a stock single-tenant build. + */ +data class LMSDirectoryConfig( + @SerializedName("ENABLED") + val enabled: Boolean = false, + + @SerializedName("DIRECTORY_URL") + val directoryUrl: String = "", + + /** + * A JSON document added to the app's assets, e.g. "lms_directory.json". Set it + * and the app reads its platform list from there and never asks the network. + */ + @SerializedName("DIRECTORY_FILE") + val directoryFile: String = "", + + @SerializedName("DIRECTORY_MODE") + val directoryMode: String = "", +) { + + /** + * How to read the directory. + * + * A bundled file wins over a URL: a build that ships its own copy has opted out + * of the network, and quietly preferring a remote list would undo that. + */ + sealed interface Source { + /** A JSON document in the app's assets. */ + data class BundledDocument(val fileName: String) : Source + + /** A JSON document to fetch once. */ + data class Document(val url: String) : Source + + /** A live catalog answering /api/v1/directory. */ + data class Service(val url: String) : Source + } + + val source: Source? + get() { + if (!enabled) return null + val file = directoryFile.trim() + if (file.isNotEmpty()) return Source.BundledDocument(file) + val url = directoryUrl.trim() + if (url.isEmpty()) return null + // A ".json" address is a document; anything else is a service to query. + // The difference is visible in the config file, which is where whoever + // set it will look when the app does not do what they expected. + val path = url.substringBefore('?').substringBefore('#') + return if (path.endsWith(".json", ignoreCase = true)) { + Source.Document(url) + } else { + Source.Service(url) + } + } + + /** + * The single gate for activating any LMS Directory behaviour: the feature only + * works with a registry to talk to, so an ENABLED:true build with a blank + * [directoryUrl] stays fully single-tenant instead of building clients against an + * invalid stub. + * + * This deliberately diverges from the white-label source, which gates purely on the + * directory URL being non-blank. It is equivalent in effect (a directory URL is only + * ever present when the feature is on) and safer, because it also refuses to activate + * on the ENABLED:true + empty-URL misconfiguration. + */ + val isReachable: Boolean + get() = enabled && (directoryFile.isNotBlank() || directoryUrl.isNotBlank()) + + /** + * Whether this build can report a platform to anyone. + * + * Reporting exists because the open catalog lets a stranger list anything; it + * belongs to the universal app, not to a provider's own list. A directory read + * from a document has no service behind it, so there is nothing to post to and + * the entry point must not appear. + * + * A live service in curated mode also refuses reports, and is hidden separately + * by the mode itself. + */ + val supportsReporting: Boolean get() = source is Source.Service + + /** + * A stable identifier for the configured source. + * + * Anything remembered *about* a source — the last mode the server reported, + * for instance — is only meaningful while the source is the same one. Storing + * this beside such a value is what lets a reader notice the build has been + * pointed somewhere else and ignore what it remembers. + */ + val sourceKey: String + get() = when (val current = source) { + is Source.Service -> "service:${current.url}" + is Source.Document -> "document:${current.url}" + is Source.BundledDocument -> "file:${current.fileName}" + null -> "" + } + + /** + * What kind of list this is, as far as the config file alone can settle it. + * + * A document is a fixed list by construction. For a service, DIRECTORY_MODE + * settles it either way when set. Null means the config does not know and the + * server has to be asked. + */ + val configuredMode: LmsDirectoryMode? + get() = when (source) { + is Source.Document, is Source.BundledDocument -> LmsDirectoryMode.CURATED + null -> LmsDirectoryMode.CURATED + else -> when (directoryMode.trim().lowercase()) { + "curated" -> LmsDirectoryMode.CURATED + "search" -> LmsDirectoryMode.SEARCH + else -> null + } + } +} 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..c15515804 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 @@ -3,6 +3,7 @@ package org.openedx.core.data.storage import org.openedx.core.data.model.User import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoSettings +import org.openedx.core.lmsdirectory.LmsHistoryEntry interface CorePreferences { var accessToken: String @@ -15,5 +16,53 @@ interface CorePreferences { var canResetAppDirectory: Boolean var isRelativeDatesEnabled: Boolean + /** + * Base URL of the LMS the learner picked in the LMS Directory, or null when + * none is selected (or the feature is off). When set, [org.openedx.core.config.Config.getApiHostURL] + * returns this instead of the baked-in host. + */ + var selectedBaseUrl: String? + + /** Accent color (hex, e.g. "#f15d49") of the selected LMS, used to re-theme the app. */ + var selectedLmsAccentColor: String? + + /** OAuth mobile client id of the selected LMS. Sign-in uses this instead of the config value. */ + var selectedOAuthClientId: String? + + /** Feedback email of the selected LMS. */ + var selectedFeedbackEmail: String? + + /** Logo URL of the selected LMS, shown on the sign-in screen. */ + var selectedLmsLogoUrl: String? + + /** Login background image URL of the selected LMS, shown behind the sign-in header. */ + var selectedLmsLoginBackgroundUrl: String? + + /** Human title of the selected LMS, shown in the sign-in "Change" banner. */ + var selectedLmsTitle: String? + + /** + * What the directory last said it was — the name of an + * [org.openedx.core.lmsdirectory.LmsDirectoryMode], or empty for "not yet + * known". Read through + * [org.openedx.core.lmsdirectory.LmsDirectoryState], never directly: on its + * own it says nothing about which directory it describes. + */ + var lmsDirectoryMode: String + + /** + * Which directory source [lmsDirectoryMode] was recorded against. A remembered + * answer means nothing once the build points somewhere else, and this is what + * lets a reader tell. + */ + var lmsDirectorySourceKey: String + + /** + * Recently selected LMS platforms, most-recent-first (capped). Shown as the + * directory "History" section. Persists across logout (matches iOS): logging out + * clears the pinned selection but keeps the history so the picker can offer it. + */ + var lmsHistory: List + suspend fun clearCorePreferences() } diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt new file mode 100644 index 000000000..a44fc5407 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt @@ -0,0 +1,94 @@ +package org.openedx.core.lmsdirectory + +import com.google.gson.annotations.SerializedName + +/** Wire DTOs for the registry catalog. Mirrors the FastAPI response shapes. */ + +data class DirectoryListResponse( + @SerializedName("items") val items: List = emptyList(), +) + +data class LmsSummaryDto( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String, + @SerializedName("short_description") val shortDescription: String? = null, + @SerializedName("base_url") val baseUrl: String, + @SerializedName("logo_url") val logoUrl: String? = null, + @SerializedName("accent_color") val accentColor: String? = null, +) { + fun toDomain() = LmsSummary( + id = id, + title = title, + shortDescription = shortDescription.orEmpty(), + baseUrl = baseUrl, + logoUrl = logoUrl, + accentColor = accentColor, + ) +} + +data class LmsDetailDto( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String, + @SerializedName("short_description") val shortDescription: String? = null, + @SerializedName("base_url") val baseUrl: String, + @SerializedName("logo_url") val logoUrl: String? = null, + @SerializedName("accent_color") val accentColor: String? = null, + @SerializedName("api") val api: ApiDto? = null, + @SerializedName("theme") val theme: ThemeDto? = null, + @SerializedName("feature_flags") val featureFlags: FeatureFlagsDto? = null, +) { + data class ApiDto( + @SerializedName("host_url") val hostUrl: String? = null, + @SerializedName("oauth_client_id") val oauthClientId: String? = null, + @SerializedName("feedback_email") val feedbackEmail: String? = null, + ) + + data class ThemeDto( + @SerializedName("login_background_url") val loginBackgroundUrl: String? = null, + ) + + data class FeatureFlagsDto( + @SerializedName("pre_login_discovery") val preLoginDiscovery: Boolean = false, + ) + + fun toDomain() = LmsDetail( + id = id, + title = title, + shortDescription = shortDescription.orEmpty(), + baseUrl = api?.hostUrl?.ifBlank { null } ?: baseUrl, + logoUrl = logoUrl, + accentColor = accentColor, + oauthClientId = api?.oauthClientId?.ifBlank { null }, + feedbackEmail = api?.feedbackEmail?.ifBlank { null }, + loginBackgroundUrl = theme?.loginBackgroundUrl?.ifBlank { null }, + preLoginDiscovery = featureFlags?.preLoginDiscovery ?: false, + ) +} + +data class DirectoryConfigDto( + @SerializedName("directory_mode") val directoryMode: String = "search", + @SerializedName("provider_name") val providerName: String = "", + @SerializedName("provider_tagline") val providerTagline: String = "", +) { + fun toDomain() = DirectoryConfig( + directoryMode = directoryMode, + providerName = providerName, + providerTagline = providerTagline, + ) +} + +data class ReportRequestBody( + @SerializedName("lms_id") val lmsId: Int?, + @SerializedName("base_url") val baseUrl: String, + @SerializedName("category") val category: String, + @SerializedName("message") val message: String, + @SerializedName("reporter_email") val reporterEmail: String?, + @SerializedName("platform") val platform: String = "android", + @SerializedName("app_version") val appVersion: String, + @SerializedName("screenshot_base64") val screenshotBase64: String? = null, +) + +data class ReportResponse( + @SerializedName("id") val id: Int, + @SerializedName("status") val status: String, +) diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt new file mode 100644 index 000000000..93c5b9d3f --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt @@ -0,0 +1,90 @@ +package org.openedx.core.lmsdirectory + +/** + * Domain models for the LMS registry catalog. + * The registry is the same backend the iOS app talks to (`/api/v1/directory`). + */ + +data class LmsSummary( + val id: String, + val title: String, + val shortDescription: String, + val baseUrl: String, + val logoUrl: String?, + val accentColor: String?, +) + +/** + * Full record for one platform, fetched when the learner picks it. Carries the + * per-LMS OAuth client id and feedback email needed to actually sign in against it, + * plus branding (logo, accent) — the catalog summary alone can't log you in. + */ +data class LmsDetail( + val id: String, + val title: String, + val shortDescription: String = "", + val baseUrl: String, + val logoUrl: String?, + val accentColor: String?, + val oauthClientId: String?, + val feedbackEmail: String?, + val loginBackgroundUrl: String?, + /** When true, the app opens the pre-login course Discovery screen instead of sign-in. */ + val preLoginDiscovery: Boolean = false, +) + +/** + * A platform the learner previously opened. Persisted (Gson JSON) so the directory + * screen can show a "History" section when the search field is empty — mirrors iOS. + * Carries the full detail needed to re-select without re-validating over the network. + */ +data class LmsHistoryEntry( + val baseUrl: String, + val title: String, + val shortDescription: String = "", + val logoUrl: String? = null, + val accentColor: String? = null, + val oauthClientId: String? = null, + val feedbackEmail: String? = null, + val loginBackgroundUrl: String? = null, + val preLoginDiscovery: Boolean = false, +) + +data class DirectoryConfig( + val directoryMode: String, + val providerName: String, + val providerTagline: String, +) { + val isCurated: Boolean get() = directoryMode.equals("curated", ignoreCase = true) + + companion object { + val SEARCH_DEFAULT = DirectoryConfig( + directoryMode = "search", + providerName = "", + providerTagline = "", + ) + } +} + +/** + * Why a learner flags an LMS. Moderation reasons (trust & safety), not tech + * support. Raw values match the registry's categories. + */ +enum class ReportCategory(val apiValue: String) { + INAPPROPRIATE("inappropriate"), + SCAM("scam"), + IMPERSONATION("impersonation"), + SPAM("spam"), + BROKEN("broken"), + OTHER("other"), +} + +data class ReportDraft( + val lmsId: String?, + val baseUrl: String, + val category: ReportCategory, + val message: String, + val reporterEmail: String?, + /** A compressed screenshot as base64 (no data: prefix), or null. */ + val screenshotBase64: String? = null, +) diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt new file mode 100644 index 000000000..785a7a03b --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt @@ -0,0 +1,26 @@ +package org.openedx.core.lmsdirectory + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** Retrofit interface for the LMS registry (same backend as the iOS app). */ +interface LmsDirectoryApi { + + @GET("api/v1/config") + suspend fun getConfig(): DirectoryConfigDto + + @GET("api/v1/directory") + suspend fun search( + @Query("q") query: String? = null, + @Query("featured") featured: Boolean? = null, + ): DirectoryListResponse + + @GET("api/v1/directory/{id}") + suspend fun detail(@Path("id") id: String): LmsDetailDto + + @POST("api/v1/reports") + suspend fun submitReport(@Body body: ReportRequestBody): ReportResponse +} diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt new file mode 100644 index 000000000..4430a8a72 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt @@ -0,0 +1,84 @@ +package org.openedx.core.lmsdirectory + +import android.content.Context +import okhttp3.OkHttpClient +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.openedx.core.config.Config +import org.openedx.core.config.LMSDirectoryConfig +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +/** + * Koin module for the LMS directory. + * + * The list comes from whichever source the config names — a live catalog, a JSON + * document to fetch, or one shipped in the app's assets. Only the live catalog + * builds a Retrofit client, because only it has anything to send back. + */ +val lmsDirectoryModule = module { + + single(qualifier = named("LmsDirectory")) { + OkHttpClient.Builder() + .connectTimeout(DIRECTORY_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(DIRECTORY_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + } + + single { + val rawUrl = get().getLMSDirectoryConfig().directoryUrl + Retrofit.Builder() + .baseUrl(normalizeBaseUrl(rawUrl)) + .client(get(qualifier = named("LmsDirectory"))) + .addConverterFactory(GsonConverterFactory.create()) + .build() + .create(LmsDirectoryApi::class.java) + } + + single { + when (val source = get().getLMSDirectoryConfig().source) { + is LMSDirectoryConfig.Source.BundledDocument -> + DocumentLmsDirectorySource.fromAsset(get(), source.fileName) + + is LMSDirectoryConfig.Source.Document -> + DocumentLmsDirectorySource.fromUrl( + get(qualifier = named("LmsDirectory")), + source.url + ) + + // Null means the feature is off or unconfigured. The directory is gated + // on isReachable before anything resolves this, so the Retrofit client + // built against the stub URL is never actually called. + else -> ApiLmsDirectorySource(get()) + } + } + + single { + val context = get() + val version = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull().orEmpty() + val isService = get().getLMSDirectoryConfig().source is LMSDirectoryConfig.Source.Service + LmsDirectoryRepository( + source = get(), + appVersion = version, + // Reporting needs somewhere to post; a document has nowhere. + api = if (isService) get() else null, + ) + } +} + +private const val DIRECTORY_TIMEOUT_SECONDS = 20L + +/** Retrofit requires an absolute URL ending in "/". Blank config yields a safe stub. */ +private fun normalizeBaseUrl(url: String): String { + val trimmed = url.trim() + if (trimmed.isEmpty()) return "https://directory.invalid/" + val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "https://$trimmed" + } + return if (withScheme.endsWith("/")) withScheme else "$withScheme/" +} diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt new file mode 100644 index 000000000..951b51d7d --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt @@ -0,0 +1,85 @@ +package org.openedx.core.lmsdirectory + +import android.util.Log + +/** + * Supplies the platform list. All calls return [Result] so callers can fall back + * gracefully when the source is unreachable. + * + * [source] decides where the list comes from — a live service or a single JSON + * document, hosted or shipped in the app. [api] is only needed for the one thing + * a document cannot do, which is send a report back. + */ +class LmsDirectoryRepository( + private val source: LmsDirectorySource, + private val appVersion: String, + private val api: LmsDirectoryApi? = null, +) { + companion object { + private const val TAG = "LmsDirectory" + } + + suspend fun fetchConfig(): DirectoryConfig { + return try { + source.config() + } catch (e: Exception) { + // Only a live service can fail this way, and falling back to search is + // why a white-label build needs the local DIRECTORY_MODE guard. A + // document source never reaches here: it has no server to ask. + Log.w(TAG, "Config fetch failed, defaulting to search mode: ${e.message}") + DirectoryConfig.SEARCH_DEFAULT + } + } + + /** + * The registry's own answer, or null when it could not be reached. + * + * Unlike [fetchConfig] this does not fall back to search: a network error is + * not evidence that a catalog is open to anyone, and whoever records the mode + * has to be able to tell those two apart. + */ + suspend fun fetchConfigOrNull(): DirectoryConfig? = + runCatching { source.config() } + .onFailure { Log.w(TAG, "Config fetch failed: ${it.message}") } + .getOrNull() + + suspend fun search(query: String): Result> = runCatching { + source.search(query) + }.onFailure { Log.w(TAG, "Search failed: ${it.message}") } + + suspend fun fetchFeatured(): Result> = runCatching { + source.featured() + }.onFailure { Log.w(TAG, "Featured fetch failed: ${it.message}") } + + /** Full record for one platform (includes the OAuth client id needed to sign in). */ + suspend fun fetchDetail(id: String): Result = runCatching { + source.detail(id) + }.onFailure { Log.w(TAG, "Detail fetch failed: ${it.message}") } + + /** Images the list will need, for warming before the screens that show them. */ + suspend fun imageReferences(): List = + runCatching { source.imageReferences() } + .onFailure { Log.w(TAG, "Could not list directory images: ${it.message}") } + .getOrDefault(emptyList()) + + /** + * Send a complaint back to the service. Fails when the directory came from a + * document, which is a one-way list with nothing to post to — the screens that + * offer this are hidden in that case. + */ + suspend fun submitReport(draft: ReportDraft): Result = runCatching { + val api = requireNotNull(api) { "This directory has no service to report to" } + api.submitReport( + ReportRequestBody( + lmsId = draft.lmsId?.toIntOrNull(), + baseUrl = draft.baseUrl, + category = draft.category.apiValue, + message = draft.message, + reporterEmail = draft.reporterEmail?.trim()?.ifBlank { null }, + appVersion = appVersion, + screenshotBase64 = draft.screenshotBase64, + ) + ) + Unit + }.onFailure { Log.w(TAG, "Report submit failed: ${it.message}") } +} diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt new file mode 100644 index 000000000..49e4a2c71 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt @@ -0,0 +1,174 @@ +package org.openedx.core.lmsdirectory + +import android.content.Context +import android.util.Log +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Where the platform list comes from. + * + * Two shapes exist. A live service is queried for each thing the app needs; a + * document is read once and answers everything from memory. The repository above + * cannot tell them apart, which is the point — whoever ships a build decides + * where the list lives, and the app is not told anything about that choice. + */ +interface LmsDirectorySource { + suspend fun config(): DirectoryConfig + suspend fun search(query: String): List + suspend fun featured(): List + suspend fun detail(id: String): LmsDetail + + /** + * Every image the list will ask for, so a caller can warm them before the + * screens that show them are built. Empty when the source cannot know them + * up front. + */ + suspend fun imageReferences(): List = emptyList() +} + +/** The live catalog: one request per thing the app needs. */ +class ApiLmsDirectorySource(private val api: LmsDirectoryApi) : LmsDirectorySource { + + override suspend fun config(): DirectoryConfig = api.getConfig().toDomain() + + override suspend fun search(query: String): List = + api.search(query = query.ifBlank { null }).items.map { it.toDomain() } + + override suspend fun featured(): List = + api.search(featured = true).items.map { it.toDomain() } + + override suspend fun detail(id: String): LmsDetail = api.detail(id).toDomain() +} + +/** + * A single JSON document, fetched from a URL or read out of the app's assets. + * + * Read once and kept: the picker, the theming and the image prefetch all work + * from the same copy rather than parsing it three times. Because everything + * arrives together, a platform's sign-in background is known before the learner + * has picked anything, which is what lets the artwork be warmed in advance. + */ +class DocumentLmsDirectorySource( + private val loader: DocumentLoader, + private val gson: Gson = Gson(), +) : LmsDirectorySource { + + /** How the bytes are obtained. Kept separate so tests need no network or app. */ + fun interface DocumentLoader { + suspend fun load(): String + } + + private var cached: DirectoryDocumentDto? = null + + override suspend fun config(): DirectoryConfig { + val provider = document().provider + // A fixed list has nothing to search across, so it is always curated. There + // is no server to ask, which is exactly why a document build cannot fall + // back to open search when it is offline. + return DirectoryConfig( + directoryMode = "curated", + providerName = provider?.name.orEmpty(), + providerTagline = provider?.tagline.orEmpty(), + ) + } + + override suspend fun search(query: String): List { + val platforms = document().platforms + val needle = query.trim().lowercase() + val matches = if (needle.isEmpty()) { + platforms + } else { + platforms.filter { + it.title.lowercase().contains(needle) || it.baseUrl.lowercase().contains(needle) + } + } + return matches.map { it.toSummary() } + } + + override suspend fun featured(): List = document().platforms.map { it.toSummary() } + + override suspend fun detail(id: String): LmsDetail = + document().platforms.firstOrNull { it.id == id }?.toDomain() + ?: throw NoSuchElementException("No platform with id $id in the directory document") + + override suspend fun imageReferences(): List = + document().platforms.flatMap { + listOfNotNull(it.logoUrl, it.theme?.loginBackgroundUrl) + }.filter { it.isNotBlank() } + + private suspend fun document(): DirectoryDocumentDto { + cached?.let { return it } + val raw = loader.load() + val parsed = gson.fromJson(raw, DirectoryDocumentDto::class.java) + checkNotNull(parsed) { "Directory document is empty" } + if (parsed.platforms.isEmpty()) { + Log.w(TAG, "Directory document parsed but lists no platforms") + } + cached = parsed + return parsed + } + + companion object { + private const val TAG = "LmsDirectory" + + /** Reads a document shipped in the app's assets. Never touches the network. */ + fun fromAsset(context: Context, fileName: String): DocumentLmsDirectorySource = + DocumentLmsDirectorySource( + loader = { + withContext(Dispatchers.IO) { + context.assets.open(fileName).bufferedReader().use { it.readText() } + } + } + ) + + /** Fetches a document over HTTP, once. */ + fun fromUrl(client: OkHttpClient, url: String): DocumentLmsDirectorySource = + DocumentLmsDirectorySource( + loader = { + withContext(Dispatchers.IO) { + client.newCall(Request.Builder().url(url).build()).execute().use { response -> + check(response.isSuccessful) { + "Directory document returned ${response.code}" + } + checkNotNull(response.body?.string()) { + "Directory document had no body" + } + } + } + } + ) + } +} + +/** + * Wire format of the directory document. + * + * `platforms` entries are the same objects `/api/v1/directory/{id}` returns, so + * [LmsDetailDto] is reused rather than duplicated — a document and a live service + * describe a platform identically, and keeping one parser is what guarantees it. + */ +data class DirectoryDocumentDto( + @SerializedName("version") val version: Int = 1, + @SerializedName("provider") val provider: ProviderDto? = null, + @SerializedName("platforms") val platforms: List = emptyList(), +) { + data class ProviderDto( + @SerializedName("name") val name: String? = null, + @SerializedName("tagline") val tagline: String? = null, + @SerializedName("logo_url") val logoUrl: String? = null, + ) +} + +private fun LmsDetailDto.toSummary(): LmsSummary = LmsSummary( + id = id, + title = title, + shortDescription = shortDescription.orEmpty(), + baseUrl = api?.hostUrl?.ifBlank { null } ?: baseUrl, + logoUrl = logoUrl, + accentColor = accentColor, +) diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt new file mode 100644 index 000000000..d8a8b81bf --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt @@ -0,0 +1,120 @@ +package org.openedx.core.lmsdirectory + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.openedx.core.config.LMSDirectoryConfig +import org.openedx.core.data.storage.CorePreferences + +/** What kind of list a directory is. */ +enum class LmsDirectoryMode { + /** Nobody has said yet. A live service that has not answered this launch. */ + UNKNOWN, + + /** An open catalog: anyone may list a platform, so nothing is vouched for. */ + SEARCH, + + /** A fixed list published by one organisation. */ + CURATED +} + +/** + * What the app knows about the directory it reads, and how long that knowledge is + * good for. + * + * Whether a live catalog is open to anyone or is one organisation's own list is + * something only the server can say, and it says so on a screen the app stops + * showing once a platform has been picked. So the answer is remembered — and a + * remembered answer is worth nothing after the build is pointed somewhere else, + * or after the server changes its mind. Hence three states rather than a flag: + * until the current source has actually answered, the mode is [UNKNOWN], and + * anything that depends on it stays hidden. + * + * A single object, like [LmsThemeController]: the answer is a property of the + * install, and every screen has to see the same one. + */ +object LmsDirectoryState { + + private val _revision = MutableStateFlow(0L) + + /** Emits whenever the stored answer changes, so a screen can redraw. */ + val revision: StateFlow = _revision.asStateFlow() + + /** + * What this build's directory is, as far as anyone currently knows. + * + * A document is a fixed list by construction. DIRECTORY_MODE settles it locally + * either way. Only a live service with no override has to ask, and until it has + * answered *for this exact source* the honest answer is [LmsDirectoryMode.UNKNOWN]. + */ + fun mode(config: LMSDirectoryConfig, preferences: CorePreferences): LmsDirectoryMode { + val configured = config.configuredMode + val remembered = if (preferences.lmsDirectorySourceKey == config.sourceKey) { + runCatching { LmsDirectoryMode.valueOf(preferences.lmsDirectoryMode) } + .getOrDefault(LmsDirectoryMode.UNKNOWN) + } else { + LmsDirectoryMode.UNKNOWN + } + return configured ?: remembered + } + + /** + * Whether to offer reporting a platform. + * + * Reporting exists because an open catalog lets a stranger list anything, so it + * needs both a live service to post to and a list nobody vouched for. A document + * has no service; a curated catalog vouches for its own platforms; and an + * unanswered service is not yet known to be either, so it shows nothing rather + * than guessing. + */ + fun canReport(config: LMSDirectoryConfig, preferences: CorePreferences): Boolean = + config.supportsReporting && mode(config, preferences) == LmsDirectoryMode.SEARCH + + /** Record what a source said, against the source that said it. */ + fun remember( + mode: LmsDirectoryMode, + config: LMSDirectoryConfig, + preferences: CorePreferences + ) { + if (mode == LmsDirectoryMode.UNKNOWN) return + val changed = preferences.lmsDirectoryMode != mode.name || + preferences.lmsDirectorySourceKey != config.sourceKey + preferences.lmsDirectoryMode = mode.name + preferences.lmsDirectorySourceKey = config.sourceKey + if (changed) _revision.value++ + } + + /** Forget everything source-specific. Used when the feature is switched off. */ + fun clear(preferences: CorePreferences) { + preferences.lmsDirectoryMode = "" + preferences.lmsDirectorySourceKey = "" + _revision.value++ + } + + /** + * Drop knowledge that belongs to a directory this build no longer reads. + * Safe on every launch; does nothing when the source is unchanged. + */ + fun reconcile(config: LMSDirectoryConfig, preferences: CorePreferences) { + val stored = preferences.lmsDirectorySourceKey + if (stored.isEmpty() || stored == config.sourceKey) return + clear(preferences) + } + + /** + * Ask the directory what it is, now, rather than trusting what it said last time. + * + * Called at launch so a service that has changed its mode is noticed even by a + * build that never shows the platform picker again. A failure leaves what is + * already known untouched, because a network error is not evidence of anything. + */ + suspend fun refresh( + config: LMSDirectoryConfig, + preferences: CorePreferences, + ask: suspend () -> LmsDirectoryMode + ) { + if (config.configuredMode != null || !config.supportsReporting) return + val answered = runCatching { ask() }.getOrNull() ?: return + if (answered != LmsDirectoryMode.UNKNOWN) remember(answered, config, preferences) + } +} diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsImageSource.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsImageSource.kt new file mode 100644 index 000000000..2b233c93d --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsImageSource.kt @@ -0,0 +1,51 @@ +package org.openedx.core.lmsdirectory + +import android.content.Context +import coil.ImageLoader +import coil.request.ImageRequest + +/** + * Turns a directory image field into something Coil can load. + * + * The document carries image fields as plain strings. A value that looks like a + * web address is downloaded; anything else is the name of a file shipped in the + * app's assets. That one rule is what lets the same document serve an operator + * who hosts their images and one who bundles them, with no second set of fields + * to keep in step. + */ +object LmsImageSource { + + private const val ASSET_SCHEME = "file:///android_asset/" + + /** + * A Coil model for [value], or null when there is nothing to show. + * + * Coil reads `file:///android_asset/…` natively, so a bundled image needs no + * special case anywhere it is rendered — only here. + */ + fun model(value: String?): String? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isEmpty()) return null + return if (isRemote(trimmed)) trimmed else ASSET_SCHEME + trimmed.trimStart('/') + } + + fun isRemote(value: String): Boolean = + value.startsWith("http://", ignoreCase = true) || + value.startsWith("https://", ignoreCase = true) + + /** + * Warm images before the screens that show them are built. + * + * Worth doing only because the whole directory arrives at once: a platform's + * sign-in background is known while the learner is still choosing, so by the + * time they pick one it is already decoded and the branded screen does not + * visibly assemble itself. + */ + fun prefetch(context: Context, values: List, loader: ImageLoader? = null) { + val imageLoader = loader ?: coil.Coil.imageLoader(context) + values.asSequence() + .mapNotNull { model(it) } + .distinct() + .forEach { imageLoader.enqueue(ImageRequest.Builder(context).data(it).build()) } + } +} diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt new file mode 100644 index 000000000..5de895521 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt @@ -0,0 +1,54 @@ +package org.openedx.core.lmsdirectory + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color + +/** + * Holds the accent color the app re-themes to when a learner picks an LMS from the + * directory. [OpenEdXTheme][org.openedx.core.ui.theme.OpenEdXTheme] reads [accentColor] + * and, when present, tints the palette's accent-driven surfaces (buttons, primary). + * + * Backed by [mutableStateOf] so setting it recomposes the theme. The app seeds it at + * launch from the persisted selection and updates it the moment a platform is chosen. + */ +object LmsThemeController { + + var accentColor by mutableStateOf(null) + private set + + /** + * The selected platform's login background image URL. Drives the branded header on + * the auth (sign-in/register/reset) and settings screens, mirroring iOS's + * `LmsHeaderBackground`. Null (default build / no custom image) keeps the stock header. + */ + var loginBackgroundUrl by mutableStateOf(null) + private set + + /** Apply a hex color like "#f15d49". Invalid or blank input clears the override. */ + fun apply(hex: String?) { + accentColor = parseHexColor(hex) + } + + /** Apply the selected LMS's login background image URL (blank/null clears it). */ + fun applyBackground(url: String?) { + loginBackgroundUrl = url?.takeIf { it.isNotBlank() } + } + + fun clear() { + accentColor = null + loginBackgroundUrl = null + } + + @Suppress("MagicNumber", "ReturnCount") + fun parseHexColor(hex: String?): Color? { + val raw = hex?.trim()?.removePrefix("#") ?: return null + if (raw.length != 6 && raw.length != 8) return null + val value = raw.toLongOrNull(16) ?: return null + return when (raw.length) { + 6 -> Color(0xFF000000 or value) + else -> Color(value) + } + } +} 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..77e17b7f2 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -37,7 +37,10 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest import org.openedx.core.R +import org.openedx.core.lmsdirectory.LmsThemeController import org.openedx.core.presentation.global.InsetHolder const val KEYBOARD_VISIBILITY_THRESHOLD = 0.15f @@ -172,9 +175,24 @@ fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { } fun Modifier.settingsHeaderBackground(): Modifier = composed { + // LMS Directory: brand the header with the selected platform's login background image, + // falling back to the stock gradient header (matches iOS's LmsHeaderBackground). + val backgroundUrl = LmsThemeController.loginBackgroundUrl + val painter = if (!backgroundUrl.isNullOrBlank()) { + rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(backgroundUrl) + .placeholder(R.drawable.core_top_header) + .error(R.drawable.core_top_header) + .crossfade(true) + .build() + ) + } else { + 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/LmsHeaderImage.kt b/core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt new file mode 100644 index 000000000..e2f64ea97 --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt @@ -0,0 +1,48 @@ +package org.openedx.core.ui + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import coil.compose.AsyncImage +import coil.request.ImageRequest +import org.openedx.core.R +import org.openedx.core.lmsdirectory.LmsImageSource +import org.openedx.core.lmsdirectory.LmsThemeController + +/** + * Header image for the auth screens (sign-in / register / reset password). When the LMS + * Directory feature has a selected platform with a custom login background, shows that + * image; otherwise the stock gradient header. Mirrors iOS's `LmsHeaderBackground`, so a + * branded platform looks the same across sign-in, register, reset and the settings screens. + */ +@Composable +fun LmsHeaderImage(modifier: Modifier = Modifier) { + val background = LmsImageSource.model(LmsThemeController.loginBackgroundUrl) + if (background != null) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(background) + .placeholder(R.drawable.core_top_header) + .error(R.drawable.core_top_header) + // No crossfade: the image is prefetched while the learner is still + // choosing a platform, so it is already decoded by the time this is + // built. Fading it in would put back the appearing-image effect that + // prefetching exists to remove. + .crossfade(false) + .build(), + modifier = modifier, + contentScale = ContentScale.FillBounds, + contentDescription = null, + ) + } else { + Image( + modifier = modifier, + painter = painterResource(id = R.drawable.core_top_header), + contentScale = ContentScale.FillBounds, + contentDescription = 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..fce872875 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 @@ -10,6 +10,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import org.openedx.core.lmsdirectory.LmsThemeController internal val LocalAppColors = staticCompositionLocalOf { error("No AppColors provided") @@ -223,11 +225,14 @@ val MaterialTheme.appColors: AppColors @OptIn(ExperimentalFoundationApi::class) @Composable fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) { + val basePalette = if (darkTheme) { DarkColorPalette } else { LightColorPalette } + // LMS Directory: re-tint accent surfaces to the selected platform's brand color. + // Null (default / stock build) leaves the baked-in palette untouched. + val colors = LmsThemeController.accentColor?.let { basePalette.withAccent(it) } ?: basePalette MaterialTheme( colorScheme = colors.material3, @@ -240,3 +245,33 @@ fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl ) } } + +/** + * Returns a copy of this palette with the accent-driven surfaces re-tinted to + * [accent] — the primary/secondary buttons, the Material3 primary/tertiary roles, + * the accent text, and every link/interactive accent role (mirroring iOS's + * LMSThemeApplier: accentColor/infoColor, the outlined secondary-button text & + * border, and the toggle switch). This drives the SignIn "Change" / "Register" + * (primary) and "Forgot password" (infoVariant) text links, plus page indicators + * and toggles, so the whole app adopts the platform's brand color. Everything else + * (backgrounds, body text, on-button text, borders) is preserved so the app keeps + * its light/dark identity and text-on-surface readability. + */ +private fun AppColors.withAccent(accent: Color): AppColors { + return copy( + material3 = material3.copy( + primary = accent, + tertiary = accent, + surfaceTint = accent, + ), + textAccent = accent, + primaryButtonBackground = accent, + secondaryButtonBackground = accent, + secondaryButtonBorder = accent, + secondaryButtonBorderedText = accent, + bottomSheetToggle = accent, + info = accent, + infoVariant = accent, + progressBarColor = accent, + ) +} diff --git a/core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt b/core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt new file mode 100644 index 000000000..2dbe71f69 --- /dev/null +++ b/core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt @@ -0,0 +1,83 @@ +package org.openedx.core.config + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Which source a build reads its platform list from is decided entirely by the + * config file, so the rule has to be exactly what the comments in that file say. + */ +class LMSDirectoryConfigTest { + + @Test + fun `a json address is a document, anything else is a service`() { + assertEquals( + LMSDirectoryConfig.Source.Document("https://example.com/directory.json"), + LMSDirectoryConfig(enabled = true, directoryUrl = "https://example.com/directory.json").source + ) + assertEquals( + LMSDirectoryConfig.Source.Service("https://example.com"), + LMSDirectoryConfig(enabled = true, directoryUrl = "https://example.com").source + ) + } + + @Test + fun `a query string does not hide the json suffix`() { + assertEquals( + LMSDirectoryConfig.Source.Document("https://example.com/d.json?v=2"), + LMSDirectoryConfig(enabled = true, directoryUrl = "https://example.com/d.json?v=2").source + ) + } + + @Test + fun `a bundled file wins over an address`() { + // A build shipping its own copy has opted out of the network; quietly + // preferring a remote list would undo that. + assertEquals( + LMSDirectoryConfig.Source.BundledDocument("lms_directory.json"), + LMSDirectoryConfig( + enabled = true, + directoryUrl = "https://example.com/directory.json", + directoryFile = "lms_directory.json", + ).source + ) + } + + @Test + fun `nothing configured means no source and nothing reachable`() { + assertNull(LMSDirectoryConfig(enabled = true).source) + assertFalse(LMSDirectoryConfig(enabled = true).isReachable) + // Off is off, whatever else is filled in. + assertNull( + LMSDirectoryConfig(enabled = false, directoryUrl = "https://example.com/d.json").source + ) + assertFalse( + LMSDirectoryConfig(enabled = false, directoryUrl = "https://example.com/d.json").isReachable + ) + } + + @Test + fun `only a live service can be reported to`() { + // Reporting belongs to the universal app. A document has no service behind + // it, so there is nowhere to post and the entry point must stay hidden. + assertTrue( + LMSDirectoryConfig(enabled = true, directoryUrl = "https://example.com").supportsReporting + ) + assertFalse( + LMSDirectoryConfig(enabled = true, directoryUrl = "https://example.com/d.json").supportsReporting + ) + assertFalse( + LMSDirectoryConfig(enabled = true, directoryFile = "lms_directory.json").supportsReporting + ) + assertFalse(LMSDirectoryConfig(enabled = false, directoryUrl = "https://example.com").supportsReporting) + assertFalse(LMSDirectoryConfig(enabled = true).supportsReporting) + } + + @Test + fun `a bundled file alone is enough to be reachable`() { + assertTrue(LMSDirectoryConfig(enabled = true, directoryFile = "lms_directory.json").isReachable) + } +} diff --git a/core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt b/core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt new file mode 100644 index 000000000..f84797e3d --- /dev/null +++ b/core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt @@ -0,0 +1,156 @@ +package org.openedx.core.lmsdirectory + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * A directory read from a single JSON document — hosted or shipped in the app — + * has to behave exactly like one read from a live service, and keep behaving + * that way with no network at all. That is the promise the document makes. + */ +class DocumentLmsDirectorySourceTest { + + private val document = """ + { + "version": 1, + "provider": { "name": "Northwind", "tagline": "Five campuses, one app" }, + "platforms": [ + { + "id": "1", + "title": "Alpha", + "short_description": "Alpha", + "base_url": "https://alpha.example.edu", + "logo_url": "https://cdn.example.com/alpha.png", + "accent_color": "#112233", + "api": { + "host_url": "https://alpha.example.edu", + "oauth_client_id": "alpha-client", + "feedback_email": "support@example.edu" + }, + "feature_flags": { "pre_login_discovery": true }, + "theme": { "login_background_url": "alpha-bg.png" } + }, + { + "id": "2", + "title": "Beta", + "short_description": "Beta", + "base_url": "https://beta.example.edu", + "logo_url": "beta-logo.png", + "api": { + "host_url": "https://beta.example.edu", + "oauth_client_id": "beta-client", + "feedback_email": "" + }, + "feature_flags": { "pre_login_discovery": false } + } + ] + } + """.trimIndent() + + private fun source(payload: String = document, onLoad: () -> Unit = {}) = + DocumentLmsDirectorySource( + loader = { + onLoad() + payload + } + ) + + @Test + fun `featured lists every platform in document order`() = runTest { + assertEquals(listOf("Alpha", "Beta"), source().featured().map { it.title }) + } + + @Test + fun `detail comes from the same copy without loading again`() = runTest { + var loads = 0 + val source = source(onLoad = { loads++ }) + source.featured() + val detail = source.detail("2") + + assertEquals("Beta", detail.title) + assertEquals("beta-client", detail.oauthClientId) + // Read once and kept: the picker, the theming and the prefetch all work + // from one copy rather than fetching it three times. + assertEquals(1, loads) + } + + @Test + fun `an unknown id is an error rather than a silent empty result`() = runTest { + val source = source() + try { + source.detail("nope") + throw AssertionError("Expected a failure for an unknown id") + } catch (e: NoSuchElementException) { + assertTrue(e.message!!.contains("nope")) + } + } + + @Test + fun `search matches on title and on host`() = runTest { + val source = source() + assertEquals(listOf("Beta"), source.search("beta").map { it.title }) + assertEquals(listOf("Alpha"), source.search("alpha.example.edu").map { it.title }) + assertEquals(2, source.search(" ").size) + } + + @Test + fun `a document is always curated and carries the provider name`() = runTest { + val config = source().config() + // The property that removes the offline footgun: there is no server to ask + // what mode to be in, so this build cannot fall back to open search. + assertTrue(config.isCurated) + assertEquals("Northwind", config.providerName) + assertEquals("Five campuses, one app", config.providerTagline) + } + + @Test + fun `image references cover logos and sign-in backgrounds`() = runTest { + val refs = source().imageReferences() + assertTrue(refs.contains("https://cdn.example.com/alpha.png")) + assertTrue(refs.contains("alpha-bg.png")) + assertTrue(refs.contains("beta-logo.png")) + } + + @Test + fun `a minimal hand-written document is accepted`() = runTest { + // The smallest document a person could reasonably write. The same file + // has to work on iOS, so anything omitted here must have a default on + // both platforms — not just on this one, where Gson is forgiving. + val minimal = """ + { + "version": 1, + "platforms": [ + { + "id": "1", + "title": "Alpha", + "description": "Alpha campus", + "short_description": "Alpha", + "base_url": "https://alpha.example.edu", + "api": { + "host_url": "https://alpha.example.edu", + "oauth_client_id": "alpha-client", + "feedback_email": "support@example.edu" + } + } + ] + } + """.trimIndent() + + val detail = source(payload = minimal).detail("1") + + assertEquals("Alpha", detail.title) + assertEquals("alpha-client", detail.oauthClientId) + } + + @Test + fun `a document that cannot be parsed fails instead of looking empty`() = runTest { + try { + source(payload = "not json at all").featured() + throw AssertionError("Expected a parse failure") + } catch (e: Exception) { + assertTrue(e !is AssertionError) + } + } +} diff --git a/core/src/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt b/core/src/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt new file mode 100644 index 000000000..98cb77437 --- /dev/null +++ b/core/src/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt @@ -0,0 +1,273 @@ +package org.openedx.core.lmsdirectory + +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.openedx.core.config.LMSDirectoryConfig +import org.openedx.core.data.model.User +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.domain.model.AppConfig +import org.openedx.core.domain.model.VideoSettings + +/** + * Whether a build offers to report a platform depends on something only a live + * server can say, remembered between launches. These cover the ways a remembered + * answer stops being true: the build is pointed elsewhere, the server changes its + * mind at the same address, or the value was written by a version that recorded + * no source at all. + */ +class LmsDirectoryStateTest { + + private val openCatalog = LMSDirectoryConfig( + enabled = true, + directoryUrl = "https://registry.example.com" + ) + private val otherCatalog = LMSDirectoryConfig( + enabled = true, + directoryUrl = "https://other-registry.example.com" + ) + private val document = LMSDirectoryConfig( + enabled = true, + directoryUrl = "https://cdn.example.com/directory.json" + ) + private val bundled = LMSDirectoryConfig( + enabled = true, + directoryFile = "lms_directory.json" + ) + + private lateinit var prefs: FakePreferences + + @Before + fun setUp() { + prefs = FakePreferences() + LmsDirectoryState.clear(prefs) + } + + // What the configuration alone decides + + @Test + fun `a document is curated without asking anyone`() { + assertEquals(LmsDirectoryMode.CURATED, LmsDirectoryState.mode(document, prefs)) + assertEquals(LmsDirectoryMode.CURATED, LmsDirectoryState.mode(bundled, prefs)) + assertFalse(LmsDirectoryState.canReport(document, prefs)) + assertFalse(LmsDirectoryState.canReport(bundled, prefs)) + } + + @Test + fun `a service is unknown until it answers`() { + // The point of three states: an unanswered service is not "open", it is + // unknown, and nothing that depends on the answer may appear yet. + assertEquals(LmsDirectoryMode.UNKNOWN, LmsDirectoryState.mode(openCatalog, prefs)) + assertFalse(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `a forced mode is honoured without the server`() { + val curated = openCatalog.copy(directoryMode = "curated") + val search = openCatalog.copy(directoryMode = "search") + + assertEquals(LmsDirectoryMode.CURATED, LmsDirectoryState.mode(curated, prefs)) + assertFalse(LmsDirectoryState.canReport(curated, prefs)) + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(search, prefs)) + assertTrue(LmsDirectoryState.canReport(search, prefs)) + } + + @Test + fun `a disabled directory offers nothing`() { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + assertFalse(LmsDirectoryState.canReport(LMSDirectoryConfig(enabled = false), prefs)) + } + + // What a remembered answer is good for + + @Test + fun `an answered service offers reporting`() { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(openCatalog, prefs)) + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `an answer does not follow the build to another service`() { + // The picker is skipped once a platform is selected, so nothing would ever + // overwrite an answer left by a different registry. + LmsDirectoryState.remember(LmsDirectoryMode.CURATED, openCatalog, prefs) + + assertEquals(LmsDirectoryMode.UNKNOWN, LmsDirectoryState.mode(otherCatalog, prefs)) + assertFalse(LmsDirectoryState.canReport(otherCatalog, prefs)) + } + + @Test + fun `moving from a document to a service leaves the mode unknown`() { + LmsDirectoryState.remember(LmsDirectoryMode.CURATED, document, prefs) + + assertEquals(LmsDirectoryMode.UNKNOWN, LmsDirectoryState.mode(openCatalog, prefs)) + assertFalse(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `moving from a service to a document hides reporting`() { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + assertEquals(LmsDirectoryMode.CURATED, LmsDirectoryState.mode(document, prefs)) + assertFalse(LmsDirectoryState.canReport(document, prefs)) + } + + // The same address changing its mind + + @Test + fun `the same service can change its mode`() { + LmsDirectoryState.remember(LmsDirectoryMode.CURATED, openCatalog, prefs) + assertFalse(LmsDirectoryState.canReport(openCatalog, prefs)) + + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(openCatalog, prefs)) + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `a refresh records what the server now says`() = runTest { + LmsDirectoryState.remember(LmsDirectoryMode.CURATED, openCatalog, prefs) + + LmsDirectoryState.refresh(openCatalog, prefs) { LmsDirectoryMode.SEARCH } + + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(openCatalog, prefs)) + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `an unreachable registry changes nothing`() = runTest { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + LmsDirectoryState.refresh(openCatalog, prefs) { LmsDirectoryMode.UNKNOWN } + LmsDirectoryState.refresh(openCatalog, prefs) { error("offline") } + + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(openCatalog, prefs)) + } + + @Test + fun `a refresh does not override a configured mode`() = runTest { + // DIRECTORY_MODE is the operator's decision; the server does not get a vote. + val curated = openCatalog.copy(directoryMode = "curated") + + LmsDirectoryState.refresh(curated, prefs) { LmsDirectoryMode.SEARCH } + + assertEquals(LmsDirectoryMode.CURATED, LmsDirectoryState.mode(curated, prefs)) + } + + @Test + fun `a document is never refreshed`() = runTest { + var asked = false + + LmsDirectoryState.refresh(document, prefs) { + asked = true + LmsDirectoryMode.SEARCH + } + + assertFalse("a document has no server to ask", asked) + } + + // Upgrades from a build that stored only a boolean + + @Test + fun `a value from an older build is not trusted`() { + // Upgrades carry a flag that names no source. Both ways round it must mean + // "not known yet", which hides reporting rather than revealing it. + prefs.lmsDirectoryMode = "true" + prefs.lmsDirectorySourceKey = "" + + assertEquals(LmsDirectoryMode.UNKNOWN, LmsDirectoryState.mode(openCatalog, prefs)) + assertFalse(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `an upgraded install starts reporting only once the registry answers`() { + prefs.lmsDirectoryMode = "false" + prefs.lmsDirectorySourceKey = "" + assertFalse(LmsDirectoryState.canReport(openCatalog, prefs)) + + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + // Housekeeping + + @Test + fun `reconcile drops a value belonging to a different source`() { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + LmsDirectoryState.reconcile(otherCatalog, prefs) + + assertEquals(LmsDirectoryMode.UNKNOWN, LmsDirectoryState.mode(otherCatalog, prefs)) + } + + @Test + fun `reconcile leaves a matching value alone`() { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + LmsDirectoryState.reconcile(openCatalog, prefs) + + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(openCatalog, prefs)) + } + + @Test + fun `clear leaves nothing to be believed`() { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + + LmsDirectoryState.clear(prefs) + + assertEquals(LmsDirectoryMode.UNKNOWN, LmsDirectoryState.mode(openCatalog, prefs)) + assertFalse(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `every source has its own key`() { + assertTrue(openCatalog.sourceKey != document.sourceKey) + assertTrue(document.sourceKey != bundled.sourceKey) + assertTrue(openCatalog.sourceKey != otherCatalog.sourceKey) + } + + @Test + fun `a changed answer is observable`() { + // The Profile screen redraws off this; without it the entry point would + // only appear on the next visit. + val before = LmsDirectoryState.revision.value + + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + LmsDirectoryState.remember(LmsDirectoryMode.CURATED, openCatalog, prefs) + + assertEquals(before + 2, LmsDirectoryState.revision.value) + } + + /** In-memory preferences: only the two directory fields matter here. */ + private class FakePreferences : CorePreferences { + override var accessToken: String = "" + override var refreshToken: String = "" + override var pushToken: String = "" + override var accessTokenExpiresAt: Long = 0 + override var user: User? = null + override var videoSettings: VideoSettings = VideoSettings.default + override var appConfig: AppConfig = AppConfig() + override var canResetAppDirectory: Boolean = false + override var isRelativeDatesEnabled: Boolean = true + override var selectedBaseUrl: String? = null + override var selectedLmsAccentColor: String? = null + override var selectedOAuthClientId: String? = null + override var selectedFeedbackEmail: String? = null + override var selectedLmsLogoUrl: String? = null + override var selectedLmsLoginBackgroundUrl: String? = null + override var selectedLmsTitle: String? = null + override var lmsDirectoryMode: String = "" + override var lmsDirectorySourceKey: String = "" + override var lmsHistory: List = emptyList() + + override suspend fun clearCorePreferences() = Unit + } +} diff --git a/core/src/test/java/org/openedx/core/lmsdirectory/LmsImageSourceTest.kt b/core/src/test/java/org/openedx/core/lmsdirectory/LmsImageSourceTest.kt new file mode 100644 index 000000000..3d2ad44fa --- /dev/null +++ b/core/src/test/java/org/openedx/core/lmsdirectory/LmsImageSourceTest.kt @@ -0,0 +1,60 @@ +package org.openedx.core.lmsdirectory + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * One field decides whether an image is downloaded or read out of the app. The + * rule is simple enough to state in a sentence, which is why it needs tests: an + * operator editing the document by hand will lean on it. + */ +class LmsImageSourceTest { + + @Test + fun `web addresses are passed through untouched`() { + assertEquals( + "https://cdn.example.com/logo.png", + LmsImageSource.model("https://cdn.example.com/logo.png") + ) + assertEquals( + "http://cdn.example.com/logo.png", + LmsImageSource.model("http://cdn.example.com/logo.png") + ) + } + + @Test + fun `anything else becomes an asset in the app`() { + assertEquals("file:///android_asset/acme.png", LmsImageSource.model("acme.png")) + assertEquals("file:///android_asset/logos/acme.png", LmsImageSource.model("logos/acme.png")) + // A leading slash is a habit from the hosted form; it must not produce a + // double slash that fails to resolve. + assertEquals("file:///android_asset/acme.png", LmsImageSource.model("/acme.png")) + } + + @Test + fun `surrounding whitespace does not change the answer`() { + assertEquals( + "https://cdn.example.com/logo.png", + LmsImageSource.model(" https://cdn.example.com/logo.png ") + ) + assertEquals("file:///android_asset/acme.png", LmsImageSource.model(" acme.png ")) + } + + @Test + fun `empty and missing values produce nothing`() { + assertNull(LmsImageSource.model(null)) + assertNull(LmsImageSource.model("")) + assertNull(LmsImageSource.model(" ")) + } + + @Test + fun `remote is decided by scheme, not by looking like a URL`() { + assertTrue(LmsImageSource.isRemote("https://a.test/x.png")) + assertTrue(LmsImageSource.isRemote("HTTPS://a.test/x.png")) + assertFalse(LmsImageSource.isRemote("a.test/x.png")) + assertFalse(LmsImageSource.isRemote("x.png")) + } +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index f2868eb78..0cc0eef16 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -94,3 +94,29 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false +# Where the app gets its list of Open edX platforms. Off by default: with +# ENABLED false the app behaves exactly like a stock single-tenant build. +# +# Set ONE source. Each of these is a complete, working example: +# +# DIRECTORY_URL: "https://example.com/lms_directory.json" a JSON document +# DIRECTORY_URL: "https://registry.example.com" a live registry +# DIRECTORY_FILE: "lms_directory.json" shipped in the app +# +# The address decides which it is: one ending in .json is read as a document — +# a single file, fetched once, that already contains every platform and its +# branding. Anything else is treated as the base URL of a service answering +# /api/v1/directory. A bundled file wins over an address. +# +# DIRECTORY_MODE: "" | "search" | "curated" — only for a live registry, to +# override what the server would otherwise decide. A document is always a fixed +# list, so the key is ignored for one. +# +# Image fields inside a document are either web addresses or names of files +# shipped with the app; anything that is not http(s) is looked up locally. +# See Documentation/LMSDirectory.md for the file format. +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_FILE: "" + DIRECTORY_MODE: "" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f2868eb78..0cc0eef16 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -94,3 +94,29 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false +# Where the app gets its list of Open edX platforms. Off by default: with +# ENABLED false the app behaves exactly like a stock single-tenant build. +# +# Set ONE source. Each of these is a complete, working example: +# +# DIRECTORY_URL: "https://example.com/lms_directory.json" a JSON document +# DIRECTORY_URL: "https://registry.example.com" a live registry +# DIRECTORY_FILE: "lms_directory.json" shipped in the app +# +# The address decides which it is: one ending in .json is read as a document — +# a single file, fetched once, that already contains every platform and its +# branding. Anything else is treated as the base URL of a service answering +# /api/v1/directory. A bundled file wins over an address. +# +# DIRECTORY_MODE: "" | "search" | "curated" — only for a live registry, to +# override what the server would otherwise decide. A document is always a fixed +# list, so the key is ignored for one. +# +# Image fields inside a document are either web addresses or names of files +# shipped with the app; anything that is not http(s) is looked up locally. +# See Documentation/LMSDirectory.md for the file format. +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_FILE: "" + DIRECTORY_MODE: "" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index f2868eb78..0cc0eef16 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -94,3 +94,29 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false +# Where the app gets its list of Open edX platforms. Off by default: with +# ENABLED false the app behaves exactly like a stock single-tenant build. +# +# Set ONE source. Each of these is a complete, working example: +# +# DIRECTORY_URL: "https://example.com/lms_directory.json" a JSON document +# DIRECTORY_URL: "https://registry.example.com" a live registry +# DIRECTORY_FILE: "lms_directory.json" shipped in the app +# +# The address decides which it is: one ending in .json is read as a document — +# a single file, fetched once, that already contains every platform and its +# branding. Anything else is treated as the base URL of a service answering +# /api/v1/directory. A bundled file wins over an address. +# +# DIRECTORY_MODE: "" | "search" | "curated" — only for a live registry, to +# override what the server would otherwise decide. A document is always a fixed +# list, so the key is ignored for one. +# +# Image fields inside a document are either web addresses or names of files +# shipped with the app; anything that is not http(s) is looked up locally. +# See Documentation/LMSDirectory.md for the file format. +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_FILE: "" + DIRECTORY_MODE: "" diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt index 6940055ec..1eadaf982 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt @@ -6,6 +6,9 @@ import android.view.ViewGroup import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment @@ -14,10 +17,13 @@ import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.profile.presentation.profile.compose.ProfileView import org.openedx.profile.presentation.profile.compose.ProfileViewAction +import org.openedx.profile.presentation.reportlms.ReportLmsSheet +import org.openedx.profile.presentation.reportlms.ReportLmsViewModel class ProfileFragment : Fragment() { private val viewModel: ProfileViewModel by viewModel() + private val reportViewModel: ReportLmsViewModel by viewModel() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -36,12 +42,17 @@ class ProfileFragment : Fragment() { val uiState by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.collectAsState(initial = null) val refreshing by viewModel.isUpdating.observeAsState(false) + var showReportSheet by remember { mutableStateOf(false) } + val canReportLms by viewModel.canReportLms.collectAsState() ProfileView( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, refreshing = refreshing, + // Curated/institution registries have no learner reporting, and + // neither does a catalog that has not said which it is yet. + showReportLms = canReportLms, onSettingsClick = { viewModel.profileRouter.navigateToSettings(requireActivity().supportFragmentManager) }, @@ -52,12 +63,25 @@ class ProfileFragment : Fragment() { requireParentFragment().parentFragmentManager ) } + ProfileViewAction.ReportLmsClick -> { + showReportSheet = true + } ProfileViewAction.SwipeRefresh -> { viewModel.updateAccount() } } } ) + + if (showReportSheet) { + ReportLmsSheet( + viewModel = reportViewModel, + onDismiss = { + showReportSheet = false + reportViewModel.reset() + }, + ) + } } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index 38c681bb5..8ddd26e1e 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt @@ -6,9 +6,15 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.lmsdirectory.LmsDirectoryState import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor @@ -24,9 +30,39 @@ class ProfileViewModel( private val resourceManager: ResourceManager, private val notifier: ProfileNotifier, private val analytics: ProfileAnalytics, + private val config: Config, + private val corePreferences: CorePreferences, val profileRouter: ProfileRouter ) : BaseViewModel(resourceManager) { + /** LMS Directory: show the "Report this LMS" entry only when the feature is on. */ + val isLmsDirectoryEnabled: Boolean get() = config.getLMSDirectoryConfig().isReachable + + /** + * Whether to offer "Report this LMS". + * + * Reporting belongs to the universal app: a build reading its list from a + * document has no service to post to, a curated catalog vouches for its own + * platforms, and a live catalog that has not answered yet is not known to be + * either — so all three keep the entry point hidden. + * + * A flow rather than a value, because the answer can arrive after this screen + * is already on top: the launch-time refresh is what tells a build whose + * platform picker never runs again that its registry has changed. + */ + val canReportLms: StateFlow = LmsDirectoryState.revision + .map { LmsDirectoryState.canReport(config.getLMSDirectoryConfig(), corePreferences) } + .stateIn( + viewModelScope, + SharingStarted.WhileSubscribed(STOP_TIMEOUT_MILLIS), + LmsDirectoryState.canReport(config.getLMSDirectoryConfig(), corePreferences) + ) + + private companion object { + /** Keeps the flow alive across a configuration change. */ + const val STOP_TIMEOUT_MILLIS = 5_000L + } + private val _uiState: MutableStateFlow = MutableStateFlow(ProfileUIState.Loading) internal val uiState: StateFlow = _uiState.asStateFlow() diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt index 91fd10d9e..370f4df31 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -61,6 +61,7 @@ internal fun ProfileView( uiState: ProfileUIState, uiMessage: UIMessage?, refreshing: Boolean, + showReportLms: Boolean = false, onAction: (ProfileViewAction) -> Unit, onSettingsClick: () -> Unit ) { @@ -153,6 +154,17 @@ internal fun ProfileView( borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.textAccent ) + if (showReportLms) { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource( + id = org.openedx.profile.R.string.profile_report_lms_button + ), + onClick = { onAction(ProfileViewAction.ReportLmsClick) }, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.textAccent + ) + } Spacer(modifier = Modifier.height(12.dp)) } } @@ -204,5 +216,6 @@ private fun ProfileScreenTabletPreview() { internal interface ProfileViewAction { object EditAccountClick : ProfileViewAction + object ReportLmsClick : ProfileViewAction object SwipeRefresh : ProfileViewAction } diff --git a/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt new file mode 100644 index 000000000..e2cc20e6d --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt @@ -0,0 +1,415 @@ +package org.openedx.profile.presentation.reportlms + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.outlined.AddPhotoAlternate +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.openedx.core.lmsdirectory.ReportCategory +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.R + +private const val MAX_SCREENSHOT_DIMENSION = 1280 +private const val SCREENSHOT_JPEG_QUALITY = 60 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportLmsSheet( + viewModel: ReportLmsViewModel, + onDismiss: () -> Unit, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + // Present as a bottom sheet that slides up from the bottom — matches iOS's + // native "Report a problem" sheet. skipPartiallyExpanded so the tall form + // opens fully expanded instead of at a half-height detent. + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val pickImageLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri: Uri? -> + if (uri != null) { + val bitmap = decodeSampledBitmap(context, uri) + if (bitmap != null) { + val base64 = bitmap.toBase64Jpeg() + viewModel.onScreenshotPicked(base64, bitmap.asImageBitmap()) + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.appColors.background, + dragHandle = { BottomSheetDefaults.DragHandle() }, + ) { + if (state.submitted) { + SuccessContent(host = viewModel.displayHost, onDismiss = onDismiss) + } else { + FormContent( + state = state, + subtitle = viewModel.displayHost, + onCategoryChanged = viewModel::onCategoryChanged, + onMessageChanged = viewModel::onMessageChanged, + onEmailChanged = viewModel::onEmailChanged, + onAttachClick = { + pickImageLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + onRemoveScreenshot = viewModel::onScreenshotRemoved, + onSubmit = viewModel::submit, + ) + } + } +} + +@Composable +private fun FormContent( + state: ReportLmsUiState, + subtitle: String, + onCategoryChanged: (ReportCategory) -> Unit, + onMessageChanged: (String) -> Unit, + onEmailChanged: (String) -> Unit, + onAttachClick: () -> Unit, + onRemoveScreenshot: () -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(id = R.string.profile_report_lms_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + ) + } + } + + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_whats_wrong)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ReportCategory.entries.forEach { category -> + CategoryRow( + category = category, + selected = state.category == category, + onSelect = { onCategoryChanged(category) }, + ) + } + } + } + + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_tell_us_more)) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 110.dp), + value = state.message, + onValueChange = onMessageChanged, + placeholder = { + Text( + text = stringResource(id = R.string.profile_report_lms_describe), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyLarge, + ) + }, + textStyle = MaterialTheme.appTypography.bodyLarge, + colors = reportTextFieldColors(), + ) + } + + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_screenshot)) { + val preview = state.screenshotPreview + if (preview != null) { + Image( + bitmap = preview, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(10.dp)), + ) + TextButton(onClick = onRemoveScreenshot) { + Text( + text = stringResource(id = R.string.profile_report_lms_remove_screenshot), + color = MaterialTheme.appColors.error, + ) + } + } else { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onAttachClick() }, + shape = MaterialTheme.appShapes.textFieldShape, + color = MaterialTheme.appColors.textFieldBackground, + border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.4f)), + ) { + Row( + modifier = Modifier.padding(14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.AddPhotoAlternate, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(id = R.string.profile_report_lms_attach_screenshot), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.primary, + ) + } + } + } + } + + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_email)) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.email, + onValueChange = onEmailChanged, + singleLine = true, + placeholder = { + Text( + text = stringResource(id = R.string.profile_report_lms_email_hint), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyLarge, + ) + }, + textStyle = MaterialTheme.appTypography.bodyLarge, + colors = reportTextFieldColors(), + ) + } + + if (!state.error.isNullOrEmpty()) { + Text( + text = state.error, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.error, + ) + } + + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + enabled = state.canSubmit, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonText, + onClick = onSubmit, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Text( + text = stringResource(id = R.string.profile_report_lms_send), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge, + ) + if (state.submitting) { + Spacer(modifier = Modifier.size(12.dp)) + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primaryButtonText, + ) + } + } + } + } +} + +/** Labelled section (grey title above content) — mirrors iOS's `section(title:)`. */ +@Composable +private fun ReportSection(title: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary, + ) + content() + } +} + +@Composable +private fun CategoryRow(category: ReportCategory, selected: Boolean, onSelect: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect() }, + shape = MaterialTheme.appShapes.textFieldShape, + color = if (selected) { + MaterialTheme.appColors.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.appColors.textFieldBackground + }, + border = BorderStroke( + 1.dp, + if (selected) { + MaterialTheme.appColors.primary.copy(alpha = 0.5f) + } else { + MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.4f) + } + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = category.titleRes()), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + ) + Icon( + imageVector = if (selected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked, + contentDescription = null, + tint = if (selected) MaterialTheme.appColors.primary else MaterialTheme.appColors.textSecondary, + ) + } + } +} + +@Composable +private fun SuccessContent(host: String, onDismiss: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + modifier = Modifier.size(52.dp), + ) + Text( + text = stringResource(id = R.string.profile_report_lms_thanks), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + ) + Text( + text = stringResource(id = R.string.profile_report_lms_thanks_body, host), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + textAlign = TextAlign.Center, + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonText, + onClick = onDismiss, + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_ok), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge, + ) + } + } +} + +@Composable +private fun reportTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, + focusedBorderColor = MaterialTheme.appColors.textFieldBorder, + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + cursorColor = MaterialTheme.appColors.primary, +) + +private fun ReportCategory.titleRes(): Int = when (this) { + ReportCategory.INAPPROPRIATE -> R.string.profile_report_lms_category_inappropriate + ReportCategory.SCAM -> R.string.profile_report_lms_category_scam + ReportCategory.IMPERSONATION -> R.string.profile_report_lms_category_impersonation + ReportCategory.SPAM -> R.string.profile_report_lms_category_spam + ReportCategory.BROKEN -> R.string.profile_report_lms_category_broken + ReportCategory.OTHER -> R.string.profile_report_lms_category_other +} + +/** Decode a picked image, downscaled so its largest side is ~[MAX_SCREENSHOT_DIMENSION]px. */ +private fun decodeSampledBitmap(context: android.content.Context, uri: Uri): Bitmap? { + return runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, bounds) + } + val largest = maxOf(bounds.outWidth, bounds.outHeight).coerceAtLeast(1) + var sample = 1 + while (largest / sample > MAX_SCREENSHOT_DIMENSION) { + sample *= 2 + } + val options = BitmapFactory.Options().apply { inSampleSize = sample } + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, options) + } + }.getOrNull() +} + +private fun Bitmap.toBase64Jpeg(): String { + val stream = java.io.ByteArrayOutputStream() + compress(Bitmap.CompressFormat.JPEG, SCREENSHOT_JPEG_QUALITY, stream) + return Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt new file mode 100644 index 000000000..9bc215013 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt @@ -0,0 +1,106 @@ +package org.openedx.profile.presentation.reportlms + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.config.Config +import org.openedx.core.lmsdirectory.LmsDirectoryRepository +import org.openedx.core.lmsdirectory.ReportCategory +import org.openedx.core.lmsdirectory.ReportDraft +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager +import org.openedx.profile.R + +/** + * Reports the currently signed-in LMS from the Profile tab. Reuses the registry + * submission pipeline ([LmsDirectoryRepository.submitReport]); the target is always + * the current platform ([Config.getApiHostURL]), so there is no lmsId. + */ +class ReportLmsViewModel( + private val config: Config, + private val directoryRepository: LmsDirectoryRepository, + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { + + private val _uiState = MutableStateFlow(ReportLmsUiState()) + val uiState: StateFlow = _uiState + + /** + * Host of the LMS being reported (the current platform), for the sheet header and + * success message — e.g. "sandbox.openedx.org" rather than the full "https://…/", + * matching iOS's `lmsTitle`. + */ + val displayHost: String + get() { + val url = config.getApiHostURL() + return android.net.Uri.parse(url).host ?: url + } + + fun onCategoryChanged(category: ReportCategory) { + _uiState.update { it.copy(category = category) } + } + + fun onMessageChanged(value: String) { + _uiState.update { it.copy(message = value) } + } + + fun onEmailChanged(value: String) { + _uiState.update { it.copy(email = value) } + } + + fun onScreenshotPicked(base64: String, preview: ImageBitmap) { + _uiState.update { it.copy(screenshotBase64 = base64, screenshotPreview = preview) } + } + + fun onScreenshotRemoved() { + _uiState.update { it.copy(screenshotBase64 = null, screenshotPreview = null) } + } + + fun submit() { + val state = _uiState.value + if (!state.canSubmit) return + _uiState.update { it.copy(submitting = true, error = null) } + viewModelScope.launch { + directoryRepository.submitReport( + ReportDraft( + lmsId = null, + baseUrl = config.getApiHostURL(), + category = state.category, + message = state.message.trim(), + reporterEmail = state.email.ifBlank { null }, + screenshotBase64 = state.screenshotBase64, + ) + ).onSuccess { + _uiState.update { it.copy(submitting = false, submitted = true) } + }.onFailure { + _uiState.update { + it.copy( + submitting = false, + error = resourceManager.getString(R.string.profile_report_lms_error), + ) + } + } + } + } + + /** Reset back to a fresh form (called when the sheet is dismissed). */ + fun reset() { + _uiState.value = ReportLmsUiState() + } +} + +data class ReportLmsUiState( + val category: ReportCategory = ReportCategory.INAPPROPRIATE, + val message: String = "", + val email: String = "", + val screenshotBase64: String? = null, + val screenshotPreview: ImageBitmap? = null, + val submitting: Boolean = false, + val submitted: Boolean = false, + val error: String? = null, +) { + val canSubmit: Boolean get() = !submitting && message.isNotBlank() +} diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index d2ee6951e..170a1a654 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -80,4 +80,25 @@ Select calendar Local calendar + + Report this LMS + Report a problem + What\'s wrong? + Tell us more + Screenshot (optional) + Email (optional) + Describe what happened + Attach a screenshot + Remove screenshot + So we can follow up + Send report + Thanks for the heads-up + A moderator will look into %1$s shortly. + Couldn\'t send your report. Check your connection and try again. + Inappropriate or adult content + Scam or phishing + Pretends to be someone else + Spam or fake platform + Doesn\'t work or can\'t sign in + Something else diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index 2b1cdc077..30230a31d 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -24,7 +24,10 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule import org.openedx.core.config.Config +import org.openedx.core.config.LMSDirectoryConfig +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AgreementUrls +import org.openedx.core.lmsdirectory.LmsDirectoryMode import org.openedx.foundation.presentation.UIMessage import org.openedx.foundation.presentation.captureUiMessage import org.openedx.foundation.system.ResourceManager @@ -46,6 +49,7 @@ class ProfileViewModelTest { private val dispatcher = StandardTestDispatcher() private val config = mockk() + private val corePreferences = mockk(relaxed = true) private val resourceManager = mockk() private val interactor = mockk() private val notifier = mockk() @@ -68,6 +72,11 @@ class ProfileViewModelTest { every { config.getFeedbackEmailAddress() } returns "" every { config.getAgreement(Locale.current.language) } returns AgreementUrls() every { config.getFaqUrl() } returns "" + // The Profile screen asks what kind of directory this build reads as soon + // as it is built, so every case needs an answer, not only the ones about it. + every { config.getLMSDirectoryConfig() } returns LMSDirectoryConfig() + every { corePreferences.lmsDirectoryMode } returns "" + every { corePreferences.lmsDirectorySourceKey } returns "" } @After @@ -82,6 +91,8 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns null @@ -102,6 +113,8 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns ProfileMocks.account.copy( @@ -124,6 +137,8 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns null @@ -144,6 +159,8 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns null @@ -166,6 +183,8 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns null @@ -179,4 +198,87 @@ class ProfileViewModelTest { coVerify(exactly = 2) { interactor.getAccount() } } + + /** + * What the Profile tab actually asks before drawing "Report this LMS". The + * flag it used to read was written by the platform picker and never cleared, + * so these pin the answer to the configured source instead. + */ + private fun reportingOffered( + directory: LMSDirectoryConfig, + remembered: LmsDirectoryMode? = null, + rememberedFor: String = "" + ): Boolean { + every { config.getLMSDirectoryConfig() } returns directory + every { corePreferences.lmsDirectoryMode } returns remembered?.name.orEmpty() + every { corePreferences.lmsDirectorySourceKey } returns rememberedFor + coEvery { interactor.getCachedAccount() } returns null + return ProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + corePreferences, + router + ).canReportLms.value + } + + @Test + fun `an open catalog offers reporting once it has said so`() { + val service = LMSDirectoryConfig(enabled = true, directoryUrl = "https://registry.example.com") + assertEquals( + true, + reportingOffered(service, remembered = LmsDirectoryMode.SEARCH, rememberedFor = service.sourceKey) + ) + } + + @Test + fun `a catalog that has not answered yet offers nothing`() { + val service = LMSDirectoryConfig(enabled = true, directoryUrl = "https://registry.example.com") + assertEquals(false, reportingOffered(service)) + } + + @Test + fun `a document build never offers reporting`() { + assertEquals( + false, + reportingOffered( + LMSDirectoryConfig(enabled = true, directoryUrl = "https://cdn.example.com/directory.json") + ) + ) + assertEquals( + false, + reportingOffered(LMSDirectoryConfig(enabled = true, directoryFile = "lms_directory.json")) + ) + } + + @Test + fun `a curated catalog does not offer reporting`() { + val service = LMSDirectoryConfig(enabled = true, directoryUrl = "https://registry.example.com") + assertEquals( + false, + reportingOffered(service, remembered = LmsDirectoryMode.CURATED, rememberedFor = service.sourceKey) + ) + } + + @Test + fun `an answer left by a different directory is ignored`() { + // The regression: the picker is skipped once a platform is selected, so + // nothing would ever overwrite what an older registry said. + val service = LMSDirectoryConfig(enabled = true, directoryUrl = "https://registry.example.com") + assertEquals( + false, + reportingOffered( + service, + remembered = LmsDirectoryMode.SEARCH, + rememberedFor = "service:https://old-registry.example.com" + ) + ) + } + + @Test + fun `a disabled directory offers nothing`() { + assertEquals(false, reportingOffered(LMSDirectoryConfig(enabled = false))) + } }