diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 831fe4a86..65c64e538 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -48,6 +48,12 @@
+
+
+
+
+
+
diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt
index 3ca7aea24..19c096338 100644
--- a/app/src/main/java/org/openedx/app/AppActivity.kt
+++ b/app/src/main/java/org/openedx/app/AppActivity.kt
@@ -3,6 +3,7 @@ package org.openedx.app
import android.content.Intent
import android.content.res.Configuration
import android.graphics.Color
+import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.WindowManager
@@ -24,6 +25,7 @@ import org.openedx.app.databinding.ActivityAppBinding
import org.openedx.app.deeplink.DeepLink
import org.openedx.auth.presentation.logistration.LogistrationFragment
import org.openedx.auth.presentation.signin.SignInFragment
+import org.openedx.core.ApiConstants
import org.openedx.core.data.storage.CorePreferences
import org.openedx.core.presentation.global.InsetHolder
import org.openedx.core.presentation.global.WindowSizeHolder
@@ -64,6 +66,18 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {
private var _insetCutout = 0
private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact)
+ private val authCode: String?
+ get() {
+ val data = intent?.data
+ if (
+ data is Uri &&
+ data.scheme == BuildConfig.APPLICATION_ID &&
+ data.host == ApiConstants.BrowserLogin.REDIRECT_HOST
+ ) {
+ return data.getQueryParameter(ApiConstants.BrowserLogin.CODE_QUERY_PARAM)
+ }
+ return null
+ }
private val branchCallback =
BranchUniversalReferralInitListener { branchUniversalObject, _, error ->
@@ -154,10 +168,10 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {
if (savedInstanceState == null) {
when {
corePreferencesManager.user == null -> {
- val fragment = if (viewModel.isLogistrationEnabled) {
+ val fragment = if (viewModel.isLogistrationEnabled && authCode == null) {
LogistrationFragment()
} else {
- SignInFragment()
+ SignInFragment.newInstance(null, null, authCode = authCode)
}
addFragment(fragment)
}
@@ -204,6 +218,10 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {
super.onNewIntent(intent)
this.intent = intent
+ if (authCode != null) {
+ addFragment(SignInFragment.newInstance(null, null, authCode = authCode))
+ }
+
val extras = intent?.extras
if (extras?.containsKey(DeepLink.Keys.NOTIFICATION_TYPE.value) == true) {
handlePushNotification(extras)
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 93688f663..ce6e20cd9 100644
--- a/app/src/main/java/org/openedx/app/di/AppModule.kt
+++ b/app/src/main/java/org/openedx/app/di/AppModule.kt
@@ -23,6 +23,7 @@ import org.openedx.app.room.DatabaseManager
import org.openedx.auth.presentation.AgreementProvider
import org.openedx.auth.presentation.AuthAnalytics
import org.openedx.auth.presentation.AuthRouter
+import org.openedx.auth.presentation.sso.BrowserAuthHelper
import org.openedx.auth.presentation.sso.FacebookAuthHelper
import org.openedx.auth.presentation.sso.GoogleAuthHelper
import org.openedx.auth.presentation.sso.MicrosoftAuthHelper
@@ -209,6 +210,7 @@ val appModule = module {
factory { FacebookAuthHelper() }
factory { GoogleAuthHelper(get()) }
factory { MicrosoftAuthHelper() }
+ factory { BrowserAuthHelper(get()) }
factory { OAuthHelper(get(), get(), get()) }
factory { FileUtil(get(), get().getString(R.string.app_name)) }
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 2c1dffad1..6b7692f99 100644
--- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt
+++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt
@@ -100,10 +100,11 @@ val screenModule = module {
get(),
get(),
get(),
+ get(),
)
}
- viewModel { (courseId: String?, infoType: String?) ->
+ viewModel { (courseId: String?, infoType: String?, authCode: String) ->
SignInViewModel(
get(),
get(),
@@ -118,8 +119,10 @@ val screenModule = module {
get(),
get(),
get(),
+ get(),
courseId,
infoType,
+ authCode,
)
}
diff --git a/auth/build.gradle b/auth/build.gradle
index 470174991..6b11037a2 100644
--- a/auth/build.gradle
+++ b/auth/build.gradle
@@ -54,6 +54,7 @@ android {
dependencies {
implementation project(path: ':core')
+ implementation 'androidx.browser:browser:1.7.0'
implementation "androidx.credentials:credentials:1.3.0"
implementation "androidx.credentials:credentials-play-services-auth:1.3.0"
implementation "com.facebook.android:facebook-login:16.2.0"
diff --git a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt
index 673168c57..b837648fe 100644
--- a/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt
+++ b/auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt
@@ -37,6 +37,17 @@ interface AuthApi {
@Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true,
): AuthResponse
+ @FormUrlEncoded
+ @POST(ApiConstants.URL_ACCESS_TOKEN)
+ suspend fun getAccessTokenFromCode(
+ @Field("grant_type") grantType: String,
+ @Field("client_id") clientId: String,
+ @Field("code") code: String,
+ @Field("redirect_uri") redirectUri: String,
+ @Field("token_type") tokenType: String,
+ @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true,
+ ): AuthResponse
+
@FormUrlEncoded
@POST(ApiConstants.URL_ACCESS_TOKEN)
fun refreshAccessToken(
diff --git a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt
index 5addd621c..c56ba0cf1 100644
--- a/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt
+++ b/auth/src/main/java/org/openedx/auth/data/model/AuthType.kt
@@ -13,4 +13,5 @@ enum class AuthType(val postfix: String, val methodName: String) {
GOOGLE(ApiConstants.AUTH_TYPE_GOOGLE, "Google"),
FACEBOOK(ApiConstants.AUTH_TYPE_FB, "Facebook"),
MICROSOFT(ApiConstants.AUTH_TYPE_MICROSOFT, "Microsoft"),
+ BROWSER(ApiConstants.AUTH_TYPE_BROWSER, "Browser")
}
diff --git a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt
index 617006afe..20499baf9 100644
--- a/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt
+++ b/auth/src/main/java/org/openedx/auth/data/repository/AuthRepository.kt
@@ -43,6 +43,16 @@ class AuthRepository(
.processAuthResponse()
}
+ suspend fun browserAuthCodeLogin(code: String) {
+ api.getAccessTokenFromCode(
+ grantType = ApiConstants.GRANT_TYPE_CODE,
+ clientId = config.getOAuthClientId(),
+ code = code,
+ redirectUri = "${config.getAppId()}://${ApiConstants.BrowserLogin.REDIRECT_HOST}",
+ tokenType = config.getAccessTokenType(),
+ ).mapToDomain().processAuthResponse()
+ }
+
suspend fun getRegistrationFields(): List {
return api.getRegistrationFields().fields?.map { it.mapToDomain() } ?: emptyList()
}
diff --git a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt
index cdce0dbdf..727f77a48 100644
--- a/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt
+++ b/auth/src/main/java/org/openedx/auth/domain/interactor/AuthInteractor.kt
@@ -18,6 +18,10 @@ class AuthInteractor(private val repository: AuthRepository) {
repository.socialLogin(token, authType)
}
+ suspend fun loginAuthCode(authCode: String) {
+ repository.browserAuthCodeLogin(authCode)
+ }
+
suspend fun getRegistrationFields(): List {
return repository.getRegistrationFields()
}
diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt
index a05951ca4..f8dbba635 100644
--- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt
+++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationFragment.kt
@@ -42,6 +42,7 @@ import androidx.fragment.app.Fragment
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf
import org.openedx.auth.R
+import org.openedx.core.ApiConstants
import org.openedx.core.ui.AuthButtonsPanel
import org.openedx.core.ui.SearchBar
import org.openedx.core.ui.displayCutoutForLandscape
@@ -50,6 +51,7 @@ import org.openedx.core.ui.theme.OpenEdXTheme
import org.openedx.core.ui.theme.appColors
import org.openedx.core.ui.theme.appTypography
import org.openedx.core.ui.theme.compose.LogistrationLogoView
+import org.openedx.foundation.utils.UrlUtils
class LogistrationFragment : Fragment() {
@@ -67,10 +69,22 @@ class LogistrationFragment : Fragment() {
OpenEdXTheme {
LogistrationScreen(
onSignInClick = {
- viewModel.navigateToSignIn(parentFragmentManager)
+ if (viewModel.isBrowserLoginEnabled) {
+ viewModel.signInBrowser(requireActivity())
+ } else {
+ viewModel.navigateToSignIn(parentFragmentManager)
+ }
},
onRegisterClick = {
- viewModel.navigateToSignUp(parentFragmentManager)
+ if (viewModel.isBrowserRegistrationEnabled) {
+ UrlUtils.openInBrowser(
+ activity = context,
+ apiHostUrl = viewModel.apiHostUrl,
+ url = ApiConstants.URL_REGISTER_BROWSER,
+ )
+ } else {
+ viewModel.navigateToSignUp(parentFragmentManager)
+ }
},
onSearchClick = { querySearch ->
viewModel.navigateToDiscovery(parentFragmentManager, querySearch)
diff --git a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt
index 2b9ca07e2..d7ca6e894 100644
--- a/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt
+++ b/auth/src/main/java/org/openedx/auth/presentation/logistration/LogistrationViewModel.kt
@@ -1,11 +1,16 @@
package org.openedx.auth.presentation.logistration
+import android.app.Activity
import androidx.fragment.app.FragmentManager
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.launch
import org.openedx.auth.presentation.AuthAnalytics
import org.openedx.auth.presentation.AuthAnalyticsEvent
import org.openedx.auth.presentation.AuthAnalyticsKey
import org.openedx.auth.presentation.AuthRouter
+import org.openedx.auth.presentation.sso.BrowserAuthHelper
import org.openedx.core.config.Config
+import org.openedx.core.utils.Logger
import org.openedx.foundation.extension.takeIfNotEmpty
import org.openedx.foundation.presentation.BaseViewModel
@@ -14,10 +19,16 @@ class LogistrationViewModel(
private val router: AuthRouter,
private val config: Config,
private val analytics: AuthAnalytics,
+ private val browserAuthHelper: BrowserAuthHelper,
) : BaseViewModel() {
+ private val logger = Logger("LogistrationViewModel")
+
private val discoveryTypeWebView get() = config.getDiscoveryConfig().isViewTypeWebView()
val isRegistrationEnabled get() = config.isRegistrationEnabled()
+ val isBrowserRegistrationEnabled get() = config.isBrowserRegistrationEnabled()
+ val isBrowserLoginEnabled get() = config.isBrowserLoginEnabled()
+ val apiHostUrl get() = config.getApiHostURL()
init {
logLogistrationScreenEvent()
@@ -28,6 +39,16 @@ class LogistrationViewModel(
logEvent(AuthAnalyticsEvent.SIGN_IN_CLICKED)
}
+ fun signInBrowser(activityContext: Activity) {
+ viewModelScope.launch {
+ runCatching {
+ browserAuthHelper.signIn(activityContext)
+ }.onFailure {
+ logger.e { "Browser auth error: $it" }
+ }
+ }
+ }
+
fun navigateToSignUp(parentFragmentManager: FragmentManager) {
router.navigateToSignUp(parentFragmentManager, courseId, null)
logEvent(AuthAnalyticsEvent.REGISTER_CLICKED)
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 8f55d334b..e5da6fbd9 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
@@ -25,7 +25,8 @@ class SignInFragment : Fragment() {
private val viewModel: SignInViewModel by viewModel {
parametersOf(
requireArguments().getString(ARG_COURSE_ID, ""),
- requireArguments().getString(ARG_INFO_TYPE, "")
+ requireArguments().getString(ARG_INFO_TYPE, ""),
+ requireArguments().getString(ARG_AUTH_CODE, ""),
)
}
@@ -43,6 +44,9 @@ class SignInFragment : Fragment() {
val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null)
if (appUpgradeEvent == null) {
+ if (viewModel.authCode != "" && !state.loginFailure && !state.loginSuccess) {
+ viewModel.signInAuthCode(viewModel.authCode)
+ }
LoginScreen(
windowSize = windowSize,
state = state,
@@ -59,6 +63,10 @@ class SignInFragment : Fragment() {
viewModel.navigateToForgotPassword(parentFragmentManager)
}
+ AuthEvent.SignInBrowser -> {
+ viewModel.signInBrowser(requireActivity())
+ }
+
AuthEvent.RegisterClick -> {
viewModel.navigateToSignUp(parentFragmentManager)
}
@@ -92,11 +100,13 @@ class SignInFragment : Fragment() {
companion object {
private const val ARG_COURSE_ID = "courseId"
private const val ARG_INFO_TYPE = "info_type"
- fun newInstance(courseId: String?, infoType: String?): SignInFragment {
+ private const val ARG_AUTH_CODE = "auth_code"
+ fun newInstance(courseId: String?, infoType: String?, authCode: String? = null): SignInFragment {
val fragment = SignInFragment()
fragment.arguments = bundleOf(
ARG_COURSE_ID to courseId,
- ARG_INFO_TYPE to infoType
+ ARG_INFO_TYPE to infoType,
+ ARG_AUTH_CODE to authCode,
)
return fragment
}
@@ -107,6 +117,7 @@ internal sealed interface AuthEvent {
data class SignIn(val login: String, val password: String) : AuthEvent
data class SocialSignIn(val authType: AuthType) : AuthEvent
data class OpenLink(val links: Map, val link: String) : AuthEvent
+ object SignInBrowser : AuthEvent
object RegisterClick : AuthEvent
object ForgotPasswordClick : AuthEvent
object BackClick : 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 7d472882f..c2a5f915c 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
@@ -17,9 +17,12 @@ internal data class SignInUIState(
val isGoogleAuthEnabled: Boolean = false,
val isMicrosoftAuthEnabled: Boolean = false,
val isSocialAuthEnabled: Boolean = false,
+ val isBrowserLoginEnabled: Boolean = false,
+ val isBrowserRegistrationEnabled: Boolean = false,
val isLogistrationEnabled: Boolean = false,
val isRegistrationEnabled: Boolean = true,
val showProgress: Boolean = false,
val loginSuccess: Boolean = false,
val agreement: RegistrationField? = null,
+ val loginFailure: Boolean = false,
)
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 5cc08b47e..f271927e1 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
@@ -1,5 +1,6 @@
package org.openedx.auth.presentation.signin
+import android.app.Activity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LiveData
@@ -20,6 +21,7 @@ import org.openedx.auth.presentation.AuthAnalytics
import org.openedx.auth.presentation.AuthAnalyticsEvent
import org.openedx.auth.presentation.AuthAnalyticsKey
import org.openedx.auth.presentation.AuthRouter
+import org.openedx.auth.presentation.sso.BrowserAuthHelper
import org.openedx.auth.presentation.sso.OAuthHelper
import org.openedx.core.Validator
import org.openedx.core.config.Config
@@ -53,9 +55,11 @@ class SignInViewModel(
private val calendarPreferences: CalendarPreferences,
private val calendarInteractor: CalendarInteractor,
agreementProvider: AgreementProvider,
- config: Config,
+ private val browserAuthHelper: BrowserAuthHelper,
+ val config: Config,
val courseId: String?,
val infoType: String?,
+ val authCode: String,
) : BaseViewModel() {
private val logger = Logger("SignInViewModel")
@@ -65,6 +69,8 @@ class SignInViewModel(
isFacebookAuthEnabled = config.getFacebookConfig().isEnabled(),
isGoogleAuthEnabled = config.getGoogleConfig().isEnabled(),
isMicrosoftAuthEnabled = config.getMicrosoftConfig().isEnabled(),
+ isBrowserLoginEnabled = config.isBrowserLoginEnabled(),
+ isBrowserRegistrationEnabled = config.isBrowserRegistrationEnabled(),
isSocialAuthEnabled = config.isSocialAuthEnabled(),
isLogistrationEnabled = config.isPreLoginExperienceEnabled(),
isRegistrationEnabled = config.isRegistrationEnabled(),
@@ -158,11 +164,41 @@ class SignInViewModel(
}
}
+ fun signInBrowser(activityContext: Activity) {
+ _uiState.update { it.copy(showProgress = true) }
+ viewModelScope.launch {
+ runCatching {
+ browserAuthHelper.signIn(activityContext)
+ }.onFailure {
+ logger.e { "Browser auth error: $it" }
+ }
+ }
+ }
+
fun navigateToSignUp(parentFragmentManager: FragmentManager) {
router.navigateToSignUp(parentFragmentManager, null, null)
logEvent(AuthAnalyticsEvent.REGISTER_CLICKED)
}
+ fun signInAuthCode(authCode: String) {
+ _uiState.update { it.copy(showProgress = true) }
+ viewModelScope.launch {
+ runCatching {
+ interactor.loginAuthCode(authCode)
+ }
+ .onFailure {
+ logger.e { "OAuth2 code error: $it" }
+ onUnknownError()
+ _uiState.update { it.copy(loginFailure = true) }
+ }.onSuccess {
+ _uiState.update { it.copy(loginSuccess = true) }
+ setUserId()
+ appNotifier.send(SignInEvent())
+ _uiState.update { it.copy(showProgress = false) }
+ }
+ }
+ }
+
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 d4608e4f8..e182f51d7 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
@@ -226,67 +226,73 @@ private fun AuthForm(
var isPasswordError by rememberSaveable { mutableStateOf(false) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- LoginTextField(
- modifier = Modifier
- .fillMaxWidth(),
- title = stringResource(id = R.string.auth_email_username),
- description = stringResource(id = R.string.auth_enter_email_username),
- onValueChanged = {
- login = it
- isEmailError = false
- },
- isError = isEmailError,
- errorMessages = stringResource(id = R.string.auth_error_empty_username_email)
- )
+ if (!state.isBrowserLoginEnabled) {
+ LoginTextField(
+ modifier = Modifier
+ .fillMaxWidth(),
+ title = stringResource(id = R.string.auth_email_username),
+ description = stringResource(id = R.string.auth_enter_email_username),
+ onValueChanged = {
+ login = it
+ isEmailError = false
+ },
+ isError = isEmailError,
+ errorMessages = stringResource(id = R.string.auth_error_empty_username_email)
+ )
- Spacer(modifier = Modifier.height(18.dp))
- PasswordTextField(
- modifier = Modifier
- .fillMaxWidth(),
- onValueChanged = {
- password = it
- isPasswordError = false
- },
- onPressDone = {
- keyboardController?.hide()
- if (password.isNotEmpty()) {
- onEvent(AuthEvent.SignIn(login = login, password = password))
- } else {
- isEmailError = login.isEmpty()
- isPasswordError = password.isEmpty()
- }
- },
- isError = isPasswordError,
- )
+ Spacer(modifier = Modifier.height(18.dp))
+ PasswordTextField(
+ modifier = Modifier
+ .fillMaxWidth(),
+ onValueChanged = {
+ password = it
+ isPasswordError = false
+ },
+ onPressDone = {
+ keyboardController?.hide()
+ if (password.isNotEmpty()) {
+ onEvent(AuthEvent.SignIn(login = login, password = password))
+ } else {
+ isEmailError = login.isEmpty()
+ isPasswordError = password.isEmpty()
+ }
+ },
+ isError = isPasswordError,
+ )
+ } else {
+ Spacer(modifier = Modifier.height(40.dp))
+ }
Row(
Modifier
.fillMaxWidth()
.padding(top = 20.dp, bottom = 36.dp)
) {
- if (state.isLogistrationEnabled.not() && state.isRegistrationEnabled) {
+ if (!state.isBrowserLoginEnabled) {
+ if (state.isLogistrationEnabled.not() && state.isRegistrationEnabled) {
+ Text(
+ modifier = Modifier
+ .testTag("txt_register")
+ .noRippleClickable {
+ onEvent(AuthEvent.RegisterClick)
+ },
+ text = stringResource(id = coreR.string.core_register),
+ color = MaterialTheme.appColors.primary,
+ style = MaterialTheme.appTypography.labelLarge
+ )
+ }
+ Spacer(modifier = Modifier.weight(1f))
Text(
modifier = Modifier
- .testTag("txt_register")
+ .testTag("txt_forgot_password")
.noRippleClickable {
- onEvent(AuthEvent.RegisterClick)
+ onEvent(AuthEvent.ForgotPasswordClick)
},
- text = stringResource(id = coreR.string.core_register),
- color = MaterialTheme.appColors.primary,
+ text = stringResource(id = R.string.auth_forgot_password),
+ color = MaterialTheme.appColors.infoVariant,
style = MaterialTheme.appTypography.labelLarge
)
}
- Spacer(modifier = Modifier.weight(1f))
- Text(
- modifier = Modifier
- .testTag("txt_forgot_password")
- .noRippleClickable {
- onEvent(AuthEvent.ForgotPasswordClick)
- },
- text = stringResource(id = R.string.auth_forgot_password),
- color = MaterialTheme.appColors.infoVariant,
- style = MaterialTheme.appTypography.labelLarge
- )
}
if (state.showProgress) {
@@ -298,12 +304,16 @@ private fun AuthForm(
textColor = MaterialTheme.appColors.primaryButtonText,
backgroundColor = MaterialTheme.appColors.secondaryButtonBackground,
onClick = {
- keyboardController?.hide()
- if (login.isNotEmpty() && password.isNotEmpty()) {
- onEvent(AuthEvent.SignIn(login = login, password = password))
+ if (state.isBrowserLoginEnabled) {
+ onEvent(AuthEvent.SignInBrowser)
} else {
- isEmailError = login.isEmpty()
- isPasswordError = password.isEmpty()
+ keyboardController?.hide()
+ if (login.isNotEmpty() && password.isNotEmpty()) {
+ onEvent(AuthEvent.SignIn(login = login, password = password))
+ } else {
+ isEmailError = login.isEmpty()
+ isPasswordError = password.isEmpty()
+ }
}
}
)
@@ -421,6 +431,24 @@ private fun SignInScreenPreview() {
}
}
+@Preview(uiMode = UI_MODE_NIGHT_NO)
+@Preview(uiMode = UI_MODE_NIGHT_YES)
+@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_NO)
+@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = UI_MODE_NIGHT_YES)
+@Composable
+private fun SignInUsingBrowserScreenPreview() {
+ OpenEdXTheme {
+ LoginScreen(
+ windowSize = WindowSize(WindowType.Compact, WindowType.Compact),
+ state = SignInUIState().copy(
+ isBrowserLoginEnabled = true,
+ ),
+ uiMessage = null,
+ onEvent = {},
+ )
+ }
+}
+
@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_NO)
@Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = UI_MODE_NIGHT_YES)
@Composable
diff --git a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt
index 150eacb1a..a87ffef3e 100644
--- a/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt
+++ b/auth/src/main/java/org/openedx/auth/presentation/signup/SignUpFragment.kt
@@ -66,6 +66,7 @@ class SignUpFragment : Fragment() {
this@SignUpFragment,
authType
)
+ AuthType.BROWSER -> null
}
},
onFieldUpdated = { key, value ->
diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt
new file mode 100644
index 000000000..1022da676
--- /dev/null
+++ b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt
@@ -0,0 +1,35 @@
+package org.openedx.auth.presentation.sso
+
+import android.app.Activity
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.net.Uri
+import androidx.annotation.WorkerThread
+import androidx.browser.customtabs.CustomTabsIntent
+import org.openedx.core.ApiConstants
+import org.openedx.core.config.Config
+import org.openedx.core.utils.Logger
+
+class BrowserAuthHelper(private val config: Config) {
+
+ private val logger = Logger(TAG)
+
+ @WorkerThread
+ suspend fun signIn(activityContext: Activity) {
+ logger.d { "Browser-based auth initiated" }
+ val uri = Uri.parse("${config.getApiHostURL()}${ApiConstants.URL_AUTHORIZE}").buildUpon()
+ .appendQueryParameter("client_id", config.getOAuthClientId())
+ .appendQueryParameter(
+ "redirect_uri",
+ "${activityContext.packageName}://${ApiConstants.BrowserLogin.REDIRECT_HOST}"
+ )
+ .appendQueryParameter("response_type", ApiConstants.BrowserLogin.RESPONSE_TYPE).build()
+ val intent =
+ CustomTabsIntent.Builder().setUrlBarHidingEnabled(true).setShowTitle(true).build()
+ intent.intent.setFlags(FLAG_ACTIVITY_NEW_TASK)
+ intent.launchUrl(activityContext, uri)
+ }
+
+ private companion object {
+ const val TAG = "BrowserAuthHelper"
+ }
+}
diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt
index 776df7c46..ccb094fae 100644
--- a/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt
+++ b/auth/src/main/java/org/openedx/auth/presentation/sso/OAuthHelper.kt
@@ -21,6 +21,7 @@ class OAuthHelper(
AuthType.GOOGLE -> googleAuthHelper.socialAuth(fragment.requireActivity())
AuthType.FACEBOOK -> facebookAuthHelper.socialAuth(fragment)
AuthType.MICROSOFT -> microsoftAuthHelper.socialAuth(fragment.requireActivity())
+ AuthType.BROWSER -> null
}
}
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 48480e310..52c9e96a7 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
@@ -26,6 +26,7 @@ import org.openedx.auth.domain.interactor.AuthInteractor
import org.openedx.auth.presentation.AgreementProvider
import org.openedx.auth.presentation.AuthAnalytics
import org.openedx.auth.presentation.AuthRouter
+import org.openedx.auth.presentation.sso.BrowserAuthHelper
import org.openedx.auth.presentation.sso.OAuthHelper
import org.openedx.core.Validator
import org.openedx.core.config.Config
@@ -66,6 +67,7 @@ class SignInViewModelTest {
private val whatsNewGlobalManager = mockk()
private val calendarInteractor = mockk()
private val calendarPreferences = mockk()
+ private val browserAuthHelper = mockk()
private val invalidCredential = "Invalid credentials"
private val noInternet = "Slow or no internet connection"
@@ -95,6 +97,8 @@ class SignInViewModelTest {
coEvery { calendarInteractor.clearCalendarCachedData() } returns Unit
every { analytics.logScreenEvent(any(), any()) } returns Unit
every { config.isRegistrationEnabled() } returns true
+ every { config.isBrowserLoginEnabled() } returns false
+ every { config.isBrowserRegistrationEnabled() } returns false
}
@After
@@ -120,10 +124,12 @@ class SignInViewModelTest {
config = config,
router = router,
whatsNewGlobalManager = whatsNewGlobalManager,
+ browserAuthHelper = browserAuthHelper,
courseId = "",
infoType = "",
calendarInteractor = calendarInteractor,
- calendarPreferences = calendarPreferences
+ calendarPreferences = calendarPreferences,
+ authCode = "",
)
viewModel.login("", "")
coVerify(exactly = 0) { interactor.login(any(), any()) }
@@ -156,10 +162,12 @@ class SignInViewModelTest {
config = config,
router = router,
whatsNewGlobalManager = whatsNewGlobalManager,
+ browserAuthHelper = browserAuthHelper,
courseId = "",
infoType = "",
calendarInteractor = calendarInteractor,
- calendarPreferences = calendarPreferences
+ calendarPreferences = calendarPreferences,
+ authCode = "",
)
viewModel.login("acc@test.o", "")
coVerify(exactly = 0) { interactor.login(any(), any()) }
@@ -192,10 +200,12 @@ class SignInViewModelTest {
config = config,
router = router,
whatsNewGlobalManager = whatsNewGlobalManager,
+ browserAuthHelper = browserAuthHelper,
courseId = "",
infoType = "",
calendarInteractor = calendarInteractor,
- calendarPreferences = calendarPreferences
+ calendarPreferences = calendarPreferences,
+ authCode = "",
)
viewModel.login("acc@test.org", "")
@@ -227,10 +237,12 @@ class SignInViewModelTest {
config = config,
router = router,
whatsNewGlobalManager = whatsNewGlobalManager,
+ browserAuthHelper = browserAuthHelper,
courseId = "",
infoType = "",
calendarInteractor = calendarInteractor,
- calendarPreferences = calendarPreferences
+ calendarPreferences = calendarPreferences,
+ authCode = "",
)
viewModel.login("acc@test.org", "ed")
@@ -266,10 +278,12 @@ class SignInViewModelTest {
config = config,
router = router,
whatsNewGlobalManager = whatsNewGlobalManager,
+ browserAuthHelper = browserAuthHelper,
courseId = "",
infoType = "",
calendarInteractor = calendarInteractor,
- calendarPreferences = calendarPreferences
+ calendarPreferences = calendarPreferences,
+ authCode = "",
)
coEvery { interactor.login("acc@test.org", "edx") } returns Unit
viewModel.login("acc@test.org", "edx")
@@ -305,10 +319,12 @@ class SignInViewModelTest {
config = config,
router = router,
whatsNewGlobalManager = whatsNewGlobalManager,
+ browserAuthHelper = browserAuthHelper,
courseId = "",
infoType = "",
calendarInteractor = calendarInteractor,
- calendarPreferences = calendarPreferences
+ calendarPreferences = calendarPreferences,
+ authCode = "",
)
coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException()
viewModel.login("acc@test.org", "edx")
@@ -346,10 +362,12 @@ class SignInViewModelTest {
config = config,
router = router,
whatsNewGlobalManager = whatsNewGlobalManager,
+ browserAuthHelper = browserAuthHelper,
courseId = "",
infoType = "",
calendarInteractor = calendarInteractor,
- calendarPreferences = calendarPreferences
+ calendarPreferences = calendarPreferences,
+ authCode = "",
)
coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException()
viewModel.login("acc@test.org", "edx")
@@ -387,10 +405,12 @@ class SignInViewModelTest {
config = config,
router = router,
whatsNewGlobalManager = whatsNewGlobalManager,
+ browserAuthHelper = browserAuthHelper,
courseId = "",
infoType = "",
calendarInteractor = calendarInteractor,
- calendarPreferences = calendarPreferences
+ calendarPreferences = calendarPreferences,
+ authCode = "",
)
coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException()
viewModel.login("acc@test.org", "edx")
diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt
index 786d63cc4..959d3c224 100644
--- a/core/src/main/java/org/openedx/core/ApiConstants.kt
+++ b/core/src/main/java/org/openedx/core/ApiConstants.kt
@@ -2,6 +2,7 @@ package org.openedx.core
object ApiConstants {
const val URL_LOGIN = "/oauth2/login/"
+ const val URL_AUTHORIZE = "/oauth2/authorize/"
const val URL_ACCESS_TOKEN = "/oauth2/access_token/"
const val URL_EXCHANGE_TOKEN = "/oauth2/exchange_access_token/{auth_type}/"
const val GET_USER_PROFILE = "/api/mobile/v0.5/my_user_info"
@@ -9,15 +10,18 @@ object ApiConstants {
const val URL_REGISTRATION_FIELDS = "/user_api/v1/account/registration"
const val URL_VALIDATE_REGISTRATION_FIELDS = "/api/user/v1/validation/registration"
const val URL_REGISTER = "/api/user/v1/account/registration/"
+ const val URL_REGISTER_BROWSER = "/register"
const val URL_PASSWORD_RESET = "/password_reset/"
const val GRANT_TYPE_PASSWORD = "password"
+ const val GRANT_TYPE_CODE = "authorization_code"
const val TOKEN_TYPE_BEARER = "Bearer"
const val TOKEN_TYPE_JWT = "jwt"
const val TOKEN_TYPE_REFRESH = "refresh_token"
const val ACCESS_TOKEN = "access_token"
+
const val CLIENT_ID = "client_id"
const val EMAIL = "email"
const val NAME = "name"
@@ -27,6 +31,7 @@ object ApiConstants {
const val AUTH_TYPE_GOOGLE = "google-oauth2"
const val AUTH_TYPE_FB = "facebook"
const val AUTH_TYPE_MICROSOFT = "azuread-oauth2"
+ const val AUTH_TYPE_BROWSER = "browser"
const val COURSE_KEY = "course_key"
@@ -34,4 +39,10 @@ object ApiConstants {
const val HONOR_CODE = "honor_code"
const val MARKETING_EMAILS = "marketing_emails_opt_in"
}
+
+ object BrowserLogin {
+ const val REDIRECT_HOST = "oauth2Callback"
+ const val CODE_QUERY_PARAM = "code"
+ const val RESPONSE_TYPE = "code"
+ }
}
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 1b58c7e44..f240b9531 100644
--- a/core/src/main/java/org/openedx/core/config/Config.kt
+++ b/core/src/main/java/org/openedx/core/config/Config.kt
@@ -112,6 +112,14 @@ class Config(context: Context) {
return getBoolean(REGISTRATION_ENABLED, true)
}
+ fun isBrowserLoginEnabled(): Boolean {
+ return getBoolean(BROWSER_LOGIN, false)
+ }
+
+ fun isBrowserRegistrationEnabled(): Boolean {
+ return getBoolean(BROWSER_REGISTRATION, false)
+ }
+
private fun getString(key: String, defaultValue: String = ""): String {
val element = getObject(key)
return if (element != null) {
@@ -166,6 +174,8 @@ class Config(context: Context) {
private const val MICROSOFT = "MICROSOFT"
private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_ENABLED"
private const val REGISTRATION_ENABLED = "REGISTRATION_ENABLED"
+ private const val BROWSER_LOGIN = "BROWSER_LOGIN"
+ private const val BROWSER_REGISTRATION = "BROWSER_REGISTRATION"
private const val DISCOVERY = "DISCOVERY"
private const val PROGRAM = "PROGRAM"
private const val DASHBOARD = "DASHBOARD"
diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml
index 19e53ef73..4d1d694ec 100644
--- a/default_config/dev/config.yaml
+++ b/default_config/dev/config.yaml
@@ -77,6 +77,10 @@ WHATS_NEW_ENABLED: false
SOCIAL_AUTH_ENABLED: false
#feature flag to enable registration from app
REGISTRATION_ENABLED: true
+#feature flag to do the authentication flow in the browser to log in
+BROWSER_LOGIN: false
+#feature flag to do the registration for in the browser
+BROWSER_REGISTRATION: false
#Course navigation feature flags
UI_COMPONENTS:
COURSE_DROPDOWN_NAVIGATION_ENABLED: false
diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml
index 19e53ef73..4d1d694ec 100644
--- a/default_config/prod/config.yaml
+++ b/default_config/prod/config.yaml
@@ -77,6 +77,10 @@ WHATS_NEW_ENABLED: false
SOCIAL_AUTH_ENABLED: false
#feature flag to enable registration from app
REGISTRATION_ENABLED: true
+#feature flag to do the authentication flow in the browser to log in
+BROWSER_LOGIN: false
+#feature flag to do the registration for in the browser
+BROWSER_REGISTRATION: false
#Course navigation feature flags
UI_COMPONENTS:
COURSE_DROPDOWN_NAVIGATION_ENABLED: false
diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml
index 19e53ef73..4d1d694ec 100644
--- a/default_config/stage/config.yaml
+++ b/default_config/stage/config.yaml
@@ -77,6 +77,10 @@ WHATS_NEW_ENABLED: false
SOCIAL_AUTH_ENABLED: false
#feature flag to enable registration from app
REGISTRATION_ENABLED: true
+#feature flag to do the authentication flow in the browser to log in
+BROWSER_LOGIN: false
+#feature flag to do the registration for in the browser
+BROWSER_REGISTRATION: false
#Course navigation feature flags
UI_COMPONENTS:
COURSE_DROPDOWN_NAVIGATION_ENABLED: false
diff --git a/docs/0001-strategy-for-data-streams.rst b/docs/decisions/0001-strategy-for-data-streams.rst
similarity index 100%
rename from docs/0001-strategy-for-data-streams.rst
rename to docs/decisions/0001-strategy-for-data-streams.rst
diff --git a/docs/how-tos/auth-using-browser.rst b/docs/how-tos/auth-using-browser.rst
new file mode 100644
index 000000000..49a23603b
--- /dev/null
+++ b/docs/how-tos/auth-using-browser.rst
@@ -0,0 +1,48 @@
+How to use Browser-based Login and Registration
+===============================================
+
+Introduction
+------------
+
+If your Open edX instance is set up with a custom authentication system that requires logging in
+via the browser, you can use the ``BROWSER_LOGIN`` and ``BROWSER_REGISTRATION`` flags to redirect
+login and registration to the browser.
+
+The ``BROWSER_LOGIN`` flag is used to redirect login to the browser. In this case clicking on the
+login button will open the authorization flow in an Android custom browser tab and redirect back to
+the application.
+
+The ``BROWSER_REGISTRATION`` flag is used to redirect registration to the browser. In this case
+clicking on the registration button will open the registration page in a regular browser tab. Once
+registered, the user will as of writing this document **not** be automatically redirected to the
+application.
+
+Usage
+-----
+
+In order to use the ``BROWSER_LOGIN`` feature, you need to set up an OAuth2 provider via
+``/admin/oauth2_provider/application/`` that has a redirect URL with the following format
+
+ ``://oauth2Callback``
+
+Here application ID is the ID for the Android application and defaults to ``"org.openedx.app"``. This
+URI scheme is handled by the application and will be used by the app to get the OAuth2 token for
+using the APIs.
+
+Note that normally the Django OAuth Toolkit doesn't allow custom schemes like the above as redirect
+URIs, so you will need to explicitly allow the by adding this URI scheme to
+``ALLOWED_REDIRECT_URI_SCHEMES`` in the Django OAuth Toolkit settings in ``OAUTH2_PROVIDER``. You
+can add the following line to your django settings python file:
+
+.. code-block:: python
+
+ OAUTH2_PROVIDER["ALLOWED_REDIRECT_URI_SCHEMES"] = ["https", "org.openedx.app"]
+
+Replace ``"org.openedx.app"`` with the correct id for your application. You must list all allowed
+schemes here, including ``"https"`` and ``"http"``.
+
+The authentication will then redirect to the browser in a custom tab that redirects back to the app.
+
+..note::
+
+ If a user logs out from the application, they might still be logged in, in the browser.
\ No newline at end of file
diff --git a/docs/how-tos/index.rst b/docs/how-tos/index.rst
new file mode 100644
index 000000000..202bad08b
--- /dev/null
+++ b/docs/how-tos/index.rst
@@ -0,0 +1,8 @@
+"How-To" Guides
+###############
+
+
+.. toctree::
+:glob:
+
+*