From 22a5114e788adea13f90ca8544077cbfb1ee7da3 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Jul 2026 18:37:59 +0300 Subject: [PATCH 1/9] feat: add multi-tenant LMS Directory behind a feature flag Lets a single build browse the Open edX platforms published by a site registry, re-theme to the chosen one, and sign in against it. Off by default (LMS_DIRECTORY.ENABLED=false); the app stays single-tenant when the flag is off. - core: LMSDirectoryConfig flag; Config.getApiHostURL() resolves to the selected LMS when enabled; CorePreferences.selectedBaseUrl/accentColor; lmsdirectory client (API/repo/models/Koin); LmsThemeController re-tints OpenEdXTheme to the platform's brand color - auth: LMS landing (Find my LMS / QR sign-in via ZXing), directory search/curated browse + manual entry, per-LMS theming on selection - profile: 'Report this LMS' bottom sheet (flag-gated) posting to the registry - app: flag-gated landing before sign-in; DI + startup wiring; seed theme from persisted accent on cold start - default_config: LMS_DIRECTORY block for dev/stage/prod - build: zxing-android-embedded for QR scanning --- .../main/java/org/openedx/app/AppActivity.kt | 9 +- .../main/java/org/openedx/app/AppRouter.kt | 5 + .../main/java/org/openedx/app/AppViewModel.kt | 7 + .../main/java/org/openedx/app/OpenEdXApp.kt | 12 +- .../app/data/storage/PreferencesManager.kt | 10 + .../main/java/org/openedx/app/di/AppModule.kt | 2 +- .../java/org/openedx/app/di/ScreenModule.kt | 6 + auth/build.gradle | 3 + .../openedx/auth/presentation/AuthRouter.kt | 3 + .../lmsselection/LmsLandingFragment.kt | 74 ++++ .../lmsselection/LmsLandingScreen.kt | 76 ++++ .../lmsselection/SiteSelectionCallbacks.kt | 12 + .../lmsselection/SiteSelectionFragment.kt | 68 ++++ .../lmsselection/SiteSelectionScreen.kt | 322 +++++++++++++++++ .../lmsselection/SiteSelectionUIState.kt | 24 ++ .../lmsselection/SiteSelectionViewModel.kt | 247 +++++++++++++ auth/src/main/res/values/strings.xml | 18 + build.gradle | 3 + .../java/org/openedx/core/config/Config.kt | 22 +- .../openedx/core/config/LMSDirectoryConfig.kt | 24 ++ .../core/data/storage/CorePreferences.kt | 10 + .../core/lmsdirectory/DirectoryDtos.kt | 55 +++ .../core/lmsdirectory/DirectoryModels.kt | 54 +++ .../core/lmsdirectory/LmsDirectoryApi.kt | 26 ++ .../core/lmsdirectory/LmsDirectoryModule.kt | 56 +++ .../lmsdirectory/LmsDirectoryRepository.kt | 48 +++ .../core/lmsdirectory/LmsThemeController.kt | 40 +++ .../java/org/openedx/core/ui/theme/Theme.kt | 27 +- default_config/dev/config.yaml | 6 + default_config/prod/config.yaml | 5 + default_config/stage/config.yaml | 5 + .../presentation/profile/ProfileFragment.kt | 19 + .../presentation/profile/ProfileViewModel.kt | 5 + .../profile/compose/ProfileView.kt | 13 + .../presentation/reportlms/ReportLmsSheet.kt | 336 ++++++++++++++++++ .../reportlms/ReportLmsViewModel.kt | 102 ++++++ profile/src/main/res/values/strings.xml | 21 ++ 37 files changed, 1767 insertions(+), 8 deletions(-) create mode 100644 auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt create mode 100644 auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt create mode 100644 core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt create mode 100644 profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index b904bf6a1..5750be3e0 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -24,6 +24,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.ApiConstants @@ -173,10 +174,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, authCode = authCode) + val fragment = when { + viewModel.isLmsSelectionRequired && authCode == null -> LmsLandingFragment() + viewModel.isLogistrationEnabled && authCode == null -> LogistrationFragment() + else -> SignInFragment.newInstance(null, null, authCode = authCode) } 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 bf5a611d4..9da059585 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -5,6 +5,7 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import org.openedx.app.deeplink.HomeTab import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.lmsselection.SiteSelectionFragment import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment import org.openedx.auth.presentation.signin.SignInFragment @@ -102,6 +103,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)) } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index bafddb19b..ff6f8a162 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().enabled && preferencesManager.selectedBaseUrl.isNullOrBlank() + private var logoutHandledAt: Long = 0 val isBranchEnabled get() = config.getBranchConfig().enabled diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 6524cde5d..17999ed42 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -14,11 +14,15 @@ import org.openedx.app.di.appModule import org.openedx.app.di.networkingModule import org.openedx.app.di.screenModule import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.lmsdirectory.LmsThemeController +import org.openedx.core.lmsdirectory.lmsDirectoryModule import org.openedx.firebase.OEXFirebaseAnalytics class OpenEdXApp : Application() { private val config by inject() + private val corePreferences by inject() private val pluginManager by inject() override fun onCreate() { @@ -28,9 +32,15 @@ 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().enabled) { + LmsThemeController.apply(corePreferences.selectedLmsAccentColor) + } if (config.getFirebaseConfig().enabled) { FirebaseApp.initializeApp(this) } 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..09f646e3f 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 @@ -85,6 +85,8 @@ 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") fun calendarSyncDialogShown(courseName: String) = booleanPreferencesKey("calendar_sync_dialog_${courseName.replaceSpace("_")}") @@ -201,6 +203,14 @@ 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 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 6cd507b37..739846316 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -87,7 +87,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() } 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 45a4ecc25..bdb1ba527 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -7,6 +7,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 @@ -79,6 +80,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 @@ -106,6 +108,8 @@ val screenModule = module { factory { AuthInteractor(get()) } factory { Validator() } + viewModel { SiteSelectionViewModel(get(), get(), get(), get()) } + viewModel { (courseId: String) -> LogistrationViewModel( courseId, @@ -211,6 +215,7 @@ val screenModule = module { resourceManager = get(), notifier = get(), analytics = get(), + config = get(), profileRouter = get(), ) } @@ -224,6 +229,7 @@ val screenModule = module { account ) } + viewModel { ReportLmsViewModel(get(), get(), get()) } viewModel { VideoSettingsViewModel(get(), get(), get(), get(), get()) } viewModel { (qualityType: String) -> VideoQualityViewModel( 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/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index 945acf02e..adec18fe4 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..26c0dc4b2 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt @@ -0,0 +1,74 @@ +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.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 { + LaunchedEffect(Unit) { + viewModel.actions.collect { action -> + when (action) { + is SiteSelectionViewModel.SiteSelectionAction.Success -> continueAfterSelection() + } + } + } + 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) + scanLauncher.launch(options) + } + + private fun continueAfterSelection() { + val fm = requireActivity().supportFragmentManager + if (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..a47386d17 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt @@ -0,0 +1,76 @@ +package org.openedx.auth.presentation.lmsselection + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.statusBarsPadding +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.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 + +/** + * 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)) + Text( + text = stringResource(id = R.string.auth_lms_welcome_title), + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.padding(top = 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(1f)) + 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) { + 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/SiteSelectionCallbacks.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt new file mode 100644 index 000000000..a49922253 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt @@ -0,0 +1,12 @@ +package org.openedx.auth.presentation.lmsselection + +import org.openedx.core.lmsdirectory.LmsSummary + +/** UI callbacks for [SiteSelectionScreen]. */ +class SiteSelectionCallbacks( + val onBack: () -> Unit, + val onInputChanged: (String) -> Unit, + val onSubmitManual: () -> Unit, + val onQueryChanged: (String) -> Unit, + val onCatalogItemSelected: (LmsSummary) -> 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..15ae2fe4e --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt @@ -0,0 +1,68 @@ +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 org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +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. + */ +class SiteSelectionFragment : Fragment() { + + private val viewModel: SiteSelectionViewModel by viewModel() + private val router: AuthRouter by inject() + private val config: Config by inject() + + 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() + } + } + } + + SiteSelectionScreen( + state = state, + callbacks = SiteSelectionCallbacks( + onBack = { requireActivity().supportFragmentManager.popBackStack() }, + onInputChanged = viewModel::onInputChanged, + onSubmitManual = viewModel::onSubmitManual, + onQueryChanged = viewModel::onQueryChanged, + onCatalogItemSelected = viewModel::onCatalogItemSelected, + ) + ) + } + } + } + + private fun continueAfterSelection() { + val fm = requireActivity().supportFragmentManager + if (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..e84a8d3f6 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt @@ -0,0 +1,322 @@ +package org.openedx.auth.presentation.lmsselection + +import androidx.compose.foundation.BorderStroke +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.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.rememberScrollState +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.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Public +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.ui.Alignment +import androidx.compose.ui.Modifier +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 org.openedx.auth.R +import org.openedx.core.lmsdirectory.LmsSummary +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 + +@Composable +internal fun SiteSelectionScreen( + state: SiteSelectionUIState, + callbacks: SiteSelectionCallbacks, +) { + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + containerColor = MaterialTheme.appColors.background, + topBar = { + Surface(color = MaterialTheme.appColors.background) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = { callbacks.onBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource( + id = org.openedx.core.R.string.core_accessibility_btn_back + ), + tint = MaterialTheme.appColors.textPrimary, + ) + } + Text( + text = if (state.isCurated && state.providerName.isNotBlank()) { + state.providerName + } else { + stringResource(id = R.string.auth_lms_choose_title) + }, + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + modifier = Modifier.padding(start = 8.dp) + ) + } + } + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 24.dp, vertical = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(24.dp), + ) { + if (state.isCurated) { + Text( + text = stringResource(id = R.string.auth_lms_catalog_available), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + ) + CatalogContent(state, callbacks) + } else { + SearchSection(state, callbacks) + } + ManualEntrySection(state, callbacks) + } + } +} + +@Composable +private fun SearchSection(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + 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.textSecondary, + ) + }, + textStyle = MaterialTheme.appTypography.bodyLarge, + shape = MaterialTheme.appShapes.textFieldShape, + colors = directoryTextFieldColors(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search, keyboardType = KeyboardType.Uri), + ) + CatalogContent(state, callbacks) + } +} + +@Composable +private fun CatalogContent(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { + when (val catalog = state.catalog) { + is CatalogState.Idle -> PlaceholderText(stringResource(id = R.string.auth_lms_start_typing)) + is CatalogState.Loading -> 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)) + } + is CatalogState.Empty -> PlaceholderText(stringResource(id = 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)) { + state.results.forEach { item -> + CatalogRow(item = item, onSelect = { callbacks.onCatalogItemSelected(item) }) + } + } + } +} + +@Composable +private fun CatalogRow(item: LmsSummary, 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 = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = item.title, + maxLines = 1, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + ) + if (item.shortDescription.isNotBlank()) { + Text( + text = item.shortDescription, + maxLines = 1, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textSecondary, + ) + } + Text( + text = hostOf(item.baseUrl), + maxLines = 1, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textSecondary, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + ) + } + } +} + +@Composable +private fun ManualEntrySection(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + text = stringResource(id = R.string.auth_lms_manual_entry_title), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + ) + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.inputUrl, + onValueChange = { callbacks.onInputChanged(it) }, + enabled = !state.isLoading, + singleLine = true, + placeholder = { + Text( + text = stringResource(id = R.string.auth_lms_url_placeholder), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textFieldHint, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Public, + contentDescription = null, + tint = MaterialTheme.appColors.textSecondary, + ) + }, + textStyle = MaterialTheme.appTypography.bodyLarge, + shape = MaterialTheme.appShapes.textFieldShape, + colors = directoryTextFieldColors(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Uri), + keyboardActions = KeyboardActions { + keyboardController?.hide() + focusManager.clearFocus() + callbacks.onSubmitManual() + } + ) + if (!state.errorMessage.isNullOrEmpty()) { + Text( + text = state.errorMessage, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.error, + ) + } + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + enabled = !state.isLoading, + onClick = { + keyboardController?.hide() + focusManager.clearFocus() + callbacks.onSubmitManual() + } + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_continue), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge + ) + if (state.isLoading) { + Spacer(modifier = Modifier.size(12.dp)) + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primaryButtonText, + ) + } + } + } + } +} + +@Composable +private fun directoryTextFieldColors() = 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, +) + +@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..4f2136ad1 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt @@ -0,0 +1,24 @@ +package org.openedx.auth.presentation.lmsselection + +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(), +) + +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..1374d352c --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt @@ -0,0 +1,247 @@ +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.LmsDirectoryRepository +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('/') ?: "") + ) + val uiState: StateFlow = _uiState + + private val _actions = MutableSharedFlow() + val actions: SharedFlow = _actions.asSharedFlow() + + private var searchJob: Job? = null + + init { + loadCatalogConfig() + } + + // region Catalog + + private fun loadCatalogConfig() { + viewModelScope.launch { + val remote = directoryRepository.fetchConfig() + val configuredMode = config.getLMSDirectoryConfig().directoryMode + val curated = remote.isCurated || configuredMode.equals("curated", ignoreCase = true) + _uiState.update { + it.copy(isCurated = curated, providerName = remote.providerName) + } + if (curated) { + loadFeatured() + } + } + } + + 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) { + _uiState.update { it.copy(query = value) } + 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) { + val normalized = normalizeUrl(item.baseUrl) ?: return + selectLms(normalized.newBuilder().encodedPath("/").build().toString(), item.accentColor) + viewModelScope.launch { _actions.emit(SiteSelectionAction.Success) } + } + + /** Select an LMS by URL scanned from a QR code. Validates before committing. */ + 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 confirmed = withContext(Dispatchers.IO) { confirmBaseUrl(normalized) } + if (confirmed) { + selectLms(normalized.newBuilder().encodedPath("/").build().toString(), accentColor = null) + _actions.emit(SiteSelectionAction.Success) + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = resourceManager.getString(R.string.auth_lms_error_unable_confirm_url), + ) + } + } + } + } + + // 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) + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = resourceManager.getString(R.string.auth_lms_error_unable_confirm_url), + ) + } + } + } + } + + /** Commit the chosen platform: persist host + accent, then re-theme immediately. */ + private fun selectLms(baseUrl: String, accentColor: String?) { + corePreferences.selectedBaseUrl = baseUrl + corePreferences.selectedLmsAccentColor = accentColor + LmsThemeController.apply(accentColor) + } + + 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 { + data object Success : 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 + } +} diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 77401c27f..5e4bbc94b 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -43,4 +43,22 @@ %2$s]]> Show password Hide password + + + Welcome to Open X Project + 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. + Choose your LMS + Available platforms + Search by name or address + Start typing to search the catalog. + Searching… + No platforms matched your search. + Or enter an LMS address + https://your-lms.example.com + 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. diff --git a/build.gradle b/build.gradle index 4e0b4d12e..d8ebaa7dd 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/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 7285a6b66..775670324 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().enabled) { + 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 getUriScheme(): String { return getString(URI_SCHEME) } @@ -195,6 +214,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..762715266 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt @@ -0,0 +1,24 @@ +package org.openedx.core.config + +import com.google.gson.annotations.SerializedName + +/** + * 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 = "", + + @SerializedName("DIRECTORY_MODE") + val directoryMode: String = "", +) { + /** The feature only works with a registry to talk to. */ + val isReachable: Boolean get() = enabled && directoryUrl.isNotBlank() +} 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..e58c221d0 100644 --- a/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt +++ b/core/src/main/java/org/openedx/core/data/storage/CorePreferences.kt @@ -15,5 +15,15 @@ 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? + 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..6f9ab3627 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt @@ -0,0 +1,55 @@ +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 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..3a16f2e55 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt @@ -0,0 +1,54 @@ +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?, +) + +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..756721acf --- /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): LmsSummaryDto + + @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..af7c0b3f5 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt @@ -0,0 +1,56 @@ +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 retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.util.concurrent.TimeUnit + +/** + * Koin module for the LMS registry catalog (search/browse + complaint reporting). + * Builds a clean Retrofit client pointed at the directory URL from the config flag. + */ +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 { + val context = get() + val version = runCatching { + context.packageManager.getPackageInfo(context.packageName, 0).versionName + }.getOrNull().orEmpty() + LmsDirectoryRepository(api = get(), appVersion = version) + } +} + +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..0ba129658 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt @@ -0,0 +1,48 @@ +package org.openedx.core.lmsdirectory + +import android.util.Log + +/** + * Talks to the LMS registry catalog. All calls return [Result] so callers can + * fall back gracefully when the registry is unreachable. + */ +class LmsDirectoryRepository( + private val api: LmsDirectoryApi, + private val appVersion: String, +) { + companion object { + private const val TAG = "LmsDirectory" + } + + suspend fun fetchConfig(): DirectoryConfig { + return try { + api.getConfig().toDomain() + } catch (e: Exception) { + Log.w(TAG, "Config fetch failed, defaulting to search mode: ${e.message}") + DirectoryConfig.SEARCH_DEFAULT + } + } + + suspend fun search(query: String): Result> = runCatching { + api.search(query = query.ifBlank { null }).items.map { it.toDomain() } + }.onFailure { Log.w(TAG, "Search failed: ${it.message}") } + + suspend fun fetchFeatured(): Result> = runCatching { + api.search(featured = true).items.map { it.toDomain() } + }.onFailure { Log.w(TAG, "Featured fetch failed: ${it.message}") } + + suspend fun submitReport(draft: ReportDraft): Result = runCatching { + 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/LmsThemeController.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt new file mode 100644 index 000000000..5c8c25d44 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt @@ -0,0 +1,40 @@ +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 + + /** Apply a hex color like "#f15d49". Invalid or blank input clears the override. */ + fun apply(hex: String?) { + accentColor = parseHexColor(hex) + } + + fun clear() { + accentColor = 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/theme/Theme.kt b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt index ec7997c72..d41f46863 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,23 @@ 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, + * and the accent text. Everything else (backgrounds, text, borders) is preserved so + * the app keeps its light/dark identity while adopting the platform's brand color. + */ +private fun AppColors.withAccent(accent: Color): AppColors { + return copy( + material3 = material3.copy( + primary = accent, + tertiary = accent, + surfaceTint = accent, + ), + textAccent = accent, + primaryButtonBackground = accent, + secondaryButtonBackground = accent, + progressBarColor = accent, + ) +} diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index f2868eb78..325bfebb5 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -94,3 +94,9 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false +# LMS Directory (multi-tenant): browse the Open edX platforms published by a site +# registry, re-theme to the chosen one, and sign in against it. Off by default. +LMS_DIRECTORY: + ENABLED: true + DIRECTORY_URL: "https://openedx-lms.stepanok.com" + DIRECTORY_MODE: "search" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index f2868eb78..25a711486 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -94,3 +94,8 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false +# LMS Directory (multi-tenant). Off by default; the app stays single-tenant. +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_MODE: "search" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index f2868eb78..25a711486 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -94,3 +94,8 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false +# LMS Directory (multi-tenant). Off by default; the app stays single-tenant. +LMS_DIRECTORY: + ENABLED: false + DIRECTORY_URL: "" + DIRECTORY_MODE: "search" 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..7d83f0df0 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.ReportLmsBottomSheet +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,14 @@ 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) } ProfileView( windowSize = windowSize, uiState = uiState, uiMessage = uiMessage, refreshing = refreshing, + showReportLms = viewModel.isLmsDirectoryEnabled, onSettingsClick = { viewModel.profileRouter.navigateToSettings(requireActivity().supportFragmentManager) }, @@ -52,12 +60,23 @@ class ProfileFragment : Fragment() { requireParentFragment().parentFragmentManager ) } + ProfileViewAction.ReportLmsClick -> { + reportViewModel.reset() + showReportSheet = true + } ProfileViewAction.SwipeRefresh -> { viewModel.updateAccount() } } } ) + + if (showReportSheet) { + ReportLmsBottomSheet( + viewModel = reportViewModel, + onDismiss = { showReportSheet = false }, + ) + } } } } 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..370abdd87 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 @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.openedx.core.config.Config import org.openedx.foundation.presentation.BaseViewModel import org.openedx.foundation.system.ResourceManager import org.openedx.profile.domain.interactor.ProfileInteractor @@ -24,9 +25,13 @@ class ProfileViewModel( private val resourceManager: ResourceManager, private val notifier: ProfileNotifier, private val analytics: ProfileAnalytics, + private val config: Config, 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().enabled + 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..80315c34f 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.error, + textColor = MaterialTheme.appColors.error + ) + } 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..5e1b29bba --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt @@ -0,0 +1,336 @@ +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.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.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.selection.selectable +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.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.RadioButton +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.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.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 ReportLmsBottomSheet( + viewModel: ReportLmsViewModel, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + + 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, + ) { + if (state.submitted) { + SuccessContent(lmsTitle = viewModel.lmsTitle) + } else { + FormContent( + state = state, + lmsTitle = viewModel.lmsTitle, + 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, + lmsTitle: String, + onCategoryChanged: (ReportCategory) -> Unit, + onMessageChanged: (String) -> Unit, + onEmailChanged: (String) -> Unit, + onAttachClick: () -> Unit, + onRemoveScreenshot: () -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp) + .padding(bottom = 24.dp), + verticalArrangement = Arrangement.spacedBy(16.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, + ) + Text( + text = lmsTitle, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + ) + } + + SectionLabel(stringResource(id = R.string.profile_report_lms_whats_wrong)) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ReportCategory.entries.forEach { category -> + CategoryRow( + category = category, + selected = state.category == category, + onSelect = { onCategoryChanged(category) }, + ) + } + } + + SectionLabel(stringResource(id = R.string.profile_report_lms_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, + ) + }, + colors = reportTextFieldColors(), + ) + + SectionLabel(stringResource(id = R.string.profile_report_lms_screenshot)) + val preview = state.screenshotPreview + if (preview != null) { + Image( + bitmap = preview, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 180.dp) + .clip(RoundedCornerShape(10.dp)), + ) + TextButton(onClick = onRemoveScreenshot) { + Text( + text = stringResource(id = R.string.profile_report_lms_remove_screenshot), + color = MaterialTheme.appColors.error, + ) + } + } else { + TextButton(onClick = onAttachClick) { + Text( + text = stringResource(id = R.string.profile_report_lms_attach_screenshot), + color = MaterialTheme.appColors.primary, + ) + } + } + + SectionLabel(stringResource(id = R.string.profile_report_lms_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, + ) + }, + 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, + 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(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primaryButtonText, + ) + } + } + } + } +} + +@Composable +private fun CategoryRow(category: ReportCategory, selected: Boolean, onSelect: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onSelect) + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = selected, onClick = onSelect) + Text( + text = stringResource(id = category.titleRes()), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + modifier = Modifier.padding(start = 8.dp), + ) + } +} + +@Composable +private fun SuccessContent(lmsTitle: String) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + modifier = Modifier.size(56.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, lmsTitle), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + ) + } +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary, + ) +} + +@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..96d5e375c --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt @@ -0,0 +1,102 @@ +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. */ + val lmsTitle: String + get() = config.getApiHostURL() + .removePrefix("https://") + .removePrefix("http://") + .trimEnd('/') + + 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..d87c4819f 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 + Describe what happened + Screenshot (optional) + Attach a screenshot + Remove screenshot + Email (optional) + So we can follow up + Send report + Thanks for the heads-up + A moderator will look into %1$s shortly. + We 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 From 8ad1aeae649c7e77b00e476caad37739ad1087f0 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:47:55 +0300 Subject: [PATCH 2/9] fix: make the selected LMS actually usable on Android (login + branding) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog only returned summaries, so sign-in used the empty stock OAuth client and requests still hit the config host — login failed. Now: - fetch the full record on selection (LmsDetail): OAuth client id, feedback email, logo, accent; persist them in CorePreferences - Config.getOAuthClientId()/getFeedbackEmailAddress() honor the selected LMS - BaseUrlOverrideInterceptor rewrites every request to the selected host on the fly (the Retrofit client is built once, before selection) — the missing piece that let requests reach the chosen platform - SignIn shows the selected LMS logo + a 'Selected LMS / Change' banner - verified end-to-end: pick Sandbox -> branded sign-in -> logged into sandbox.openedx.org with the demo course loaded --- .../networking/BaseUrlOverrideInterceptor.kt | 50 ++++++++++++ .../app/data/storage/PreferencesManager.kt | 20 +++++ .../org/openedx/app/di/NetworkingModule.kt | 3 + .../lmsselection/SiteSelectionViewModel.kt | 47 +++++++++-- .../presentation/signin/SignInFragment.kt | 7 ++ .../auth/presentation/signin/SignInUIState.kt | 4 + .../presentation/signin/SignInViewModel.kt | 14 ++++ .../presentation/signin/compose/SignInView.kt | 80 ++++++++++++++++++- auth/src/main/res/values/strings.xml | 2 + .../java/org/openedx/core/config/Config.kt | 14 ++++ .../core/data/storage/CorePreferences.kt | 12 +++ .../core/lmsdirectory/DirectoryDtos.kt | 25 ++++++ .../core/lmsdirectory/DirectoryModels.kt | 15 ++++ .../core/lmsdirectory/LmsDirectoryApi.kt | 2 +- .../lmsdirectory/LmsDirectoryRepository.kt | 5 ++ 15 files changed, 293 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptor.kt 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 09f646e3f..1e3f5153c 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 @@ -87,6 +87,10 @@ class PreferencesManager( 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_TITLE = stringPreferencesKey("selected_lms_title") fun calendarSyncDialogShown(courseName: String) = booleanPreferencesKey("calendar_sync_dialog_${courseName.replaceSpace("_")}") @@ -211,6 +215,22 @@ class PreferencesManager( 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 selectedLmsTitle: String? + get() = getValue(Keys.SELECTED_LMS_TITLE, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_TITLE, value.orEmpty()) + override var profile: Account? get() { val json = getEncryptedString(Keys.ACCOUNT, "") 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/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt index 1374d352c..1fc7736c1 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt @@ -116,9 +116,31 @@ class SiteSelectionViewModel( } fun onCatalogItemSelected(item: LmsSummary) { - val normalized = normalizeUrl(item.baseUrl) ?: return - selectLms(normalized.newBuilder().encodedPath("/").build().toString(), item.accentColor) - viewModelScope.launch { _actions.emit(SiteSelectionAction.Success) } + _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, + ) + _actions.emit(SiteSelectionAction.Success) + } } /** Select an LMS by URL scanned from a QR code. Validates before committing. */ @@ -179,10 +201,25 @@ class SiteSelectionViewModel( } } - /** Commit the chosen platform: persist host + accent, then re-theme immediately. */ - private fun selectLms(baseUrl: String, accentColor: String?) { + /** + * 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, + ) { corePreferences.selectedBaseUrl = baseUrl corePreferences.selectedLmsAccentColor = accentColor + corePreferences.selectedOAuthClientId = oauthClientId + corePreferences.selectedFeedbackEmail = feedbackEmail + corePreferences.selectedLmsLogoUrl = logoUrl + corePreferences.selectedLmsTitle = title LmsThemeController.apply(accentColor) } 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 e271b9044..b266533d5 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 @@ -75,6 +75,12 @@ class SignInFragment : Fragment() { requireActivity().supportFragmentManager.popBackStackImmediate() } + AuthEvent.ChangeLmsClick -> { + viewModel.navigateToLmsSelection( + requireActivity().supportFragmentManager + ) + } + is AuthEvent.OpenLink -> viewModel.openLink( parentFragmentManager, event.links, @@ -121,4 +127,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 c2a5f915c..a913b3480 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 @@ -25,4 +25,8 @@ internal data class SignInUIState( val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, val loginFailure: Boolean = false, + // 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, ) 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 11cfa2b67..6b42a47c7 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 @@ -72,6 +72,16 @@ class SignInViewModel( isLogistrationEnabled = config.isPreLoginExperienceEnabled(), isRegistrationEnabled = config.isRegistrationEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), + selectedLmsTitle = if (config.getLMSDirectoryConfig().enabled) { + preferencesManager.selectedLmsTitle + } else { + null + }, + selectedLmsLogoUrl = if (config.getLMSDirectoryConfig().enabled) { + preferencesManager.selectedLmsLogoUrl + } else { + null + }, ) ) internal val uiState: StateFlow = _uiState @@ -200,6 +210,10 @@ class SignInViewModel( } } + 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 69e3af16d..f71e5a681 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,6 +2,7 @@ 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.Box @@ -13,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding @@ -30,6 +32,7 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -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 @@ -58,6 +62,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 @@ -156,7 +162,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, @@ -171,6 +199,12 @@ internal fun LoginScreen( .displayCutoutForLandscape() .then(contentPaddings), ) { + if (!state.selectedLmsTitle.isNullOrBlank()) { + SelectedLmsBanner( + title = state.selectedLmsTitle, + onChange = { onEvent(AuthEvent.ChangeLmsClick) }, + ) + } Text( modifier = Modifier.testTag("txt_sign_in_title"), text = stringResource(id = coreR.string.core_sign_in), @@ -469,3 +503,47 @@ private fun SignInScreenTabletPreview() { ) } } + +@Composable +private fun SelectedLmsBanner( + title: String, + onChange: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + shape = MaterialTheme.appShapes.textFieldShape, + color = MaterialTheme.appColors.textFieldBackground, + border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + 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, + ) + } + TextButton( + onClick = onChange, + modifier = Modifier.testTag("change_lms_button"), + ) { + Text( + text = stringResource(id = R.string.auth_lms_change), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + ) + } + } + } +} diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 5e4bbc94b..8b1921f13 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -61,4 +61,6 @@ 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/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 775670324..7cdbc7d5c 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -52,6 +52,14 @@ class Config( } 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().enabled) { + val selected = corePreferences?.selectedOAuthClientId + if (!selected.isNullOrBlank()) { + return selected + } + } return getString(OAUTH_CLIENT_ID) } @@ -64,6 +72,12 @@ class Config( } fun getFeedbackEmailAddress(): String { + if (getLMSDirectoryConfig().enabled) { + val selected = corePreferences?.selectedFeedbackEmail + if (!selected.isNullOrBlank()) { + return selected + } + } return getString(FEEDBACK_EMAIL_ADDRESS) } 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 e58c221d0..599890cdf 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 @@ -25,5 +25,17 @@ interface CorePreferences { /** 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? + + /** Human title of the selected LMS, shown in the sign-in "Change" banner. */ + var selectedLmsTitle: String? + 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 index 6f9ab3627..c306095f4 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt @@ -26,6 +26,31 @@ data class LmsSummaryDto( ) } +data class LmsDetailDto( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String, + @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, +) { + 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, + ) + + fun toDomain() = LmsDetail( + id = id, + title = title, + baseUrl = api?.hostUrl?.ifBlank { null } ?: baseUrl, + logoUrl = logoUrl, + accentColor = accentColor, + oauthClientId = api?.oauthClientId?.ifBlank { null }, + feedbackEmail = api?.feedbackEmail?.ifBlank { null }, + ) +} + data class DirectoryConfigDto( @SerializedName("directory_mode") val directoryMode: String = "search", @SerializedName("provider_name") val providerName: 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 index 3a16f2e55..16d91c6b3 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt @@ -14,6 +14,21 @@ data class LmsSummary( 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 baseUrl: String, + val logoUrl: String?, + val accentColor: String?, + val oauthClientId: String?, + val feedbackEmail: String?, +) + data class DirectoryConfig( val directoryMode: String, val providerName: String, diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt index 756721acf..785a7a03b 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt @@ -19,7 +19,7 @@ interface LmsDirectoryApi { ): DirectoryListResponse @GET("api/v1/directory/{id}") - suspend fun detail(@Path("id") id: String): LmsSummaryDto + 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/LmsDirectoryRepository.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt index 0ba129658..a9ac5347b 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt @@ -31,6 +31,11 @@ class LmsDirectoryRepository( api.search(featured = true).items.map { it.toDomain() } }.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 { + api.detail(id).toDomain() + }.onFailure { Log.w(TAG, "Detail fetch failed: ${it.message}") } + suspend fun submitReport(draft: ReportDraft): Result = runCatching { api.submitReport( ReportRequestBody( From 4d3e854b2b6937b4212971ba8fac336505c4fd78 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:50:25 +0300 Subject: [PATCH 3/9] test: cover the Android LMS login fix - BaseUrlOverrideInterceptor routes requests to the selected host and is a no-op when nothing is selected (the mechanism that lets sign-in reach the platform). - LmsDetailDto maps the OAuth client id / feedback email / host that the catalog summary lacks, with blank values falling back correctly. --- .../BaseUrlOverrideInterceptorTest.kt | 63 +++++++++++++++++++ .../app/lmsdirectory/LmsDetailDtoTest.kt | 63 +++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 app/src/test/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptorTest.kt create mode 100644 app/src/test/java/org/openedx/app/lmsdirectory/LmsDetailDtoTest.kt 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) + } +} From b55bce3832044e9a4f97720c5f5170746bb59c60 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:50:53 +0300 Subject: [PATCH 4/9] fix: report lms sheet --- .../main/java/org/openedx/app/AppRouter.kt | 27 +- .../main/java/org/openedx/app/AppViewModel.kt | 2 +- .../main/java/org/openedx/app/MainFragment.kt | 25 ++ .../main/java/org/openedx/app/OpenEdXApp.kt | 3 +- .../app/data/storage/PreferencesManager.kt | 37 ++ .../main/java/org/openedx/app/di/AppModule.kt | 2 +- auth/src/main/AndroidManifest.xml | 15 + .../lmsselection/LmsLandingFragment.kt | 53 ++- .../lmsselection/LmsLandingScreen.kt | 43 ++- .../lmsselection/LmsQrScannerActivity.kt | 62 +++ .../lmsselection/SiteSelectionCallbacks.kt | 5 +- .../lmsselection/SiteSelectionFragment.kt | 45 ++- .../lmsselection/SiteSelectionScreen.kt | 359 ++++++++++-------- .../lmsselection/SiteSelectionUIState.kt | 4 + .../lmsselection/SiteSelectionViewModel.kt | 112 +++++- .../restore/RestorePasswordFragment.kt | 10 +- .../auth/presentation/signin/SignInUIState.kt | 1 + .../presentation/signin/SignInViewModel.kt | 9 +- .../presentation/signin/compose/SignInView.kt | 79 ++-- .../presentation/signup/compose/SignUpView.kt | 11 +- .../main/res/drawable/auth_ic_qr_close.xml | 9 + .../main/res/drawable/auth_qr_close_bg.xml | 4 + auth/src/main/res/layout/auth_qr_scanner.xml | 28 ++ auth/src/main/res/values/strings.xml | 19 +- .../SiteSelectionViewModelTest.kt | 110 ++++++ .../java/org/openedx/core/config/Config.kt | 6 +- .../openedx/core/config/LMSDirectoryConfig.kt | 12 +- .../core/data/storage/CorePreferences.kt | 18 + .../core/lmsdirectory/DirectoryDtos.kt | 14 + .../core/lmsdirectory/DirectoryModels.kt | 21 + .../core/lmsdirectory/LmsThemeController.kt | 14 + .../org/openedx/core/ui/ComposeExtensions.kt | 20 +- .../org/openedx/core/ui/LmsHeaderImage.kt | 43 +++ .../java/org/openedx/core/ui/theme/Theme.kt | 14 +- .../presentation/profile/ProfileFragment.kt | 16 +- .../presentation/profile/ProfileViewModel.kt | 2 +- .../profile/compose/ProfileView.kt | 4 +- .../presentation/reportlms/ReportLmsSheet.kt | 281 +++++++++----- .../reportlms/ReportLmsViewModel.kt | 16 +- profile/src/main/res/values/strings.xml | 10 +- 40 files changed, 1194 insertions(+), 371 deletions(-) create mode 100644 auth/src/main/AndroidManifest.xml create mode 100644 auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsQrScannerActivity.kt create mode 100644 auth/src/main/res/drawable/auth_ic_qr_close.xml create mode 100644 auth/src/main/res/drawable/auth_qr_close_bg.xml create mode 100644 auth/src/main/res/layout/auth_qr_scanner.xml create mode 100644 auth/src/test/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModelTest.kt create mode 100644 core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index 9da059585..1e4af77bb 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -5,6 +5,7 @@ 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 @@ -12,6 +13,9 @@ 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.WebContentFragment @@ -61,7 +65,10 @@ import org.openedx.profile.presentation.video.VideoSettingsFragment import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment -class AppRouter : +class AppRouter( + private val config: Config, + private val corePreferences: CorePreferences, +) : AuthRouter, DiscoveryRouter, DashboardRouter, @@ -410,10 +417,20 @@ class AppRouter : override fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) { fm.apply { clearBackStack(this) - if (isLogistrationEnabled) { - replaceFragment(fm, LogistrationFragment()) - } else { - replaceFragment(fm, SignInFragment.newInstance(null, null)) + when { + // LMS Directory: after logout the selection is cleared (see + // clearCorePreferences), so when the feature is reachable return to the + // platform picker instead of the stock sign-in — matches the app-launch + // path in AppActivity.setupInitialFragment and the iOS behavior. Reset the + // in-memory accent so the neutral landing isn't tinted by the old LMS. + config.getLMSDirectoryConfig().isReachable && + corePreferences.selectedBaseUrl.isNullOrBlank() -> { + LmsThemeController.clear() + replaceFragment(fm, 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 ff6f8a162..74939976c 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -62,7 +62,7 @@ class AppViewModel( * True only when the feature is on and nothing is selected yet. */ val isLmsSelectionRequired: Boolean - get() = config.getLMSDirectoryConfig().enabled && preferencesManager.selectedBaseUrl.isNullOrBlank() + get() = config.getLMSDirectoryConfig().isReachable && preferencesManager.selectedBaseUrl.isNullOrBlank() private var logoutHandledAt: Long = 0 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 17999ed42..ad9dcb816 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -38,8 +38,9 @@ class OpenEdXApp : Application() { } // 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().enabled) { + if (config.getLMSDirectoryConfig().isReachable) { LmsThemeController.apply(corePreferences.selectedLmsAccentColor) + LmsThemeController.applyBackground(corePreferences.selectedLmsLoginBackgroundUrl) } if (config.getFirebaseConfig().enabled) { FirebaseApp.initializeApp(this) 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 1e3f5153c..3a3c22462 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 @@ -90,7 +94,11 @@ class PreferencesManager( 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_CURATED = booleanPreferencesKey("lms_directory_curated") + val LMS_HISTORY = stringPreferencesKey("lms_history") fun calendarSyncDialogShown(courseName: String) = booleanPreferencesKey("calendar_sync_dialog_${courseName.replaceSpace("_")}") @@ -134,6 +142,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) } } @@ -227,10 +245,29 @@ class PreferencesManager( 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 lmsDirectoryCurated: Boolean + get() = getValue(Keys.LMS_DIRECTORY_CURATED, false) + set(value) = setValue(Keys.LMS_DIRECTORY_CURATED, 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 739846316..c74fd2e9a 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -121,7 +121,7 @@ val appModule = module { single { DiscoveryNotifier() } single { CalendarNotifier() } - single { AppRouter() } + single { AppRouter(get(), get()) } single { get() } single { get() } single { get() } 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/lmsselection/LmsLandingFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt index 26c0dc4b2..3fd16d56c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt @@ -4,6 +4,8 @@ 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 @@ -39,17 +41,40 @@ class LmsLandingFragment : Fragment() { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { + val state by viewModel.uiState.collectAsState() LaunchedEffect(Unit) { viewModel.actions.collect { action -> when (action) { - is SiteSelectionViewModel.SiteSelectionAction.Success -> continueAfterSelection() + is SiteSelectionViewModel.SiteSelectionAction.Success -> + continueAfterSelection(action.preLoginDiscovery) } } } - LmsLandingScreen( - onFindClick = { router.navigateToLmsSelection(requireActivity().supportFragmentManager) }, - onQrClick = { launchQrScanner() }, - ) + 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() }, + ) + } } } } @@ -60,15 +85,23 @@ class LmsLandingFragment : Fragment() { .setPrompt(getString(R.string.auth_lms_qr_prompt)) .setBeepEnabled(false) .setOrientationLocked(false) + .setCaptureActivity(LmsQrScannerActivity::class.java) scanLauncher.launch(options) } - private fun continueAfterSelection() { + private fun continueAfterSelection(preLoginDiscovery: Boolean) { val fm = requireActivity().supportFragmentManager - if (config.isPreLoginExperienceEnabled()) { - router.navigateToLogistration(fm, courseId = null) - } else { - router.navigateToSignIn(fm, courseId = null, infoType = null) + 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 index a47386d17..ebaf94296 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt @@ -1,19 +1,28 @@ 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 @@ -21,6 +30,11 @@ 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 — @@ -40,20 +54,27 @@ internal fun LmsLandingScreen( 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.padding(top = 12.dp)) + 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(1f)) + Spacer(modifier = Modifier.weight(CONTENT_TO_ACTIONS_WEIGHT)) Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, @@ -65,11 +86,19 @@ internal fun LmsLandingScreen( onClick = onFindClick, ) TextButton(onClick = onQrClick) { - Text( - text = stringResource(id = R.string.auth_lms_qr_button), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary, - ) + 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 index a49922253..b15280bd0 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt @@ -1,12 +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 onInputChanged: (String) -> 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 index 15ae2fe4e..3fabf2081 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt @@ -9,15 +9,19 @@ 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. + * 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() { @@ -25,6 +29,10 @@ class SiteSelectionFragment : Fragment() { 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?, @@ -38,31 +46,52 @@ class SiteSelectionFragment : Fragment() { LaunchedEffect(Unit) { viewModel.actions.collect { action -> when (action) { - is SiteSelectionViewModel.SiteSelectionAction.Success -> continueAfterSelection() + 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() }, - onInputChanged = viewModel::onInputChanged, + onQrClick = { launchQrScanner() }, onSubmitManual = viewModel::onSubmitManual, onQueryChanged = viewModel::onQueryChanged, onCatalogItemSelected = viewModel::onCatalogItemSelected, + onCleanHistory = viewModel::onCleanHistory, + onHistoryItemSelected = viewModel::onHistoryItemSelected, ) ) } } } - private fun continueAfterSelection() { + 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 - if (config.isPreLoginExperienceEnabled()) { - router.navigateToLogistration(fm, courseId = null) - } else { - router.navigateToSignIn(fm, courseId = null, infoType = null) + 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 index e84a8d3f6..bb505bcc3 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt @@ -1,8 +1,10 @@ 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 @@ -11,14 +13,15 @@ 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.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight -import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.filled.Search import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -32,15 +35,22 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.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.LmsSummary -import org.openedx.core.ui.OpenEdXButton +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 @@ -49,6 +59,9 @@ import org.openedx.core.ui.theme.appTypography 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() @@ -59,30 +72,30 @@ internal fun SiteSelectionScreen( containerColor = MaterialTheme.appColors.background, topBar = { Surface(color = MaterialTheme.appColors.background) { - Row( + Box( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, ) { - IconButton(onClick = { callbacks.onBack() }) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource( - id = org.openedx.core.R.string.core_accessibility_btn_back - ), + if (showBack) { + BackBtn( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 8.dp), tint = MaterialTheme.appColors.textPrimary, - ) + ) { callbacks.onBack() } } Text( - text = if (state.isCurated && state.providerName.isNotBlank()) { - state.providerName - } else { - stringResource(id = R.string.auth_lms_choose_title) - }, - style = MaterialTheme.appTypography.titleLarge, + 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, - modifier = Modifier.padding(start = 8.dp) ) } } @@ -93,77 +106,88 @@ internal fun SiteSelectionScreen( .padding(padding) .padding(horizontal = 24.dp, vertical = 16.dp) .verticalScroll(scrollState), - verticalArrangement = Arrangement.spacedBy(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (state.isCurated) { - Text( - text = stringResource(id = R.string.auth_lms_catalog_available), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary, - ) - CatalogContent(state, callbacks) - } else { - SearchSection(state, callbacks) + if (!state.isCurated) { + SearchField(state, callbacks) } - ManualEntrySection(state, callbacks) + CatalogContent(state, callbacks) } } } @Composable -private fun SearchSection(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - 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 = { +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.Search, - contentDescription = null, - tint = MaterialTheme.appColors.textSecondary, + 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), - ) - CatalogContent(state, callbacks) - } + } + }, + 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) { - is CatalogState.Idle -> PlaceholderText(stringResource(id = R.string.auth_lms_start_typing)) - is CatalogState.Loading -> 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)) + // 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.Empty -> PlaceholderText(stringResource(id = R.string.auth_lms_no_results)) + 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) }) } @@ -171,8 +195,88 @@ private fun CatalogContent(state: SiteSelectionUIState, callbacks: SiteSelection } } +/** + * "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() @@ -182,26 +286,28 @@ private fun CatalogRow(item: LmsSummary, onSelect: () -> Unit) { border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.5f)), ) { Row( - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + 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 = item.title, + text = title, maxLines = 1, style = MaterialTheme.appTypography.titleSmall, color = MaterialTheme.appColors.textPrimary, ) - if (item.shortDescription.isNotBlank()) { + if (shortDescription.isNotBlank()) { Text( - text = item.shortDescription, + text = shortDescription, maxLines = 1, style = MaterialTheme.appTypography.bodyMedium, color = MaterialTheme.appColors.textSecondary, ) } Text( - text = hostOf(item.baseUrl), + text = hostOf(baseUrl), maxLines = 1, style = MaterialTheme.appTypography.labelMedium, color = MaterialTheme.appColors.textSecondary, @@ -216,83 +322,38 @@ private fun CatalogRow(item: LmsSummary, onSelect: () -> Unit) { } } +/** + * 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 ManualEntrySection(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { - val focusManager = LocalFocusManager.current - val keyboardController = LocalSoftwareKeyboardController.current - - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(id = R.string.auth_lms_manual_entry_title), - style = MaterialTheme.appTypography.titleMedium, - color = MaterialTheme.appColors.textPrimary, +private fun LmsRowLogo(logoUrl: String?, title: String, accentColor: String?) { + val logoModifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(10.dp)) + if (!logoUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(logoUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = logoModifier, ) - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = state.inputUrl, - onValueChange = { callbacks.onInputChanged(it) }, - enabled = !state.isLoading, - singleLine = true, - placeholder = { - Text( - text = stringResource(id = R.string.auth_lms_url_placeholder), - style = MaterialTheme.appTypography.bodyLarge, - color = MaterialTheme.appColors.textFieldHint, - ) - }, - leadingIcon = { - Icon( - imageVector = Icons.Filled.Public, - contentDescription = null, - tint = MaterialTheme.appColors.textSecondary, - ) - }, - textStyle = MaterialTheme.appTypography.bodyLarge, - shape = MaterialTheme.appShapes.textFieldShape, - colors = directoryTextFieldColors(), - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done, keyboardType = KeyboardType.Uri), - keyboardActions = KeyboardActions { - keyboardController?.hide() - focusManager.clearFocus() - callbacks.onSubmitManual() - } - ) - if (!state.errorMessage.isNullOrEmpty()) { + } else { + val accent = LmsThemeController.parseHexColor(accentColor) ?: MaterialTheme.appColors.primary + Box( + modifier = logoModifier.background(accent.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { Text( - text = state.errorMessage, - style = MaterialTheme.appTypography.bodyMedium, - color = MaterialTheme.appColors.error, + text = title.trim().take(1).uppercase(), + style = MaterialTheme.appTypography.titleMedium, + color = accent, ) } - OpenEdXButton( - modifier = Modifier.fillMaxWidth(), - enabled = !state.isLoading, - onClick = { - keyboardController?.hide() - focusManager.clearFocus() - callbacks.onSubmitManual() - } - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = org.openedx.core.R.string.core_continue), - color = MaterialTheme.appColors.primaryButtonText, - style = MaterialTheme.appTypography.labelLarge - ) - if (state.isLoading) { - Spacer(modifier = Modifier.size(12.dp)) - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = MaterialTheme.appColors.primaryButtonText, - ) - } - } - } } } @@ -300,8 +361,8 @@ private fun ManualEntrySection(state: SiteSelectionUIState, callbacks: SiteSelec private fun directoryTextFieldColors() = OutlinedTextFieldDefaults.colors( focusedTextColor = MaterialTheme.appColors.textFieldText, unfocusedTextColor = MaterialTheme.appColors.textFieldText, - focusedContainerColor = MaterialTheme.appColors.textFieldBackground, - unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, + focusedContainerColor = MaterialTheme.appColors.background, + unfocusedContainerColor = MaterialTheme.appColors.background, focusedBorderColor = MaterialTheme.appColors.textFieldBorder, unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, cursorColor = MaterialTheme.appColors.primary, 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 index 4f2136ad1..dc41b2299 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt @@ -1,5 +1,6 @@ package org.openedx.auth.presentation.lmsselection +import org.openedx.core.lmsdirectory.LmsHistoryEntry import org.openedx.core.lmsdirectory.LmsSummary data class SiteSelectionUIState( @@ -13,6 +14,9 @@ data class SiteSelectionUIState( 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(), ) sealed interface 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 index 1fc7736c1..34039c6ec 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt @@ -20,6 +20,7 @@ import org.openedx.auth.R import org.openedx.core.config.Config import org.openedx.core.data.storage.CorePreferences import org.openedx.core.lmsdirectory.LmsDirectoryRepository +import org.openedx.core.lmsdirectory.LmsHistoryEntry import org.openedx.core.lmsdirectory.LmsSummary import org.openedx.core.lmsdirectory.LmsThemeController import org.openedx.foundation.presentation.BaseViewModel @@ -46,7 +47,10 @@ class SiteSelectionViewModel( .build() private val _uiState = MutableStateFlow( - SiteSelectionUIState(inputUrl = corePreferences.selectedBaseUrl?.trimEnd('/') ?: "") + SiteSelectionUIState( + inputUrl = corePreferences.selectedBaseUrl?.trimEnd('/') ?: "", + history = corePreferences.lmsHistory, + ) ) val uiState: StateFlow = _uiState @@ -66,6 +70,8 @@ class SiteSelectionViewModel( val remote = directoryRepository.fetchConfig() val configuredMode = config.getLMSDirectoryConfig().directoryMode val curated = remote.isCurated || configuredMode.equals("curated", ignoreCase = true) + // Share the mode so the Profile tab can hide "Report this LMS" in curated mode. + corePreferences.lmsDirectoryCurated = curated _uiState.update { it.copy(isCurated = curated, providerName = remote.providerName) } @@ -90,7 +96,9 @@ class SiteSelectionViewModel( } fun onQueryChanged(value: String) { - _uiState.update { it.copy(query = value) } + // 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()) { @@ -138,12 +146,21 @@ class SiteSelectionViewModel( 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) + _actions.emit(SiteSelectionAction.Success(detail?.preLoginDiscovery ?: false)) } } - /** Select an LMS by URL scanned from a QR code. Validates before committing. */ + /** + * 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 { @@ -153,10 +170,30 @@ class SiteSelectionViewModel( } _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) + _actions.emit(SiteSelectionAction.Success(preLoginDiscovery = false)) } else { _uiState.update { it.copy( @@ -168,6 +205,33 @@ class SiteSelectionViewModel( } } + /** 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) @@ -189,7 +253,7 @@ class SiteSelectionViewModel( val confirmed = withContext(Dispatchers.IO) { confirmBaseUrl(normalized) } if (confirmed) { selectLms(normalized.newBuilder().encodedPath("/").build().toString(), accentColor = null) - _actions.emit(SiteSelectionAction.Success) + _actions.emit(SiteSelectionAction.Success(preLoginDiscovery = false)) } else { _uiState.update { it.copy( @@ -213,6 +277,9 @@ class SiteSelectionViewModel( feedbackEmail: String? = null, logoUrl: String? = null, title: String? = null, + shortDescription: String = "", + loginBackgroundUrl: String? = null, + preLoginDiscovery: Boolean = false, ) { corePreferences.selectedBaseUrl = baseUrl corePreferences.selectedLmsAccentColor = accentColor @@ -220,9 +287,38 @@ class SiteSelectionViewModel( 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()) { @@ -272,7 +368,8 @@ class SiteSelectionViewModel( // endregion sealed interface SiteSelectionAction { - data object Success : SiteSelectionAction + /** [preLoginDiscovery] true -> open the pre-login Discovery catalog instead of sign-in. */ + data class Success(val preLoginDiscovery: Boolean) : SiteSelectionAction } private companion object { @@ -280,5 +377,6 @@ class SiteSelectionViewModel( 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/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index a913b3480..a8a8f5e60 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 @@ -29,4 +29,5 @@ internal data class SignInUIState( // 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 6b42a47c7..628dd2a7a 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 @@ -72,16 +72,21 @@ class SignInViewModel( isLogistrationEnabled = config.isPreLoginExperienceEnabled(), isRegistrationEnabled = config.isRegistrationEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), - selectedLmsTitle = if (config.getLMSDirectoryConfig().enabled) { + selectedLmsTitle = if (config.getLMSDirectoryConfig().isReachable) { preferencesManager.selectedLmsTitle } else { null }, - selectedLmsLogoUrl = if (config.getLMSDirectoryConfig().enabled) { + selectedLmsLogoUrl = if (config.getLMSDirectoryConfig().isReachable) { preferencesManager.selectedLmsLogoUrl } else { null }, + selectedLmsLoginBackgroundUrl = if (config.getLMSDirectoryConfig().isReachable) { + preferencesManager.selectedLmsLoginBackgroundUrl + } else { + null + }, ) ) internal val uiState: StateFlow = _uiState 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 f71e5a681..4d9f61c0e 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 @@ -5,6 +5,7 @@ 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 @@ -32,7 +33,6 @@ import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -134,14 +134,30 @@ internal fun LoginScreen( ) } - Image( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(fraction = 0.3f), - painter = painterResource(id = coreR.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null - ) + // 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), + 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( @@ -199,12 +215,6 @@ internal fun LoginScreen( .displayCutoutForLandscape() .then(contentPaddings), ) { - if (!state.selectedLmsTitle.isNullOrBlank()) { - SelectedLmsBanner( - title = state.selectedLmsTitle, - onChange = { onEvent(AuthEvent.ChangeLmsClick) }, - ) - } Text( modifier = Modifier.testTag("txt_sign_in_title"), text = stringResource(id = coreR.string.core_sign_in), @@ -219,6 +229,13 @@ internal fun LoginScreen( color = MaterialTheme.appColors.textPrimary, 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, @@ -512,16 +529,21 @@ private fun SelectedLmsBanner( Surface( modifier = Modifier .fillMaxWidth() - .padding(bottom = 16.dp), + .testTag("selected_lms_container"), shape = MaterialTheme.appShapes.textFieldShape, - color = MaterialTheme.appColors.textFieldBackground, + color = MaterialTheme.appColors.background, border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.5f)), ) { Row( - modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp, end = 4.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = stringResource(id = R.string.auth_lms_selected_label), style = MaterialTheme.appTypography.labelMedium, @@ -534,16 +556,15 @@ private fun SelectedLmsBanner( color = MaterialTheme.appColors.textPrimary, ) } - TextButton( - onClick = onChange, - modifier = Modifier.testTag("change_lms_button"), - ) { - Text( - text = stringResource(id = R.string.auth_lms_change), - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.primary, - ) - } + 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 8b1921f13..49310ddeb 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -45,19 +45,22 @@ Hide password - Welcome to Open X Project + 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. - Choose your LMS - Available platforms - Search by name or address - Start typing to search the catalog. + 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 platforms matched your search. - Or enter an LMS address - https://your-lms.example.com + 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. 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/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 7cdbc7d5c..2791759ec 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -34,7 +34,7 @@ class Config( * is used. Off (default) → always the config value, i.e. stock behaviour. */ fun getApiHostURL(): String { - if (getLMSDirectoryConfig().enabled) { + if (getLMSDirectoryConfig().isReachable) { val selected = corePreferences?.selectedBaseUrl if (!selected.isNullOrBlank()) { return selected @@ -54,7 +54,7 @@ class Config( 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().enabled) { + if (getLMSDirectoryConfig().isReachable) { val selected = corePreferences?.selectedOAuthClientId if (!selected.isNullOrBlank()) { return selected @@ -72,7 +72,7 @@ class Config( } fun getFeedbackEmailAddress(): String { - if (getLMSDirectoryConfig().enabled) { + if (getLMSDirectoryConfig().isReachable) { val selected = corePreferences?.selectedFeedbackEmail if (!selected.isNullOrBlank()) { return selected diff --git a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt index 762715266..2c61eab66 100644 --- a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt +++ b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt @@ -19,6 +19,16 @@ data class LMSDirectoryConfig( @SerializedName("DIRECTORY_MODE") val directoryMode: String = "", ) { - /** The feature only works with a registry to talk to. */ + /** + * 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 && directoryUrl.isNotBlank() } 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 599890cdf..09544601e 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 @@ -34,8 +35,25 @@ interface CorePreferences { /** 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? + /** + * True when the LMS registry runs in curated/institution mode. In that mode the + * catalog is the org's own platforms, so there is no learner "Report this LMS". + * Set when the directory config is fetched; read by the Profile tab to hide reports. + */ + var lmsDirectoryCurated: Boolean + + /** + * 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 index c306095f4..a44fc5407 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt @@ -29,10 +29,13 @@ data class LmsSummaryDto( 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, @@ -40,14 +43,25 @@ data class LmsDetailDto( @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, ) } diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt index 16d91c6b3..93c5b9d3f 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt @@ -22,11 +22,32 @@ data class LmsSummary( 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( diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt index 5c8c25d44..5de895521 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt @@ -18,13 +18,27 @@ 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") 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..19b2054bf --- /dev/null +++ b/core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt @@ -0,0 +1,43 @@ +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.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 backgroundUrl = LmsThemeController.loginBackgroundUrl + if (!backgroundUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(backgroundUrl) + .placeholder(R.drawable.core_top_header) + .error(R.drawable.core_top_header) + .crossfade(true) + .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 d41f46863..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 @@ -249,8 +249,13 @@ 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, - * and the accent text. Everything else (backgrounds, text, borders) is preserved so - * the app keeps its light/dark identity while adopting the platform's brand color. + * 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( @@ -262,6 +267,11 @@ private fun AppColors.withAccent(accent: Color): AppColors { textAccent = accent, primaryButtonBackground = accent, secondaryButtonBackground = accent, + secondaryButtonBorder = accent, + secondaryButtonBorderedText = accent, + bottomSheetToggle = accent, + info = accent, + infoVariant = accent, progressBarColor = accent, ) } 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 7d83f0df0..44f8cbead 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 @@ -12,18 +12,21 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment +import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.data.storage.CorePreferences 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.ReportLmsBottomSheet +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() + private val corePreferences: CorePreferences by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -49,7 +52,8 @@ class ProfileFragment : Fragment() { uiState = uiState, uiMessage = uiMessage, refreshing = refreshing, - showReportLms = viewModel.isLmsDirectoryEnabled, + // Curated/institution registries have no learner reporting. + showReportLms = viewModel.isLmsDirectoryEnabled && !corePreferences.lmsDirectoryCurated, onSettingsClick = { viewModel.profileRouter.navigateToSettings(requireActivity().supportFragmentManager) }, @@ -61,7 +65,6 @@ class ProfileFragment : Fragment() { ) } ProfileViewAction.ReportLmsClick -> { - reportViewModel.reset() showReportSheet = true } ProfileViewAction.SwipeRefresh -> { @@ -72,9 +75,12 @@ class ProfileFragment : Fragment() { ) if (showReportSheet) { - ReportLmsBottomSheet( + ReportLmsSheet( viewModel = reportViewModel, - onDismiss = { showReportSheet = false }, + 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 370abdd87..0a41add2c 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 @@ -30,7 +30,7 @@ class ProfileViewModel( ) : BaseViewModel(resourceManager) { /** LMS Directory: show the "Report this LMS" entry only when the feature is on. */ - val isLmsDirectoryEnabled: Boolean get() = config.getLMSDirectoryConfig().enabled + val isLmsDirectoryEnabled: Boolean get() = config.getLMSDirectoryConfig().isReachable 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 80315c34f..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 @@ -161,8 +161,8 @@ internal fun ProfileView( id = org.openedx.profile.R.string.profile_report_lms_button ), onClick = { onAction(ProfileViewAction.ReportLmsClick) }, - borderColor = MaterialTheme.appColors.error, - textColor = MaterialTheme.appColors.error + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.textAccent ) } Spacer(modifier = Modifier.height(12.dp)) 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 index 5e1b29bba..e2cc20e6d 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt @@ -7,7 +7,9 @@ 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 @@ -17,11 +19,14 @@ 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.selection.selectable 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 @@ -29,7 +34,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.RadioButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState @@ -42,11 +47,13 @@ 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 @@ -55,13 +62,16 @@ private const val SCREENSHOT_JPEG_QUALITY = 60 @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ReportLmsBottomSheet( +fun ReportLmsSheet( viewModel: ReportLmsViewModel, onDismiss: () -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) 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() @@ -79,13 +89,14 @@ fun ReportLmsBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, containerColor = MaterialTheme.appColors.background, + dragHandle = { BottomSheetDefaults.DragHandle() }, ) { if (state.submitted) { - SuccessContent(lmsTitle = viewModel.lmsTitle) + SuccessContent(host = viewModel.displayHost, onDismiss = onDismiss) } else { FormContent( state = state, - lmsTitle = viewModel.lmsTitle, + subtitle = viewModel.displayHost, onCategoryChanged = viewModel::onCategoryChanged, onMessageChanged = viewModel::onMessageChanged, onEmailChanged = viewModel::onEmailChanged, @@ -104,7 +115,7 @@ fun ReportLmsBottomSheet( @Composable private fun FormContent( state: ReportLmsUiState, - lmsTitle: String, + subtitle: String, onCategoryChanged: (ReportCategory) -> Unit, onMessageChanged: (String) -> Unit, onEmailChanged: (String) -> Unit, @@ -114,11 +125,9 @@ private fun FormContent( ) { Column( modifier = Modifier - .fillMaxWidth() - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .padding(bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( @@ -126,81 +135,111 @@ private fun FormContent( style = MaterialTheme.appTypography.titleLarge, color = MaterialTheme.appColors.textPrimary, ) - Text( - text = lmsTitle, - style = MaterialTheme.appTypography.bodyLarge, - color = MaterialTheme.appColors.textSecondary, - ) - } - - SectionLabel(stringResource(id = R.string.profile_report_lms_whats_wrong)) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - ReportCategory.entries.forEach { category -> - CategoryRow( - category = category, - selected = state.category == category, - onSelect = { onCategoryChanged(category) }, + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, ) } } - SectionLabel(stringResource(id = R.string.profile_report_lms_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, - ) - }, - colors = reportTextFieldColors(), - ) + 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) }, + ) + } + } + } - SectionLabel(stringResource(id = R.string.profile_report_lms_screenshot)) - val preview = state.screenshotPreview - if (preview != null) { - Image( - bitmap = preview, - contentDescription = null, - contentScale = ContentScale.Fit, + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_tell_us_more)) { + OutlinedTextField( modifier = Modifier .fillMaxWidth() - .heightIn(max = 180.dp) - .clip(RoundedCornerShape(10.dp)), + .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(), ) - TextButton(onClick = onRemoveScreenshot) { - Text( - text = stringResource(id = R.string.profile_report_lms_remove_screenshot), - color = MaterialTheme.appColors.error, - ) - } - } else { - TextButton(onClick = onAttachClick) { - Text( - text = stringResource(id = R.string.profile_report_lms_attach_screenshot), - color = MaterialTheme.appColors.primary, + } + + 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, + ) + } + } } } - SectionLabel(stringResource(id = R.string.profile_report_lms_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, - ) - }, - colors = reportTextFieldColors(), - ) + 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( @@ -213,6 +252,8 @@ private fun FormContent( OpenEdXButton( modifier = Modifier.fillMaxWidth(), enabled = state.canSubmit, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonText, onClick = onSubmit, ) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { @@ -224,7 +265,7 @@ private fun FormContent( if (state.submitting) { Spacer(modifier = Modifier.size(12.dp)) CircularProgressIndicator( - modifier = Modifier.size(20.dp), + modifier = Modifier.size(18.dp), strokeWidth = 2.dp, color = MaterialTheme.appColors.primaryButtonText, ) @@ -234,39 +275,73 @@ private fun FormContent( } } +/** 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) { - Row( + Surface( modifier = Modifier .fillMaxWidth() - .selectable(selected = selected, onClick = onSelect) - .padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, + .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) + } + ), ) { - RadioButton(selected = selected, onClick = onSelect) - Text( - text = stringResource(id = category.titleRes()), - style = MaterialTheme.appTypography.bodyLarge, - color = MaterialTheme.appColors.textPrimary, - modifier = Modifier.padding(start = 8.dp), - ) + 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(lmsTitle: String) { +private fun SuccessContent(host: String, onDismiss: () -> Unit) { Column( modifier = Modifier .fillMaxWidth() - .padding(32.dp), + .padding(28.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { Icon( imageVector = Icons.Filled.CheckCircle, contentDescription = null, tint = MaterialTheme.appColors.primary, - modifier = Modifier.size(56.dp), + modifier = Modifier.size(52.dp), ) Text( text = stringResource(id = R.string.profile_report_lms_thanks), @@ -274,22 +349,26 @@ private fun SuccessContent(lmsTitle: String) { color = MaterialTheme.appColors.textPrimary, ) Text( - text = stringResource(id = R.string.profile_report_lms_thanks_body, lmsTitle), + 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 SectionLabel(text: String) { - Text( - text = text, - style = MaterialTheme.appTypography.labelLarge, - color = MaterialTheme.appColors.textSecondary, - ) -} - @Composable private fun reportTextFieldColors() = OutlinedTextFieldDefaults.colors( focusedTextColor = MaterialTheme.appColors.textFieldText, 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 index 96d5e375c..9bc215013 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt @@ -28,12 +28,16 @@ class ReportLmsViewModel( private val _uiState = MutableStateFlow(ReportLmsUiState()) val uiState: StateFlow = _uiState - /** Host of the LMS being reported (the current platform), for the sheet header. */ - val lmsTitle: String - get() = config.getApiHostURL() - .removePrefix("https://") - .removePrefix("http://") - .trimEnd('/') + /** + * 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) } diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index d87c4819f..170a1a654 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -83,18 +83,18 @@ Report this LMS Report a problem - What\'s wrong? - Tell us more + What\'s wrong? + Tell us more + Screenshot (optional) + Email (optional) Describe what happened - Screenshot (optional) Attach a screenshot Remove screenshot - Email (optional) So we can follow up Send report Thanks for the heads-up A moderator will look into %1$s shortly. - We couldn\'t send your report. Check your connection and try again. + Couldn\'t send your report. Check your connection and try again. Inappropriate or adult content Scam or phishing Pretends to be someone else From bba5ae0dd66d04304b6df5fe4421e17ae11268d0 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:08:43 +0300 Subject: [PATCH 5/9] feat: read the LMS directory from a single JSON document MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the iOS change. The app can now take its platform list from one JSON file instead of a live service, and that file can be fetched from a URL or read out of the app's assets. Nothing above the source can tell which. LMS_DIRECTORY: ENABLED: true DIRECTORY_URL: "https://example.com/directory.json" # or DIRECTORY_FILE: "lms_directory.json" # in assets, no network LmsDirectoryRepository now delegates to an LmsDirectorySource rather than calling Retrofit itself. Reporting still needs the service, so the repository takes the api only when there is one — a document is a one-way list. Image fields are web addresses or names of files in assets; anything that is not http(s) becomes file:///android_asset/…, which Coil reads natively, so only the resolver knows about the distinction. Three things fall out of reading everything at once: - A document is always curated. There is no server to ask what mode to be in, so a build cannot silently fall back to open search when it is offline — which is what used to happen here, without even a log line the user could see. - isCurated is now seeded from the config before any work starts, so a document build no longer flashes the generic landing during the config round trip. - Every sign-in background is known before a platform is tapped, so the artwork is prefetched into Coil while the learner is still choosing, and the header no longer crossfades in after the screen is already up. 17 new tests over the document, the image rule and the config's source rule; the 24 existing auth tests still pass. --- .../lmsselection/SiteSelectionScreen.kt | 14 +- .../lmsselection/SiteSelectionUIState.kt | 8 + .../lmsselection/SiteSelectionViewModel.kt | 21 +++ .../openedx/core/config/LMSDirectoryConfig.kt | 44 ++++- .../core/lmsdirectory/LmsDirectoryModule.kt | 34 +++- .../lmsdirectory/LmsDirectoryRepository.kt | 34 +++- .../core/lmsdirectory/LmsDirectorySource.kt | 173 ++++++++++++++++++ .../core/lmsdirectory/LmsImageSource.kt | 51 ++++++ .../org/openedx/core/ui/LmsHeaderImage.kt | 13 +- .../core/config/LMSDirectoryConfigTest.kt | 66 +++++++ .../DocumentLmsDirectorySourceTest.kt | 125 +++++++++++++ .../core/lmsdirectory/LmsImageSourceTest.kt | 60 ++++++ default_config/dev/config.yaml | 9 + default_config/prod/config.yaml | 1 + default_config/stage/config.yaml | 1 + 15 files changed, 637 insertions(+), 17 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/LmsImageSource.kt create mode 100644 core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt create mode 100644 core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt create mode 100644 core/src/test/java/org/openedx/core/lmsdirectory/LmsImageSourceTest.kt 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 index bb505bcc3..ab88afb59 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt @@ -33,6 +33,7 @@ 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 @@ -49,6 +50,7 @@ import coil.request.ImageRequest import org.openedx.auth.R import org.openedx.core.lmsdirectory.LmsHistoryEntry import org.openedx.core.lmsdirectory.LmsSummary +import org.openedx.core.lmsdirectory.LmsImageSource import org.openedx.core.lmsdirectory.LmsThemeController import org.openedx.core.ui.BackBtn import org.openedx.core.ui.theme.appColors @@ -108,6 +110,13 @@ internal fun SiteSelectionScreen( .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) } @@ -332,10 +341,11 @@ private fun LmsRowLogo(logoUrl: String?, title: String, accentColor: String?) { val logoModifier = Modifier .size(48.dp) .clip(RoundedCornerShape(10.dp)) - if (!logoUrl.isNullOrBlank()) { + val logoModel = LmsImageSource.model(logoUrl) + if (logoModel != null) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(logoUrl) + .data(logoModel) .crossfade(true) .build(), contentDescription = null, 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 index dc41b2299..8d370ea15 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt @@ -17,6 +17,14 @@ data class SiteSelectionUIState( // 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 { 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 index 34039c6ec..c1b2399da 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt @@ -18,6 +18,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import org.openedx.auth.R import org.openedx.core.config.Config +import org.openedx.core.config.LMSDirectoryConfig import org.openedx.core.data.storage.CorePreferences import org.openedx.core.lmsdirectory.LmsDirectoryRepository import org.openedx.core.lmsdirectory.LmsHistoryEntry @@ -60,11 +61,27 @@ class SiteSelectionViewModel( 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 source = config.getLMSDirectoryConfig().source + val isDocument = source is LMSDirectoryConfig.Source.Document || + source is LMSDirectoryConfig.Source.BundledDocument + if (isDocument || config.getLMSDirectoryConfig().directoryMode.equals("curated", ignoreCase = true)) { + corePreferences.lmsDirectoryCurated = true + _uiState.update { it.copy(isCurated = true) } + } + } + private fun loadCatalogConfig() { viewModelScope.launch { val remote = directoryRepository.fetchConfig() @@ -78,6 +95,10 @@ class SiteSelectionViewModel( 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) } + } } } diff --git a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt index 2c61eab66..049c58ff7 100644 --- a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt +++ b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt @@ -16,9 +16,50 @@ data class LMSDirectoryConfig( @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 @@ -30,5 +71,6 @@ data class LMSDirectoryConfig( * 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 && directoryUrl.isNotBlank() + val isReachable: Boolean + get() = enabled && (directoryFile.isNotBlank() || directoryUrl.isNotBlank()) } diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt index af7c0b3f5..4430a8a72 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt @@ -5,13 +5,17 @@ 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 registry catalog (search/browse + complaint reporting). - * Builds a clean Retrofit client pointed at the directory URL from the config flag. + * 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 { @@ -32,12 +36,36 @@ val lmsDirectoryModule = module { .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() - LmsDirectoryRepository(api = get(), appVersion = version) + 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, + ) } } diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt index a9ac5347b..f7b314dea 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt @@ -3,12 +3,17 @@ package org.openedx.core.lmsdirectory import android.util.Log /** - * Talks to the LMS registry catalog. All calls return [Result] so callers can - * fall back gracefully when the registry is unreachable. + * 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 api: LmsDirectoryApi, + private val source: LmsDirectorySource, private val appVersion: String, + private val api: LmsDirectoryApi? = null, ) { companion object { private const val TAG = "LmsDirectory" @@ -16,27 +21,42 @@ class LmsDirectoryRepository( suspend fun fetchConfig(): DirectoryConfig { return try { - api.getConfig().toDomain() + 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 } } suspend fun search(query: String): Result> = runCatching { - api.search(query = query.ifBlank { null }).items.map { it.toDomain() } + source.search(query) }.onFailure { Log.w(TAG, "Search failed: ${it.message}") } suspend fun fetchFeatured(): Result> = runCatching { - api.search(featured = true).items.map { it.toDomain() } + 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 { - api.detail(id).toDomain() + 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(), 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..014639684 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt @@ -0,0 +1,173 @@ +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) + ?: throw IllegalStateException("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 -> + if (!response.isSuccessful) { + throw IllegalStateException("Directory document returned ${response.code}") + } + response.body?.string() + ?: throw IllegalStateException("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/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/ui/LmsHeaderImage.kt b/core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt index 19b2054bf..e2f64ea97 100644 --- a/core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt +++ b/core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt @@ -9,6 +9,7 @@ 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 /** @@ -19,14 +20,18 @@ import org.openedx.core.lmsdirectory.LmsThemeController */ @Composable fun LmsHeaderImage(modifier: Modifier = Modifier) { - val backgroundUrl = LmsThemeController.loginBackgroundUrl - if (!backgroundUrl.isNullOrBlank()) { + val background = LmsImageSource.model(LmsThemeController.loginBackgroundUrl) + if (background != null) { AsyncImage( model = ImageRequest.Builder(LocalContext.current) - .data(backgroundUrl) + .data(background) .placeholder(R.drawable.core_top_header) .error(R.drawable.core_top_header) - .crossfade(true) + // 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, 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..a24bfdd63 --- /dev/null +++ b/core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt @@ -0,0 +1,66 @@ +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 `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..ea7b4856c --- /dev/null +++ b/core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt @@ -0,0 +1,125 @@ +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 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/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 325bfebb5..300e321f0 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -96,7 +96,16 @@ UI_COMPONENTS: COURSE_DOWNLOAD_QUEUE_SCREEN: false # LMS Directory (multi-tenant): browse the Open edX platforms published by a site # registry, re-theme to the chosen one, and sign in against it. Off by default. +# Where the app gets its list of Open edX platforms. +# Set exactly one source. A bundled file wins if both are set. +# DIRECTORY_URL a JSON document (…/directory.json), or the base URL of a +# service that answers /api/v1/directory +# DIRECTORY_FILE a JSON document in app/src/main/assets, for a build that +# ships its list and never asks the network for it +# Image fields in the document are either web addresses or the names of files in +# assets; anything that is not http(s) is read from the app. LMS_DIRECTORY: ENABLED: true DIRECTORY_URL: "https://openedx-lms.stepanok.com" + DIRECTORY_FILE: "" DIRECTORY_MODE: "search" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 25a711486..1c8c38a60 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -98,4 +98,5 @@ UI_COMPONENTS: LMS_DIRECTORY: ENABLED: false DIRECTORY_URL: "" + DIRECTORY_FILE: "" DIRECTORY_MODE: "search" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 25a711486..1c8c38a60 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -98,4 +98,5 @@ UI_COMPONENTS: LMS_DIRECTORY: ENABLED: false DIRECTORY_URL: "" + DIRECTORY_FILE: "" DIRECTORY_MODE: "search" From 7a76b04ef982ffd2275a92636cc6bb731ad6f3cd Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:36:04 +0300 Subject: [PATCH 6/9] fix: reporting a platform requires a service to report to Mirrors the iOS change. Reporting exists because the open catalog lets a stranger list anything; it belongs to the universal app, not to a provider shipping their own list. A build reading its directory from a document has nowhere to post, so the entry point must not appear. The gate used to be isReachable && !lmsDirectoryCurated, where the curated half came from a persisted preference written by the picker. That asked the wrong question and could go stale. It now derives from the configured source: supportsReporting is true only for a live service. The curated check stays, since a service in curated mode refuses reports too. The repository already refused to build a report without an api; this stops the button appearing at all rather than failing when pressed. --- .../openedx/core/config/LMSDirectoryConfig.kt | 13 +++++++++++++ .../core/config/LMSDirectoryConfigTest.kt | 17 +++++++++++++++++ .../presentation/profile/ProfileFragment.kt | 2 +- .../presentation/profile/ProfileViewModel.kt | 6 ++++++ 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt index 049c58ff7..2548a4b79 100644 --- a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt +++ b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt @@ -73,4 +73,17 @@ data class LMSDirectoryConfig( */ 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 } diff --git a/core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt b/core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt index a24bfdd63..2dbe71f69 100644 --- a/core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt +++ b/core/src/test/java/org/openedx/core/config/LMSDirectoryConfigTest.kt @@ -59,6 +59,23 @@ class LMSDirectoryConfigTest { ) } + @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/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt index 44f8cbead..6e944ab48 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 @@ -53,7 +53,7 @@ class ProfileFragment : Fragment() { uiMessage = uiMessage, refreshing = refreshing, // Curated/institution registries have no learner reporting. - showReportLms = viewModel.isLmsDirectoryEnabled && !corePreferences.lmsDirectoryCurated, + showReportLms = viewModel.canReportLms && !corePreferences.lmsDirectoryCurated, onSettingsClick = { viewModel.profileRouter.navigateToSettings(requireActivity().supportFragmentManager) }, 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 0a41add2c..af62e8f70 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 @@ -32,6 +32,12 @@ class ProfileViewModel( /** LMS Directory: show the "Report this LMS" entry only when the feature is on. */ val isLmsDirectoryEnabled: Boolean get() = config.getLMSDirectoryConfig().isReachable + /** + * Reporting belongs to the universal app. A build reading its list from a + * document has no service to post to, so the entry point stays hidden. + */ + val canReportLms: Boolean get() = config.getLMSDirectoryConfig().supportsReporting + private val _uiState: MutableStateFlow = MutableStateFlow(ProfileUIState.Loading) internal val uiState: StateFlow = _uiState.asStateFlow() From 83436eb802ecf6b09b080daa5de2dcd5c86a3fd0 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:49:22 +0300 Subject: [PATCH 7/9] fix: stop trusting a curated answer that belongs to another directory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a live catalog is curated is something only the server knows, and it says so on the platform picker — a screen the app stops showing once a platform has been chosen. The answer was therefore remembered in preferences, and then believed forever: point the build at a different registry, or at a document, and it kept obeying what the old one had said. The Profile tab read that flag to decide whether to offer reporting, so the entry point could stay hidden for a whole release. The answer is now stored beside a key naming the source that gave it, and read back only while the build still reads that source. A value left by an older build carries no key and is not trusted; a source change drops it at launch. ProfileViewModel derives the decision instead of the fragment reading the raw flag. Verified signed in on an emulator, both ways round: a document build offers no reporting, and the same app rebuilt against a service — data kept, picker skipped — offers it again. --- .../main/java/org/openedx/app/OpenEdXApp.kt | 6 + .../app/data/storage/PreferencesManager.kt | 5 + .../java/org/openedx/app/di/ScreenModule.kt | 1 + .../lmsselection/SiteSelectionScreen.kt | 2 +- .../lmsselection/SiteSelectionViewModel.kt | 9 +- .../openedx/core/config/LMSDirectoryConfig.kt | 29 +++ .../core/data/storage/CorePreferences.kt | 7 + .../core/lmsdirectory/LmsDirectorySource.kt | 11 +- .../core/lmsdirectory/LmsDirectoryState.kt | 69 ++++++++ .../lmsdirectory/LmsDirectoryStateTest.kt | 167 ++++++++++++++++++ .../presentation/profile/ProfileFragment.kt | 5 +- .../presentation/profile/ProfileViewModel.kt | 13 +- .../profile/ProfileViewModelTest.kt | 78 ++++++++ 13 files changed, 387 insertions(+), 15 deletions(-) create mode 100644 core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt create mode 100644 core/src/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index ad9dcb816..1e6cdeb0c 100644 --- a/app/src/main/java/org/openedx/app/OpenEdXApp.kt +++ b/app/src/main/java/org/openedx/app/OpenEdXApp.kt @@ -15,6 +15,7 @@ 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.LmsDirectoryState import org.openedx.core.lmsdirectory.LmsThemeController import org.openedx.core.lmsdirectory.lmsDirectoryModule import org.openedx.firebase.OEXFirebaseAnalytics @@ -39,8 +40,13 @@ class OpenEdXApp : Application() { // 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) + } else { + LmsDirectoryState.clear(corePreferences) } if (config.getFirebaseConfig().enabled) { FirebaseApp.initializeApp(this) 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 3a3c22462..4b361e162 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 @@ -98,6 +98,7 @@ class PreferencesManager( stringPreferencesKey("selected_lms_login_background") val SELECTED_LMS_TITLE = stringPreferencesKey("selected_lms_title") val LMS_DIRECTORY_CURATED = booleanPreferencesKey("lms_directory_curated") + val LMS_DIRECTORY_SOURCE_KEY = stringPreferencesKey("lms_directory_source_key") val LMS_HISTORY = stringPreferencesKey("lms_history") fun calendarSyncDialogShown(courseName: String) = @@ -257,6 +258,10 @@ class PreferencesManager( get() = getValue(Keys.LMS_DIRECTORY_CURATED, false) set(value) = setValue(Keys.LMS_DIRECTORY_CURATED, value) + 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, "") 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 889402bdc..5e5951797 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -218,6 +218,7 @@ val screenModule = module { notifier = get(), analytics = get(), config = get(), + corePreferences = get(), profileRouter = get(), ) } 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 index ab88afb59..93222463d 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt @@ -49,8 +49,8 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import org.openedx.auth.R import org.openedx.core.lmsdirectory.LmsHistoryEntry -import org.openedx.core.lmsdirectory.LmsSummary 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 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 index c1b2399da..147fe9dc2 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt @@ -21,6 +21,7 @@ import org.openedx.core.config.Config import org.openedx.core.config.LMSDirectoryConfig import org.openedx.core.data.storage.CorePreferences 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 @@ -77,7 +78,7 @@ class SiteSelectionViewModel( val isDocument = source is LMSDirectoryConfig.Source.Document || source is LMSDirectoryConfig.Source.BundledDocument if (isDocument || config.getLMSDirectoryConfig().directoryMode.equals("curated", ignoreCase = true)) { - corePreferences.lmsDirectoryCurated = true + LmsDirectoryState.rememberCurated(true, config.getLMSDirectoryConfig(), corePreferences) _uiState.update { it.copy(isCurated = true) } } } @@ -87,8 +88,10 @@ class SiteSelectionViewModel( val remote = directoryRepository.fetchConfig() val configuredMode = config.getLMSDirectoryConfig().directoryMode val curated = remote.isCurated || configuredMode.equals("curated", ignoreCase = true) - // Share the mode so the Profile tab can hide "Report this LMS" in curated mode. - corePreferences.lmsDirectoryCurated = curated + // Share the mode so the Profile tab can hide "Report this LMS" in curated + // mode. Stamped with the source, so it is ignored if the build is later + // pointed at a different directory. + LmsDirectoryState.rememberCurated(curated, config.getLMSDirectoryConfig(), corePreferences) _uiState.update { it.copy(isCurated = curated, providerName = remote.providerName) } diff --git a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt index 2548a4b79..0bb6bbb64 100644 --- a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt +++ b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt @@ -36,8 +36,10 @@ data class LMSDirectoryConfig( 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 } @@ -86,4 +88,31 @@ data class LMSDirectoryConfig( * 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 -> "" + } + + /** + * Whether this build lists a fixed set of platforms, as far as the config + * alone can say. A document always does; a service can be forced with + * DIRECTORY_MODE, but otherwise only the server knows. + */ + val isCuratedByConfiguration: Boolean + get() = when (source) { + is Source.Document, is Source.BundledDocument -> true + else -> directoryMode.trim().equals("curated", ignoreCase = true) + } } 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 09544601e..cbcf5b81f 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 @@ -48,6 +48,13 @@ interface CorePreferences { */ var lmsDirectoryCurated: Boolean + /** + * Which directory source [lmsDirectoryCurated] 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 diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt index 014639684..49e4a2c71 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectorySource.kt @@ -105,7 +105,7 @@ class DocumentLmsDirectorySource( cached?.let { return it } val raw = loader.load() val parsed = gson.fromJson(raw, DirectoryDocumentDto::class.java) - ?: throw IllegalStateException("Directory document is empty") + checkNotNull(parsed) { "Directory document is empty" } if (parsed.platforms.isEmpty()) { Log.w(TAG, "Directory document parsed but lists no platforms") } @@ -132,11 +132,12 @@ class DocumentLmsDirectorySource( loader = { withContext(Dispatchers.IO) { client.newCall(Request.Builder().url(url).build()).execute().use { response -> - if (!response.isSuccessful) { - throw IllegalStateException("Directory document returned ${response.code}") + check(response.isSuccessful) { + "Directory document returned ${response.code}" + } + checkNotNull(response.body?.string()) { + "Directory document had no body" } - response.body?.string() - ?: throw IllegalStateException("Directory document had no body") } } } 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..639403682 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt @@ -0,0 +1,69 @@ +package org.openedx.core.lmsdirectory + +import org.openedx.core.config.LMSDirectoryConfig +import org.openedx.core.data.storage.CorePreferences + +/** + * What the app remembers about the directory between launches, and the rule that + * decides when to stop believing it. + * + * Only the server can say whether a live catalog is curated, and it says so on a + * screen the app skips once a platform has been picked. So the answer is + * remembered — and a remembered answer is worth exactly nothing after the build + * has been pointed at a different directory. Everything stored here is therefore + * stamped with the source it came from, and read back only when that still matches. + */ +object LmsDirectoryState { + + /** Record what the server said, against the source that said it. */ + fun rememberCurated( + curated: Boolean, + config: LMSDirectoryConfig, + preferences: CorePreferences + ) { + preferences.lmsDirectoryCurated = curated + preferences.lmsDirectorySourceKey = config.sourceKey + } + + /** Forget everything source-specific. Used when the feature is switched off. */ + fun clear(preferences: CorePreferences) { + preferences.lmsDirectoryCurated = false + preferences.lmsDirectorySourceKey = "" + } + + /** + * Whether this build shows a fixed list of platforms. + * + * The config decides on its own whenever it can. Only when it cannot — a live + * service with no forced mode — does the remembered answer matter, and then + * only if it was recorded against this same source. A value left over from a + * different directory is discarded rather than obeyed, which is what stops a + * build that used to be curated from staying curated after it is pointed at an + * open catalog. + */ + fun isCurated(config: LMSDirectoryConfig, preferences: CorePreferences): Boolean { + val remembersThisSource = preferences.lmsDirectorySourceKey == config.sourceKey + return config.isCuratedByConfiguration || + (remembersThisSource && preferences.lmsDirectoryCurated) + } + + /** + * Whether to offer reporting a platform. + * + * Reporting exists because an open catalog lets a stranger list anything, so it + * needs 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. + */ + fun canReport(config: LMSDirectoryConfig, preferences: CorePreferences): Boolean = + config.supportsReporting && !isCurated(config, preferences) + + /** + * Drop a remembered answer that belongs to a directory this build no longer + * reads. Safe to call 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) + } +} 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..18dba74fa --- /dev/null +++ b/core/src/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt @@ -0,0 +1,167 @@ +package org.openedx.core.lmsdirectory + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +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 + +/** + * The persisted "curated" answer used to be believed forever. These cover the + * transitions where that was wrong: the build gets pointed somewhere else, the + * picker is skipped because a platform is already selected, and the stale value + * decides whether reporting appears. + */ +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" + ) + + @Test + fun `an open catalog reports until the server says it is curated`() { + val prefs = FakePreferences() + + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + + LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + assertTrue(LmsDirectoryState.isCurated(openCatalog, prefs)) + assertFalse(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `a curated answer does not follow the build to another service`() { + // The bug this exists for: the picker is skipped once a platform is + // selected, so nothing would ever overwrite the old answer. + val prefs = FakePreferences() + LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + + assertFalse(LmsDirectoryState.isCurated(otherCatalog, prefs)) + assertTrue(LmsDirectoryState.canReport(otherCatalog, prefs)) + } + + @Test + fun `moving from a document to an open catalog restores reporting`() { + val prefs = FakePreferences() + LmsDirectoryState.rememberCurated(true, document, prefs) + + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `moving from an open catalog to a document hides reporting`() { + val prefs = FakePreferences() + LmsDirectoryState.rememberCurated(false, openCatalog, prefs) + + assertTrue(LmsDirectoryState.isCurated(document, prefs)) + assertFalse(LmsDirectoryState.canReport(document, prefs)) + assertTrue(LmsDirectoryState.isCurated(bundled, prefs)) + assertFalse(LmsDirectoryState.canReport(bundled, prefs)) + } + + @Test + fun `a remembered open answer cannot unlock reporting on a curated build`() { + val prefs = FakePreferences() + val forcedCurated = LMSDirectoryConfig( + enabled = true, + directoryUrl = "https://registry.example.com", + directoryMode = "curated" + ) + LmsDirectoryState.rememberCurated(false, forcedCurated, prefs) + + assertTrue(LmsDirectoryState.isCurated(forcedCurated, prefs)) + assertFalse(LmsDirectoryState.canReport(forcedCurated, prefs)) + } + + @Test + fun `a value stored by an older build without a source key is not trusted`() { + // Upgrades carry the flag but no key, and the flag alone is what went stale. + val prefs = FakePreferences().apply { lmsDirectoryCurated = true } + + assertFalse(LmsDirectoryState.isCurated(openCatalog, prefs)) + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `reconcile drops a value belonging to a different source`() { + val prefs = FakePreferences() + LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + + LmsDirectoryState.reconcile(otherCatalog, prefs) + + assertFalse(prefs.lmsDirectoryCurated) + assertTrue(prefs.lmsDirectorySourceKey.isEmpty()) + } + + @Test + fun `reconcile keeps a value belonging to the same source`() { + val prefs = FakePreferences() + LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + + LmsDirectoryState.reconcile(openCatalog, prefs) + + assertTrue(prefs.lmsDirectoryCurated) + assertTrue(LmsDirectoryState.isCurated(openCatalog, prefs)) + } + + @Test + fun `clear leaves nothing to be believed`() { + val prefs = FakePreferences() + LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + + LmsDirectoryState.clear(prefs) + + assertFalse(LmsDirectoryState.isCurated(openCatalog, prefs)) + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + } + + @Test + fun `a disabled feature reports nothing at all`() { + val prefs = FakePreferences() + LmsDirectoryState.rememberCurated(false, openCatalog, prefs) + + val off = LMSDirectoryConfig(enabled = false, directoryUrl = "https://registry.example.com") + assertFalse(LmsDirectoryState.canReport(off, prefs)) + } + + /** 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 lmsDirectoryCurated: Boolean = false + override var lmsDirectorySourceKey: String = "" + override var lmsHistory: List = emptyList() + + override suspend fun clearCorePreferences() = Unit + } +} 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 6e944ab48..3c2a3cca4 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 @@ -12,9 +12,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.fragment.app.Fragment -import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.data.storage.CorePreferences import org.openedx.core.ui.theme.OpenEdXTheme import org.openedx.foundation.presentation.rememberWindowSize import org.openedx.profile.presentation.profile.compose.ProfileView @@ -26,7 +24,6 @@ class ProfileFragment : Fragment() { private val viewModel: ProfileViewModel by viewModel() private val reportViewModel: ReportLmsViewModel by viewModel() - private val corePreferences: CorePreferences by inject() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -53,7 +50,7 @@ class ProfileFragment : Fragment() { uiMessage = uiMessage, refreshing = refreshing, // Curated/institution registries have no learner reporting. - showReportLms = viewModel.canReportLms && !corePreferences.lmsDirectoryCurated, + showReportLms = viewModel.canReportLms, onSettingsClick = { viewModel.profileRouter.navigateToSettings(requireActivity().supportFragmentManager) }, 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 af62e8f70..c3a1d8cc3 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 @@ -10,6 +10,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow 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 @@ -26,6 +28,7 @@ class ProfileViewModel( private val notifier: ProfileNotifier, private val analytics: ProfileAnalytics, private val config: Config, + private val corePreferences: CorePreferences, val profileRouter: ProfileRouter ) : BaseViewModel(resourceManager) { @@ -34,9 +37,15 @@ class ProfileViewModel( /** * Reporting belongs to the universal app. A build reading its list from a - * document has no service to post to, so the entry point stays hidden. + * document has no service to post to, and a curated catalog vouches for its + * own platforms, so in both cases the entry point stays hidden. + * + * Derived from the configured source every time it is read: the remembered + * "curated" answer only counts while it still belongs to the directory this + * build actually reads. */ - val canReportLms: Boolean get() = config.getLMSDirectoryConfig().supportsReporting + val canReportLms: Boolean + get() = LmsDirectoryState.canReport(config.getLMSDirectoryConfig(), corePreferences) private val _uiState: MutableStateFlow = MutableStateFlow(ProfileUIState.Loading) internal val uiState: StateFlow = _uiState.asStateFlow() 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 cbe3d7e2c..6c8898aa9 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,6 +24,8 @@ 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.foundation.presentation.UIMessage import org.openedx.foundation.presentation.captureUiMessage @@ -46,6 +48,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() @@ -83,6 +86,7 @@ class ProfileViewModelTest { notifier, analytics, config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns null @@ -104,6 +108,7 @@ class ProfileViewModelTest { notifier, analytics, config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns ProfileMocks.account.copy( @@ -127,6 +132,7 @@ class ProfileViewModelTest { notifier, analytics, config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns null @@ -148,6 +154,7 @@ class ProfileViewModelTest { notifier, analytics, config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns null @@ -171,6 +178,7 @@ class ProfileViewModelTest { notifier, analytics, config, + corePreferences, router ) coEvery { interactor.getCachedAccount() } returns null @@ -184,4 +192,74 @@ 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: Boolean = false, + rememberedFor: String = "" + ): Boolean { + every { config.getLMSDirectoryConfig() } returns directory + every { corePreferences.lmsDirectoryCurated } returns remembered + every { corePreferences.lmsDirectorySourceKey } returns rememberedFor + coEvery { interactor.getCachedAccount() } returns null + return ProfileViewModel( + interactor, + resourceManager, + notifier, + analytics, + config, + corePreferences, + router + ).canReportLms + } + + @Test + fun `an open catalog offers reporting`() { + val service = LMSDirectoryConfig(enabled = true, directoryUrl = "https://registry.example.com") + assertEquals(true, 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 = true, rememberedFor = service.sourceKey) + ) + } + + @Test + fun `a curated answer left by a different directory is ignored`() { + // The regression: the picker is skipped once a platform is selected, so a + // build repointed at an open catalog kept hiding the entry point. + val service = LMSDirectoryConfig(enabled = true, directoryUrl = "https://registry.example.com") + assertEquals( + true, + reportingOffered(service, remembered = true, rememberedFor = "service:https://old-registry.example.com") + ) + } + + @Test + fun `a disabled directory offers nothing`() { + assertEquals(false, reportingOffered(LMSDirectoryConfig(enabled = false))) + } } From ff1292f6bcf26aab66d68c1f3f6668ac83dab20a Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:22:11 +0300 Subject: [PATCH 8/9] fix: hide reporting until the directory has actually said what it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app remembered whether its catalog was curated as a boolean in preferences, so the absent value had to mean something, and it meant open. Two ordinary situations start there: an install upgraded from a build that stored no source, and a build newly pointed at a different registry. Both would show a learner "Report this LMS" for a catalog nobody had vouched for — and, the other way round, a stale flag could keep the entry point hidden for the life of a release. There are now three states. A document settles it locally, DIRECTORY_MODE settles it either way, and a live catalog is unknown until it answers for the exact source this build reads. Unknown shows nothing. The answer is refreshed at launch as well as by the platform picker, because a learner who has chosen a platform never sees that picker again. fetchConfigOrNull exists so that refresh can tell an unreachable registry from an answer: fetchConfig falls back to search, which is right for drawing the picker and wrong for recording what a catalog is. ProfileViewModel exposes the decision as a flow so the screen updates when the answer arrives. --- .../main/java/org/openedx/app/OpenEdXApp.kt | 29 +++ .../app/data/storage/PreferencesManager.kt | 8 +- .../lmsselection/SiteSelectionViewModel.kt | 29 +-- core/build.gradle | 1 + .../openedx/core/config/LMSDirectoryConfig.kt | 20 +- .../core/data/storage/CorePreferences.kt | 16 +- .../lmsdirectory/LmsDirectoryRepository.kt | 12 + .../core/lmsdirectory/LmsDirectoryState.kt | 125 +++++++--- .../lmsdirectory/LmsDirectoryStateTest.kt | 222 +++++++++++++----- .../presentation/profile/ProfileFragment.kt | 6 +- .../presentation/profile/ProfileViewModel.kt | 32 ++- .../profile/ProfileViewModelTest.kt | 41 +++- 12 files changed, 394 insertions(+), 147 deletions(-) diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 1e6cdeb0c..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 @@ -15,6 +19,8 @@ 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 @@ -25,6 +31,7 @@ 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() @@ -45,6 +52,7 @@ class OpenEdXApp : Application() { LmsDirectoryState.reconcile(config.getLMSDirectoryConfig(), corePreferences) LmsThemeController.apply(corePreferences.selectedLmsAccentColor) LmsThemeController.applyBackground(corePreferences.selectedLmsLoginBackgroundUrl) + refreshDirectoryMode() } else { LmsDirectoryState.clear(corePreferences) } @@ -81,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/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 4b361e162..31c541de3 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 @@ -97,7 +97,7 @@ class PreferencesManager( val SELECTED_LMS_LOGIN_BACKGROUND = stringPreferencesKey("selected_lms_login_background") val SELECTED_LMS_TITLE = stringPreferencesKey("selected_lms_title") - val LMS_DIRECTORY_CURATED = booleanPreferencesKey("lms_directory_curated") + val LMS_DIRECTORY_MODE = stringPreferencesKey("lms_directory_mode") val LMS_DIRECTORY_SOURCE_KEY = stringPreferencesKey("lms_directory_source_key") val LMS_HISTORY = stringPreferencesKey("lms_history") @@ -254,9 +254,9 @@ class PreferencesManager( get() = getValue(Keys.SELECTED_LMS_TITLE, "").ifEmpty { null } set(value) = setValue(Keys.SELECTED_LMS_TITLE, value.orEmpty()) - override var lmsDirectoryCurated: Boolean - get() = getValue(Keys.LMS_DIRECTORY_CURATED, false) - set(value) = setValue(Keys.LMS_DIRECTORY_CURATED, value) + override var lmsDirectoryMode: String + get() = getValue(Keys.LMS_DIRECTORY_MODE, "") + set(value) = setValue(Keys.LMS_DIRECTORY_MODE, value) override var lmsDirectorySourceKey: String get() = getValue(Keys.LMS_DIRECTORY_SOURCE_KEY, "") 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 index 147fe9dc2..0e0b39463 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt @@ -18,8 +18,8 @@ import okhttp3.OkHttpClient import okhttp3.Request import org.openedx.auth.R import org.openedx.core.config.Config -import org.openedx.core.config.LMSDirectoryConfig 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 @@ -74,26 +74,27 @@ class SiteSelectionViewModel( * cold start while the config round trip is still in flight. */ private fun seedCuratedFromConfig() { - val source = config.getLMSDirectoryConfig().source - val isDocument = source is LMSDirectoryConfig.Source.Document || - source is LMSDirectoryConfig.Source.BundledDocument - if (isDocument || config.getLMSDirectoryConfig().directoryMode.equals("curated", ignoreCase = true)) { - LmsDirectoryState.rememberCurated(true, config.getLMSDirectoryConfig(), corePreferences) + 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 remote = directoryRepository.fetchConfig() - val configuredMode = config.getLMSDirectoryConfig().directoryMode - val curated = remote.isCurated || configuredMode.equals("curated", ignoreCase = true) - // Share the mode so the Profile tab can hide "Report this LMS" in curated - // mode. Stamped with the source, so it is ignored if the build is later - // pointed at a different directory. - LmsDirectoryState.rememberCurated(curated, config.getLMSDirectoryConfig(), corePreferences) + 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 = remote.providerName) + it.copy(isCurated = curated, providerName = answer?.providerName.orEmpty()) } if (curated) { loadFeatured() 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/LMSDirectoryConfig.kt b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt index 0bb6bbb64..96d49973f 100644 --- a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt +++ b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt @@ -1,6 +1,7 @@ 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. @@ -106,13 +107,20 @@ data class LMSDirectoryConfig( } /** - * Whether this build lists a fixed set of platforms, as far as the config - * alone can say. A document always does; a service can be forced with - * DIRECTORY_MODE, but otherwise only the server knows. + * 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 isCuratedByConfiguration: Boolean + val configuredMode: LmsDirectoryMode? get() = when (source) { - is Source.Document, is Source.BundledDocument -> true - else -> directoryMode.trim().equals("curated", ignoreCase = true) + 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 cbcf5b81f..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 @@ -42,16 +42,18 @@ interface CorePreferences { var selectedLmsTitle: String? /** - * True when the LMS registry runs in curated/institution mode. In that mode the - * catalog is the org's own platforms, so there is no learner "Report this LMS". - * Set when the directory config is fetched; read by the Profile tab to hide reports. + * 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 lmsDirectoryCurated: Boolean + var lmsDirectoryMode: String /** - * Which directory source [lmsDirectoryCurated] was recorded against. A - * remembered answer means nothing once the build points somewhere else, and - * this is what lets a reader tell. + * 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 diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt index f7b314dea..951b51d7d 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt @@ -31,6 +31,18 @@ class LmsDirectoryRepository( } } + /** + * 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}") } diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt index 639403682..d8a8b81bf 100644 --- a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryState.kt @@ -1,69 +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 remembers about the directory between launches, and the rule that - * decides when to stop believing it. + * What the app knows about the directory it reads, and how long that knowledge is + * good for. * - * Only the server can say whether a live catalog is curated, and it says so on a - * screen the app skips once a platform has been picked. So the answer is - * remembered — and a remembered answer is worth exactly nothing after the build - * has been pointed at a different directory. Everything stored here is therefore - * stamped with the source it came from, and read back only when that still matches. + * 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 { - /** Record what the server said, against the source that said it. */ - fun rememberCurated( - curated: Boolean, - config: LMSDirectoryConfig, - preferences: CorePreferences - ) { - preferences.lmsDirectoryCurated = curated - preferences.lmsDirectorySourceKey = config.sourceKey - } + private val _revision = MutableStateFlow(0L) - /** Forget everything source-specific. Used when the feature is switched off. */ - fun clear(preferences: CorePreferences) { - preferences.lmsDirectoryCurated = false - preferences.lmsDirectorySourceKey = "" - } + /** Emits whenever the stored answer changes, so a screen can redraw. */ + val revision: StateFlow = _revision.asStateFlow() /** - * Whether this build shows a fixed list of platforms. + * What this build's directory is, as far as anyone currently knows. * - * The config decides on its own whenever it can. Only when it cannot — a live - * service with no forced mode — does the remembered answer matter, and then - * only if it was recorded against this same source. A value left over from a - * different directory is discarded rather than obeyed, which is what stops a - * build that used to be curated from staying curated after it is pointed at an - * open catalog. + * 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 isCurated(config: LMSDirectoryConfig, preferences: CorePreferences): Boolean { - val remembersThisSource = preferences.lmsDirectorySourceKey == config.sourceKey - return config.isCuratedByConfiguration || - (remembersThisSource && preferences.lmsDirectoryCurated) + 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 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. + * 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 && !isCurated(config, preferences) + 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 a remembered answer that belongs to a directory this build no longer - * reads. Safe to call on every launch; does nothing when the source is unchanged. + * 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/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt b/core/src/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt index 18dba74fa..98cb77437 100644 --- a/core/src/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt +++ b/core/src/test/java/org/openedx/core/lmsdirectory/LmsDirectoryStateTest.kt @@ -1,7 +1,10 @@ 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 @@ -10,10 +13,11 @@ import org.openedx.core.domain.model.AppConfig import org.openedx.core.domain.model.VideoSettings /** - * The persisted "curated" answer used to be believed forever. These cover the - * transitions where that was wrong: the build gets pointed somewhere else, the - * picker is skipped because a platform is already selected, and the stale value - * decides whether reporting appears. + * 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 { @@ -34,110 +38,212 @@ class LmsDirectoryStateTest { 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 open catalog reports until the server says it is curated`() { - val prefs = FakePreferences() + fun `an answered service offers reporting`() { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(openCatalog, prefs)) assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + } - LmsDirectoryState.rememberCurated(true, openCatalog, prefs) - assertTrue(LmsDirectoryState.isCurated(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 `a curated answer does not follow the build to another service`() { - // The bug this exists for: the picker is skipped once a platform is - // selected, so nothing would ever overwrite the old answer. - val prefs = FakePreferences() - LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + 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) - assertFalse(LmsDirectoryState.isCurated(otherCatalog, prefs)) - assertTrue(LmsDirectoryState.canReport(otherCatalog, prefs)) + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(openCatalog, prefs)) + assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) } @Test - fun `moving from a document to an open catalog restores reporting`() { - val prefs = FakePreferences() - LmsDirectoryState.rememberCurated(true, document, prefs) + 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 `moving from an open catalog to a document hides reporting`() { - val prefs = FakePreferences() - LmsDirectoryState.rememberCurated(false, openCatalog, prefs) + fun `an unreachable registry changes nothing`() = runTest { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) - assertTrue(LmsDirectoryState.isCurated(document, prefs)) - assertFalse(LmsDirectoryState.canReport(document, prefs)) - assertTrue(LmsDirectoryState.isCurated(bundled, prefs)) - assertFalse(LmsDirectoryState.canReport(bundled, 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 remembered open answer cannot unlock reporting on a curated build`() { - val prefs = FakePreferences() - val forcedCurated = LMSDirectoryConfig( - enabled = true, - directoryUrl = "https://registry.example.com", - directoryMode = "curated" - ) - LmsDirectoryState.rememberCurated(false, forcedCurated, prefs) + fun `a document is never refreshed`() = runTest { + var asked = false - assertTrue(LmsDirectoryState.isCurated(forcedCurated, prefs)) - assertFalse(LmsDirectoryState.canReport(forcedCurated, prefs)) + 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 `a value stored by an older build without a source key is not trusted`() { - // Upgrades carry the flag but no key, and the flag alone is what went stale. - val prefs = FakePreferences().apply { lmsDirectoryCurated = true } + 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) - assertFalse(LmsDirectoryState.isCurated(openCatalog, prefs)) assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) } + // Housekeeping + @Test fun `reconcile drops a value belonging to a different source`() { - val prefs = FakePreferences() - LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) LmsDirectoryState.reconcile(otherCatalog, prefs) - assertFalse(prefs.lmsDirectoryCurated) - assertTrue(prefs.lmsDirectorySourceKey.isEmpty()) + assertEquals(LmsDirectoryMode.UNKNOWN, LmsDirectoryState.mode(otherCatalog, prefs)) } @Test - fun `reconcile keeps a value belonging to the same source`() { - val prefs = FakePreferences() - LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + fun `reconcile leaves a matching value alone`() { + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) LmsDirectoryState.reconcile(openCatalog, prefs) - assertTrue(prefs.lmsDirectoryCurated) - assertTrue(LmsDirectoryState.isCurated(openCatalog, prefs)) + assertEquals(LmsDirectoryMode.SEARCH, LmsDirectoryState.mode(openCatalog, prefs)) } @Test fun `clear leaves nothing to be believed`() { - val prefs = FakePreferences() - LmsDirectoryState.rememberCurated(true, openCatalog, prefs) + LmsDirectoryState.remember(LmsDirectoryMode.SEARCH, openCatalog, prefs) LmsDirectoryState.clear(prefs) - assertFalse(LmsDirectoryState.isCurated(openCatalog, prefs)) - assertTrue(LmsDirectoryState.canReport(openCatalog, prefs)) + assertEquals(LmsDirectoryMode.UNKNOWN, LmsDirectoryState.mode(openCatalog, prefs)) + assertFalse(LmsDirectoryState.canReport(openCatalog, prefs)) } @Test - fun `a disabled feature reports nothing at all`() { - val prefs = FakePreferences() - LmsDirectoryState.rememberCurated(false, openCatalog, prefs) + 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) - val off = LMSDirectoryConfig(enabled = false, directoryUrl = "https://registry.example.com") - assertFalse(LmsDirectoryState.canReport(off, prefs)) + assertEquals(before + 2, LmsDirectoryState.revision.value) } /** In-memory preferences: only the two directory fields matter here. */ @@ -158,7 +264,7 @@ class LmsDirectoryStateTest { override var selectedLmsLogoUrl: String? = null override var selectedLmsLoginBackgroundUrl: String? = null override var selectedLmsTitle: String? = null - override var lmsDirectoryCurated: Boolean = false + override var lmsDirectoryMode: String = "" override var lmsDirectorySourceKey: String = "" override var lmsHistory: List = emptyList() 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 3c2a3cca4..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 @@ -43,14 +43,16 @@ class ProfileFragment : Fragment() { 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. - showReportLms = viewModel.canReportLms, + // 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) }, 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 c3a1d8cc3..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,8 +6,11 @@ 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 @@ -36,16 +39,29 @@ class ProfileViewModel( val isLmsDirectoryEnabled: Boolean get() = config.getLMSDirectoryConfig().isReachable /** - * Reporting belongs to the universal app. A build reading its list from a - * document has no service to post to, and a curated catalog vouches for its - * own platforms, so in both cases the entry point stays hidden. + * Whether to offer "Report this LMS". * - * Derived from the configured source every time it is read: the remembered - * "curated" answer only counts while it still belongs to the directory this - * build actually reads. + * 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: Boolean - get() = LmsDirectoryState.canReport(config.getLMSDirectoryConfig(), corePreferences) + 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/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index 6c8898aa9..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 @@ -27,6 +27,7 @@ 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 @@ -71,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 @@ -200,11 +206,11 @@ class ProfileViewModelTest { */ private fun reportingOffered( directory: LMSDirectoryConfig, - remembered: Boolean = false, + remembered: LmsDirectoryMode? = null, rememberedFor: String = "" ): Boolean { every { config.getLMSDirectoryConfig() } returns directory - every { corePreferences.lmsDirectoryCurated } returns remembered + every { corePreferences.lmsDirectoryMode } returns remembered?.name.orEmpty() every { corePreferences.lmsDirectorySourceKey } returns rememberedFor coEvery { interactor.getCachedAccount() } returns null return ProfileViewModel( @@ -215,13 +221,22 @@ class ProfileViewModelTest { config, corePreferences, router - ).canReportLms + ).canReportLms.value } @Test - fun `an open catalog offers reporting`() { + 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)) + 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 @@ -243,18 +258,22 @@ class ProfileViewModelTest { val service = LMSDirectoryConfig(enabled = true, directoryUrl = "https://registry.example.com") assertEquals( false, - reportingOffered(service, remembered = true, rememberedFor = service.sourceKey) + reportingOffered(service, remembered = LmsDirectoryMode.CURATED, rememberedFor = service.sourceKey) ) } @Test - fun `a curated answer left by a different directory is ignored`() { - // The regression: the picker is skipped once a platform is selected, so a - // build repointed at an open catalog kept hiding the entry point. + 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( - true, - reportingOffered(service, remembered = true, rememberedFor = "service:https://old-registry.example.com") + false, + reportingOffered( + service, + remembered = LmsDirectoryMode.SEARCH, + rememberedFor = "service:https://old-registry.example.com" + ) ) } From 28a5771715ec13d3db92465af8aeb57d6bc6f18c Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Thu, 20 Aug 2026 18:39:28 +0300 Subject: [PATCH 9/9] docs: say where the platform list comes from, and ship the feature off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were wrong for anyone reading this repository rather than running it. The dev config had the feature on and pointed at somebody's private deployment, so a checkout built a multi-tenant app aimed at a host the reader has no business talking to. All three environments now ship it off with no URL set, which is what a default should be. And the config never made it obvious that DIRECTORY_URL takes either kind of address. It does — a .json address is a document, anything else is a live registry — so the comment now says so with a working example of each, and Documentation/LMSDirectory.md spells out the document format field by field, including how to bundle one with its images for a build that never asks the network. A test pins the smallest file a person could reasonably write by hand, because iOS accepts exactly the same one. Also drops the boolean this feature used to store once an upgraded install has recorded a real answer, so nothing is left behind that a later reader could mistake for one. --- Documentation/LMSDirectory.md | 178 ++++++++++++++++++ .../app/data/storage/PreferencesManager.kt | 11 +- .../DocumentLmsDirectorySourceTest.kt | 31 +++ default_config/dev/config.yaml | 37 ++-- default_config/prod/config.yaml | 24 ++- default_config/stage/config.yaml | 24 ++- 6 files changed, 287 insertions(+), 18 deletions(-) create mode 100644 Documentation/LMSDirectory.md 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/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 31c541de3..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 @@ -98,6 +98,9 @@ class PreferencesManager( 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") @@ -256,7 +259,13 @@ class PreferencesManager( override var lmsDirectoryMode: String get() = getValue(Keys.LMS_DIRECTORY_MODE, "") - set(value) = setValue(Keys.LMS_DIRECTORY_MODE, value) + 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, "") diff --git a/core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt b/core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt index ea7b4856c..f84797e3d 100644 --- a/core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt +++ b/core/src/test/java/org/openedx/core/lmsdirectory/DocumentLmsDirectorySourceTest.kt @@ -113,6 +113,37 @@ class DocumentLmsDirectorySourceTest { 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 { diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index 300e321f0..0cc0eef16 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -94,18 +94,29 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false -# LMS Directory (multi-tenant): browse the Open edX platforms published by a site -# registry, re-theme to the chosen one, and sign in against it. Off by default. -# Where the app gets its list of Open edX platforms. -# Set exactly one source. A bundled file wins if both are set. -# DIRECTORY_URL a JSON document (…/directory.json), or the base URL of a -# service that answers /api/v1/directory -# DIRECTORY_FILE a JSON document in app/src/main/assets, for a build that -# ships its list and never asks the network for it -# Image fields in the document are either web addresses or the names of files in -# assets; anything that is not http(s) is read from the app. +# 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: true - DIRECTORY_URL: "https://openedx-lms.stepanok.com" + ENABLED: false + DIRECTORY_URL: "" DIRECTORY_FILE: "" - DIRECTORY_MODE: "search" + DIRECTORY_MODE: "" diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index 1c8c38a60..0cc0eef16 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -94,9 +94,29 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false -# LMS Directory (multi-tenant). Off by default; the app stays single-tenant. +# 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: "search" + DIRECTORY_MODE: "" diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index 1c8c38a60..0cc0eef16 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -94,9 +94,29 @@ UI_COMPONENTS: COURSE_DROPDOWN_NAVIGATION_ENABLED: false COURSE_UNIT_PROGRESS_ENABLED: false COURSE_DOWNLOAD_QUEUE_SCREEN: false -# LMS Directory (multi-tenant). Off by default; the app stays single-tenant. +# 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: "search" + DIRECTORY_MODE: ""