diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt index f0a71f713..9593c83a5 100644 --- a/app/src/main/java/org/openedx/app/AppActivity.kt +++ b/app/src/main/java/org/openedx/app/AppActivity.kt @@ -21,6 +21,7 @@ import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel import org.openedx.app.databinding.ActivityAppBinding import org.openedx.app.deeplink.DeepLink +import org.openedx.auth.presentation.lmsselection.LmsLandingFragment import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.core.data.storage.CorePreferences @@ -158,10 +159,10 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder { if (savedInstanceState == null) { when { corePreferencesManager.user == null -> { - val fragment = if (viewModel.isLogistrationEnabled && authCode == null) { - LogistrationFragment() - } else { - SignInFragment.newInstance(null, null) + val fragment = when { + viewModel.isLmsSelectionRequired && authCode == null -> LmsLandingFragment() + viewModel.isLogistrationEnabled && authCode == null -> LogistrationFragment() + else -> SignInFragment.newInstance(null, null) } addFragment(fragment) } diff --git a/app/src/main/java/org/openedx/app/AppRouter.kt b/app/src/main/java/org/openedx/app/AppRouter.kt index a511dc839..9bbd47d41 100644 --- a/app/src/main/java/org/openedx/app/AppRouter.kt +++ b/app/src/main/java/org/openedx/app/AppRouter.kt @@ -5,12 +5,17 @@ import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentTransaction import org.openedx.app.deeplink.HomeTab import org.openedx.auth.presentation.AuthRouter +import org.openedx.auth.presentation.lmsselection.LmsLandingFragment +import org.openedx.auth.presentation.lmsselection.SiteSelectionFragment import org.openedx.auth.presentation.logistration.LogistrationFragment import org.openedx.auth.presentation.restore.RestorePasswordFragment import org.openedx.auth.presentation.signin.SignInFragment import org.openedx.auth.presentation.signup.SignUpFragment import org.openedx.core.CalendarRouter import org.openedx.core.FragmentViewType +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.lmsdirectory.LmsThemeController import org.openedx.core.presentation.global.appupgrade.AppUpgradeRouter import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment import org.openedx.core.presentation.global.webview.SSOWebContentFragment @@ -61,7 +66,10 @@ import org.openedx.profile.presentation.video.VideoSettingsFragment import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.presentation.whatsnew.WhatsNewFragment -class AppRouter : +class AppRouter( + private val config: Config, + private val corePreferences: CorePreferences, +) : AuthRouter, DiscoveryRouter, DashboardRouter, @@ -103,6 +111,10 @@ class AppRouter : replaceFragmentWithBackStack(fm, LogistrationFragment.newInstance(courseId)) } + override fun navigateToLmsSelection(fm: FragmentManager) { + replaceFragmentWithBackStack(fm, SiteSelectionFragment()) + } + override fun navigateToDownloadQueue(fm: FragmentManager, descendants: List) { replaceFragmentWithBackStack(fm, DownloadQueueFragment.newInstance(descendants)) } @@ -406,10 +418,20 @@ class AppRouter : override fun restartApp(fm: FragmentManager, isLogistrationEnabled: Boolean) { fm.apply { clearBackStack(this) - if (isLogistrationEnabled) { - replaceFragment(fm, LogistrationFragment()) - } else { - replaceFragment(fm, SignInFragment.newInstance(null, null)) + when { + // LMS Directory: after logout the selection is cleared (see + // clearCorePreferences), so when the feature is reachable return to the + // platform picker instead of the stock sign-in — matches the app-launch + // path in AppActivity.setupInitialFragment and the iOS behavior. Reset the + // in-memory accent so the neutral landing isn't tinted by the old LMS. + config.getLMSDirectoryConfig().isReachable && + corePreferences.selectedBaseUrl.isNullOrBlank() -> { + LmsThemeController.clear() + replaceFragment(fm, LmsLandingFragment()) + } + + isLogistrationEnabled -> replaceFragment(fm, LogistrationFragment()) + else -> replaceFragment(fm, SignInFragment.newInstance(null, null)) } } } diff --git a/app/src/main/java/org/openedx/app/AppViewModel.kt b/app/src/main/java/org/openedx/app/AppViewModel.kt index bafddb19b..74939976c 100644 --- a/app/src/main/java/org/openedx/app/AppViewModel.kt +++ b/app/src/main/java/org/openedx/app/AppViewModel.kt @@ -57,6 +57,13 @@ class AppViewModel( val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled() + /** + * LMS Directory: on first launch (before sign-in) the learner must pick a platform. + * True only when the feature is on and nothing is selected yet. + */ + val isLmsSelectionRequired: Boolean + get() = config.getLMSDirectoryConfig().isReachable && preferencesManager.selectedBaseUrl.isNullOrBlank() + private var logoutHandledAt: Long = 0 val isBranchEnabled get() = config.getBranchConfig().enabled diff --git a/app/src/main/java/org/openedx/app/MainFragment.kt b/app/src/main/java/org/openedx/app/MainFragment.kt index 397216b74..c96697a57 100644 --- a/app/src/main/java/org/openedx/app/MainFragment.kt +++ b/app/src/main/java/org/openedx/app/MainFragment.kt @@ -1,5 +1,6 @@ package org.openedx.app +import android.content.res.ColorStateList import android.os.Bundle import android.view.Menu import android.view.View @@ -8,6 +9,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.toArgb +import androidx.core.content.ContextCompat import androidx.core.os.bundleOf import androidx.core.view.forEach import androidx.fragment.app.Fragment @@ -22,6 +25,7 @@ import org.openedx.app.deeplink.HomeTab import org.openedx.core.AppUpdateState import org.openedx.core.AppUpdateState.wasUpgradeDialogClosed import org.openedx.core.adapter.NavigationFragmentAdapter +import org.openedx.core.lmsdirectory.LmsThemeController import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment @@ -84,10 +88,31 @@ class MainFragment : Fragment(R.layout.fragment_main) { val tabList = createTabList(openTabArg) addMenuItems(menu, tabList) setupBottomNavListener(tabList) + applyLmsAccentTint() requireArguments().remove(ARG_OPEN_TAB) } + /** + * LMS Directory: the bottom bar is a View-based [BottomNavigationView], so the Compose + * accent theme doesn't reach it — its selected color stays the baked-in stock blue. + * When a platform is selected, tint the checked item with the LMS accent so the tab bar + * matches the rest of the re-themed app (and iOS). Unchecked keeps the stock grey. + */ + private fun applyLmsAccentTint() { + val accent = LmsThemeController.accentColor ?: return + val unchecked = ContextCompat.getColor(requireContext(), org.openedx.core.R.color.unchecked_tab_item) + val tint = ColorStateList( + arrayOf( + intArrayOf(android.R.attr.state_checked), + intArrayOf(-android.R.attr.state_checked), + ), + intArrayOf(accent.toArgb(), unchecked), + ) + binding.bottomNavView.itemIconTintList = tint + binding.bottomNavView.itemTextColor = tint + } + private fun createTabList(openTabArg: String): List Fragment>> { val learnFragmentFactory = { LearnFragment.newInstance( diff --git a/app/src/main/java/org/openedx/app/OpenEdXApp.kt b/app/src/main/java/org/openedx/app/OpenEdXApp.kt index 6524cde5d..ad9dcb816 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,16 @@ class OpenEdXApp : Application() { modules( appModule, networkingModule, - screenModule + screenModule, + lmsDirectoryModule ) } + // LMS Directory: re-apply the selected platform's brand color on cold start so + // the whole app is themed before the first screen composes. No-op when off. + if (config.getLMSDirectoryConfig().isReachable) { + LmsThemeController.apply(corePreferences.selectedLmsAccentColor) + LmsThemeController.applyBackground(corePreferences.selectedLmsLoginBackgroundUrl) + } if (config.getFirebaseConfig().enabled) { FirebaseApp.initializeApp(this) } diff --git a/app/src/main/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptor.kt b/app/src/main/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptor.kt new file mode 100644 index 000000000..301bfb8e3 --- /dev/null +++ b/app/src/main/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptor.kt @@ -0,0 +1,50 @@ +package org.openedx.app.data.networking + +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Interceptor +import okhttp3.Response +import org.openedx.core.data.storage.CorePreferences + +/** + * LMS Directory: routes every API request to the platform the learner selected. + * + * The Retrofit client is built once with the config host, but the selected LMS can + * differ (and is chosen after the client exists). This rewrites each request's + * scheme/host/port to [CorePreferences.selectedBaseUrl] on the fly. No selection + * (feature off, or a stock build) → requests pass through untouched. + */ +class BaseUrlOverrideInterceptor( + private val corePreferences: CorePreferences, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val override = corePreferences.selectedBaseUrl + val original = chain.request() + + if (override.isNullOrBlank()) { + return chain.proceed(original) + } + + val baseUrl = override.toHttpUrlOrNull() + val originalUrl = original.url + + val needsUpdate = baseUrl != null && ( + originalUrl.host != baseUrl.host || + originalUrl.port != baseUrl.port || + originalUrl.scheme != baseUrl.scheme + ) + + val requestToProcess = if (needsUpdate && baseUrl != null) { + val updatedUrl = originalUrl.newBuilder() + .scheme(baseUrl.scheme) + .host(baseUrl.host) + .port(baseUrl.port) + .build() + original.newBuilder().url(updatedUrl).build() + } else { + original + } + + return chain.proceed(requestToProcess) + } +} diff --git a/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt b/app/src/main/java/org/openedx/app/data/storage/PreferencesManager.kt index 48b0d58a1..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 @@ -85,6 +89,16 @@ class PreferencesManager( val LAST_WHATS_NEW_VERSION = stringPreferencesKey("last_whats_new_version") val LAST_REVIEW_VERSION = stringPreferencesKey("last_review_version") val WAS_POSITIVE_RATED = booleanPreferencesKey("app_was_positive_rated") + val SELECTED_BASE_URL = stringPreferencesKey("selected_base_url") + val SELECTED_LMS_ACCENT_COLOR = stringPreferencesKey("selected_lms_accent_color") + val SELECTED_OAUTH_CLIENT_ID = stringPreferencesKey("selected_oauth_client_id") + val SELECTED_FEEDBACK_EMAIL = stringPreferencesKey("selected_feedback_email") + val SELECTED_LMS_LOGO_URL = stringPreferencesKey("selected_lms_logo_url") + val SELECTED_LMS_LOGIN_BACKGROUND = + stringPreferencesKey("selected_lms_login_background") + val SELECTED_LMS_TITLE = stringPreferencesKey("selected_lms_title") + val LMS_DIRECTORY_CURATED = booleanPreferencesKey("lms_directory_curated") + val LMS_HISTORY = stringPreferencesKey("lms_history") fun calendarSyncDialogShown(courseName: String) = booleanPreferencesKey("calendar_sync_dialog_${courseName.replaceSpace("_")}") @@ -128,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) } } @@ -201,6 +225,49 @@ class PreferencesManager( get() = getValue(Keys.IS_RELATIVE_DATES_ENABLED, true) set(value) = setValue(Keys.IS_RELATIVE_DATES_ENABLED, value) + override var selectedBaseUrl: String? + get() = getValue(Keys.SELECTED_BASE_URL, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_BASE_URL, value.orEmpty()) + + override var selectedLmsAccentColor: String? + get() = getValue(Keys.SELECTED_LMS_ACCENT_COLOR, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_ACCENT_COLOR, value.orEmpty()) + + override var selectedOAuthClientId: String? + get() = getValue(Keys.SELECTED_OAUTH_CLIENT_ID, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_OAUTH_CLIENT_ID, value.orEmpty()) + + override var selectedFeedbackEmail: String? + get() = getValue(Keys.SELECTED_FEEDBACK_EMAIL, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_FEEDBACK_EMAIL, value.orEmpty()) + + override var selectedLmsLogoUrl: String? + get() = getValue(Keys.SELECTED_LMS_LOGO_URL, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_LOGO_URL, value.orEmpty()) + + override var selectedLmsLoginBackgroundUrl: String? + get() = getValue(Keys.SELECTED_LMS_LOGIN_BACKGROUND, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_LOGIN_BACKGROUND, value.orEmpty()) + + override var selectedLmsTitle: String? + get() = getValue(Keys.SELECTED_LMS_TITLE, "").ifEmpty { null } + set(value) = setValue(Keys.SELECTED_LMS_TITLE, value.orEmpty()) + + override var 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 267b73432..fcee60ab9 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -86,7 +86,7 @@ import org.openedx.core.DatabaseManager as IDatabaseManager val appModule = module { - single { Config(get()) } + single { Config(context = get(), corePreferences = get()) } single { PreferencesManager(get(), get()) } single { get() } single { get() } @@ -120,7 +120,7 @@ val appModule = module { single { DiscoveryNotifier() } single { CalendarNotifier() } - single { AppRouter() } + single { AppRouter(get(), get()) } single { get() } single { get() } single { get() } diff --git a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt index 6360e7fba..6c09ac913 100644 --- a/app/src/main/java/org/openedx/app/di/NetworkingModule.kt +++ b/app/src/main/java/org/openedx/app/di/NetworkingModule.kt @@ -5,6 +5,7 @@ import okhttp3.logging.HttpLoggingInterceptor import org.koin.dsl.module import org.openedx.app.data.api.NotificationsApi import org.openedx.app.data.networking.AppUpgradeInterceptor +import org.openedx.app.data.networking.BaseUrlOverrideInterceptor import org.openedx.app.data.networking.HandleErrorInterceptor import org.openedx.app.data.networking.HeadersInterceptor import org.openedx.app.data.networking.OauthRefreshTokenAuthenticator @@ -29,6 +30,8 @@ val networkingModule = module { writeTimeout(60, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) addInterceptor(HeadersInterceptor(get(), get(), get())) + // LMS Directory: redirect requests to the selected platform (no-op when off). + addInterceptor(BaseUrlOverrideInterceptor(get())) if (BuildConfig.DEBUG) { addNetworkInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY)) } diff --git a/app/src/main/java/org/openedx/app/di/ScreenModule.kt b/app/src/main/java/org/openedx/app/di/ScreenModule.kt index 1799dafc6..889402bdc 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -8,6 +8,7 @@ import org.openedx.app.AppViewModel import org.openedx.app.MainViewModel import org.openedx.auth.data.repository.AuthRepository import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.lmsselection.SiteSelectionViewModel import org.openedx.auth.presentation.logistration.LogistrationViewModel import org.openedx.auth.presentation.restore.RestorePasswordViewModel import org.openedx.auth.presentation.signin.SignInViewModel @@ -80,6 +81,7 @@ import org.openedx.profile.presentation.delete.DeleteProfileViewModel import org.openedx.profile.presentation.edit.EditProfileViewModel import org.openedx.profile.presentation.manageaccount.ManageAccountViewModel import org.openedx.profile.presentation.profile.ProfileViewModel +import org.openedx.profile.presentation.reportlms.ReportLmsViewModel import org.openedx.profile.presentation.settings.SettingsViewModel import org.openedx.profile.presentation.video.VideoSettingsViewModel import org.openedx.whatsnew.presentation.whatsnew.WhatsNewViewModel @@ -107,6 +109,8 @@ val screenModule = module { factory { AuthInteractor(get()) } factory { Validator() } + viewModel { SiteSelectionViewModel(get(), get(), get(), get()) } + viewModel { (courseId: String) -> LogistrationViewModel( courseId, @@ -213,6 +217,7 @@ val screenModule = module { resourceManager = get(), notifier = get(), analytics = get(), + config = get(), profileRouter = get(), ) } @@ -226,6 +231,7 @@ val screenModule = module { account ) } + viewModel { ReportLmsViewModel(get(), get(), get()) } viewModel { VideoSettingsViewModel(get(), get(), get(), get(), get()) } viewModel { (qualityType: String) -> VideoQualityViewModel( diff --git a/app/src/test/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptorTest.kt b/app/src/test/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptorTest.kt new file mode 100644 index 000000000..66392b2f1 --- /dev/null +++ b/app/src/test/java/org/openedx/app/data/networking/BaseUrlOverrideInterceptorTest.kt @@ -0,0 +1,63 @@ +package org.openedx.app.data.networking + +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import okhttp3.Interceptor +import okhttp3.Request +import okhttp3.Response +import org.junit.Assert.assertEquals +import org.junit.Test +import org.openedx.core.data.storage.CorePreferences + +/** + * Regression coverage for the LMS Directory login fix: with a platform selected, + * every request must be routed to that host (the Retrofit client is built once, + * before selection). No selection → the request is untouched. + */ +class BaseUrlOverrideInterceptorTest { + + private val corePreferences = mockk() + + private fun proceededRequest(selected: String?, requestUrl: String): Request { + every { corePreferences.selectedBaseUrl } returns selected + val original = Request.Builder().url(requestUrl).build() + val captured = slot() + val chain = mockk() + every { chain.request() } returns original + every { chain.proceed(capture(captured)) } returns mockk(relaxed = true) + + BaseUrlOverrideInterceptor(corePreferences).intercept(chain) + return captured.captured + } + + @Test + fun `rewrites host to the selected LMS`() { + val request = proceededRequest( + selected = "https://sandbox.openedx.org/", + requestUrl = "http://localhost:8000/oauth2/access_token", + ) + assertEquals("sandbox.openedx.org", request.url.host) + assertEquals("https", request.url.scheme) + assertEquals("/oauth2/access_token", request.url.encodedPath) + } + + @Test + fun `passes request through when nothing is selected`() { + val request = proceededRequest( + selected = null, + requestUrl = "http://localhost:8000/oauth2/access_token", + ) + assertEquals("localhost", request.url.host) + assertEquals(8000, request.url.port) + } + + @Test + fun `passes request through when selection is blank`() { + val request = proceededRequest( + selected = "", + requestUrl = "https://config-host.example.com/api/v1/x", + ) + assertEquals("config-host.example.com", request.url.host) + } +} diff --git a/app/src/test/java/org/openedx/app/lmsdirectory/LmsDetailDtoTest.kt b/app/src/test/java/org/openedx/app/lmsdirectory/LmsDetailDtoTest.kt new file mode 100644 index 000000000..3f009e393 --- /dev/null +++ b/app/src/test/java/org/openedx/app/lmsdirectory/LmsDetailDtoTest.kt @@ -0,0 +1,63 @@ +package org.openedx.app.lmsdirectory + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.openedx.core.lmsdirectory.LmsDetailDto + +/** + * The catalog summary can't log you in — only the detail carries the per-LMS OAuth + * client id and feedback email. This verifies the mapping the selection flow relies on. + */ +class LmsDetailDtoTest { + + @Test + fun `maps api fields to domain`() { + val detail = LmsDetailDto( + id = "4", + title = "Sandbox Env", + baseUrl = "https://sandbox.openedx.org", + logoUrl = "https://cdn.example.com/logo.png", + accentColor = "#6a2e7b", + api = LmsDetailDto.ApiDto( + hostUrl = "https://sandbox.openedx.org", + oauthClientId = "android", + feedbackEmail = "team@example.com", + ), + ).toDomain() + + assertEquals("android", detail.oauthClientId) + assertEquals("team@example.com", detail.feedbackEmail) + assertEquals("https://sandbox.openedx.org", detail.baseUrl) + assertEquals("#6a2e7b", detail.accentColor) + assertEquals("https://cdn.example.com/logo.png", detail.logoUrl) + } + + @Test + fun `blank api values fall back to null and base_url`() { + val detail = LmsDetailDto( + id = "1", + title = "Fallback", + baseUrl = "https://fallback.example.com", + api = LmsDetailDto.ApiDto(hostUrl = "", oauthClientId = "", feedbackEmail = null), + ).toDomain() + + // Blank host_url → the top-level base_url is used. + assertEquals("https://fallback.example.com", detail.baseUrl) + assertNull(detail.oauthClientId) + assertNull(detail.feedbackEmail) + } + + @Test + fun `null api yields base_url and null credentials`() { + val detail = LmsDetailDto( + id = "2", + title = "No API block", + baseUrl = "https://noapi.example.com", + api = null, + ).toDomain() + + assertEquals("https://noapi.example.com", detail.baseUrl) + assertNull(detail.oauthClientId) + } +} diff --git a/auth/build.gradle b/auth/build.gradle index 3bd660c15..39646d102 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -74,6 +74,9 @@ dependencies { implementation("io.opentelemetry:opentelemetry-api:$opentelemetry_version") implementation("io.opentelemetry:opentelemetry-context:$opentelemetry_version") + // LMS Directory: QR sign-in scanner + implementation "com.journeyapps:zxing-android-embedded:$zxing_embedded_version" + testImplementation "junit:junit:$junit_version" testImplementation "io.mockk:mockk:$mockk_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" diff --git a/auth/src/main/AndroidManifest.xml b/auth/src/main/AndroidManifest.xml new file mode 100644 index 000000000..4b63cf14d --- /dev/null +++ b/auth/src/main/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt index ac657271f..65b40a3a4 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/AuthRouter.kt @@ -15,6 +15,9 @@ interface AuthRouter { fun navigateToLogistration(fm: FragmentManager, courseId: String?) + /** LMS Directory: open the "Find my LMS" browse/search screen. */ + fun navigateToLmsSelection(fm: FragmentManager) + fun navigateToSignUp(fm: FragmentManager, courseId: String?, infoType: String?) fun navigateToRestorePassword(fm: FragmentManager) diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt new file mode 100644 index 000000000..3fd16d56c --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingFragment.kt @@ -0,0 +1,107 @@ +package org.openedx.auth.presentation.lmsselection + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.auth.R +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.config.Config +import org.openedx.core.ui.theme.OpenEdXTheme + +/** + * LMS Directory landing shown before sign-in when the feature is on and no platform + * has been chosen yet. Offers browse/search or QR sign-in. Reuses + * [SiteSelectionViewModel] for the QR path (validate + select + re-theme). + */ +class LmsLandingFragment : Fragment() { + + private val viewModel: SiteSelectionViewModel by viewModel() + private val router: AuthRouter by inject() + private val config: Config by inject() + + private val scanLauncher = registerForActivityResult(ScanContract()) { result -> + result.contents?.let { viewModel.onUrlScanned(it) } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val state by viewModel.uiState.collectAsState() + LaunchedEffect(Unit) { + viewModel.actions.collect { action -> + when (action) { + is SiteSelectionViewModel.SiteSelectionAction.Success -> + continueAfterSelection(action.preLoginDiscovery) + } + } + } + if (state.isCurated) { + // Curated / company mode: the registry serves a fixed list of platforms, + // so skip the "Choose your learning platform" intro and show the list + // straight away (matches iOS). No back button — this is the entry point. + SiteSelectionScreen( + state = state, + showBack = false, + callbacks = SiteSelectionCallbacks( + onBack = {}, + onQrClick = { launchQrScanner() }, + onSubmitManual = viewModel::onSubmitManual, + onQueryChanged = viewModel::onQueryChanged, + onCatalogItemSelected = viewModel::onCatalogItemSelected, + onCleanHistory = viewModel::onCleanHistory, + onHistoryItemSelected = viewModel::onHistoryItemSelected, + ), + ) + } else { + // Open (search) mode: tapping "Find my LMS" opens search; "Sign in with + // QR code" opens the camera scanner directly (no instructions screen). + LmsLandingScreen( + onFindClick = { router.navigateToLmsSelection(requireActivity().supportFragmentManager) }, + onQrClick = { launchQrScanner() }, + ) + } + } + } + } + + private fun launchQrScanner() { + val options = ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setPrompt(getString(R.string.auth_lms_qr_prompt)) + .setBeepEnabled(false) + .setOrientationLocked(false) + .setCaptureActivity(LmsQrScannerActivity::class.java) + scanLauncher.launch(options) + } + + private fun continueAfterSelection(preLoginDiscovery: Boolean) { + val fm = requireActivity().supportFragmentManager + when { + // The selected LMS is configured to start on the course Discovery screen — + // open it (native or webview per config) instead of sign-in, matching iOS. + preLoginDiscovery -> if (config.getDiscoveryConfig().isViewTypeWebView()) { + router.navigateToWebDiscoverCourses(fm, querySearch = "") + } else { + router.navigateToNativeDiscoverCourses(fm, querySearch = "") + } + + config.isPreLoginExperienceEnabled() -> router.navigateToLogistration(fm, courseId = null) + else -> router.navigateToSignIn(fm, courseId = null, infoType = null) + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt new file mode 100644 index 000000000..ebaf94296 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsLandingScreen.kt @@ -0,0 +1,105 @@ +package org.openedx.auth.presentation.lmsselection + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.openedx.auth.R +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.R as coreR + +// Weight of the gap between the welcome block and the action buttons; biases the +// logo/title group toward the upper third of the screen (mirrors the iOS landing). +private const val CONTENT_TO_ACTIONS_WEIGHT = 1.6f + +/** + * The LMS Directory entry point: welcome the learner and offer two ways in — + * browse/search the catalog ("Find my LMS") or scan a platform's QR code. + */ +@Composable +internal fun LmsLandingScreen( + onFindClick: () -> Unit, + onQrClick: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.weight(1f)) + Image( + painter = painterResource(id = coreR.drawable.core_ic_logo), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.appColors.primary), + modifier = Modifier.height(48.dp), + ) + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = stringResource(id = R.string.auth_lms_welcome_title), + style = MaterialTheme.appTypography.displaySmall, + color = MaterialTheme.appColors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(12.dp)) + Text( + text = stringResource(id = R.string.auth_lms_welcome_subtitle), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.weight(CONTENT_TO_ACTIONS_WEIGHT)) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.auth_lms_find_button), + onClick = onFindClick, + ) + TextButton(onClick = onQrClick) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = Icons.Filled.QrCodeScanner, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(id = R.string.auth_lms_qr_button), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + ) + } + } + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsQrScannerActivity.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsQrScannerActivity.kt new file mode 100644 index 000000000..2dd2d14eb --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/LmsQrScannerActivity.kt @@ -0,0 +1,62 @@ +package org.openedx.auth.presentation.lmsselection + +import android.app.Activity +import android.os.Bundle +import android.view.View +import com.journeyapps.barcodescanner.CaptureManager +import com.journeyapps.barcodescanner.DecoratedBarcodeView +import org.openedx.auth.R + +/** + * Full-screen QR scanner for the LMS Directory "Sign in with QR code" flow. + * + * Wraps zxing's [DecoratedBarcodeView] — which draws the framing viewfinder (the scan + * rectangle) — and adds a Close button so the learner can back out of the full-screen + * camera without relying on the system Back gesture. The scanned contents are returned + * to the caller through ScanContract, exactly like the default zxing CaptureActivity. + */ +class LmsQrScannerActivity : Activity() { + + private lateinit var capture: CaptureManager + private lateinit var barcodeScannerView: DecoratedBarcodeView + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.auth_qr_scanner) + barcodeScannerView = findViewById(R.id.barcode_scanner) + capture = CaptureManager(this, barcodeScannerView) + capture.initializeFromIntent(intent, savedInstanceState) + capture.decode() + findViewById(R.id.qr_close).setOnClickListener { finish() } + } + + override fun onResume() { + super.onResume() + capture.onResume() + } + + override fun onPause() { + super.onPause() + capture.onPause() + } + + override fun onDestroy() { + super.onDestroy() + capture.onDestroy() + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + capture.onSaveInstanceState(outState) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + // Forward to CaptureManager so the camera starts once permission is granted. + capture.onRequestPermissionsResult(requestCode, permissions, grantResults) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt new file mode 100644 index 000000000..b15280bd0 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionCallbacks.kt @@ -0,0 +1,15 @@ +package org.openedx.auth.presentation.lmsselection + +import org.openedx.core.lmsdirectory.LmsHistoryEntry +import org.openedx.core.lmsdirectory.LmsSummary + +/** UI callbacks for [SiteSelectionScreen]. */ +class SiteSelectionCallbacks( + val onBack: () -> Unit, + val onQrClick: () -> Unit, + val onSubmitManual: () -> Unit, + val onQueryChanged: (String) -> Unit, + val onCatalogItemSelected: (LmsSummary) -> Unit, + val onCleanHistory: () -> Unit, + val onHistoryItemSelected: (LmsHistoryEntry) -> Unit, +) diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt new file mode 100644 index 000000000..3fabf2081 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionFragment.kt @@ -0,0 +1,97 @@ +package org.openedx.auth.presentation.lmsselection + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.Fragment +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +import org.koin.android.ext.android.inject +import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.auth.R +import org.openedx.auth.presentation.AuthRouter +import org.openedx.core.config.Config +import org.openedx.core.ui.theme.OpenEdXTheme + +/** + * "Find my LMS" — browse or search the registry catalog (or type a URL). Picking a + * platform re-themes the app to it and continues to the normal sign-in flow. The + * search field's QR button opens the camera scanner directly, same as the landing. + */ +class SiteSelectionFragment : Fragment() { + + private val viewModel: SiteSelectionViewModel by viewModel() + private val router: AuthRouter by inject() + private val config: Config by inject() + + private val scanLauncher = registerForActivityResult(ScanContract()) { result -> + result.contents?.let { viewModel.onUrlScanned(it) } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ) = ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + OpenEdXTheme { + val state by viewModel.uiState.collectAsState() + + LaunchedEffect(Unit) { + viewModel.actions.collect { action -> + when (action) { + is SiteSelectionViewModel.SiteSelectionAction.Success -> + continueAfterSelection(action.preLoginDiscovery) + } + } + } + + // The QR button opens the camera scanner directly (no instructions screen). + SiteSelectionScreen( + state = state, + callbacks = SiteSelectionCallbacks( + onBack = { requireActivity().supportFragmentManager.popBackStack() }, + onQrClick = { launchQrScanner() }, + onSubmitManual = viewModel::onSubmitManual, + onQueryChanged = viewModel::onQueryChanged, + onCatalogItemSelected = viewModel::onCatalogItemSelected, + onCleanHistory = viewModel::onCleanHistory, + onHistoryItemSelected = viewModel::onHistoryItemSelected, + ) + ) + } + } + } + + private fun launchQrScanner() { + val options = ScanOptions() + .setDesiredBarcodeFormats(ScanOptions.QR_CODE) + .setPrompt(getString(R.string.auth_lms_qr_prompt)) + .setBeepEnabled(false) + .setOrientationLocked(false) + .setCaptureActivity(LmsQrScannerActivity::class.java) + scanLauncher.launch(options) + } + + private fun continueAfterSelection(preLoginDiscovery: Boolean) { + val fm = requireActivity().supportFragmentManager + when { + // The selected LMS is configured to start on the course Discovery screen — + // open it (native or webview per config) instead of sign-in, matching iOS. + preLoginDiscovery -> if (config.getDiscoveryConfig().isViewTypeWebView()) { + router.navigateToWebDiscoverCourses(fm, querySearch = "") + } else { + router.navigateToNativeDiscoverCourses(fm, querySearch = "") + } + + config.isPreLoginExperienceEnabled() -> router.navigateToLogistration(fm, courseId = null) + else -> router.navigateToSignIn(fm, courseId = null, infoType = null) + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt new file mode 100644 index 000000000..bb505bcc3 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionScreen.kt @@ -0,0 +1,383 @@ +package org.openedx.auth.presentation.lmsselection + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.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.lmsdirectory.LmsThemeController +import org.openedx.core.ui.BackBtn +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography + +@Composable +internal fun SiteSelectionScreen( + state: SiteSelectionUIState, + callbacks: SiteSelectionCallbacks, + // Hidden when this screen IS the entry point (curated mode landing) — there is + // nothing to go back to. Shown when pushed from the "Find my LMS" landing button. + showBack: Boolean = true, +) { + val scrollState = rememberScrollState() + + Scaffold( + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + containerColor = MaterialTheme.appColors.background, + topBar = { + Surface(color = MaterialTheme.appColors.background) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + if (showBack) { + BackBtn( + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 8.dp), + tint = MaterialTheme.appColors.textPrimary, + ) { callbacks.onBack() } + } + Text( + text = stringResource( + id = if (state.isCurated) { + R.string.auth_lms_curated_title + } else { + R.string.auth_lms_choose_title + } + ), + style = MaterialTheme.appTypography.titleMedium, + color = MaterialTheme.appColors.textPrimary, + ) + } + } + } + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = 24.dp, vertical = 16.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (!state.isCurated) { + SearchField(state, callbacks) + } + CatalogContent(state, callbacks) + } + } +} + +@Composable +private fun SearchField(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.query, + onValueChange = { callbacks.onQueryChanged(it) }, + singleLine = true, + placeholder = { + Text( + text = stringResource(id = R.string.auth_lms_search_hint), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textFieldHint, + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Filled.Search, + contentDescription = null, + tint = MaterialTheme.appColors.textFieldText, + ) + }, + trailingIcon = { + IconButton(onClick = { callbacks.onQrClick() }) { + Icon( + imageVector = Icons.Filled.QrCodeScanner, + contentDescription = stringResource(id = R.string.auth_lms_qr_button), + tint = MaterialTheme.appColors.primary, + ) + } + }, + textStyle = MaterialTheme.appTypography.bodyLarge, + shape = MaterialTheme.appShapes.textFieldShape, + colors = directoryTextFieldColors(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search, keyboardType = KeyboardType.Uri), + keyboardActions = KeyboardActions( + onSearch = { + keyboardController?.hide() + focusManager.clearFocus() + callbacks.onSubmitManual() + } + ), + ) +} + +@Composable +private fun CatalogContent(state: SiteSelectionUIState, callbacks: SiteSelectionCallbacks) { + when (val catalog = state.catalog) { + // Curated mode has no search/history — the org's fixed list loads straight away, + // so an idle state just means "still loading the list". + is CatalogState.Idle -> when { + state.isCurated -> LoadingRow() + state.history.isNotEmpty() -> HistorySection(history = state.history, callbacks = callbacks) + else -> PlaceholderText(stringResource(id = R.string.auth_lms_start_typing)) + } + is CatalogState.Loading -> LoadingRow() + is CatalogState.Empty -> PlaceholderText( + stringResource( + id = if (state.isCurated) R.string.auth_lms_curated_empty else R.string.auth_lms_no_results + ) + ) + is CatalogState.Error -> Text( + text = catalog.message, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.error, + ) + is CatalogState.Loaded -> Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + // "Results" is a search concept — in curated mode the list is just the platforms. + if (!state.isCurated) { + SectionHeader(text = stringResource(id = R.string.auth_lms_results)) + } + state.results.forEach { item -> + CatalogRow(item = item, onSelect = { callbacks.onCatalogItemSelected(item) }) + } + } + } +} + +/** + * "History" section shown when the search field is empty: a header with a + * "Clean history" action, then the recently selected platforms rendered with the + * same row visual as search results. Mirrors iOS `historySection`. + */ +@Composable +private fun HistorySection(history: List, callbacks: SiteSelectionCallbacks) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + SectionHeader(text = stringResource(id = R.string.auth_lms_history)) + Spacer(modifier = Modifier.weight(1f)) + Text( + text = stringResource(id = R.string.auth_lms_clean_history), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.primary, + modifier = Modifier.clickable { callbacks.onCleanHistory() }, + ) + } + history.forEach { entry -> + CatalogRow(entry = entry, onSelect = { callbacks.onHistoryItemSelected(entry) }) + } + } +} + +@Composable +private fun LoadingRow() { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(vertical = 8.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primary, + ) + PlaceholderText(stringResource(id = R.string.auth_lms_searching)) + } +} + +@Composable +private fun SectionHeader(text: String) { + Text( + text = text, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary, + ) +} + +@Composable +private fun CatalogRow(item: LmsSummary, onSelect: () -> Unit) { + CatalogRow( + title = item.title, + shortDescription = item.shortDescription, + baseUrl = item.baseUrl, + logoUrl = item.logoUrl, + accentColor = item.accentColor, + onSelect = onSelect, + ) +} + +@Composable +private fun CatalogRow(entry: LmsHistoryEntry, onSelect: () -> Unit) { + CatalogRow( + title = entry.title, + shortDescription = entry.shortDescription, + baseUrl = entry.baseUrl, + logoUrl = entry.logoUrl, + accentColor = entry.accentColor, + onSelect = onSelect, + ) +} + +@Composable +private fun CatalogRow( + title: String, + shortDescription: String, + baseUrl: String, + logoUrl: String?, + accentColor: String?, + onSelect: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect() }, + shape = MaterialTheme.appShapes.textFieldShape, + color = MaterialTheme.appColors.background, + border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier.padding(start = 12.dp, top = 10.dp, bottom = 10.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LmsRowLogo(logoUrl = logoUrl, title = title, accentColor = accentColor) + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + maxLines = 1, + style = MaterialTheme.appTypography.titleSmall, + color = MaterialTheme.appColors.textPrimary, + ) + if (shortDescription.isNotBlank()) { + Text( + text = shortDescription, + maxLines = 1, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.textSecondary, + ) + } + Text( + text = hostOf(baseUrl), + maxLines = 1, + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textSecondary, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + ) + } + } +} + +/** + * Platform logo for a catalog row. Loads the LMS's logo when available; otherwise + * falls back to a colored initial badge tinted with the platform's accent color — + * mirroring the iOS directory rows. + */ +@Composable +private fun LmsRowLogo(logoUrl: String?, title: String, accentColor: String?) { + val logoModifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(10.dp)) + if (!logoUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(logoUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = logoModifier, + ) + } else { + val accent = LmsThemeController.parseHexColor(accentColor) ?: MaterialTheme.appColors.primary + Box( + modifier = logoModifier.background(accent.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = title.trim().take(1).uppercase(), + style = MaterialTheme.appTypography.titleMedium, + color = accent, + ) + } + } +} + +@Composable +private fun directoryTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.background, + unfocusedContainerColor = MaterialTheme.appColors.background, + focusedBorderColor = MaterialTheme.appColors.textFieldBorder, + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + cursorColor = MaterialTheme.appColors.primary, +) + +@Composable +private fun PlaceholderText(text: String) { + Text( + text = text, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + modifier = Modifier.padding(vertical = 8.dp), + ) +} + +private fun hostOf(url: String): String { + return url.removePrefix("https://").removePrefix("http://").trimEnd('/') +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt new file mode 100644 index 000000000..dc41b2299 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionUIState.kt @@ -0,0 +1,28 @@ +package org.openedx.auth.presentation.lmsselection + +import org.openedx.core.lmsdirectory.LmsHistoryEntry +import org.openedx.core.lmsdirectory.LmsSummary + +data class SiteSelectionUIState( + val inputUrl: String = "", + val isLoading: Boolean = false, + val errorMessage: String? = null, + + // Registry catalog (search / curated browse) + val isCurated: Boolean = false, + val providerName: String = "", + val query: String = "", + val catalog: CatalogState = CatalogState.Idle, + val results: List = emptyList(), + + // Recently selected platforms, shown when the search field is empty. + val history: List = emptyList(), +) + +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..34039c6ec --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModel.kt @@ -0,0 +1,382 @@ +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.LmsHistoryEntry +import org.openedx.core.lmsdirectory.LmsSummary +import org.openedx.core.lmsdirectory.LmsThemeController +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager +import java.io.IOException +import java.util.concurrent.TimeUnit + +/** + * Drives the LMS Directory selection screen: search or browse the registry catalog, + * or enter an LMS URL by hand. Picking a platform persists it as the app's host, + * re-themes the app to its brand color, then signals the fragment to continue to + * sign-in. + */ +class SiteSelectionViewModel( + private val corePreferences: CorePreferences, + private val resourceManager: ResourceManager, + private val config: Config, + private val directoryRepository: LmsDirectoryRepository, +) : BaseViewModel(resourceManager) { + + private val validationClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(VALIDATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .readTimeout(VALIDATION_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .build() + + private val _uiState = MutableStateFlow( + SiteSelectionUIState( + inputUrl = corePreferences.selectedBaseUrl?.trimEnd('/') ?: "", + history = corePreferences.lmsHistory, + ) + ) + val uiState: StateFlow = _uiState + + private val _actions = MutableSharedFlow() + val actions: SharedFlow = _actions.asSharedFlow() + + private var searchJob: Job? = null + + init { + 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) + // 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) + } + 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) { + // The single search field doubles as URL entry (iOS parity): keep inputUrl in + // sync so submitting the field (IME "search") can connect to a typed-in URL. + _uiState.update { it.copy(query = value, inputUrl = value, errorMessage = null) } + searchJob?.cancel() + val trimmed = value.trim() + if (trimmed.isEmpty()) { + _uiState.update { it.copy(catalog = CatalogState.Idle, results = emptyList()) } + return + } + _uiState.update { it.copy(catalog = CatalogState.Loading) } + searchJob = viewModelScope.launch { + delay(SEARCH_DEBOUNCE_MS) + directoryRepository.search(trimmed) + .onSuccess { items -> applyResults(items) } + .onFailure { showCatalogError() } + } + } + + private fun applyResults(items: List) { + _uiState.update { + it.copy( + results = items, + catalog = if (items.isEmpty()) CatalogState.Empty else CatalogState.Loaded, + ) + } + } + + fun onCatalogItemSelected(item: LmsSummary) { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + // Fetch the full record: the catalog summary has no OAuth client id, and + // sign-in needs the platform's own registered mobile client to work. + val detail = directoryRepository.fetchDetail(item.id).getOrNull() + val normalized = normalizeUrl(detail?.baseUrl ?: item.baseUrl) + if (normalized == null) { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = resourceManager.getString(R.string.auth_lms_error_invalid_url), + ) + } + return@launch + } + selectLms( + baseUrl = normalized.newBuilder().encodedPath("/").build().toString(), + accentColor = detail?.accentColor ?: item.accentColor, + oauthClientId = detail?.oauthClientId, + feedbackEmail = detail?.feedbackEmail, + logoUrl = detail?.logoUrl ?: item.logoUrl, + title = detail?.title ?: item.title, + shortDescription = detail?.shortDescription ?: item.shortDescription, + loginBackgroundUrl = detail?.loginBackgroundUrl, + preLoginDiscovery = detail?.preLoginDiscovery ?: false, + ) + _actions.emit(SiteSelectionAction.Success(detail?.preLoginDiscovery ?: false)) + } + } + + /** + * Select an LMS from a scanned QR (the code holds the platform's base URL). Resolve + * the host against the registry so a scanned platform gets its full branding + OAuth + * client and routes to sign-in or pre-login Discovery per its settings — exactly like + * tapping it in the catalog. If the host isn't registered, fall back to validating the + * URL directly and continue to sign-in. + */ + fun onUrlScanned(rawUrl: String) { + val normalized = normalizeUrl(rawUrl) ?: run { + _uiState.update { + it.copy(errorMessage = resourceManager.getString(R.string.auth_lms_error_invalid_url)) + } + return + } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val match = directoryRepository.search(normalized.host).getOrNull() + ?.firstOrNull { it.baseUrl.toHttpUrlOrNull()?.host.equals(normalized.host, ignoreCase = true) } + if (match != null) { + val detail = directoryRepository.fetchDetail(match.id).getOrNull() + val resolved = normalizeUrl(detail?.baseUrl ?: match.baseUrl) ?: normalized + selectLms( + baseUrl = resolved.newBuilder().encodedPath("/").build().toString(), + accentColor = detail?.accentColor ?: match.accentColor, + oauthClientId = detail?.oauthClientId, + feedbackEmail = detail?.feedbackEmail, + logoUrl = detail?.logoUrl ?: match.logoUrl, + title = detail?.title ?: match.title, + shortDescription = detail?.shortDescription ?: match.shortDescription, + loginBackgroundUrl = detail?.loginBackgroundUrl, + preLoginDiscovery = detail?.preLoginDiscovery ?: false, + ) + _actions.emit(SiteSelectionAction.Success(detail?.preLoginDiscovery ?: false)) + return@launch + } + // Not in the registry — validate the URL directly, then continue to sign-in. + val confirmed = withContext(Dispatchers.IO) { confirmBaseUrl(normalized) } + if (confirmed) { + selectLms(normalized.newBuilder().encodedPath("/").build().toString(), accentColor = null) + _actions.emit(SiteSelectionAction.Success(preLoginDiscovery = false)) + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = resourceManager.getString(R.string.auth_lms_error_unable_confirm_url), + ) + } + } + } + } + + /** Clear the persisted directory history and drop it from the UI. */ + fun onCleanHistory() { + corePreferences.lmsHistory = emptyList() + _uiState.update { it.copy(history = emptyList()) } + } + + /** + * Re-select a platform straight from history. Its details were already validated + * when first added, so there's no network round-trip — commit and continue. + */ + fun onHistoryItemSelected(entry: LmsHistoryEntry) { + viewModelScope.launch { + selectLms( + baseUrl = entry.baseUrl, + accentColor = entry.accentColor, + oauthClientId = entry.oauthClientId, + feedbackEmail = entry.feedbackEmail, + logoUrl = entry.logoUrl, + title = entry.title, + shortDescription = entry.shortDescription, + loginBackgroundUrl = entry.loginBackgroundUrl, + preLoginDiscovery = entry.preLoginDiscovery, + ) + _actions.emit(SiteSelectionAction.Success(entry.preLoginDiscovery)) + } + } + + // endregion + + // region Manual URL entry (fallback when the registry is unreachable) + + fun onInputChanged(value: String) { + _uiState.update { it.copy(inputUrl = value, errorMessage = null) } + } + + fun onSubmitManual() { + val normalized = normalizeUrl(_uiState.value.inputUrl) + if (normalized == null) { + _uiState.update { + it.copy(errorMessage = resourceManager.getString(R.string.auth_lms_error_invalid_url)) + } + return + } + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + viewModelScope.launch { + val confirmed = withContext(Dispatchers.IO) { confirmBaseUrl(normalized) } + if (confirmed) { + selectLms(normalized.newBuilder().encodedPath("/").build().toString(), accentColor = null) + _actions.emit(SiteSelectionAction.Success(preLoginDiscovery = false)) + } else { + _uiState.update { + it.copy( + isLoading = false, + errorMessage = resourceManager.getString(R.string.auth_lms_error_unable_confirm_url), + ) + } + } + } + } + + /** + * Commit the chosen platform: persist host, OAuth client, feedback, branding, then + * re-theme immediately. Manual/QR entry passes only the URL, clearing the per-LMS + * OAuth override so sign-in falls back to the config client for unknown hosts. + */ + private fun selectLms( + baseUrl: String, + accentColor: String?, + oauthClientId: String? = null, + feedbackEmail: String? = null, + logoUrl: String? = null, + title: String? = null, + shortDescription: String = "", + loginBackgroundUrl: String? = null, + preLoginDiscovery: Boolean = false, + ) { + corePreferences.selectedBaseUrl = baseUrl + corePreferences.selectedLmsAccentColor = accentColor + corePreferences.selectedOAuthClientId = oauthClientId + corePreferences.selectedFeedbackEmail = feedbackEmail + corePreferences.selectedLmsLogoUrl = logoUrl + corePreferences.selectedLmsTitle = title + corePreferences.selectedLmsLoginBackgroundUrl = loginBackgroundUrl + LmsThemeController.apply(accentColor) + LmsThemeController.applyBackground(loginBackgroundUrl) + rememberInHistory( + LmsHistoryEntry( + baseUrl = baseUrl, + title = title.orEmpty(), + shortDescription = shortDescription, + logoUrl = logoUrl, + accentColor = accentColor, + oauthClientId = oauthClientId, + feedbackEmail = feedbackEmail, + loginBackgroundUrl = loginBackgroundUrl, + preLoginDiscovery = preLoginDiscovery, + ) + ) + } + + /** + * Prepend [entry] to the persisted history as the most recent, deduping by base URL + * (case- and trailing-slash-insensitive) and capping the list. Mirrors iOS. + */ + private fun rememberInHistory(entry: LmsHistoryEntry) { + val key = historyKey(entry.baseUrl) + val deduped = corePreferences.lmsHistory.filterNot { historyKey(it.baseUrl) == key } + val updated = (listOf(entry) + deduped).take(HISTORY_LIMIT) + corePreferences.lmsHistory = updated + _uiState.update { it.copy(history = updated) } + } + + private fun historyKey(url: String) = url.trimEnd('/').lowercase() + + private fun normalizeUrl(text: String): HttpUrl? { + val trimmed = text.trim() + if (trimmed.isEmpty()) { + return null + } + val withScheme = if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + trimmed + } else { + "https://$trimmed" + } + val candidate = withScheme.toHttpUrlOrNull() + return when { + candidate == null || candidate.host.isEmpty() -> null + else -> candidate.newBuilder() + .encodedPath("/") + .query(null) + .fragment(null) + .build() + } + } + + @Suppress("MagicNumber") + private fun confirmBaseUrl(base: HttpUrl): Boolean { + return try { + val registrationRequest = Request.Builder() + .get() + .url(base.newBuilder().addPathSegments(REGISTRATION_PATH).build()) + .build() + val registrationSuccessful = validationClient.newCall(registrationRequest).execute() + .use { response -> response.isSuccessful } + + if (registrationSuccessful) { + true + } else { + val oauthRequest = Request.Builder() + .get() + .url(base.newBuilder().addPathSegments(OAUTH_PATH).build()) + .build() + validationClient.newCall(oauthRequest).execute() + .use { response -> response.code in 200..399 } + } + } catch (_: IOException) { + false + } + } + + // endregion + + sealed interface SiteSelectionAction { + /** [preLoginDiscovery] true -> open the pre-login Discovery catalog instead of sign-in. */ + data class Success(val preLoginDiscovery: Boolean) : SiteSelectionAction + } + + private companion object { + const val REGISTRATION_PATH = "user_api/v1/account/registration/" + const val OAUTH_PATH = "oauth2/login/" + const val SEARCH_DEBOUNCE_MS = 400L + const val VALIDATION_TIMEOUT_SECONDS = 20L + const val HISTORY_LIMIT = 10 + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt index beebf4eaa..a3cbb0c9e 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/restore/RestorePasswordFragment.kt @@ -4,7 +4,6 @@ import android.content.res.Configuration import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -41,7 +40,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.ViewCompositionStrategy @@ -64,6 +62,7 @@ import org.openedx.core.R import org.openedx.core.presentation.global.appupgrade.AppUpgradeRequiredScreen import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.LmsHeaderImage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.displayCutoutForLandscape import org.openedx.core.ui.statusBarsInset @@ -186,13 +185,10 @@ private fun RestorePasswordScreen( ) } - Image( + LmsHeaderImage( modifier = Modifier .fillMaxWidth() - .height(200.dp), - painter = painterResource(id = R.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null + .height(200.dp) ) HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt index fc72523a8..2921d0e1f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInFragment.kt @@ -71,6 +71,12 @@ class SignInFragment : Fragment() { requireActivity().supportFragmentManager.popBackStackImmediate() } + AuthEvent.ChangeLmsClick -> { + viewModel.navigateToLmsSelection( + requireActivity().supportFragmentManager + ) + } + is AuthEvent.OpenLink -> viewModel.openLink( parentFragmentManager, event.links, @@ -124,4 +130,5 @@ internal sealed interface AuthEvent { object RegisterClick : AuthEvent object ForgotPasswordClick : AuthEvent object BackClick : AuthEvent + object ChangeLmsClick : AuthEvent } diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt index f7b56084c..95337b51c 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -26,4 +26,9 @@ internal data class SignInUIState( val showProgress: Boolean = false, val loginSuccess: Boolean = false, val agreement: RegistrationField? = null, + // LMS Directory: branding of the platform the learner picked (null when the + // feature is off or nothing selected — sign-in then shows the app's own logo). + val selectedLmsTitle: String? = null, + val selectedLmsLogoUrl: String? = null, + val selectedLmsLoginBackgroundUrl: String? = null, ) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt index 395cf7ec5..b73e2563f 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInViewModel.kt @@ -73,6 +73,21 @@ class SignInViewModel( isLogistrationEnabled = config.isPreLoginExperienceEnabled(), isRegistrationEnabled = config.isRegistrationEnabled(), agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(), + selectedLmsTitle = if (config.getLMSDirectoryConfig().isReachable) { + preferencesManager.selectedLmsTitle + } else { + null + }, + selectedLmsLogoUrl = if (config.getLMSDirectoryConfig().isReachable) { + preferencesManager.selectedLmsLogoUrl + } else { + null + }, + selectedLmsLoginBackgroundUrl = if (config.getLMSDirectoryConfig().isReachable) { + preferencesManager.selectedLmsLoginBackgroundUrl + } else { + null + }, ) ) internal val uiState: StateFlow = _uiState @@ -202,6 +217,10 @@ class SignInViewModel( logEvent(AuthAnalyticsEvent.REGISTER_CLICKED) } + fun navigateToLmsSelection(parentFragmentManager: FragmentManager) { + router.navigateToLmsSelection(parentFragmentManager) + } + fun navigateToForgotPassword(parentFragmentManager: FragmentManager) { router.navigateToRestorePassword(parentFragmentManager) logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED) diff --git a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt index f5b9bc867..80b1c7123 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -2,8 +2,10 @@ package org.openedx.auth.presentation.signin.compose import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -13,6 +15,7 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding @@ -41,6 +44,7 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag @@ -59,6 +63,8 @@ import androidx.compose.ui.tooling.preview.Devices import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import coil.request.ImageRequest import org.openedx.auth.R import org.openedx.auth.presentation.signin.AuthEvent import org.openedx.auth.presentation.signin.SignInUIState @@ -133,15 +139,30 @@ internal fun LoginScreen( ) } - Image( - modifier = - Modifier + // LMS Directory: brand the header background with the selected platform's own + // login background image, falling back to the default gradient header. + if (!state.selectedLmsLoginBackgroundUrl.isNullOrBlank()) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(state.selectedLmsLoginBackgroundUrl) + .crossfade(true) + .build(), + modifier = Modifier .fillMaxWidth() .fillMaxHeight(fraction = 0.3f), - painter = painterResource(id = coreR.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null, - ) + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + } else { + Image( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.3f), + painter = painterResource(id = coreR.drawable.core_top_header), + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + } HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) if (state.isLogistrationEnabled) { Box( @@ -163,7 +184,29 @@ internal fun LoginScreen( Modifier.padding(it), horizontalAlignment = Alignment.CenterHorizontally, ) { - SignInLogoView() + // LMS Directory: brand the header with the selected platform's logo. + if (!state.selectedLmsLogoUrl.isNullOrBlank()) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(fraction = 0.2f), + contentAlignment = Alignment.Center + ) { + AsyncImage( + model = ImageRequest.Builder(LocalContext.current) + .data(state.selectedLmsLogoUrl) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .padding(top = 20.dp, start = 24.dp, end = 24.dp) + .heightIn(max = 80.dp) + ) + } + } else { + SignInLogoView() + } Surface( color = MaterialTheme.appColors.background, shape = MaterialTheme.appShapes.screenBackgroundShape, @@ -197,7 +240,13 @@ internal fun LoginScreen( style = MaterialTheme.appTypography.titleSmall, ) } - + if (!state.selectedLmsTitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(16.dp)) + SelectedLmsBanner( + title = state.selectedLmsTitle, + onChange = { onEvent(AuthEvent.ChangeLmsClick) }, + ) + } Spacer(modifier = Modifier.height(24.dp)) AuthForm( buttonWidth, @@ -533,3 +582,51 @@ private fun SignInScreenTabletPreview() { ) } } + +@Composable +private fun SelectedLmsBanner( + title: String, + onChange: () -> Unit, +) { + Surface( + modifier = Modifier + .fillMaxWidth() + .testTag("selected_lms_container"), + shape = MaterialTheme.appShapes.textFieldShape, + color = MaterialTheme.appColors.background, + border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.5f)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource(id = R.string.auth_lms_selected_label), + style = MaterialTheme.appTypography.labelMedium, + color = MaterialTheme.appColors.textSecondary, + ) + Text( + text = title, + maxLines = 1, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + ) + } + Text( + modifier = Modifier + .noRippleClickable { onChange() } + .padding(start = 12.dp) + .testTag("change_lms_button"), + text = stringResource(id = R.string.auth_lms_change), + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.primary, + ) + } + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt index 5354a081a..1ca3026c8 100644 --- a/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt +++ b/auth/src/main/java/org/openedx/auth/presentation/signup/compose/SignUpView.kt @@ -3,7 +3,6 @@ package org.openedx.auth.presentation.signup.compose import android.content.res.Configuration import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -45,12 +44,10 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId @@ -73,6 +70,7 @@ import org.openedx.core.domain.model.RegistrationField import org.openedx.core.domain.model.RegistrationFieldType import org.openedx.core.ui.BackBtn import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.LmsHeaderImage import org.openedx.core.ui.OpenEdXButton import org.openedx.core.ui.SheetContent import org.openedx.core.ui.displayCutoutForLandscape @@ -234,13 +232,10 @@ internal fun SignUpView( } } - Image( + LmsHeaderImage( modifier = Modifier .fillMaxWidth() - .fillMaxHeight(fraction = 0.3f), - painter = painterResource(id = coreR.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null + .fillMaxHeight(fraction = 0.3f) ) HandleUIMessage(uiMessage = uiMessage, snackbarHostState = snackbarHostState) Column( diff --git a/auth/src/main/res/drawable/auth_ic_qr_close.xml b/auth/src/main/res/drawable/auth_ic_qr_close.xml new file mode 100644 index 000000000..d2364c84c --- /dev/null +++ b/auth/src/main/res/drawable/auth_ic_qr_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/auth/src/main/res/drawable/auth_qr_close_bg.xml b/auth/src/main/res/drawable/auth_qr_close_bg.xml new file mode 100644 index 000000000..587c07c72 --- /dev/null +++ b/auth/src/main/res/drawable/auth_qr_close_bg.xml @@ -0,0 +1,4 @@ + + + diff --git a/auth/src/main/res/layout/auth_qr_scanner.xml b/auth/src/main/res/layout/auth_qr_scanner.xml new file mode 100644 index 000000000..80be0bbae --- /dev/null +++ b/auth/src/main/res/layout/auth_qr_scanner.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 77401c27f..49310ddeb 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -43,4 +43,27 @@ %2$s]]> Show password Hide password + + + Choose your learning platform + Connect to any LMS in our library to explore courses or continue learning. + Find my LMS + Sign in with QR code + Point your camera at the QR code shown on your platform. + Close + Find your LMS + Choose your platform + No platforms are available yet. + Enter LMS URL or name + Start typing to find your platform. + Results + History + Clean history + Searching… + No matching platforms found. + Couldn\'t load the catalog. Check your connection and try again. + Enter a valid platform address. + We couldn\'t reach that platform. Check the address and try again. + Selected LMS + Change diff --git a/auth/src/test/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModelTest.kt new file mode 100644 index 000000000..f5df63e7f --- /dev/null +++ b/auth/src/test/java/org/openedx/auth/presentation/lmsselection/SiteSelectionViewModelTest.kt @@ -0,0 +1,110 @@ +package org.openedx.auth.presentation.lmsselection + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.openedx.core.config.Config +import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.lmsdirectory.DirectoryConfig +import org.openedx.core.lmsdirectory.LmsDetail +import org.openedx.core.lmsdirectory.LmsDirectoryRepository +import org.openedx.core.lmsdirectory.LmsSummary +import org.openedx.core.lmsdirectory.LmsThemeController +import org.openedx.foundation.system.ResourceManager + +/** + * Covers the QR path: scanning a platform's URL resolves it against the registry and + * emits the routing signal (sign-in vs pre-login Discovery) that matches the LMS's + * settings — the behavior that replaced the old "prefill the search box" QR flow. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SiteSelectionViewModelTest { + + private val dispatcher = StandardTestDispatcher() + private val corePreferences = mockk(relaxed = true) + private val resourceManager = mockk(relaxed = true) + private val config = mockk(relaxed = true) + private val repository = mockk(relaxed = true) + + @Before + fun setUp() { + Dispatchers.setMain(dispatcher) + coEvery { repository.fetchConfig() } returns DirectoryConfig.SEARCH_DEFAULT + } + + @After + fun tearDown() { + Dispatchers.resetMain() + LmsThemeController.clear() + } + + @Test + fun `onUrlScanned resolves registry detail and routes to discovery`() = runTest(dispatcher) { + val actions = givenScan(preLoginDiscovery = true) + assertEquals(1, actions.size) + val success = actions.first() as SiteSelectionViewModel.SiteSelectionAction.Success + assertTrue("Discovery LMS must route to pre-login Discovery", success.preLoginDiscovery) + // The scanned platform got its full record + brand, not a bare URL. + coVerify { repository.fetchDetail("4") } + } + + @Test + fun `onUrlScanned resolves registry detail and routes to sign-in`() = runTest(dispatcher) { + val actions = givenScan(preLoginDiscovery = false) + assertEquals(1, actions.size) + val success = actions.first() as SiteSelectionViewModel.SiteSelectionAction.Success + assertTrue("Non-discovery LMS must route to sign-in", !success.preLoginDiscovery) + } + + private fun kotlinx.coroutines.test.TestScope.givenScan( + preLoginDiscovery: Boolean, + ): List { + val summary = LmsSummary( + id = "4", + title = "Sandbox Env", + shortDescription = "", + baseUrl = "https://sandbox.openedx.org", + logoUrl = null, + accentColor = "#6a2e7b", + ) + val detail = LmsDetail( + id = "4", + title = "Sandbox Env", + shortDescription = "", + baseUrl = "https://sandbox.openedx.org", + logoUrl = null, + accentColor = "#6a2e7b", + oauthClientId = "client-id", + feedbackEmail = null, + loginBackgroundUrl = null, + preLoginDiscovery = preLoginDiscovery, + ) + coEvery { repository.search("sandbox.openedx.org") } returns Result.success(listOf(summary)) + coEvery { repository.fetchDetail("4") } returns Result.success(detail) + + val viewModel = SiteSelectionViewModel(corePreferences, resourceManager, config, repository) + val actions = mutableListOf() + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { + viewModel.actions.toList(actions) + } + + viewModel.onUrlScanned("https://sandbox.openedx.org") + advanceUntilIdle() + return actions + } +} diff --git a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt index ce6044eaf..0ee2a1a9f 100644 --- a/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt +++ b/auth/src/test/java/org/openedx/auth/presentation/signin/SignInViewModelTest.kt @@ -32,6 +32,7 @@ import org.openedx.core.Validator import org.openedx.core.config.Config import org.openedx.core.config.FacebookConfig import org.openedx.core.config.GoogleConfig +import org.openedx.core.config.LMSDirectoryConfig import org.openedx.core.config.MicrosoftConfig import org.openedx.core.data.storage.CalendarPreferences import org.openedx.core.data.storage.CorePreferences @@ -91,6 +92,7 @@ class SignInViewModelTest { every { appNotifier.notifier } returns emptyFlow() every { agreementProvider.getAgreement(true) } returns null every { config.isPreLoginExperienceEnabled() } returns false + every { config.getLMSDirectoryConfig() } returns LMSDirectoryConfig() every { config.isSocialAuthEnabled() } returns false every { config.getFacebookConfig() } returns FacebookConfig() every { config.getGoogleConfig() } returns GoogleConfig() diff --git a/build.gradle b/build.gradle index 889599361..450edc9e6 100644 --- a/build.gradle +++ b/build.gradle @@ -51,6 +51,9 @@ buildscript { // OpenTelemetry versions opentelemetry_version = '1.53.0' + // LMS Directory QR scanner + zxing_embedded_version = '4.3.0' + // Testing versions compose_ui_tooling = '1.7.8' mockk_version = '1.14.5' diff --git a/core/src/main/java/org/openedx/core/config/Config.kt b/core/src/main/java/org/openedx/core/config/Config.kt index 1f07f43ad..f81ede613 100644 --- a/core/src/main/java/org/openedx/core/config/Config.kt +++ b/core/src/main/java/org/openedx/core/config/Config.kt @@ -5,11 +5,15 @@ import com.google.gson.Gson import com.google.gson.JsonElement import com.google.gson.JsonObject import com.google.gson.JsonParser +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.AgreementUrls import java.io.InputStreamReader @Suppress("TooManyFunctions") -class Config(context: Context) { +class Config( + context: Context, + private val corePreferences: CorePreferences? = null, +) { private var configProperties: JsonObject = try { val inputStream = context.assets.open("config/config.json") @@ -24,10 +28,25 @@ class Config(context: Context) { return getString(APPLICATION_ID, "") } + /** + * The LMS the app talks to. With the LMS Directory feature on and a platform + * picked, that selection wins over the baked-in host; otherwise the config value + * is used. Off (default) → always the config value, i.e. stock behaviour. + */ fun getApiHostURL(): String { + if (getLMSDirectoryConfig().isReachable) { + val selected = corePreferences?.selectedBaseUrl + if (!selected.isNullOrBlank()) { + return selected + } + } return getString(API_HOST_URL) } + fun getLMSDirectoryConfig(): LMSDirectoryConfig { + return getObjectOrNewInstance(LMS_DIRECTORY, LMSDirectoryConfig::class.java) + } + fun getSSOURL(): String { return getString(SSO_URL, "") } @@ -40,6 +59,14 @@ class Config(context: Context) { } fun getOAuthClientId(): String { + // LMS Directory: sign in with the selected platform's own registered mobile + // OAuth client. Off (default) or no selection → the config value. + if (getLMSDirectoryConfig().isReachable) { + val selected = corePreferences?.selectedOAuthClientId + if (!selected.isNullOrBlank()) { + return selected + } + } return getString(OAUTH_CLIENT_ID) } @@ -52,6 +79,12 @@ class Config(context: Context) { } fun getFeedbackEmailAddress(): String { + if (getLMSDirectoryConfig().isReachable) { + val selected = corePreferences?.selectedFeedbackEmail + if (!selected.isNullOrBlank()) { + return selected + } + } return getString(FEEDBACK_EMAIL_ADDRESS) } @@ -217,6 +250,7 @@ class Config(context: Context) { private const val BRANCH = "BRANCH" private const val UI_COMPONENTS = "UI_COMPONENTS" private const val PLATFORM_NAME = "PLATFORM_NAME" + private const val LMS_DIRECTORY = "LMS_DIRECTORY" } enum class ViewType { diff --git a/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt new file mode 100644 index 000000000..2c61eab66 --- /dev/null +++ b/core/src/main/java/org/openedx/core/config/LMSDirectoryConfig.kt @@ -0,0 +1,34 @@ +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 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 9e42a5273..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 @@ -15,5 +16,44 @@ interface CorePreferences { var canResetAppDirectory: Boolean var isRelativeDatesEnabled: Boolean + /** + * Base URL of the LMS the learner picked in the LMS Directory, or null when + * none is selected (or the feature is off). When set, [org.openedx.core.config.Config.getApiHostURL] + * returns this instead of the baked-in host. + */ + var selectedBaseUrl: String? + + /** Accent color (hex, e.g. "#f15d49") of the selected LMS, used to re-theme the app. */ + var selectedLmsAccentColor: String? + + /** OAuth mobile client id of the selected LMS. Sign-in uses this instead of the config value. */ + var selectedOAuthClientId: String? + + /** Feedback email of the selected LMS. */ + var selectedFeedbackEmail: String? + + /** Logo URL of the selected LMS, shown on the sign-in screen. */ + var selectedLmsLogoUrl: String? + + /** Login background image URL of the selected LMS, shown behind the sign-in header. */ + var selectedLmsLoginBackgroundUrl: String? + + /** Human title of the selected LMS, shown in the sign-in "Change" banner. */ + var selectedLmsTitle: String? + + /** + * 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 new file mode 100644 index 000000000..a44fc5407 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryDtos.kt @@ -0,0 +1,94 @@ +package org.openedx.core.lmsdirectory + +import com.google.gson.annotations.SerializedName + +/** Wire DTOs for the registry catalog. Mirrors the FastAPI response shapes. */ + +data class DirectoryListResponse( + @SerializedName("items") val items: List = emptyList(), +) + +data class LmsSummaryDto( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String, + @SerializedName("short_description") val shortDescription: String? = null, + @SerializedName("base_url") val baseUrl: String, + @SerializedName("logo_url") val logoUrl: String? = null, + @SerializedName("accent_color") val accentColor: String? = null, +) { + fun toDomain() = LmsSummary( + id = id, + title = title, + shortDescription = shortDescription.orEmpty(), + baseUrl = baseUrl, + logoUrl = logoUrl, + accentColor = accentColor, + ) +} + +data class LmsDetailDto( + @SerializedName("id") val id: String, + @SerializedName("title") val title: String, + @SerializedName("short_description") val shortDescription: String? = null, + @SerializedName("base_url") val baseUrl: String, + @SerializedName("logo_url") val logoUrl: String? = null, + @SerializedName("accent_color") val accentColor: String? = null, + @SerializedName("api") val api: ApiDto? = null, + @SerializedName("theme") val theme: ThemeDto? = null, + @SerializedName("feature_flags") val featureFlags: FeatureFlagsDto? = null, +) { + data class ApiDto( + @SerializedName("host_url") val hostUrl: String? = null, + @SerializedName("oauth_client_id") val oauthClientId: String? = null, + @SerializedName("feedback_email") val feedbackEmail: String? = null, + ) + + data class ThemeDto( + @SerializedName("login_background_url") val loginBackgroundUrl: String? = null, + ) + + data class FeatureFlagsDto( + @SerializedName("pre_login_discovery") val preLoginDiscovery: Boolean = false, + ) + + fun toDomain() = LmsDetail( + id = id, + title = title, + shortDescription = shortDescription.orEmpty(), + baseUrl = api?.hostUrl?.ifBlank { null } ?: baseUrl, + logoUrl = logoUrl, + accentColor = accentColor, + oauthClientId = api?.oauthClientId?.ifBlank { null }, + feedbackEmail = api?.feedbackEmail?.ifBlank { null }, + loginBackgroundUrl = theme?.loginBackgroundUrl?.ifBlank { null }, + preLoginDiscovery = featureFlags?.preLoginDiscovery ?: false, + ) +} + +data class DirectoryConfigDto( + @SerializedName("directory_mode") val directoryMode: String = "search", + @SerializedName("provider_name") val providerName: String = "", + @SerializedName("provider_tagline") val providerTagline: String = "", +) { + fun toDomain() = DirectoryConfig( + directoryMode = directoryMode, + providerName = providerName, + providerTagline = providerTagline, + ) +} + +data class ReportRequestBody( + @SerializedName("lms_id") val lmsId: Int?, + @SerializedName("base_url") val baseUrl: String, + @SerializedName("category") val category: String, + @SerializedName("message") val message: String, + @SerializedName("reporter_email") val reporterEmail: String?, + @SerializedName("platform") val platform: String = "android", + @SerializedName("app_version") val appVersion: String, + @SerializedName("screenshot_base64") val screenshotBase64: String? = null, +) + +data class ReportResponse( + @SerializedName("id") val id: Int, + @SerializedName("status") val status: String, +) diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt new file mode 100644 index 000000000..93c5b9d3f --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/DirectoryModels.kt @@ -0,0 +1,90 @@ +package org.openedx.core.lmsdirectory + +/** + * Domain models for the LMS registry catalog. + * The registry is the same backend the iOS app talks to (`/api/v1/directory`). + */ + +data class LmsSummary( + val id: String, + val title: String, + val shortDescription: String, + val baseUrl: String, + val logoUrl: String?, + val accentColor: String?, +) + +/** + * Full record for one platform, fetched when the learner picks it. Carries the + * per-LMS OAuth client id and feedback email needed to actually sign in against it, + * plus branding (logo, accent) — the catalog summary alone can't log you in. + */ +data class LmsDetail( + val id: String, + val title: String, + val shortDescription: String = "", + val baseUrl: String, + val logoUrl: String?, + val accentColor: String?, + val oauthClientId: String?, + val feedbackEmail: String?, + val loginBackgroundUrl: String?, + /** When true, the app opens the pre-login course Discovery screen instead of sign-in. */ + val preLoginDiscovery: Boolean = false, +) + +/** + * A platform the learner previously opened. Persisted (Gson JSON) so the directory + * screen can show a "History" section when the search field is empty — mirrors iOS. + * Carries the full detail needed to re-select without re-validating over the network. + */ +data class LmsHistoryEntry( + val baseUrl: String, + val title: String, + val shortDescription: String = "", + val logoUrl: String? = null, + val accentColor: String? = null, + val oauthClientId: String? = null, + val feedbackEmail: String? = null, + val loginBackgroundUrl: String? = null, + val preLoginDiscovery: Boolean = false, +) + +data class DirectoryConfig( + val directoryMode: String, + val providerName: String, + val providerTagline: String, +) { + val isCurated: Boolean get() = directoryMode.equals("curated", ignoreCase = true) + + companion object { + val SEARCH_DEFAULT = DirectoryConfig( + directoryMode = "search", + providerName = "", + providerTagline = "", + ) + } +} + +/** + * Why a learner flags an LMS. Moderation reasons (trust & safety), not tech + * support. Raw values match the registry's categories. + */ +enum class ReportCategory(val apiValue: String) { + INAPPROPRIATE("inappropriate"), + SCAM("scam"), + IMPERSONATION("impersonation"), + SPAM("spam"), + BROKEN("broken"), + OTHER("other"), +} + +data class ReportDraft( + val lmsId: String?, + val baseUrl: String, + val category: ReportCategory, + val message: String, + val reporterEmail: String?, + /** A compressed screenshot as base64 (no data: prefix), or null. */ + val screenshotBase64: String? = null, +) diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt new file mode 100644 index 000000000..785a7a03b --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryApi.kt @@ -0,0 +1,26 @@ +package org.openedx.core.lmsdirectory + +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import retrofit2.http.Query + +/** Retrofit interface for the LMS registry (same backend as the iOS app). */ +interface LmsDirectoryApi { + + @GET("api/v1/config") + suspend fun getConfig(): DirectoryConfigDto + + @GET("api/v1/directory") + suspend fun search( + @Query("q") query: String? = null, + @Query("featured") featured: Boolean? = null, + ): DirectoryListResponse + + @GET("api/v1/directory/{id}") + suspend fun detail(@Path("id") id: String): LmsDetailDto + + @POST("api/v1/reports") + suspend fun submitReport(@Body body: ReportRequestBody): ReportResponse +} diff --git a/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryModule.kt new file mode 100644 index 000000000..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..a9ac5347b --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsDirectoryRepository.kt @@ -0,0 +1,53 @@ +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}") } + + /** 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( + 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..5de895521 --- /dev/null +++ b/core/src/main/java/org/openedx/core/lmsdirectory/LmsThemeController.kt @@ -0,0 +1,54 @@ +package org.openedx.core.lmsdirectory + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color + +/** + * Holds the accent color the app re-themes to when a learner picks an LMS from the + * directory. [OpenEdXTheme][org.openedx.core.ui.theme.OpenEdXTheme] reads [accentColor] + * and, when present, tints the palette's accent-driven surfaces (buttons, primary). + * + * Backed by [mutableStateOf] so setting it recomposes the theme. The app seeds it at + * launch from the persisted selection and updates it the moment a platform is chosen. + */ +object LmsThemeController { + + var accentColor by mutableStateOf(null) + private set + + /** + * The selected platform's login background image URL. Drives the branded header on + * the auth (sign-in/register/reset) and settings screens, mirroring iOS's + * `LmsHeaderBackground`. Null (default build / no custom image) keeps the stock header. + */ + var loginBackgroundUrl by mutableStateOf(null) + private set + + /** Apply a hex color like "#f15d49". Invalid or blank input clears the override. */ + fun apply(hex: String?) { + accentColor = parseHexColor(hex) + } + + /** Apply the selected LMS's login background image URL (blank/null clears it). */ + fun applyBackground(url: String?) { + loginBackgroundUrl = url?.takeIf { it.isNotBlank() } + } + + fun clear() { + accentColor = null + loginBackgroundUrl = null + } + + @Suppress("MagicNumber", "ReturnCount") + fun parseHexColor(hex: String?): Color? { + val raw = hex?.trim()?.removePrefix("#") ?: return null + if (raw.length != 6 && raw.length != 8) return null + val value = raw.toLongOrNull(16) ?: return null + return when (raw.length) { + 6 -> Color(0xFF000000 or value) + else -> Color(value) + } + } +} diff --git a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt index 1351662eb..77e17b7f2 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeExtensions.kt @@ -37,7 +37,10 @@ import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import coil.request.ImageRequest import org.openedx.core.R +import org.openedx.core.lmsdirectory.LmsThemeController import org.openedx.core.presentation.global.InsetHolder const val KEYBOARD_VISIBILITY_THRESHOLD = 0.15f @@ -172,9 +175,24 @@ fun PagerState.calculateCurrentOffsetForPage(page: Int): Float { } fun Modifier.settingsHeaderBackground(): Modifier = composed { + // LMS Directory: brand the header with the selected platform's login background image, + // falling back to the stock gradient header (matches iOS's LmsHeaderBackground). + val backgroundUrl = LmsThemeController.loginBackgroundUrl + val painter = if (!backgroundUrl.isNullOrBlank()) { + rememberAsyncImagePainter( + model = ImageRequest.Builder(LocalContext.current) + .data(backgroundUrl) + .placeholder(R.drawable.core_top_header) + .error(R.drawable.core_top_header) + .crossfade(true) + .build() + ) + } else { + painterResource(id = R.drawable.core_top_header) + } return@composed this .paint( - painter = painterResource(id = R.drawable.core_top_header), + painter = painter, contentScale = ContentScale.FillWidth, alignment = Alignment.TopCenter ) diff --git a/core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt b/core/src/main/java/org/openedx/core/ui/LmsHeaderImage.kt new file mode 100644 index 000000000..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 ec7997c72..fce872875 100644 --- a/core/src/main/java/org/openedx/core/ui/theme/Theme.kt +++ b/core/src/main/java/org/openedx/core/ui/theme/Theme.kt @@ -10,6 +10,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import org.openedx.core.lmsdirectory.LmsThemeController internal val LocalAppColors = staticCompositionLocalOf { error("No AppColors provided") @@ -223,11 +225,14 @@ val MaterialTheme.appColors: AppColors @OptIn(ExperimentalFoundationApi::class) @Composable fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { - val colors = if (darkTheme) { + val basePalette = if (darkTheme) { DarkColorPalette } else { LightColorPalette } + // LMS Directory: re-tint accent surfaces to the selected platform's brand color. + // Null (default / stock build) leaves the baked-in palette untouched. + val colors = LmsThemeController.accentColor?.let { basePalette.withAccent(it) } ?: basePalette MaterialTheme( colorScheme = colors.material3, @@ -240,3 +245,33 @@ fun OpenEdXTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composabl ) } } + +/** + * Returns a copy of this palette with the accent-driven surfaces re-tinted to + * [accent] — the primary/secondary buttons, the Material3 primary/tertiary roles, + * the accent text, and every link/interactive accent role (mirroring iOS's + * LMSThemeApplier: accentColor/infoColor, the outlined secondary-button text & + * border, and the toggle switch). This drives the SignIn "Change" / "Register" + * (primary) and "Forgot password" (infoVariant) text links, plus page indicators + * and toggles, so the whole app adopts the platform's brand color. Everything else + * (backgrounds, body text, on-button text, borders) is preserved so the app keeps + * its light/dark identity and text-on-surface readability. + */ +private fun AppColors.withAccent(accent: Color): AppColors { + return copy( + material3 = material3.copy( + primary = accent, + tertiary = accent, + surfaceTint = accent, + ), + textAccent = accent, + primaryButtonBackground = accent, + secondaryButtonBackground = accent, + secondaryButtonBorder = accent, + secondaryButtonBorderedText = accent, + bottomSheetToggle = accent, + info = accent, + infoVariant = accent, + progressBarColor = accent, + ) +} diff --git a/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..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 @@ -6,18 +6,27 @@ 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 +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.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) @@ -36,12 +45,15 @@ 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, + // Curated/institution registries have no learner reporting. + showReportLms = viewModel.isLmsDirectoryEnabled && !corePreferences.lmsDirectoryCurated, onSettingsClick = { viewModel.profileRouter.navigateToSettings(requireActivity().supportFragmentManager) }, @@ -52,12 +64,25 @@ class ProfileFragment : Fragment() { requireParentFragment().parentFragmentManager ) } + ProfileViewAction.ReportLmsClick -> { + showReportSheet = true + } ProfileViewAction.SwipeRefresh -> { viewModel.updateAccount() } } } ) + + if (showReportSheet) { + ReportLmsSheet( + viewModel = reportViewModel, + onDismiss = { + showReportSheet = false + reportViewModel.reset() + }, + ) + } } } } diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileViewModel.kt index 38c681bb5..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 @@ -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().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 91fd10d9e..370f4df31 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/compose/ProfileView.kt @@ -61,6 +61,7 @@ internal fun ProfileView( uiState: ProfileUIState, uiMessage: UIMessage?, refreshing: Boolean, + showReportLms: Boolean = false, onAction: (ProfileViewAction) -> Unit, onSettingsClick: () -> Unit ) { @@ -153,6 +154,17 @@ internal fun ProfileView( borderColor = MaterialTheme.appColors.primaryButtonBackground, textColor = MaterialTheme.appColors.textAccent ) + if (showReportLms) { + OpenEdXOutlinedButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource( + id = org.openedx.profile.R.string.profile_report_lms_button + ), + onClick = { onAction(ProfileViewAction.ReportLmsClick) }, + borderColor = MaterialTheme.appColors.primaryButtonBackground, + textColor = MaterialTheme.appColors.textAccent + ) + } Spacer(modifier = Modifier.height(12.dp)) } } @@ -204,5 +216,6 @@ private fun ProfileScreenTabletPreview() { internal interface ProfileViewAction { object EditAccountClick : ProfileViewAction + object ReportLmsClick : ProfileViewAction object SwipeRefresh : ProfileViewAction } diff --git a/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt new file mode 100644 index 000000000..e2cc20e6d --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsSheet.kt @@ -0,0 +1,415 @@ +package org.openedx.profile.presentation.reportlms + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import android.util.Base64 +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.RadioButtonChecked +import androidx.compose.material.icons.filled.RadioButtonUnchecked +import androidx.compose.material.icons.outlined.AddPhotoAlternate +import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.openedx.core.lmsdirectory.ReportCategory +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.theme.appColors +import org.openedx.core.ui.theme.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.profile.R + +private const val MAX_SCREENSHOT_DIMENSION = 1280 +private const val SCREENSHOT_JPEG_QUALITY = 60 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ReportLmsSheet( + viewModel: ReportLmsViewModel, + onDismiss: () -> Unit, +) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + val context = LocalContext.current + // Present as a bottom sheet that slides up from the bottom — matches iOS's + // native "Report a problem" sheet. skipPartiallyExpanded so the tall form + // opens fully expanded instead of at a half-height detent. + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + val pickImageLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.PickVisualMedia() + ) { uri: Uri? -> + if (uri != null) { + val bitmap = decodeSampledBitmap(context, uri) + if (bitmap != null) { + val base64 = bitmap.toBase64Jpeg() + viewModel.onScreenshotPicked(base64, bitmap.asImageBitmap()) + } + } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + containerColor = MaterialTheme.appColors.background, + dragHandle = { BottomSheetDefaults.DragHandle() }, + ) { + if (state.submitted) { + SuccessContent(host = viewModel.displayHost, onDismiss = onDismiss) + } else { + FormContent( + state = state, + subtitle = viewModel.displayHost, + onCategoryChanged = viewModel::onCategoryChanged, + onMessageChanged = viewModel::onMessageChanged, + onEmailChanged = viewModel::onEmailChanged, + onAttachClick = { + pickImageLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + }, + onRemoveScreenshot = viewModel::onScreenshotRemoved, + onSubmit = viewModel::submit, + ) + } + } +} + +@Composable +private fun FormContent( + state: ReportLmsUiState, + subtitle: String, + onCategoryChanged: (ReportCategory) -> Unit, + onMessageChanged: (String) -> Unit, + onEmailChanged: (String) -> Unit, + onAttachClick: () -> Unit, + onRemoveScreenshot: () -> Unit, + onSubmit: () -> Unit, +) { + Column( + modifier = Modifier + .padding(20.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + text = stringResource(id = R.string.profile_report_lms_title), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + ) + if (subtitle.isNotBlank()) { + Text( + text = subtitle, + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + ) + } + } + + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_whats_wrong)) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + ReportCategory.entries.forEach { category -> + CategoryRow( + category = category, + selected = state.category == category, + onSelect = { onCategoryChanged(category) }, + ) + } + } + } + + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_tell_us_more)) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 110.dp), + value = state.message, + onValueChange = onMessageChanged, + placeholder = { + Text( + text = stringResource(id = R.string.profile_report_lms_describe), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyLarge, + ) + }, + textStyle = MaterialTheme.appTypography.bodyLarge, + colors = reportTextFieldColors(), + ) + } + + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_screenshot)) { + val preview = state.screenshotPreview + if (preview != null) { + Image( + bitmap = preview, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 200.dp) + .clip(RoundedCornerShape(10.dp)), + ) + TextButton(onClick = onRemoveScreenshot) { + Text( + text = stringResource(id = R.string.profile_report_lms_remove_screenshot), + color = MaterialTheme.appColors.error, + ) + } + } else { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onAttachClick() }, + shape = MaterialTheme.appShapes.textFieldShape, + color = MaterialTheme.appColors.textFieldBackground, + border = BorderStroke(1.dp, MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.4f)), + ) { + Row( + modifier = Modifier.padding(14.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Outlined.AddPhotoAlternate, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + ) + Spacer(modifier = Modifier.size(8.dp)) + Text( + text = stringResource(id = R.string.profile_report_lms_attach_screenshot), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.primary, + ) + } + } + } + } + + ReportSection(title = stringResource(id = R.string.profile_report_lms_section_email)) { + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = state.email, + onValueChange = onEmailChanged, + singleLine = true, + placeholder = { + Text( + text = stringResource(id = R.string.profile_report_lms_email_hint), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyLarge, + ) + }, + textStyle = MaterialTheme.appTypography.bodyLarge, + colors = reportTextFieldColors(), + ) + } + + if (!state.error.isNullOrEmpty()) { + Text( + text = state.error, + style = MaterialTheme.appTypography.bodyMedium, + color = MaterialTheme.appColors.error, + ) + } + + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + enabled = state.canSubmit, + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonText, + onClick = onSubmit, + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center) { + Text( + text = stringResource(id = R.string.profile_report_lms_send), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge, + ) + if (state.submitting) { + Spacer(modifier = Modifier.size(12.dp)) + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.appColors.primaryButtonText, + ) + } + } + } + } +} + +/** Labelled section (grey title above content) — mirrors iOS's `section(title:)`. */ +@Composable +private fun ReportSection(title: String, content: @Composable () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + style = MaterialTheme.appTypography.labelLarge, + color = MaterialTheme.appColors.textSecondary, + ) + content() + } +} + +@Composable +private fun CategoryRow(category: ReportCategory, selected: Boolean, onSelect: () -> Unit) { + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect() }, + shape = MaterialTheme.appShapes.textFieldShape, + color = if (selected) { + MaterialTheme.appColors.primary.copy(alpha = 0.12f) + } else { + MaterialTheme.appColors.textFieldBackground + }, + border = BorderStroke( + 1.dp, + if (selected) { + MaterialTheme.appColors.primary.copy(alpha = 0.5f) + } else { + MaterialTheme.appColors.textFieldBorder.copy(alpha = 0.4f) + } + ), + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + modifier = Modifier.weight(1f), + text = stringResource(id = category.titleRes()), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textPrimary, + ) + Icon( + imageVector = if (selected) Icons.Filled.RadioButtonChecked else Icons.Filled.RadioButtonUnchecked, + contentDescription = null, + tint = if (selected) MaterialTheme.appColors.primary else MaterialTheme.appColors.textSecondary, + ) + } + } +} + +@Composable +private fun SuccessContent(host: String, onDismiss: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(28.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Filled.CheckCircle, + contentDescription = null, + tint = MaterialTheme.appColors.primary, + modifier = Modifier.size(52.dp), + ) + Text( + text = stringResource(id = R.string.profile_report_lms_thanks), + style = MaterialTheme.appTypography.titleLarge, + color = MaterialTheme.appColors.textPrimary, + ) + Text( + text = stringResource(id = R.string.profile_report_lms_thanks_body, host), + style = MaterialTheme.appTypography.bodyLarge, + color = MaterialTheme.appColors.textSecondary, + textAlign = TextAlign.Center, + ) + OpenEdXButton( + modifier = Modifier.fillMaxWidth(), + backgroundColor = MaterialTheme.appColors.secondaryButtonBackground, + textColor = MaterialTheme.appColors.primaryButtonText, + onClick = onDismiss, + ) { + Text( + text = stringResource(id = org.openedx.core.R.string.core_ok), + color = MaterialTheme.appColors.primaryButtonText, + style = MaterialTheme.appTypography.labelLarge, + ) + } + } +} + +@Composable +private fun reportTextFieldColors() = OutlinedTextFieldDefaults.colors( + focusedTextColor = MaterialTheme.appColors.textFieldText, + unfocusedTextColor = MaterialTheme.appColors.textFieldText, + focusedContainerColor = MaterialTheme.appColors.textFieldBackground, + unfocusedContainerColor = MaterialTheme.appColors.textFieldBackground, + focusedBorderColor = MaterialTheme.appColors.textFieldBorder, + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + cursorColor = MaterialTheme.appColors.primary, +) + +private fun ReportCategory.titleRes(): Int = when (this) { + ReportCategory.INAPPROPRIATE -> R.string.profile_report_lms_category_inappropriate + ReportCategory.SCAM -> R.string.profile_report_lms_category_scam + ReportCategory.IMPERSONATION -> R.string.profile_report_lms_category_impersonation + ReportCategory.SPAM -> R.string.profile_report_lms_category_spam + ReportCategory.BROKEN -> R.string.profile_report_lms_category_broken + ReportCategory.OTHER -> R.string.profile_report_lms_category_other +} + +/** Decode a picked image, downscaled so its largest side is ~[MAX_SCREENSHOT_DIMENSION]px. */ +private fun decodeSampledBitmap(context: android.content.Context, uri: Uri): Bitmap? { + return runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, bounds) + } + val largest = maxOf(bounds.outWidth, bounds.outHeight).coerceAtLeast(1) + var sample = 1 + while (largest / sample > MAX_SCREENSHOT_DIMENSION) { + sample *= 2 + } + val options = BitmapFactory.Options().apply { inSampleSize = sample } + context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, options) + } + }.getOrNull() +} + +private fun Bitmap.toBase64Jpeg(): String { + val stream = java.io.ByteArrayOutputStream() + compress(Bitmap.CompressFormat.JPEG, SCREENSHOT_JPEG_QUALITY, stream) + return Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP) +} diff --git a/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt new file mode 100644 index 000000000..9bc215013 --- /dev/null +++ b/profile/src/main/java/org/openedx/profile/presentation/reportlms/ReportLmsViewModel.kt @@ -0,0 +1,106 @@ +package org.openedx.profile.presentation.reportlms + +import androidx.compose.ui.graphics.ImageBitmap +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.openedx.core.config.Config +import org.openedx.core.lmsdirectory.LmsDirectoryRepository +import org.openedx.core.lmsdirectory.ReportCategory +import org.openedx.core.lmsdirectory.ReportDraft +import org.openedx.foundation.presentation.BaseViewModel +import org.openedx.foundation.system.ResourceManager +import org.openedx.profile.R + +/** + * Reports the currently signed-in LMS from the Profile tab. Reuses the registry + * submission pipeline ([LmsDirectoryRepository.submitReport]); the target is always + * the current platform ([Config.getApiHostURL]), so there is no lmsId. + */ +class ReportLmsViewModel( + private val config: Config, + private val directoryRepository: LmsDirectoryRepository, + private val resourceManager: ResourceManager, +) : BaseViewModel(resourceManager) { + + private val _uiState = MutableStateFlow(ReportLmsUiState()) + val uiState: StateFlow = _uiState + + /** + * Host of the LMS being reported (the current platform), for the sheet header and + * success message — e.g. "sandbox.openedx.org" rather than the full "https://…/", + * matching iOS's `lmsTitle`. + */ + val displayHost: String + get() { + val url = config.getApiHostURL() + return android.net.Uri.parse(url).host ?: url + } + + fun onCategoryChanged(category: ReportCategory) { + _uiState.update { it.copy(category = category) } + } + + fun onMessageChanged(value: String) { + _uiState.update { it.copy(message = value) } + } + + fun onEmailChanged(value: String) { + _uiState.update { it.copy(email = value) } + } + + fun onScreenshotPicked(base64: String, preview: ImageBitmap) { + _uiState.update { it.copy(screenshotBase64 = base64, screenshotPreview = preview) } + } + + fun onScreenshotRemoved() { + _uiState.update { it.copy(screenshotBase64 = null, screenshotPreview = null) } + } + + fun submit() { + val state = _uiState.value + if (!state.canSubmit) return + _uiState.update { it.copy(submitting = true, error = null) } + viewModelScope.launch { + directoryRepository.submitReport( + ReportDraft( + lmsId = null, + baseUrl = config.getApiHostURL(), + category = state.category, + message = state.message.trim(), + reporterEmail = state.email.ifBlank { null }, + screenshotBase64 = state.screenshotBase64, + ) + ).onSuccess { + _uiState.update { it.copy(submitting = false, submitted = true) } + }.onFailure { + _uiState.update { + it.copy( + submitting = false, + error = resourceManager.getString(R.string.profile_report_lms_error), + ) + } + } + } + } + + /** Reset back to a fresh form (called when the sheet is dismissed). */ + fun reset() { + _uiState.value = ReportLmsUiState() + } +} + +data class ReportLmsUiState( + val category: ReportCategory = ReportCategory.INAPPROPRIATE, + val message: String = "", + val email: String = "", + val screenshotBase64: String? = null, + val screenshotPreview: ImageBitmap? = null, + val submitting: Boolean = false, + val submitted: Boolean = false, + val error: String? = null, +) { + val canSubmit: Boolean get() = !submitting && message.isNotBlank() +} diff --git a/profile/src/main/res/values/strings.xml b/profile/src/main/res/values/strings.xml index d2ee6951e..170a1a654 100644 --- a/profile/src/main/res/values/strings.xml +++ b/profile/src/main/res/values/strings.xml @@ -80,4 +80,25 @@ Select calendar Local calendar + + Report this LMS + Report a problem + What\'s wrong? + Tell us more + Screenshot (optional) + Email (optional) + Describe what happened + Attach a screenshot + Remove screenshot + So we can follow up + Send report + Thanks for the heads-up + A moderator will look into %1$s shortly. + Couldn\'t send your report. Check your connection and try again. + Inappropriate or adult content + Scam or phishing + Pretends to be someone else + Spam or fake platform + Doesn\'t work or can\'t sign in + Something else diff --git a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt index 2b1cdc077..cbe3d7e2c 100644 --- a/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt +++ b/profile/src/test/java/org/openedx/profile/presentation/profile/ProfileViewModelTest.kt @@ -82,6 +82,7 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, router ) coEvery { interactor.getCachedAccount() } returns null @@ -102,6 +103,7 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, router ) coEvery { interactor.getCachedAccount() } returns ProfileMocks.account.copy( @@ -124,6 +126,7 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, router ) coEvery { interactor.getCachedAccount() } returns null @@ -144,6 +147,7 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, router ) coEvery { interactor.getCachedAccount() } returns null @@ -166,6 +170,7 @@ class ProfileViewModelTest { resourceManager, notifier, analytics, + config, router ) coEvery { interactor.getCachedAccount() } returns null