Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions app/src/main/java/org/openedx/app/AppActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.koin.android.ext.android.inject
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.openedx.app.databinding.ActivityAppBinding
import org.openedx.app.deeplink.DeepLink
import org.openedx.auth.presentation.lmsselection.LmsLandingFragment
import org.openedx.auth.presentation.logistration.LogistrationFragment
import org.openedx.auth.presentation.signin.SignInFragment
import org.openedx.core.data.storage.CorePreferences
Expand Down Expand Up @@ -158,10 +159,10 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {
if (savedInstanceState == null) {
when {
corePreferencesManager.user == null -> {
val fragment = if (viewModel.isLogistrationEnabled && authCode == null) {
LogistrationFragment()
} else {
SignInFragment.newInstance(null, null)
val fragment = when {
viewModel.isLmsSelectionRequired && authCode == null -> LmsLandingFragment()
viewModel.isLogistrationEnabled && authCode == null -> LogistrationFragment()
else -> SignInFragment.newInstance(null, null)
}
addFragment(fragment)
}
Expand Down
32 changes: 27 additions & 5 deletions app/src/main/java/org/openedx/app/AppRouter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String>) {
replaceFragmentWithBackStack(fm, DownloadQueueFragment.newInstance(descendants))
}
Expand Down Expand Up @@ -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))
}
}
}
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/org/openedx/app/AppViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,13 @@ class AppViewModel(

val isLogistrationEnabled get() = config.isPreLoginExperienceEnabled()

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

private var logoutHandledAt: Long = 0

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

import android.content.res.ColorStateList
import android.os.Bundle
import android.view.Menu
import android.view.View
Expand All @@ -8,6 +9,8 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.core.content.ContextCompat
import androidx.core.os.bundleOf
import androidx.core.view.forEach
import androidx.fragment.app.Fragment
Expand All @@ -22,6 +25,7 @@ import org.openedx.app.deeplink.HomeTab
import org.openedx.core.AppUpdateState
import org.openedx.core.AppUpdateState.wasUpgradeDialogClosed
import org.openedx.core.adapter.NavigationFragmentAdapter
import org.openedx.core.lmsdirectory.LmsThemeController
import org.openedx.core.presentation.dialog.appupgrade.AppUpgradeDialogFragment
import org.openedx.core.presentation.global.appupgrade.AppUpgradeRecommendedBox
import org.openedx.core.presentation.global.appupgrade.UpgradeRequiredFragment
Expand Down Expand Up @@ -84,10 +88,31 @@ class MainFragment : Fragment(R.layout.fragment_main) {
val tabList = createTabList(openTabArg)
addMenuItems(menu, tabList)
setupBottomNavListener(tabList)
applyLmsAccentTint()

requireArguments().remove(ARG_OPEN_TAB)
}

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

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

class OpenEdXApp : Application() {

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

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

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

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

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

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

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

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

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

return chain.proceed(requestToProcess)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -56,6 +58,8 @@ class PreferencesManager(

private val encryption = DataStoreEncryption()

private val gson = Gson()

private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

@Volatile
Expand Down Expand Up @@ -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("_")}")
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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<LmsHistoryEntry>
get() {
val json = getValue(Keys.LMS_HISTORY, "")
if (json.isEmpty()) return emptyList()
return runCatching {
val type = object : TypeToken<List<LmsHistoryEntry>>() {}.type
gson.fromJson<List<LmsHistoryEntry>>(json, type) ?: emptyList()
}.getOrDefault(emptyList())
}
set(value) = setValue(Keys.LMS_HISTORY, gson.toJson(value))

override var profile: Account?
get() {
val json = getEncryptedString(Keys.ACCOUNT, "")
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/org/openedx/app/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<CorePreferences> { get<PreferencesManager>() }
single<ProfilePreferences> { get<PreferencesManager>() }
Expand Down Expand Up @@ -120,7 +120,7 @@ val appModule = module {
single { DiscoveryNotifier() }
single { CalendarNotifier() }

single { AppRouter() }
single { AppRouter(get(), get()) }
single<AuthRouter> { get<AppRouter>() }
single<DiscoveryRouter> { get<AppRouter>() }
single<DashboardRouter> { get<AppRouter>() }
Expand Down
3 changes: 3 additions & 0 deletions app/src/main/java/org/openedx/app/di/NetworkingModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
Expand Down
Loading
Loading