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/4] 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/4] 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/4] 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/4] 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