diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 33e9421b4..879f8b83b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -36,7 +36,8 @@ jobs: uses: gradle/gradle-build-action@v2.6.1 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - + - name: Generate mock files + run: ./gradlew generateMockedRawFile - name: Run unit tests run: ./gradlew testProdReleaseUnitTest $CI_GRADLE_ARG_PROPERTIES diff --git a/app/build.gradle b/app/build.gradle index b7e99d896..91456c233 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,3 +1,14 @@ +import org.yaml.snakeyaml.Yaml + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath 'org.yaml:snakeyaml:1.33' + } +} + plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' @@ -6,11 +17,13 @@ plugins { id 'com.google.firebase.crashlytics' } +def config = new Yaml().load(new File("config.yaml").newInputStream()) + android { compileSdk 34 defaultConfig { - applicationId "org.openedx.app" + applicationId config.applicationId minSdk 24 targetSdk 34 versionCode 1 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 98e09bf08..b506d0e24 100644 --- a/app/src/main/java/org/openedx/app/di/AppModule.kt +++ b/app/src/main/java/org/openedx/app/di/AppModule.kt @@ -4,15 +4,31 @@ import androidx.room.Room import androidx.room.RoomDatabase import com.google.gson.Gson import com.google.gson.GsonBuilder +import kotlinx.coroutines.Dispatchers +import org.koin.android.ext.koin.androidApplication +import org.koin.core.qualifier.named +import org.koin.dsl.module +import org.openedx.app.AnalyticsManager +import org.openedx.app.AppAnalytics +import org.openedx.app.AppRouter +import org.openedx.app.data.storage.PreferencesManager +import org.openedx.app.room.AppDatabase +import org.openedx.app.room.DATABASE_NAME +import org.openedx.app.system.notifier.AppNotifier +import org.openedx.auth.presentation.sso.FacebookAuthHelper +import org.openedx.auth.presentation.sso.GoogleAuthHelper +import org.openedx.auth.presentation.sso.MicrosoftAuthHelper import org.openedx.auth.presentation.AuthAnalytics import org.openedx.auth.presentation.AuthRouter -import org.openedx.app.data.storage.PreferencesManager +import org.openedx.core.data.storage.CorePreferences import org.openedx.core.module.DownloadWorkerController import org.openedx.core.module.TranscriptManager import org.openedx.core.module.download.FileDownloader +import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter import org.openedx.core.system.AppCookieManager import org.openedx.core.system.ResourceManager import org.openedx.core.system.connection.NetworkConnection +import org.openedx.core.system.notifier.AppUpgradeNotifier import org.openedx.core.system.notifier.CourseNotifier import org.openedx.course.presentation.CourseAnalytics import org.openedx.course.presentation.CourseRouter @@ -23,23 +39,10 @@ import org.openedx.discovery.presentation.DiscoveryRouter import org.openedx.discussion.presentation.DiscussionAnalytics import org.openedx.discussion.presentation.DiscussionRouter import org.openedx.discussion.system.notifier.DiscussionNotifier -import org.openedx.app.AnalyticsManager -import org.openedx.app.AppAnalytics -import org.openedx.app.AppRouter -import org.openedx.app.room.AppDatabase -import org.openedx.app.room.DATABASE_NAME -import org.openedx.app.system.notifier.AppNotifier +import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.profile.presentation.ProfileAnalytics import org.openedx.profile.presentation.ProfileRouter import org.openedx.profile.system.notifier.ProfileNotifier -import kotlinx.coroutines.Dispatchers -import org.koin.android.ext.koin.androidApplication -import org.koin.core.qualifier.named -import org.koin.dsl.module -import org.openedx.core.data.storage.CorePreferences -import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRouter -import org.openedx.core.system.notifier.AppUpgradeNotifier -import org.openedx.profile.data.storage.ProfilePreferences import org.openedx.whatsnew.WhatsNewFileManager import org.openedx.whatsnew.WhatsNewRouter import org.openedx.whatsnew.data.storage.WhatsNewPreferences @@ -133,4 +136,8 @@ val appModule = module { single { get() } single { get() } single { get() } + + single { FacebookAuthHelper() } + single { GoogleAuthHelper() } + single { MicrosoftAuthHelper() } } \ No newline at end of file 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 94c219d88..811180564 100644 --- a/app/src/main/java/org/openedx/app/di/ScreenModule.kt +++ b/app/src/main/java/org/openedx/app/di/ScreenModule.kt @@ -60,7 +60,7 @@ val screenModule = module { factory { AuthRepository(get(), get()) } factory { AuthInteractor(get()) } factory { Validator() } - viewModel { SignInViewModel(get(), get(), get(), get(), get(), get()) } + viewModel { SignInViewModel(get(), get(), get(), get(), get(), get(), get(), get(), get()) } viewModel { SignUpViewModel(get(), get(), get(), get(), get()) } viewModel { RestorePasswordViewModel(get(), get(), get(), get()) } diff --git a/auth/build.gradle b/auth/build.gradle index 788bb0153..1596bdbfd 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -55,6 +55,12 @@ android { dependencies { implementation project(path: ':core') + 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" + implementation "com.google.android.gms:play-services-auth:20.7.0" + implementation "com.google.android.libraries.identity.googleid:googleid:1.1.0" + implementation 'com.microsoft.identity.client:msal:4.9.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" @@ -64,5 +70,4 @@ dependencies { testImplementation "io.mockk:mockk-android:$mockk_version" testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutines_version" testImplementation "androidx.arch.core:core-testing:$android_arch_version" - } \ No newline at end of file diff --git a/auth/proguard-rules.pro b/auth/proguard-rules.pro index 481bb4348..82ef50a20 100644 --- a/auth/proguard-rules.pro +++ b/auth/proguard-rules.pro @@ -18,4 +18,9 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +-if class androidx.credentials.CredentialManager +-keep class androidx.credentials.playservices.** { + *; +} 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 736c01995..71750251d 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 @@ -11,6 +11,16 @@ import retrofit2.http.* interface AuthApi { + @FormUrlEncoded + @POST(ApiConstants.URL_EXCHANGE_TOKEN) + suspend fun exchangeAccessToken( + @Field("access_token") accessToken: String, + @Field("client_id") clientId: String, + @Field("token_type") tokenType: String, + @Field("asymmetric_jwt") isAsymmetricJwt: Boolean = true, + @Path("login_type") loginType: String, + ): AuthResponse + @FormUrlEncoded @POST(ApiConstants.URL_ACCESS_TOKEN) suspend fun getAccessToken( diff --git a/auth/src/main/java/org/openedx/auth/data/model/LoginType.kt b/auth/src/main/java/org/openedx/auth/data/model/LoginType.kt new file mode 100644 index 000000000..63cd70e9e --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/data/model/LoginType.kt @@ -0,0 +1,16 @@ +package org.openedx.auth.data.model + +import org.openedx.core.ApiConstants + +/** + * Enum class with types of supported login types + * + * @param postfix postfix to add to the API call + * @param methodName name of the login type + */ +enum class LoginType(val postfix: String, val methodName: String) { + PASSWORD("", "Password"), + GOOGLE(ApiConstants.LOGIN_TYPE_GOOGLE, "Google"), + FACEBOOK(ApiConstants.LOGIN_TYPE_FB, "Facebook"), + MICROSOFT(ApiConstants.LOGIN_TYPE_MICROSOFT, "Microsoft"), +} 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 21f93176e..5e4ddb94c 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,8 +1,10 @@ package org.openedx.auth.data.repository import org.openedx.auth.data.api.AuthApi +import org.openedx.auth.data.model.LoginType import org.openedx.auth.data.model.ValidationFields import org.openedx.core.ApiConstants +import org.openedx.core.BuildConfig import org.openedx.core.data.storage.CorePreferences import org.openedx.core.domain.model.RegistrationField import org.openedx.core.system.EdxError @@ -18,10 +20,27 @@ class AuthRepository( ) { val authResponse = api.getAccessToken( ApiConstants.GRANT_TYPE_PASSWORD, - org.openedx.core.BuildConfig.CLIENT_ID, + BuildConfig.CLIENT_ID, username, password, - org.openedx.core.BuildConfig.ACCESS_TOKEN_TYPE + BuildConfig.ACCESS_TOKEN_TYPE + ) + if (authResponse.error != null) { + throw EdxError.UnknownException(authResponse.error!!) + } + preferencesManager.accessToken = authResponse.accessToken ?: "" + preferencesManager.refreshToken = authResponse.refreshToken ?: "" + val user = api.getProfile() + preferencesManager.user = user + } + + suspend fun socialLogin(token: String?, loginType: LoginType) { + if (token.isNullOrBlank()) throw IllegalArgumentException("Token is null") + val authResponse = api.exchangeAccessToken( + accessToken = token, + clientId = BuildConfig.CLIENT_ID, + tokenType = BuildConfig.ACCESS_TOKEN_TYPE, + loginType = loginType.postfix ) if (authResponse.error != null) { throw EdxError.UnknownException(authResponse.error!!) @@ -47,4 +66,4 @@ class AuthRepository( suspend fun passwordReset(email: String): Boolean { return api.passwordReset(email).success } -} \ No newline at end of file +} 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 53d68fd22..ae53f0210 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 @@ -1,5 +1,6 @@ package org.openedx.auth.domain.interactor +import org.openedx.auth.data.model.LoginType import org.openedx.auth.data.model.ValidationFields import org.openedx.auth.data.repository.AuthRepository import org.openedx.core.domain.model.RegistrationField @@ -13,6 +14,10 @@ class AuthInteractor(private val repository: AuthRepository) { repository.login(username, password) } + suspend fun loginSocial(token: String?, loginType: LoginType) { + repository.socialLogin(token, loginType) + } + suspend fun getRegistrationFields(): List { return repository.getRegistrationFields() } 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 868765c06..726d615fb 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,84 +1,24 @@ package org.openedx.auth.presentation.signin -import android.content.res.Configuration.UI_MODE_NIGHT_NO -import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.CircularProgressIndicator -import androidx.compose.material.MaterialTheme -import androidx.compose.material.OutlinedTextField -import androidx.compose.material.Scaffold -import androidx.compose.material.Surface -import androidx.compose.material.Text -import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.rememberScaffoldState -import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.tooling.preview.Devices -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp import androidx.fragment.app.Fragment import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.auth.R import org.openedx.auth.presentation.AuthRouter -import org.openedx.auth.presentation.ui.LoginTextField -import org.openedx.core.UIMessage +import org.openedx.auth.presentation.signin.compose.LoginScreen +import org.openedx.core.AppUpdateState +import org.openedx.core.presentation.global.AppDataHolder import org.openedx.core.presentation.global.app_upgrade.AppUpgradeRequiredScreen -import org.openedx.core.ui.HandleUIMessage -import org.openedx.core.ui.OpenEdXButton -import org.openedx.core.ui.WindowSize -import org.openedx.core.ui.WindowType -import org.openedx.core.ui.displayCutoutForLandscape -import org.openedx.core.ui.noRippleClickable import org.openedx.core.ui.rememberWindowSize import org.openedx.core.ui.theme.OpenEdXTheme -import org.openedx.core.ui.theme.appColors -import org.openedx.core.ui.theme.appShapes -import org.openedx.core.ui.theme.appTypography -import org.openedx.core.ui.windowSizeValue -import org.openedx.core.AppUpdateState -import org.koin.android.ext.android.inject -import org.koin.androidx.viewmodel.ext.android.viewModel -import org.openedx.core.presentation.global.AppDataHolder class SignInFragment : Fragment() { @@ -95,32 +35,44 @@ class SignInFragment : Fragment() { OpenEdXTheme { val windowSize = rememberWindowSize() - val showProgress by viewModel.showProgress.observeAsState(initial = false) + val state by viewModel.uiState.collectAsState() val uiMessage by viewModel.uiMessage.observeAsState() - val loginSuccess by viewModel.loginSuccess.observeAsState(initial = false) val appUpgradeEvent by viewModel.appUpgradeEvent.observeAsState(null) if (appUpgradeEvent == null) { LoginScreen( windowSize = windowSize, - showProgress = showProgress, + state = state, uiMessage = uiMessage, - onLoginClick = { login, password -> - viewModel.login(login, password) - }, - onRegisterClick = { - viewModel.signUpClickedEvent() - router.navigateToSignUp(parentFragmentManager) + onEvent = { event -> + when (event) { + is AuthEvent.SignIn -> viewModel.login(event.login, event.password) + AuthEvent.SignInGoogle -> viewModel.signInGoogle(requireActivity()) + AuthEvent.SignInFacebook -> { + viewModel.signInFacebook(this@SignInFragment) + } + + AuthEvent.SignInMicrosoft -> { + viewModel.signInMicrosoft(requireActivity()) + } + + AuthEvent.ForgotPasswordClick -> { + viewModel.forgotPasswordClickedEvent() + router.navigateToRestorePassword(parentFragmentManager) + } + + AuthEvent.RegisterClick -> { + viewModel.signUpClickedEvent() + router.navigateToSignUp(parentFragmentManager) + } + } }, - onForgotPasswordClick = { - viewModel.forgotPasswordClickedEvent() - router.navigateToRestorePassword(parentFragmentManager) - } ) - LaunchedEffect(loginSuccess) { - val isNeedToShowWhatsNew = (requireActivity() as AppDataHolder).shouldShowWhatsNew() - if (loginSuccess) { + LaunchedEffect(state.loginSuccess) { + val isNeedToShowWhatsNew = + (requireActivity() as AppDataHolder).shouldShowWhatsNew() + if (state.loginSuccess) { if (isNeedToShowWhatsNew) { router.navigateToWhatsNew(parentFragmentManager) } else { @@ -141,279 +93,11 @@ class SignInFragment : Fragment() { } } -@Composable -private fun LoginScreen( - windowSize: WindowSize, - showProgress: Boolean, - uiMessage: UIMessage?, - onLoginClick: (login: String, password: String) -> Unit, - onRegisterClick: () -> Unit, - onForgotPasswordClick: () -> Unit -) { - val scaffoldState = rememberScaffoldState() - val scrollState = rememberScrollState() - - Scaffold( - scaffoldState = scaffoldState, - modifier = Modifier - .fillMaxSize() - .navigationBarsPadding(), - backgroundColor = MaterialTheme.appColors.background - ) { - - val contentPaddings by remember { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier - .widthIn(Dp.Unspecified, 420.dp) - .padding( - top = 32.dp, - bottom = 40.dp - ), - compact = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 28.dp) - ) - ) - } - val buttonWidth by remember(key1 = windowSize) { - mutableStateOf( - windowSize.windowSizeValue( - expanded = Modifier.widthIn(232.dp, Dp.Unspecified), - compact = Modifier.fillMaxWidth() - ) - ) - } - - Image( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.3f), - painter = painterResource(id = org.openedx.core.R.drawable.core_top_header), - contentScale = ContentScale.FillBounds, - contentDescription = null - ) - HandleUIMessage( - uiMessage = uiMessage, - scaffoldState = scaffoldState - ) - - Column( - Modifier.padding(it), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .fillMaxHeight(0.2f), - contentAlignment = Alignment.Center - ) { - Image( - painter = painterResource(id = org.openedx.core.R.drawable.core_ic_logo), - contentDescription = null, - modifier = Modifier - .padding(top = 20.dp) - .width(170.dp) - .height(48.dp) - ) - } - Surface( - color = MaterialTheme.appColors.background, - shape = MaterialTheme.appShapes.screenBackgroundShape, - modifier = Modifier - .fillMaxSize() - ) { - Box(contentAlignment = Alignment.TopCenter) { - Column( - modifier = Modifier - .background(MaterialTheme.appColors.background) - .verticalScroll(scrollState) - .displayCutoutForLandscape() - .then(contentPaddings), - ) { - Text( - text = stringResource(id = R.string.auth_sign_in), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.displaySmall - ) - Text( - modifier = Modifier.padding(top = 4.dp), - text = stringResource(id = R.string.auth_welcome_back), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.titleSmall - ) - Spacer(modifier = Modifier.height(24.dp)) - AuthForm( - buttonWidth, - showProgress, - onLoginClick, - onRegisterClick, - onForgotPasswordClick - ) - } - } - } - } - } -} - -@Composable -private fun AuthForm( - buttonWidth: Modifier, - isLoading: Boolean = false, - onLoginClick: (login: String, password: String) -> Unit, - onRegisterClick: () -> Unit, - onForgotPasswordClick: () -> Unit -) { - var login by rememberSaveable { mutableStateOf("") } - var password by rememberSaveable { mutableStateOf("") } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - LoginTextField( - modifier = Modifier - .fillMaxWidth(), - onValueChanged = { - login = it - }) - - Spacer(modifier = Modifier.height(18.dp)) - PasswordTextField( - modifier = Modifier - .fillMaxWidth(), - onValueChanged = { - password = it - }, - onPressDone = { - onLoginClick(login, password) - } - ) - - Row( - Modifier - .fillMaxWidth() - .padding(top = 20.dp, bottom = 36.dp) - ) { - Text( - modifier = Modifier.noRippleClickable { - onRegisterClick() - }, - text = stringResource(id = R.string.auth_register), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(modifier = Modifier.weight(1f)) - Text( - modifier = Modifier.noRippleClickable { - onForgotPasswordClick() - }, - text = stringResource(id = R.string.auth_forgot_password), - color = MaterialTheme.appColors.primary, - style = MaterialTheme.appTypography.labelLarge - ) - } - - if (isLoading) { - CircularProgressIndicator(color = MaterialTheme.appColors.primary) - } else { - OpenEdXButton( - width = buttonWidth, - text = stringResource(id = R.string.auth_sign_in), - onClick = { - onLoginClick(login, password) - } - ) - } - } -} - - -@Composable -private fun PasswordTextField( - modifier: Modifier = Modifier, - onValueChanged: (String) -> Unit, - onPressDone: () -> Unit, -) { - var passwordTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue("") - ) - } - val focusManager = LocalFocusManager.current - Text( - modifier = Modifier.fillMaxWidth(), - text = stringResource(id = org.openedx.core.R.string.core_password), - color = MaterialTheme.appColors.textPrimary, - style = MaterialTheme.appTypography.labelLarge - ) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - modifier = modifier, - value = passwordTextFieldValue, - onValueChange = { - passwordTextFieldValue = it - onValueChanged(it.text.trim()) - }, - colors = TextFieldDefaults.outlinedTextFieldColors( - unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, - backgroundColor = MaterialTheme.appColors.textFieldBackground - ), - shape = MaterialTheme.appShapes.textFieldShape, - placeholder = { - Text( - text = stringResource(id = R.string.auth_enter_password), - color = MaterialTheme.appColors.textFieldHint, - style = MaterialTheme.appTypography.bodyMedium - ) - }, - keyboardOptions = KeyboardOptions.Default.copy( - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Done - ), - visualTransformation = PasswordVisualTransformation(), - keyboardActions = KeyboardActions { - focusManager.clearFocus() - onPressDone() - }, - textStyle = MaterialTheme.appTypography.bodyMedium, - singleLine = true - ) +internal sealed interface AuthEvent { + data class SignIn(val login: String, val password: String) : AuthEvent + object SignInGoogle : AuthEvent + object SignInFacebook : AuthEvent + object SignInMicrosoft : AuthEvent + object RegisterClick : AuthEvent + object ForgotPasswordClick : AuthEvent } - -@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 SignInScreenPreview() { - OpenEdXTheme { - LoginScreen( - windowSize = WindowSize(WindowType.Compact, WindowType.Compact), - showProgress = false, - uiMessage = null, - onLoginClick = { _, _ -> - - }, - onRegisterClick = {}, - onForgotPasswordClick = {} - ) - } -} - - -@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 -private fun SignInScreenTabletPreview() { - OpenEdXTheme { - LoginScreen( - windowSize = WindowSize(WindowType.Expanded, WindowType.Expanded), - showProgress = false, - uiMessage = null, - onLoginClick = { _, _ -> - - }, - onRegisterClick = {}, - onForgotPasswordClick = {} - ) - } -} \ No newline at end of file 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 new file mode 100644 index 000000000..df486e73a --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/SignInUIState.kt @@ -0,0 +1,14 @@ +package org.openedx.auth.presentation.signin + +/** + * Data class to store UI state of the SignIn screen + * + * @param shouldShowSocialLogin is SSO buttons visible + * @param showProgress is progress visible + * @param loginSuccess is login succeed + */ +internal data class SignInUIState( + val shouldShowSocialLogin: Boolean = false, + val showProgress: Boolean = false, + val loginSuccess: 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 c1a4d1126..a9ea13aa0 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,13 +1,25 @@ package org.openedx.auth.presentation.signin +import android.app.Activity +import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.openedx.auth.R +import org.openedx.auth.data.model.LoginType import org.openedx.auth.domain.interactor.AuthInteractor import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.FacebookAuthHelper +import org.openedx.auth.presentation.sso.GoogleAuthHelper +import org.openedx.auth.presentation.sso.MicrosoftAuthHelper import org.openedx.core.BaseViewModel +import org.openedx.core.BuildConfig import org.openedx.core.SingleEventLiveData import org.openedx.core.UIMessage import org.openedx.core.Validator @@ -17,6 +29,7 @@ import org.openedx.core.system.EdxError import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeEvent import org.openedx.core.system.notifier.AppUpgradeNotifier +import org.openedx.core.utils.Logger import org.openedx.core.R as CoreRes class SignInViewModel( @@ -24,22 +37,24 @@ class SignInViewModel( private val resourceManager: ResourceManager, private val preferencesManager: CorePreferences, private val validator: Validator, + private val appUpgradeNotifier: AppUpgradeNotifier, private val analytics: AuthAnalytics, - private val appUpgradeNotifier: AppUpgradeNotifier + private val facebookAuthHelper: FacebookAuthHelper, + private val googleAuthHelper: GoogleAuthHelper, + private val microsoftAuthHelper: MicrosoftAuthHelper, ) : BaseViewModel() { - private val _showProgress = MutableLiveData() - val showProgress: LiveData - get() = _showProgress + private val logger = Logger("SignInViewModel") + + private val _uiState = MutableStateFlow( + SignInUIState(shouldShowSocialLogin = BuildConfig.FF_SHOW_SOCIAL_LOGIN) + ) + internal val uiState: StateFlow = _uiState private val _uiMessage = SingleEventLiveData() val uiMessage: LiveData get() = _uiMessage - private val _loginSuccess = SingleEventLiveData() - val loginSuccess: LiveData - get() = _loginSuccess - private val _appUpgradeEvent = MutableLiveData() val appUpgradeEvent: LiveData get() = _appUpgradeEvent @@ -60,13 +75,13 @@ class SignInViewModel( return } - _showProgress.value = true + _uiState.update { it.copy(showProgress = true) } viewModelScope.launch { try { interactor.login(username, password) - _loginSuccess.value = true + _uiState.update { it.copy(loginSuccess = true) } setUserId() - analytics.userLoginEvent(LoginMethod.PASSWORD.methodName) + analytics.userLoginEvent(LoginType.PASSWORD.methodName) } catch (e: Exception) { if (e is EdxError.InvalidGrantException) { _uiMessage.value = @@ -79,7 +94,7 @@ class SignInViewModel( UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_unknown_error)) } } - _showProgress.value = false + _uiState.update { it.copy(showProgress = false) } } } @@ -91,6 +106,47 @@ class SignInViewModel( } } + fun signInGoogle(activityContext: Activity) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { + googleAuthHelper.signIn(activityContext) + } + }.getOrNull()?.let { + exchangeToken(it, LoginType.GOOGLE) + } ?: onUnknownError() + } + } + + fun signInFacebook(fragment: Fragment) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + runCatching { + facebookAuthHelper.signIn(fragment) + }.onFailure { + logger.e { "Facebook auth error: $it" } + }.getOrNull()?.let { + exchangeToken(it, LoginType.FACEBOOK) + } ?: onUnknownError() + } + } + + fun signInMicrosoft(activityContext: Activity) { + _uiState.update { it.copy(showProgress = true) } + viewModelScope.launch { + withContext(Dispatchers.IO) { + runCatching { + microsoftAuthHelper.signIn(activityContext) + } + }.onFailure { + logger.e { "Microsoft auth error: $it" } + }.getOrNull()?.let { + exchangeToken(it, LoginType.MICROSOFT) + } ?: onUnknownError() + } + } + fun signUpClickedEvent() { analytics.signUpClickedEvent() } @@ -99,17 +155,39 @@ class SignInViewModel( analytics.forgotPasswordClickedEvent() } + override fun onCleared() { + super.onCleared() + facebookAuthHelper.clear() + } + + private suspend fun exchangeToken(token: String?, loginType: LoginType) { + runCatching { + interactor.loginSocial(token, loginType) + }.onFailure { error -> + logger.e { "Social login error: $error" } + onUnknownError() + }.onSuccess { + logger.d { "Social login (${loginType.methodName}) success" } + _uiState.update { it.copy(loginSuccess = true) } + setUserId() + analytics.userLoginEvent(loginType.methodName) + _uiState.update { it.copy(showProgress = false) } + } + } + + private fun onUnknownError(message: (() -> String)? = null) { + message?.let { + logger.e { it() } + } + _uiMessage.value = UIMessage.SnackBarMessage( + resourceManager.getString(CoreRes.string.core_error_unknown_error) + ) + _uiState.update { it.copy(showProgress = false) } + } + private fun setUserId() { preferencesManager.user?.let { analytics.setUserIdForSession(it.id) } } } - -private enum class LoginMethod(val methodName: String) { - PASSWORD("Password"), - FACEBOOK("Facebook"), - GOOGLE("Google"), - MICROSOFT("Microsoft") -} - 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 new file mode 100644 index 000000000..ce9aa4e4c --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/signin/compose/SignInView.kt @@ -0,0 +1,403 @@ +package org.openedx.auth.presentation.signin.compose + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Scaffold +import androidx.compose.material.Surface +import androidx.compose.material.Text +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.rememberScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Devices +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import org.openedx.auth.R +import org.openedx.auth.presentation.signin.AuthEvent +import org.openedx.auth.presentation.signin.SignInUIState +import org.openedx.auth.presentation.ui.LoginTextField +import org.openedx.core.UIMessage +import org.openedx.core.ui.HandleUIMessage +import org.openedx.core.ui.OpenEdXButton +import org.openedx.core.ui.OpenEdXOutlinedButton +import org.openedx.core.ui.WindowSize +import org.openedx.core.ui.WindowType +import org.openedx.core.ui.displayCutoutForLandscape +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.appShapes +import org.openedx.core.ui.theme.appTypography +import org.openedx.core.ui.windowSizeValue + +@Composable +internal fun LoginScreen( + windowSize: WindowSize, + state: SignInUIState, + uiMessage: UIMessage?, + onEvent: (AuthEvent) -> Unit, +) { + val scaffoldState = rememberScaffoldState() + val scrollState = rememberScrollState() + + Scaffold( + scaffoldState = scaffoldState, + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding(), + backgroundColor = MaterialTheme.appColors.background + ) { + val contentPaddings by remember { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier + .widthIn(Dp.Unspecified, 420.dp) + .padding( + top = 32.dp, + bottom = 40.dp + ), + compact = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 28.dp) + ) + ) + } + val buttonWidth by remember(key1 = windowSize) { + mutableStateOf( + windowSize.windowSizeValue( + expanded = Modifier.widthIn(232.dp, Dp.Unspecified), + compact = Modifier.fillMaxWidth() + ) + ) + } + + Image( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.3f), + painter = painterResource(id = org.openedx.core.R.drawable.core_top_header), + contentScale = ContentScale.FillBounds, + contentDescription = null + ) + HandleUIMessage( + uiMessage = uiMessage, + scaffoldState = scaffoldState + ) + + Column( + Modifier.padding(it), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.2f), + contentAlignment = Alignment.Center + ) { + Image( + painter = painterResource(id = org.openedx.core.R.drawable.core_ic_logo), + contentDescription = null, + modifier = Modifier + .padding(top = 20.dp) + .width(170.dp) + .height(48.dp) + ) + } + Surface( + color = MaterialTheme.appColors.background, + shape = MaterialTheme.appShapes.screenBackgroundShape, + modifier = Modifier + .fillMaxSize() + ) { + Box(contentAlignment = Alignment.TopCenter) { + Column( + modifier = Modifier + .background(MaterialTheme.appColors.background) + .verticalScroll(scrollState) + .displayCutoutForLandscape() + .then(contentPaddings), + ) { + Text( + text = stringResource(id = R.string.auth_sign_in), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.displaySmall + ) + Text( + modifier = Modifier.padding(top = 4.dp), + text = stringResource(id = R.string.auth_welcome_back), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.titleSmall + ) + Spacer(modifier = Modifier.height(24.dp)) + AuthForm( + buttonWidth, + state, + onEvent, + ) + } + } + } + } + } +} + +@Composable +private fun AuthForm( + buttonWidth: Modifier, + state: SignInUIState, + onEvent: (AuthEvent) -> Unit, +) { + var login by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + LoginTextField( + modifier = Modifier + .fillMaxWidth(), + onValueChanged = { + login = it + }) + + Spacer(modifier = Modifier.height(18.dp)) + PasswordTextField( + modifier = Modifier + .fillMaxWidth(), + onValueChanged = { + password = it + }, + onPressDone = { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } + ) + + Row( + Modifier + .fillMaxWidth() + .padding(top = 20.dp, bottom = 36.dp) + ) { + Text( + modifier = Modifier.noRippleClickable { + onEvent(AuthEvent.RegisterClick) + }, + text = stringResource(id = R.string.auth_register), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.weight(1f)) + Text( + modifier = Modifier.noRippleClickable { + onEvent(AuthEvent.ForgotPasswordClick) + }, + text = stringResource(id = R.string.auth_forgot_password), + color = MaterialTheme.appColors.primary, + style = MaterialTheme.appTypography.labelLarge + ) + } + + if (state.showProgress) { + CircularProgressIndicator(color = MaterialTheme.appColors.primary) + } else { + OpenEdXButton( + width = buttonWidth, + text = stringResource(id = R.string.auth_sign_in), + onClick = { + onEvent(AuthEvent.SignIn(login = login, password = password)) + } + ) + } + if (state.shouldShowSocialLogin) { + SocialLoginView(buttonWidth = buttonWidth, onEvent = onEvent) + } + } +} + +@Composable +private fun SocialLoginView( + buttonWidth: Modifier, + onEvent: (AuthEvent) -> Unit, +) { + OpenEdXOutlinedButton( + modifier = buttonWidth.padding(top = 24.dp), + backgroundColor = colorResource(id = org.openedx.core.R.color.background), + borderColor = colorResource(id = org.openedx.core.R.color.primary), + textColor = Color.Unspecified, + onClick = { + onEvent(AuthEvent.SignInGoogle) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_google), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier.padding(start = 10.dp), + text = stringResource(id = R.string.auth_google) + ) + } + } + OpenEdXButton( + width = buttonWidth.padding(top = 12.dp), + text = stringResource(id = R.string.auth_facebook), + backgroundColor = colorResource(id = R.color.auth_facebook_button), + onClick = { + onEvent(AuthEvent.SignInFacebook) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_facebook), + contentDescription = null, + tint = MaterialTheme.appColors.buttonText, + ) + Text( + modifier = Modifier.padding(start = 10.dp), + color = MaterialTheme.appColors.buttonText, + text = stringResource(id = R.string.auth_facebook) + ) + } + } + OpenEdXButton( + width = buttonWidth.padding(top = 12.dp), + text = stringResource(id = R.string.auth_microsoft), + backgroundColor = colorResource(id = R.color.auth_microsoft_button), + onClick = { + onEvent(AuthEvent.SignInMicrosoft) + } + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = painterResource(id = R.drawable.ic_auth_microsoft), + contentDescription = null, + tint = Color.Unspecified, + ) + Text( + modifier = Modifier.padding(start = 10.dp), + color = MaterialTheme.appColors.buttonText, + text = stringResource(id = R.string.auth_microsoft) + ) + } + } +} + +@Composable +private fun PasswordTextField( + modifier: Modifier = Modifier, + onValueChanged: (String) -> Unit, + onPressDone: () -> Unit, +) { + var passwordTextFieldValue by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) + } + val focusManager = LocalFocusManager.current + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = org.openedx.core.R.string.core_password), + color = MaterialTheme.appColors.textPrimary, + style = MaterialTheme.appTypography.labelLarge + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + modifier = modifier, + value = passwordTextFieldValue, + onValueChange = { + passwordTextFieldValue = it + onValueChanged(it.text.trim()) + }, + colors = TextFieldDefaults.outlinedTextFieldColors( + unfocusedBorderColor = MaterialTheme.appColors.textFieldBorder, + backgroundColor = MaterialTheme.appColors.textFieldBackground + ), + shape = MaterialTheme.appShapes.textFieldShape, + placeholder = { + Text( + text = stringResource(id = R.string.auth_enter_password), + color = MaterialTheme.appColors.textFieldHint, + style = MaterialTheme.appTypography.bodyMedium + ) + }, + keyboardOptions = KeyboardOptions.Default.copy( + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Done + ), + visualTransformation = PasswordVisualTransformation(), + keyboardActions = KeyboardActions { + focusManager.clearFocus() + onPressDone() + }, + textStyle = MaterialTheme.appTypography.bodyMedium, + singleLine = true + ) +} + +@Preview(uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Preview(name = "NEXUS_5_Light", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_5_Dark", device = Devices.NEXUS_5, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SignInScreenPreview() { + OpenEdXTheme { + LoginScreen( + windowSize = WindowSize(WindowType.Compact, WindowType.Compact), + state = SignInUIState(), + uiMessage = null, + onEvent = {}, + ) + } +} + + +@Preview(name = "NEXUS_9_Light", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_NO) +@Preview(name = "NEXUS_9_Night", device = Devices.NEXUS_9, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun SignInScreenTabletPreview() { + OpenEdXTheme { + LoginScreen( + windowSize = WindowSize(WindowType.Expanded, WindowType.Expanded), + state = SignInUIState().copy(shouldShowSocialLogin = true), + uiMessage = null, + onEvent = {}, + ) + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt new file mode 100644 index 000000000..9ca6056a6 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/FacebookAuthHelper.kt @@ -0,0 +1,54 @@ +package org.openedx.auth.presentation.sso + +import androidx.fragment.app.Fragment +import com.facebook.CallbackManager +import com.facebook.FacebookCallback +import com.facebook.FacebookException +import com.facebook.login.LoginManager +import com.facebook.login.LoginResult +import kotlinx.coroutines.suspendCancellableCoroutine +import org.openedx.core.utils.Logger +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class FacebookAuthHelper { + + private val logger = Logger(TAG) + private val callbackManager = CallbackManager.Factory.create() + + suspend fun signIn(fragment: Fragment): String? = suspendCancellableCoroutine { continuation -> + LoginManager.getInstance().registerCallback( + callbackManager, + object : FacebookCallback { + override fun onCancel() { + logger.d { "Facebook login canceled" } + continuation.cancel() + } + + override fun onError(error: FacebookException) { + logger.e { "Facebook login error: $error" } + continuation.resumeWithException(error) + } + + override fun onSuccess(result: LoginResult) { + logger.d { "Facebook login success" } + continuation.resume(result.accessToken.token) + } + } + ) + LoginManager.getInstance().logInWithReadPermissions( + fragment, + callbackManager, + PERMISSIONS_LIST + ) + } + + fun clear() { + LoginManager.getInstance().unregisterCallback(callbackManager) + } + + private companion object { + const val TAG = "FacebookAuthHelper" + val PERMISSIONS_LIST = listOf("email", "public_profile") + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt new file mode 100644 index 000000000..a5b211f71 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/GoogleAuthHelper.kt @@ -0,0 +1,78 @@ +package org.openedx.auth.presentation.sso + +import android.accounts.Account +import android.app.Activity +import androidx.annotation.WorkerThread +import androidx.credentials.Credential +import androidx.credentials.CredentialManager +import androidx.credentials.GetCredentialRequest +import com.google.android.gms.auth.GoogleAuthUtil +import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption +import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential +import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException +import org.openedx.core.BuildConfig +import org.openedx.core.utils.Logger + +class GoogleAuthHelper { + + private val logger = Logger(TAG) + + private fun getAuthToken(activityContext: Activity, name: String): String? { + return runCatching { + GoogleAuthUtil.getToken( + activityContext, + Account(name, GoogleAuthUtil.GOOGLE_ACCOUNT_TYPE), + SCOPE + ) + }.getOrNull() + } + + private suspend fun getCredentials(activityContext: Activity): GoogleIdTokenCredential? { + return runCatching { + val credentialManager = CredentialManager.create(activityContext) + val googleIdOption = + GetSignInWithGoogleOption.Builder(BuildConfig.GOOGLE_CLIENT_ID).build() + val request: GetCredentialRequest = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + val result = credentialManager.getCredential( + request = request, + context = activityContext, + ) + getGoogleIdToken(result.credential) + }.onFailure { + logger.e { "GetCredentials error: ${it.message}" } + }.getOrNull() + } + + private fun getGoogleIdToken(credential: Credential): GoogleIdTokenCredential? { + return when (credential) { + is GoogleIdTokenCredential -> { + try { + GoogleIdTokenCredential.createFrom(credential.data) + } catch (e: GoogleIdTokenParsingException) { + logger.e { "Token parsing exception: $e" } + null + } + } + + else -> { + logger.e { "Unknown credential type" } + null + } + } + } + + @WorkerThread + suspend fun signIn(activityContext: Activity): String? { + val credentials = getCredentials(activityContext) + return credentials?.id?.let { + getAuthToken(activityContext, it) + } + } + + private companion object { + const val TAG = "GoogleAuthHelper" + const val SCOPE = "oauth2: https://www.googleapis.com/auth/userinfo.email" + } +} diff --git a/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt new file mode 100644 index 000000000..23469aad9 --- /dev/null +++ b/auth/src/main/java/org/openedx/auth/presentation/sso/MicrosoftAuthHelper.kt @@ -0,0 +1,52 @@ +package org.openedx.auth.presentation.sso + +import android.app.Activity +import androidx.annotation.WorkerThread +import com.microsoft.identity.client.AcquireTokenParameters +import com.microsoft.identity.client.AuthenticationCallback +import com.microsoft.identity.client.IAuthenticationResult +import com.microsoft.identity.client.PublicClientApplication +import com.microsoft.identity.client.exception.MsalException +import kotlinx.coroutines.suspendCancellableCoroutine +import org.openedx.core.R +import org.openedx.core.utils.Logger +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +class MicrosoftAuthHelper { + private val logger = Logger(TAG) + + @WorkerThread + suspend fun signIn(activityContext: Activity): String? = + suspendCancellableCoroutine { continuation -> + val clientApplication = + PublicClientApplication.createSingleAccountPublicClientApplication( + activityContext, + R.raw.auth_config + ) + val params = AcquireTokenParameters.Builder() + .startAuthorizationFromActivity(activityContext) + .withScopes(SCOPES) + .withCallback(object : AuthenticationCallback { + override fun onSuccess(authenticationResult: IAuthenticationResult?) { + continuation.resume(authenticationResult?.accessToken) + } + + override fun onError(exception: MsalException) { + logger.e { "Microsoft auth error: $exception" } + continuation.resumeWithException(exception) + } + + override fun onCancel() { + logger.d { "Microsoft auth canceled" } + continuation.cancel() + } + }).build() + clientApplication.acquireToken(params) + } + + private companion object { + const val TAG = "MicrosoftAuthHelper" + val SCOPES = listOf("User.Read") + } +} diff --git a/auth/src/main/res/drawable/ic_auth_facebook.xml b/auth/src/main/res/drawable/ic_auth_facebook.xml new file mode 100644 index 000000000..b8e7aa393 --- /dev/null +++ b/auth/src/main/res/drawable/ic_auth_facebook.xml @@ -0,0 +1,9 @@ + + + diff --git a/auth/src/main/res/drawable/ic_auth_google.xml b/auth/src/main/res/drawable/ic_auth_google.xml new file mode 100644 index 000000000..95bbcc563 --- /dev/null +++ b/auth/src/main/res/drawable/ic_auth_google.xml @@ -0,0 +1,22 @@ + + + + + + diff --git a/auth/src/main/res/drawable/ic_auth_microsoft.xml b/auth/src/main/res/drawable/ic_auth_microsoft.xml new file mode 100644 index 000000000..ce31faab7 --- /dev/null +++ b/auth/src/main/res/drawable/ic_auth_microsoft.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/auth/src/main/res/values/colors.xml b/auth/src/main/res/values/colors.xml new file mode 100644 index 000000000..3934d5449 --- /dev/null +++ b/auth/src/main/res/values/colors.xml @@ -0,0 +1,5 @@ + + + #0866FF + #FA000000 + diff --git a/auth/src/main/res/values/strings.xml b/auth/src/main/res/values/strings.xml index 57b333598..50ba4524b 100644 --- a/auth/src/main/res/values/strings.xml +++ b/auth/src/main/res/values/strings.xml @@ -20,4 +20,7 @@ username@domain.com Enter password Create new account. + Sign in with Google + Sign in with Facebook + Sign in with Microsoft \ No newline at end of file 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 b608bd479..82e8c1562 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 @@ -1,14 +1,6 @@ package org.openedx.auth.presentation.signin import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import org.openedx.auth.R -import org.openedx.auth.domain.interactor.AuthInteractor -import org.openedx.auth.presentation.AuthAnalytics -import org.openedx.core.UIMessage -import org.openedx.core.Validator -import org.openedx.core.data.model.User -import org.openedx.core.system.EdxError -import org.openedx.core.system.ResourceManager import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -24,11 +16,23 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule +import org.openedx.auth.R +import org.openedx.auth.domain.interactor.AuthInteractor +import org.openedx.auth.presentation.AuthAnalytics +import org.openedx.auth.presentation.sso.FacebookAuthHelper +import org.openedx.auth.presentation.sso.GoogleAuthHelper +import org.openedx.auth.presentation.sso.MicrosoftAuthHelper +import org.openedx.core.UIMessage +import org.openedx.core.Validator +import org.openedx.core.data.model.User import org.openedx.core.data.storage.CorePreferences +import org.openedx.core.system.EdxError +import org.openedx.core.system.ResourceManager import org.openedx.core.system.notifier.AppUpgradeNotifier import java.net.UnknownHostException import org.openedx.core.R as CoreRes @@ -47,6 +51,9 @@ class SignInViewModelTest { private val interactor = mockk() private val analytics = mockk() private val appUpgradeNotifier = mockk() + private val facebookAuthHelper = mockk() + private val googleAuthHelper = mockk() + private val microsoftAuthHelper = mockk() private val invalidCredential = "Invalid credentials" private val noInternet = "Slow or no internet connection" @@ -77,16 +84,26 @@ class SignInViewModelTest { every { validator.isEmailValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics, appUpgradeNotifier) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + facebookAuthHelper = facebookAuthHelper, + googleAuthHelper = googleAuthHelper, + microsoftAuthHelper = microsoftAuthHelper + ) viewModel.login("", "") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - + val uiState = viewModel.uiState.value assertEquals(invalidEmail, message.message) - assert(viewModel.showProgress.value != true) - assert(viewModel.loginSuccess.value != true) + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) } @Test @@ -94,17 +111,26 @@ class SignInViewModelTest { every { validator.isEmailValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics, appUpgradeNotifier) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + facebookAuthHelper = facebookAuthHelper, + googleAuthHelper = googleAuthHelper, + microsoftAuthHelper = microsoftAuthHelper + ) viewModel.login("acc@test.o", "") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - + val uiState = viewModel.uiState.value assertEquals(invalidEmail, message.message) - assert(viewModel.showProgress.value != true) - assert(viewModel.loginSuccess.value != true) + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) } @Test @@ -114,16 +140,26 @@ class SignInViewModelTest { every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit coVerify(exactly = 0) { interactor.login(any(), any()) } - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics, appUpgradeNotifier) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + facebookAuthHelper = facebookAuthHelper, + googleAuthHelper = googleAuthHelper, + microsoftAuthHelper = microsoftAuthHelper + ) viewModel.login("acc@test.org", "") verify(exactly = 0) { analytics.setUserIdForSession(any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val uiState = viewModel.uiState.value assertEquals(invalidPassword, message.message) - assert(viewModel.showProgress.value != true) - assert(viewModel.loginSuccess.value != true) + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) } @Test @@ -132,17 +168,27 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns false every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics, appUpgradeNotifier) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + facebookAuthHelper = facebookAuthHelper, + googleAuthHelper = googleAuthHelper, + microsoftAuthHelper = microsoftAuthHelper + ) viewModel.login("acc@test.org", "ed") coVerify(exactly = 0) { interactor.login(any(), any()) } verify(exactly = 0) { analytics.setUserIdForSession(any()) } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage + val uiState = viewModel.uiState.value assertEquals(invalidPassword, message.message) - assert(viewModel.showProgress.value != true) - assert(viewModel.loginSuccess.value != true) + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) } @Test @@ -152,7 +198,17 @@ class SignInViewModelTest { every { analytics.userLoginEvent(any()) } returns Unit every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics, appUpgradeNotifier) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + facebookAuthHelper = facebookAuthHelper, + googleAuthHelper = googleAuthHelper, + microsoftAuthHelper = microsoftAuthHelper + ) coEvery { interactor.login("acc@test.org", "edx") } returns Unit viewModel.login("acc@test.org", "edx") advanceUntilIdle() @@ -161,9 +217,9 @@ class SignInViewModelTest { verify(exactly = 1) { analytics.userLoginEvent(any()) } verify(exactly = 1) { analytics.setUserIdForSession(any()) } verify(exactly = 1) { appUpgradeNotifier.notifier } - - assertEquals(false, viewModel.showProgress.value) - assertEquals(true, viewModel.loginSuccess.value) + val uiState = viewModel.uiState.value + assertFalse(uiState.showProgress) + assert(uiState.loginSuccess) assertEquals(null, viewModel.uiMessage.value) } @@ -173,8 +229,17 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics, appUpgradeNotifier) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + facebookAuthHelper = facebookAuthHelper, + googleAuthHelper = googleAuthHelper, + microsoftAuthHelper = microsoftAuthHelper + ) coEvery { interactor.login("acc@test.org", "edx") } throws UnknownHostException() viewModel.login("acc@test.org", "edx") advanceUntilIdle() @@ -184,8 +249,9 @@ class SignInViewModelTest { verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as? UIMessage.SnackBarMessage - assertEquals(false, viewModel.showProgress.value) - assert(viewModel.loginSuccess.value != true) + val uiState = viewModel.uiState.value + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) assertEquals(noInternet, message?.message) } @@ -195,8 +261,17 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics, appUpgradeNotifier) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + facebookAuthHelper = facebookAuthHelper, + googleAuthHelper = googleAuthHelper, + microsoftAuthHelper = microsoftAuthHelper + ) coEvery { interactor.login("acc@test.org", "edx") } throws EdxError.InvalidGrantException() viewModel.login("acc@test.org", "edx") advanceUntilIdle() @@ -206,8 +281,9 @@ class SignInViewModelTest { verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - assertEquals(false, viewModel.showProgress.value) - assert(viewModel.loginSuccess.value != true) + val uiState = viewModel.uiState.value + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) assertEquals(invalidCredential, message.message) } @@ -217,8 +293,17 @@ class SignInViewModelTest { every { validator.isPasswordValid(any()) } returns true every { preferencesManager.user } returns user every { analytics.setUserIdForSession(any()) } returns Unit - val viewModel = - SignInViewModel(interactor, resourceManager, preferencesManager, validator, analytics, appUpgradeNotifier) + val viewModel = SignInViewModel( + interactor = interactor, + resourceManager = resourceManager, + preferencesManager = preferencesManager, + validator = validator, + analytics = analytics, + appUpgradeNotifier = appUpgradeNotifier, + facebookAuthHelper = facebookAuthHelper, + googleAuthHelper = googleAuthHelper, + microsoftAuthHelper = microsoftAuthHelper + ) coEvery { interactor.login("acc@test.org", "edx") } throws IllegalStateException() viewModel.login("acc@test.org", "edx") advanceUntilIdle() @@ -228,8 +313,9 @@ class SignInViewModelTest { verify(exactly = 1) { appUpgradeNotifier.notifier } val message = viewModel.uiMessage.value as UIMessage.SnackBarMessage - assertEquals(false, viewModel.showProgress.value) - assert(viewModel.loginSuccess.value != true) + val uiState = viewModel.uiState.value + assertFalse(uiState.showProgress) + assertFalse(uiState.loginSuccess) assertEquals(somethingWrong, message.message) } diff --git a/config.yaml b/config.yaml index 316249259..3d25c7cc2 100644 --- a/config.yaml +++ b/config.yaml @@ -13,6 +13,12 @@ environments: APPLICATION_ID: "" API_KEY: "" GCM_SENDER_ID: "" + SOCIAL: + GOOGLE_CLIENT_ID: "" + FACEBOOK_APP_ID: "" + FACEBOOK_CLIENT_TOKEN: "" + MICROSOFT_CLIENT_ID: "" + MICROSOFT_PACKAGE_SIGN: "" STAGE: URLS: API_HOST_URL: "http://stage-example.com/" @@ -26,6 +32,12 @@ environments: APPLICATION_ID: "" API_KEY: "" GCM_SENDER_ID: "" + SOCIAL: + GOOGLE_CLIENT_ID: "" + FACEBOOK_APP_ID: "" + FACEBOOK_CLIENT_TOKEN: "" + MICROSOFT_CLIENT_ID: "" + MICROSOFT_PACKAGE_SIGN: "" PROD: URLS: API_HOST_URL: "https://example.com/" @@ -39,10 +51,19 @@ environments: APPLICATION_ID: "" API_KEY: "" GCM_SENDER_ID: "" + SOCIAL: + GOOGLE_CLIENT_ID: "" + FACEBOOK_APP_ID: "" + FACEBOOK_CLIENT_TOKEN: "" + MICROSOFT_CLIENT_ID: "" + MICROSOFT_PACKAGE_SIGN: "" #Platform names platformName: "OpenEdX" platformFullName: "OpenEdX" +applicationId: "org.openedx.app" #tokenType enum accepts JWT and BEARER only tokenType: "JWT" #feature flag for activating What’s New feature showWhatsNew: false +#feature flag enable Social Login buttons on the Sign In page +ffShowSocialLogin: false diff --git a/core/.gitignore b/core/.gitignore index 42afabfd2..e6fcfbd02 100644 --- a/core/.gitignore +++ b/core/.gitignore @@ -1 +1,2 @@ -/build \ No newline at end of file +/build +auth_config.json \ No newline at end of file diff --git a/core/build.gradle b/core/build.gradle index 2c1fe76b2..af10eb2bc 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -1,3 +1,6 @@ +import java.util.regex.Matcher +import java.util.regex.Pattern +import groovy.json.JsonBuilder import org.yaml.snakeyaml.Yaml buildscript { @@ -17,8 +20,8 @@ plugins { id 'kotlin-kapt' } - def config = new Yaml().load(new File("config.yaml").newInputStream()) +def currentFlavour = getCurrentFlavor() android { compileSdk 34 @@ -32,6 +35,7 @@ android { buildConfigField "String", "ACCESS_TOKEN_TYPE", "\"${config.tokenType}\"" buildConfigField "Boolean", "SHOW_WHATS_NEW", "${config.showWhatsNew}" + buildConfigField "Boolean", "FF_SHOW_SOCIAL_LOGIN", "${config.ffShowSocialLogin}" } namespace 'org.openedx.core' @@ -40,15 +44,15 @@ android { productFlavors { prod { dimension 'env' - insertBuildConfigFields(config, it, "PROD") + insertBuildConfigFields(currentFlavour, config, it, "PROD") } develop { dimension 'env' - insertBuildConfigFields(config, it, "DEV") + insertBuildConfigFields(currentFlavour, config, it, "DEV") } stage { dimension 'env' - insertBuildConfigFields(config, it, "STAGE") + insertBuildConfigFields(currentFlavour, config, it, "STAGE") } } @@ -166,17 +170,29 @@ def setValue(value) { return result } -def insertBuildConfigFields(config, buildType, String keyName) { +def insertBuildConfigFields(currentFlavour, config, buildType, keyName) { def envMap = config.environments.find { it.key == keyName } def clientId = envMap.value.OAUTH_CLIENT_ID def envUrls = envMap.value.URLS + def social = envMap.value.SOCIAL def firebase = getFirebaseConfig(envMap) + if (currentFlavour == buildType.name) { + writeMicrosoftConfig(config.applicationId, social.MICROSOFT_CLIENT_ID, social.MICROSOFT_PACKAGE_SIGN) + } + buildType.buildConfigField "String", "BASE_URL", "\"${envUrls.API_HOST_URL}\"" buildType.buildConfigField "String", "CLIENT_ID", "\"${clientId}\"" buildType.buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" buildType.buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" buildType.buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" + buildType.buildConfigField "String", "GOOGLE_CLIENT_ID", "\"${social.GOOGLE_CLIENT_ID}\"" + buildType.buildConfigField "String", "MICROSOFT_CLIENT_ID", "\"${social.MICROSOFT_CLIENT_ID}\"" + buildType.buildConfigField "String", "MICROSOFT_PACKAGE_SIGN", "\"${social.MICROSOFT_PACKAGE_SIGN}\"" + + buildType.resValue "string", "facebook_app_id", social.FACEBOOK_APP_ID + buildType.resValue "string", "fb_login_protocol_scheme", "fb${social.FACEBOOK_APP_ID}" + buildType.resValue "string", "facebook_client_token", social.FACEBOOK_CLIENT_TOKEN buildType.resValue "string", "google_app_id", firebase.appId buildType.resValue "string", "platform_name", config.platformName buildType.resValue "string", "platform_full_name", config.platformFullName @@ -184,4 +200,42 @@ def insertBuildConfigFields(config, buildType, String keyName) { buildType.resValue "string", "terms_of_service_link", envUrls.termsOfService buildType.resValue "string", "contact_us_link", envUrls.contactUs buildType.resValue "string", "feedback_email_address", envUrls.FEEDBACK_EMAIL_ADDRESS + + buildType.manifestPlaceholders = [microsoftSignature: social.MICROSOFT_PACKAGE_SIGN] +} + +def writeMicrosoftConfig(applicationId, clientId, packageSign) { + def microsoftConfigsJsonPath = projectDir.path + "/src/main/res/raw/" + project.file(microsoftConfigsJsonPath).mkdirs() + def sign = URLEncoder.encode(packageSign, "UTF-8") + def configJson = [ + client_id : clientId, + redirect_uri : "msauth://$applicationId/$sign", + account_mode : "SINGLE", + broker_redirect_uri_registered: true + ] + new FileWriter(microsoftConfigsJsonPath + "/auth_config.json").withWriter { + it.write(new JsonBuilder(configJson).toPrettyString()) + } +} + +def getCurrentFlavor() { + String tskReqStr = getGradle().getStartParameter().getTaskRequests().toString() + Pattern pattern + if (tskReqStr.contains("assemble")) // to run ./gradlew assembleRelease to build APK + pattern = Pattern.compile("assemble(\\w+)(Release|Debug)") + else if (tskReqStr.contains("bundle")) // to run ./gradlew bundleRelease to build .aab + pattern = Pattern.compile("bundle(\\w+)(Release|Debug)") + else + pattern = Pattern.compile("generate(\\w+)(Release|Debug)") + Matcher matcher = pattern.matcher(tskReqStr) + if (matcher.find()) { + return matcher.group(1).toLowerCase() + } else { + return "" + } +} + +task generateMockedRawFile() { + writeMicrosoftConfig("", "", "") } diff --git a/core/src/main/AndroidManifest.xml b/core/src/main/AndroidManifest.xml index 8c4c98268..1e4fd8434 100644 --- a/core/src/main/AndroidManifest.xml +++ b/core/src/main/AndroidManifest.xml @@ -1,5 +1,50 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index b0b17f841..c3fafad5d 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -3,6 +3,7 @@ package org.openedx.core object ApiConstants { const val URL_LOGIN = "/oauth2/login/" const val URL_ACCESS_TOKEN = "/oauth2/access_token/" + const val URL_EXCHANGE_TOKEN = "/oauth2/exchange_access_token/{login_type}/" const val GET_USER_PROFILE = "/api/mobile/v0.5/my_user_info" const val URL_REVOKE_TOKEN = "/oauth2/revoke_token/" const val URL_REGISTRATION_FIELDS = "/user_api/v1/account/registration" @@ -18,4 +19,8 @@ object ApiConstants { const val EMAIL = "email" const val PASSWORD = "password" + + const val LOGIN_TYPE_GOOGLE = "google-oauth2" + const val LOGIN_TYPE_FB = "facebook" + const val LOGIN_TYPE_MICROSOFT = "azuread-oauth2" } \ No newline at end of file diff --git a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt index af884aef7..844249d39 100644 --- a/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt +++ b/core/src/main/java/org/openedx/core/ui/ComposeCommon.kt @@ -986,7 +986,7 @@ fun OpenEdXOutlinedButton( backgroundColor: Color = Color.Transparent, borderColor: Color, textColor: Color, - text: String, + text: String = "", onClick: () -> Unit, content: (@Composable RowScope.() -> Unit)? = null ) { diff --git a/core/src/main/java/org/openedx/core/utils/Logger.kt b/core/src/main/java/org/openedx/core/utils/Logger.kt new file mode 100644 index 000000000..f6bb4ecb0 --- /dev/null +++ b/core/src/main/java/org/openedx/core/utils/Logger.kt @@ -0,0 +1,18 @@ +package org.openedx.core.utils + +import android.util.Log +import org.openedx.core.BuildConfig + +class Logger(private val tag: String) { + fun d(message: () -> String) { + if (BuildConfig.DEBUG) Log.d(tag, message()) + } + + fun e(message: () -> String) { + if (BuildConfig.DEBUG) Log.e(tag, message()) + } + + fun w(message: () -> String) { + if (BuildConfig.DEBUG) Log.w(tag, message()) + } +} diff --git a/settings.gradle b/settings.gradle index 088d8db34..e0a869615 100644 --- a/settings.gradle +++ b/settings.gradle @@ -21,6 +21,7 @@ dependencyResolutionManagement { repositories { google() mavenCentral() + maven { url "https://pkgs.dev.azure.com/MicrosoftDeviceSDK/DuoSDK-Public/_packaging/Duo-SDK-Feed/maven/v1" } } } rootProject.name = "OpenEdX"