Skip to content

Commit

Permalink
feat: Support for login and registration via a browser custom tab
Browse files Browse the repository at this point in the history
This change adds support for logging in and registering a new account using the
browser. This can be useful for cases where the only way to log into the
instatance is via a custom third-party auth provider.

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8020f6b..e2c208d 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -45,6 +45,12 @@

                 <category android:name="android.intent.category.LAUNCHER" />
             </intent-filter>
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+                <data android:scheme="${applicationId}"  />
+            </intent-filter>

             <!-- Branch URI Scheme -->
             <intent-filter>
diff --git a/app/src/main/java/org/openedx/app/AppActivity.kt b/app/src/main/java/org/openedx/app/AppActivity.kt
index 5ab0d0b..6e5089e 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
@@ -56,6 +57,14 @@ 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 == "oauth2Callback") {
+                return data.getQueryParameter("code")
+            }
+            return null
+        }

     override fun onSaveInstanceState(outState: Bundle) {
         outState.putInt(TOP_INSET, topInset)
@@ -119,10 +128,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {
         if (savedInstanceState == null) {
             when {
                 corePreferencesManager.user == null -> {
-                    if (viewModel.isLogistrationEnabled) {
+                    val authCode = authCode;
+                    if (viewModel.isLogistrationEnabled && authCode == null) {
                         addFragment(LogistrationFragment())
                     } else {
-                        addFragment(SignInFragment())
+                        val bundle = Bundle()
+                        bundle.putString("auth_code", authCode)
+                        val fragment = SignInFragment()
+                        fragment.arguments = bundle
+                        addFragment(fragment)
                     }
                 }

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 16a30c0..0d3feb4 100644
--- a/app/src/main/java/org/openedx/app/di/AppModule.kt
+++ b/app/src/main/java/org/openedx/app/di/AppModule.kt
@@ -21,6 +21,7 @@ import org.openedx.app.system.notifier.AppNotifier
 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
@@ -180,5 +181,6 @@ val appModule = module {
     factory { FacebookAuthHelper() }
     factory { GoogleAuthHelper(get()) }
     factory { MicrosoftAuthHelper() }
+    factory { BrowserAuthHelper(get()) }
     factory { OAuthHelper(get(), get(), get()) }
 }
diff --git a/auth/build.gradle b/auth/build.gradle
index 7cf4d0a..b66db95 100644
--- a/auth/build.gradle
+++ b/auth/build.gradle
@@ -55,6 +55,7 @@ android {
 dependencies {
     implementation project(path: ':core')

+    implementation 'androidx.browser:browser:1.7.0'
     implementation "androidx.credentials:credentials:1.2.0"
     implementation "androidx.credentials:credentials-play-services-auth:1.2.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 903cbd6..6d40554 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
@@ -32,6 +32,14 @@ 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,
+    ): 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 5addd62..c56ba0c 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 6cf54a7..a7d364a 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
@@ -1,5 +1,6 @@
 package org.openedx.auth.data.repository

+import android.util.Log
 import org.openedx.auth.data.api.AuthApi
 import org.openedx.auth.data.model.AuthType
 import org.openedx.auth.data.model.ValidationFields
@@ -43,6 +44,14 @@ class AuthRepository(
             .processAuthResponse()
     }

+    suspend fun browserAuthCodeLogin(code: String) {
+        api.getAccessTokenFromCode(
+            grantType = ApiConstants.GRANT_TYPE_CODE,
+            clientId = config.getOAuthClientId(),
+            code = code,
+        ).mapToDomain().processAuthResponse()
+    }
+
     suspend fun getRegistrationFields(): List<RegistrationField> {
         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 00fe509..d81c51e 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<RegistrationField> {
         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 738364c..0b615f3 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
@@ -1,6 +1,8 @@
 package org.openedx.auth.presentation.logistration

+import android.content.Intent
 import android.content.res.Configuration
+import android.net.Uri
 import android.os.Bundle
 import android.view.LayoutInflater
 import android.view.ViewGroup
@@ -41,6 +43,9 @@ 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.auth.presentation.AuthRouter
+import org.openedx.core.config.Config
+import org.openedx.core.presentation.dialog.alert.ActionDialogFragment
 import org.openedx.core.ui.AuthButtonsPanel
 import org.openedx.core.ui.SearchBar
 import org.openedx.core.ui.displayCutoutForLandscape
@@ -48,6 +53,7 @@ import org.openedx.core.ui.noRippleClickable
 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.utils.UrlUtils
 import org.openedx.core.ui.theme.compose.LogistrationLogoView

 class LogistrationFragment : Fragment() {
@@ -55,6 +61,8 @@ class LogistrationFragment : Fragment() {
     private val viewModel: LogistrationViewModel by viewModel {
         parametersOf(arguments?.getString(ARG_COURSE_ID, "") ?: "")
     }
+    private val router: AuthRouter by inject()
+    private val config: Config by inject()

     override fun onCreateView(
         inflater: LayoutInflater,
@@ -70,6 +78,15 @@ class LogistrationFragment : Fragment() {
                     },
                     onRegisterClick = {
                         viewModel.navigateToSignUp(parentFragmentManager)
+                        if (config.isBrowserRegistrationEnabled()) {
+                            UrlUtils.openInBrowser(
+                                activity = context,
+                                apiHostUrl = config.getApiHostURL(),
+                                url = "/register",
+                            )
+                        } else {
+                            router.navigateToSignUp(parentFragmentManager, courseId)
+                        }
                     },
                     onSearchClick = { querySearch ->
                         viewModel.navigateToDiscovery(parentFragmentManager, querySearch)
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 fabd8a4..e89c000 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
@@ -1,6 +1,7 @@
 package org.openedx.auth.presentation.signin

 import android.os.Bundle
+import android.util.Log
 import android.view.LayoutInflater
 import android.view.ViewGroup
 import androidx.compose.runtime.LaunchedEffect
@@ -43,6 +44,11 @@ class SignInFragment : Fragment() {
                 val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null)

                 if (appUpgradeEvent == null) {
+                    val authCode = arguments?.getString("auth_code")
+                    if (authCode is String && !state.loginFailure && !state.loginSuccess) {
+                        arguments?.remove("auth_code")
+                        viewModel.signInAuthCode(authCode)
+                    }
                     LoginScreen(
                         windowSize = windowSize,
                         state = state,
@@ -59,6 +65,10 @@ class SignInFragment : Fragment() {
                                     viewModel.navigateToForgotPassword(parentFragmentManager)
                                 }

+                                AuthEvent.SignInBrowser -> {
+                                    viewModel.signInBrowser(requireActivity())
+                                }
+
                                 AuthEvent.RegisterClick -> {
                                     viewModel.navigateToSignUp(parentFragmentManager)
                                 }
@@ -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<String, String>, 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 9ce5cfc..8954c1f 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,8 +17,11 @@ 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 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 7ebc5a5..4e6db6a 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
@@ -17,6 +17,7 @@ import org.openedx.auth.domain.interactor.AuthInteractor
 import org.openedx.auth.domain.model.SocialAuthResponse
 import org.openedx.auth.presentation.AgreementProvider
 import org.openedx.auth.presentation.AuthAnalytics
+import org.openedx.auth.presentation.sso.BrowserAuthHelper
 import org.openedx.auth.presentation.AuthAnalyticsEvent
 import org.openedx.auth.presentation.AuthAnalyticsKey
 import org.openedx.auth.presentation.AuthRouter
@@ -49,6 +50,11 @@ class SignInViewModel(
     private val whatsNewGlobalManager: WhatsNewGlobalManager,
     agreementProvider: AgreementProvider,
     config: Config,
+    private val facebookAuthHelper: FacebookAuthHelper,
+    private val googleAuthHelper: GoogleAuthHelper,
+    private val microsoftAuthHelper: MicrosoftAuthHelper,
+    private val browserAuthHelper: BrowserAuthHelper,
+    val config: Config,
     val courseId: String?,
     val infoType: String?,
 ) : BaseViewModel() {
@@ -60,6 +66,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(),
             agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(),
@@ -144,11 +152,42 @@ 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 {
+                    logger.d { "Browser login success" }
+                    _uiState.update { it.copy(loginSuccess = true) }
+                    setUserId()
+                    analytics.userLoginEvent(AuthType.BROWSER.methodName)
+                    _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 77e2909..642ab2f 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
@@ -218,55 +218,60 @@ private fun AuthForm(
     var password by rememberSaveable { mutableStateOf("") }

     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
-            })
+        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
+                })

-        Spacer(modifier = Modifier.height(18.dp))
-        PasswordTextField(
-            modifier = Modifier
-                .fillMaxWidth(),
-            onValueChanged = {
-                password = it
-            },
-            onPressDone = {
-                onEvent(AuthEvent.SignIn(login = login, password = password))
-            }
-        )
+            Spacer(modifier = Modifier.height(18.dp))
+            PasswordTextField(
+                modifier = Modifier
+                    .fillMaxWidth(),
+                onValueChanged = {
+                    password = it
+                },
+                onPressDone = {
+                    onEvent(AuthEvent.SignIn(login = login, password = password))
+                }
+            )
+        } else {
+            Spacer(modifier = Modifier.height(40.dp))
+        }

         Row(
             Modifier
                 .fillMaxWidth()
                 .padding(top = 20.dp, bottom = 36.dp)
         ) {
-            if (state.isLogistrationEnabled.not()) {
-                Text(
-                    modifier = Modifier
+            if (!state.isBrowserLoginEnabled) {
+                if (state.isLogistrationEnabled.not()) {
+                    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
+                        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_forgot_password")
                     .noRippleClickable {
                         onEvent(AuthEvent.ForgotPasswordClick)
                     },
-                text = stringResource(id = R.string.auth_forgot_password),
-                color = MaterialTheme.appColors.primary,
-                style = MaterialTheme.appTypography.labelLarge
-            )
+                    text = stringResource(id = R.string.auth_forgot_password),
+                    color = MaterialTheme.appColors.primary,
+                    style = MaterialTheme.appTypography.labelLarge
+                )
+            }
         }

         if (state.showProgress) {
@@ -276,7 +281,11 @@ private fun AuthForm(
                 modifier = buttonWidth.testTag("btn_sign_in"),
                 text = stringResource(id = coreR.string.core_sign_in),
                 onClick = {
-                    onEvent(AuthEvent.SignIn(login = login, password = password))
+                    if(state.isBrowserLoginEnabled) {
+                        onEvent(AuthEvent.SignInBrowser)
+                    } else {
+                        onEvent(AuthEvent.SignIn(login = login, password = password))
+                    }
                 }
             )
         }
@@ -365,6 +374,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/sso/BrowserAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt
new file mode 100644
index 0000000..5822bab
--- /dev/null
+++ b/auth/src/main/java/org/openedx/auth/presentation/sso/BrowserAuthHelper.kt
@@ -0,0 +1,32 @@
+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.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()}/oauth2/authorize").buildUpon()
+            .appendQueryParameter("client_id", config.getOAuthClientId())
+            .appendQueryParameter("redirect_uri", "${activityContext.packageName}://oauth2Callback")
+            .appendQueryParameter("response_type", "code").build()
+        val intent =
+            CustomTabsIntent.Builder().setUrlBarHidingEnabled(true).setShowTitle(true).build()
+        intent.intent.setFlags(FLAG_ACTIVITY_NEW_TASK)
+        logger.d { "Launching custom tab with ${uri.toString()}"}
+        intent.launchUrl(activityContext, uri)
+    }
+
+    private companion object {
+        const val TAG = "BrowserAuthHelper"
+    }
+}
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 b36aabb..0d22d93 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
@@ -25,6 +25,7 @@ import org.openedx.auth.R
 import org.openedx.auth.domain.interactor.AuthInteractor
 import org.openedx.auth.presentation.AgreementProvider
 import org.openedx.auth.presentation.AuthAnalytics
+import org.openedx.auth.presentation.sso.BrowserAuthHelper
 import org.openedx.auth.presentation.AuthRouter
 import org.openedx.auth.presentation.sso.OAuthHelper
 import org.openedx.core.UIMessage
@@ -61,6 +62,7 @@ class SignInViewModelTest {
     private val oAuthHelper = mockk<OAuthHelper>()
     private val router = mockk<AuthRouter>()
     private val whatsNewGlobalManager = mockk<WhatsNewGlobalManager>()
+    private val browserAuthHelper = mockk<BrowserAuthHelper>()

     private val invalidCredential = "Invalid credentials"
     private val noInternet = "Slow or no internet connection"
@@ -85,6 +87,8 @@ class SignInViewModelTest {
         every { config.getFacebookConfig() } returns FacebookConfig()
         every { config.getGoogleConfig() } returns GoogleConfig()
         every { config.getMicrosoftConfig() } returns MicrosoftConfig()
+        every { config.isBrowserLoginEnabled() } returns false
+        every { config.isBrowserRegistrationEnabled() } returns false
     }

     @after
@@ -110,6 +114,7 @@ class SignInViewModelTest {
             config = config,
             router = router,
             whatsNewGlobalManager = whatsNewGlobalManager,
+            browserAuthHelper = browserAuthHelper,
             courseId = "",
             infoType = "",
         )
@@ -143,6 +148,7 @@ class SignInViewModelTest {
             config = config,
             router = router,
             whatsNewGlobalManager = whatsNewGlobalManager,
+            browserAuthHelper = browserAuthHelper,
             courseId = "",
             infoType = "",
         )
@@ -177,6 +183,7 @@ class SignInViewModelTest {
             config = config,
             router = router,
             whatsNewGlobalManager = whatsNewGlobalManager,
+            browserAuthHelper = browserAuthHelper,
             courseId = "",
             infoType = "",
         )
@@ -210,6 +217,7 @@ class SignInViewModelTest {
             config = config,
             router = router,
             whatsNewGlobalManager = whatsNewGlobalManager,
+            browserAuthHelper = browserAuthHelper,
             courseId = "",
             infoType = "",
         )
@@ -245,6 +253,7 @@ class SignInViewModelTest {
             config = config,
             router = router,
             whatsNewGlobalManager = whatsNewGlobalManager,
+            browserAuthHelper = browserAuthHelper,
             courseId = "",
             infoType = "",
         )
@@ -281,6 +290,7 @@ class SignInViewModelTest {
             config = config,
             router = router,
             whatsNewGlobalManager = whatsNewGlobalManager,
+            browserAuthHelper = browserAuthHelper,
             courseId = "",
             infoType = "",
         )
@@ -319,6 +329,7 @@ class SignInViewModelTest {
             config = config,
             router = router,
             whatsNewGlobalManager = whatsNewGlobalManager,
+            browserAuthHelper = browserAuthHelper,
             courseId = "",
             infoType = "",
         )
@@ -357,6 +368,7 @@ class SignInViewModelTest {
             config = config,
             router = router,
             whatsNewGlobalManager = whatsNewGlobalManager,
+            browserAuthHelper = browserAuthHelper,
             courseId = "",
             infoType = "",
         )
diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt
index 786d63c..86d678c 100644
--- a/core/src/main/java/org/openedx/core/ApiConstants.kt
+++ b/core/src/main/java/org/openedx/core/ApiConstants.kt
@@ -12,6 +12,7 @@ object ApiConstants {
     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"
@@ -27,6 +28,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"

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 4b40fbc..186c6d3 100644
--- a/core/src/main/java/org/openedx/core/config/Config.kt
+++ b/core/src/main/java/org/openedx/core/config/Config.kt
@@ -111,6 +111,14 @@ class Config(context: Context) {
         return getBoolean(COURSE_UNIT_PROGRESS_ENABLED, false)
     }

+    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) {
@@ -162,6 +170,8 @@ class Config(context: Context) {
         private const val GOOGLE = "GOOGLE"
         private const val MICROSOFT = "MICROSOFT"
         private const val PRE_LOGIN_EXPERIENCE_ENABLED = "PRE_LOGIN_EXPERIENCE_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 BRANCH = "BRANCH"
diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml
index e1582bf..9347d27 100644
--- a/default_config/dev/config.yaml
+++ b/default_config/dev/config.yaml
@@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT"
 WHATS_NEW_ENABLED: false
 #feature flag enable Social Login buttons
 SOCIAL_AUTH_ENABLED: false
+#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
 COURSE_NESTED_LIST_ENABLED: false
 COURSE_UNIT_PROGRESS_ENABLED: false
diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml
index f7afc7b..fa71747 100644
--- a/default_config/prod/config.yaml
+++ b/default_config/prod/config.yaml
@@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT"
 WHATS_NEW_ENABLED: false
 #feature flag enable Social Login buttons
 SOCIAL_AUTH_ENABLED: false
+#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
 COURSE_NESTED_LIST_ENABLED: false
 COURSE_UNIT_PROGRESS_ENABLED: false
diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml
index f7afc7b..fa71747 100644
--- a/default_config/stage/config.yaml
+++ b/default_config/stage/config.yaml
@@ -75,6 +75,10 @@ TOKEN_TYPE: "JWT"
 WHATS_NEW_ENABLED: false
 #feature flag enable Social Login buttons
 SOCIAL_AUTH_ENABLED: false
+#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
 COURSE_NESTED_LIST_ENABLED: false
 COURSE_UNIT_PROGRESS_ENABLED: false
  • Loading branch information
xitij2000 committed Jul 30, 2024
1 parent 4d36310 commit b0b4165
Show file tree
Hide file tree
Showing 27 changed files with 328 additions and 40 deletions.
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${applicationId}" />
</intent-filter>

<!-- Branch URI Scheme -->
<intent-filter>
Expand Down
18 changes: 16 additions & 2 deletions app/src/main/java/org/openedx/app/AppActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,6 +57,14 @@ 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 == "oauth2Callback") {
return data.getQueryParameter("code")
}
return null
}

override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(TOP_INSET, topInset)
Expand Down Expand Up @@ -119,10 +128,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder {
if (savedInstanceState == null) {
when {
corePreferencesManager.user == null -> {
if (viewModel.isLogistrationEnabled) {
val authCode = authCode;
if (viewModel.isLogistrationEnabled && authCode == null) {
addFragment(LogistrationFragment())
} else {
addFragment(SignInFragment())
val bundle = Bundle()
bundle.putString("auth_code", authCode)
val fragment = SignInFragment()
fragment.arguments = bundle
addFragment(fragment)
}
}

Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/org/openedx/app/di/AppModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import org.openedx.app.system.notifier.AppNotifier
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
Expand Down Expand Up @@ -180,5 +181,6 @@ val appModule = module {
factory { FacebookAuthHelper() }
factory { GoogleAuthHelper(get()) }
factory { MicrosoftAuthHelper() }
factory { BrowserAuthHelper(get()) }
factory { OAuthHelper(get(), get(), get()) }
}
2 changes: 2 additions & 0 deletions app/src/main/java/org/openedx/app/di/ScreenModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ val screenModule = module {
get(),
get(),
get(),
get(),
)
}

Expand All @@ -92,6 +93,7 @@ val screenModule = module {
get(),
get(),
get(),
get(),
courseId,
infoType,
)
Expand Down
1 change: 1 addition & 0 deletions auth/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ android {
dependencies {
implementation project(path: ':core')

implementation 'androidx.browser:browser:1.7.0'
implementation "androidx.credentials:credentials:1.2.0"
implementation "androidx.credentials:credentials-play-services-auth:1.2.0"
implementation "com.facebook.android:facebook-login:16.2.0"
Expand Down
9 changes: 9 additions & 0 deletions auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ 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
): AuthResponse

@FormUrlEncoded
@POST(ApiConstants.URL_ACCESS_TOKEN)
fun refreshAccessToken(
Expand Down
1 change: 1 addition & 0 deletions auth/src/main/java/org/openedx/auth/data/model/AuthType.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ class AuthRepository(
.processAuthResponse()
}

suspend fun browserAuthCodeLogin(code: String) {
api.getAccessTokenFromCode(
grantType = ApiConstants.GRANT_TYPE_CODE,
clientId = config.getOAuthClientId(),
code = code,
redirectUri = "${config.getApplicationID()}://oauth2Callback"
).mapToDomain().processAuthResponse()
}

suspend fun getRegistrationFields(): List<RegistrationField> {
return api.getRegistrationFields().fields?.map { it.mapToDomain() } ?: emptyList()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegistrationField> {
return repository.getRegistrationFields()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,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.core.utils.UrlUtils

class LogistrationFragment : Fragment() {

Expand All @@ -66,10 +67,23 @@ 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 = "/register",
)
} else {
viewModel.navigateToSignUp(parentFragmentManager)
}
},
onSearchClick = { querySearch ->
viewModel.navigateToDiscovery(parentFragmentManager, querySearch)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,50 @@
package org.openedx.auth.presentation.logistration

import android.app.Activity
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.update
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.BaseViewModel
import org.openedx.core.config.Config
import org.openedx.core.extension.takeIfNotEmpty
import org.openedx.core.utils.Logger

class LogistrationViewModel(
private val courseId: String,
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 isBrowserRegistrationEnabled get() = config.isBrowserRegistrationEnabled()
val isBrowserLoginEnabled get() = config.isBrowserLoginEnabled()
val apiHostUrl get() = config.getApiHostURL()

fun navigateToSignIn(parentFragmentManager: FragmentManager) {
router.navigateToSignIn(parentFragmentManager, courseId, null)
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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,11 @@ class SignInFragment : Fragment() {
val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null)

if (appUpgradeEvent == null) {
val authCode = arguments?.getString("auth_code")
if (authCode is String && !state.loginFailure && !state.loginSuccess) {
arguments?.remove("auth_code")
viewModel.signInAuthCode(authCode)
}
LoginScreen(
windowSize = windowSize,
state = state,
Expand All @@ -59,6 +64,10 @@ class SignInFragment : Fragment() {
viewModel.navigateToForgotPassword(parentFragmentManager)
}

AuthEvent.SignInBrowser -> {
viewModel.signInBrowser(requireActivity())
}

AuthEvent.RegisterClick -> {
viewModel.navigateToSignUp(parentFragmentManager)
}
Expand Down Expand Up @@ -107,6 +116,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<String, String>, val link: String) : AuthEvent
object SignInBrowser : AuthEvent
object RegisterClick : AuthEvent
object ForgotPasswordClick : AuthEvent
object BackClick : AuthEvent
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ 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 showProgress: Boolean = false,
val loginSuccess: Boolean = false,
val agreement: RegistrationField? = null,
val loginFailure: Boolean = false,
)
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -17,6 +18,7 @@ import org.openedx.auth.domain.interactor.AuthInteractor
import org.openedx.auth.domain.model.SocialAuthResponse
import org.openedx.auth.presentation.AgreementProvider
import org.openedx.auth.presentation.AuthAnalytics
import org.openedx.auth.presentation.sso.BrowserAuthHelper
import org.openedx.auth.presentation.AuthAnalyticsEvent
import org.openedx.auth.presentation.AuthAnalyticsKey
import org.openedx.auth.presentation.AuthRouter
Expand Down Expand Up @@ -48,7 +50,8 @@ class SignInViewModel(
private val router: AuthRouter,
private val whatsNewGlobalManager: WhatsNewGlobalManager,
agreementProvider: AgreementProvider,
config: Config,
private val browserAuthHelper: BrowserAuthHelper,
val config: Config,
val courseId: String?,
val infoType: String?,
) : BaseViewModel() {
Expand All @@ -60,6 +63,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(),
agreement = agreementProvider.getAgreement(isSignIn = true)?.createHonorCodeField(),
Expand Down Expand Up @@ -144,11 +149,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 {
logger.d { "Browser login success" }
_uiState.update { it.copy(loginSuccess = true) }
setUserId()
_uiState.update { it.copy(showProgress = false) }
}
}
}

fun navigateToForgotPassword(parentFragmentManager: FragmentManager) {
router.navigateToRestorePassword(parentFragmentManager)
logEvent(AuthAnalyticsEvent.FORGOT_PASSWORD_CLICKED)
Expand Down
Loading

0 comments on commit b0b4165

Please sign in to comment.