diff --git a/README.md b/README.md index 392e6307e..8c63b881c 100644 --- a/README.md +++ b/README.md @@ -14,11 +14,18 @@ Modern vision of the mobile application for the Open EdX platform from Raccoon G 3. Choose ``educationx-app-android``. 4. Configure the [config.yaml](config.yaml) with URLs and OAuth credentials for your Open edX instance. + You can customise the location of this file using the `OPENEDX_ANDROID_CFG_FILE` environment variable. 5. Select the build variant ``develop``, ``stage``, or ``prod``. 6. Click the **Run** button. +## Customising + +To customise assets used in the Android app, you can specify a resource override directory in +`RES_DIR` in the `config.yaml` file. Any assets in this directory will override assets of the +same name in this repository. + ## API plugin This project uses custom APIs to improve performance and reduce the number of requests to the server. diff --git a/app/build.gradle b/app/build.gradle index 831c836b8..e0b9a2188 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,16 +1,29 @@ +import org.yaml.snakeyaml.Yaml + +buildscript { + + dependencies { + classpath 'org.yaml:snakeyaml:1.33' + } +} + plugins { id 'com.android.application' id 'org.jetbrains.kotlin.android' id 'kotlin-parcelize' id 'kotlin-kapt' + id 'com.google.gms.google-services' id "com.google.firebase.crashlytics" } +def config_file = System.getenv("OPENEDX_ANDROID_CFG_FILE") ?: "config.yaml" +def config = new Yaml().load(new File(config_file).newInputStream()) + android { compileSdk 34 defaultConfig { - applicationId "org.openedx.app" + applicationId config?.application_id ?: "org.openedx.app" minSdk 24 targetSdk 34 versionCode 1 @@ -30,9 +43,31 @@ android { } develop { dimension 'env' + applicationIdSuffix '.dev' } stage { dimension 'env' + applicationIdSuffix '.stage' + } + } + sourceSets { + prod { + def envMap = config.environments.find { it.key == "PROD" } + if (envMap.value.RES_DIR) { + res.srcDirs = [envMap.value.RES_DIR] + } + } + develop { + def envMap = config.environments.find { it.key == "DEV" } + if (envMap.value.RES_DIR) { + res.srcDirs = [envMap.value.RES_DIR] + } + } + stage { + def envMap = config.environments.find { it.key == "STAGE" } + if (envMap.value.RES_DIR) { + res.srcDirs = [envMap.value.RES_DIR] + } } } @@ -94,4 +129,12 @@ 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" + + // Import the Firebase BoM + implementation(platform("com.google.firebase:firebase-bom:32.2.3")) + + // When using the BoM, you don't specify versions in Firebase library dependencies + + // Add the dependency for the Firebase SDK for Google Analytics + implementation("com.google.firebase:firebase-analytics") } \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 49a1005fc..547b1e52c 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -61,6 +61,14 @@ -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation #===============/////GSON RULES \\\\\\\============ + +# Keep Data models from being obfuscated/minimized +-keep class org.openedx.auth.data.model.** { *; } +-keep class org.openedx.core.data.model.** { *; } +-keep class org.openedx.profile.data.model.** { *; } +-keep class org.openedx.profile.domain.model.** { *; } +-keep class org.openedx.discussion.data.model.** { *; } + ##---------------Begin: proguard configuration for Gson ---------- # Gson uses generic type information stored in a class file when working with fields. Proguard # removes such information by default, so configure it to keep all of it. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 086cf5a34..8e96e5412 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,12 @@ + + + + + + { 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..c75e9ee1a 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 @@ -13,6 +13,10 @@ class AuthInteractor(private val repository: AuthRepository) { repository.login(username, password) } + suspend fun login(code: String) { + repository.login(code) + } + 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 1f82823ac..92429d4e4 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,10 +1,13 @@ package org.openedx.auth.presentation.signin +import android.content.Intent.FLAG_ACTIVITY_NEW_TASK import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* @@ -42,6 +45,7 @@ import org.openedx.core.ui.theme.appShapes import org.openedx.core.ui.theme.appTypography import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.BuildConfig class SignInFragment : Fragment() { @@ -53,6 +57,10 @@ class SignInFragment : Fragment() { container: ViewGroup?, savedInstanceState: Bundle?, ) = ComposeView(requireContext()).apply { + val authCode = arguments?.getString("auth_code") + if (authCode is String) { + viewModel.login(authCode) + } setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) setContent { OpenEdXTheme { @@ -66,6 +74,7 @@ class SignInFragment : Fragment() { windowSize = windowSize, showProgress = showProgress, uiMessage = uiMessage, + browserLogin = BuildConfig.BROWSER_LOGIN, onLoginClick = { login, password -> viewModel.login(login, password) }, @@ -76,6 +85,20 @@ class SignInFragment : Fragment() { onForgotPasswordClick = { viewModel.forgotPasswordClickedEvent() router.navigateToRestorePassword(parentFragmentManager) + }, + onLoginClickOauth = { + val uri = Uri.parse("${BuildConfig.BASE_URL}oauth2/authorize") + .buildUpon() + .appendQueryParameter("client_id", BuildConfig.CLIENT_ID) + .appendQueryParameter("redirect_uri", "${context.packageName}://oauth2Callback") + .appendQueryParameter("response_type", "code") + .build() + val intent = CustomTabsIntent.Builder() + .setUrlBarHidingEnabled(true) + .setShowTitle(true) + .build() + intent.intent.setFlags(FLAG_ACTIVITY_NEW_TASK); + intent.launchUrl(context, uri) } ) @@ -94,7 +117,9 @@ private fun LoginScreen( windowSize: WindowSize, showProgress: Boolean, uiMessage: UIMessage?, + browserLogin: Boolean, onLoginClick: (login: String, password: String) -> Unit, + onLoginClickOauth: () -> Unit, onRegisterClick: () -> Unit, onForgotPasswordClick: () -> Unit ) { @@ -187,13 +212,27 @@ private fun LoginScreen( style = MaterialTheme.appTypography.titleSmall ) Spacer(modifier = Modifier.height(24.dp)) - AuthForm( - buttonWidth, - showProgress, - onLoginClick, - onRegisterClick, - onForgotPasswordClick - ) + if (browserLogin) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + OpenEdXButton( + width = buttonWidth, + text = stringResource(id = R.string.auth_sign_in), + onClick = onLoginClickOauth, + ) + } + } else { + AuthForm( + buttonWidth, + showProgress, + onLoginClick, + onRegisterClick, + onForgotPasswordClick, + ) + } } } } @@ -334,9 +373,11 @@ private fun SignInScreenPreview() { windowSize = WindowSize(WindowType.Compact, WindowType.Compact), showProgress = false, uiMessage = null, + browserLogin = true, onLoginClick = { _, _ -> }, + onLoginClickOauth = {}, onRegisterClick = {}, onForgotPasswordClick = {} ) @@ -353,9 +394,11 @@ private fun SignInScreenTabletPreview() { windowSize = WindowSize(WindowType.Expanded, WindowType.Expanded), showProgress = false, uiMessage = null, + browserLogin = true, onLoginClick = { _, _ -> }, + onLoginClickOauth = {}, onRegisterClick = {}, onForgotPasswordClick = {} ) 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 d9ca3bed5..d64a7a1e5 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 @@ -72,6 +72,30 @@ class SignInViewModel( } } + fun login(code: String) { + viewModelScope.launch { + try { + interactor.login(code) + _loginSuccess.value = true + setUserId() + analytics.userLoginEvent(LoginMethod.BROWSER.methodName) + } catch (e: Exception) { + if (e is EdxError.InvalidGrantException) { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_invalid_grant)) + } else if (e.isInternetError()) { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_no_connection)) + } else { + _uiMessage.value = + UIMessage.SnackBarMessage(resourceManager.getString(CoreRes.string.core_error_unknown_error)) + } + } + _showProgress.value = false + } + + } + fun signUpClickedEvent() { analytics.signUpClickedEvent() } @@ -91,6 +115,7 @@ private enum class LoginMethod(val methodName: String) { PASSWORD("Password"), FACEBOOK("Facebook"), GOOGLE("Google"), - MICROSOFT("Microsoft") + MICROSOFT("Microsoft"), + BROWSER("Browser"), } diff --git a/build.gradle b/build.gradle index 4cca63b16..420739fe7 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,7 @@ ext { firebase_version = "32.1.0" retrofit_version = '2.9.0' - logginginterceptor_version = '4.9.1' + logginginterceptor_version = '4.11.0' koin_version = '3.2.0' diff --git a/config.yaml b/config.yaml index 09972a3b6..1d7c8a909 100644 --- a/config.yaml +++ b/config.yaml @@ -1,5 +1,6 @@ environments: DEV: + # RES_DIR: /path/to/custom/resources URLS: API_HOST_URL: "https://dev-example.com/" privacyPolicy: "https://dev-example.com/privacy" @@ -13,6 +14,7 @@ environments: API_KEY: "" GCM_SENDER_ID: "" STAGE: + # RES_DIR: /path/to/custom/resources URLS: API_HOST_URL: "http://stage-example.com/" privacyPolicy: "http://stage-example.com/privacy" @@ -26,6 +28,7 @@ environments: API_KEY: "" GCM_SENDER_ID: "" PROD: + # RES_DIR: /path/to/custom/resources URLS: API_HOST_URL: "https://example.com/" privacyPolicy: "https://example.com/privacy" diff --git a/core/build.gradle b/core/build.gradle index 6bce96edf..4efa3df98 100644 --- a/core/build.gradle +++ b/core/build.gradle @@ -18,7 +18,8 @@ plugins { } -def config = new Yaml().load(new File("config.yaml").newInputStream()) +def config_file = System.getenv("OPENEDX_ANDROID_CFG_FILE") ?: "config.yaml" +def config = new Yaml().load(new File(config_file).newInputStream()) android { compileSdk 34 @@ -48,6 +49,7 @@ android { buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" + buildConfigField "boolean", "BROWSER_LOGIN", "${ envMap.value.LOGIN_VIA_BROWSER }" resValue "string", "google_app_id", firebase.appId resValue "string", "platform_name", config.platformName resValue "string", "platform_full_name", config.platformFullName @@ -69,6 +71,7 @@ android { buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" + buildConfigField "boolean", "BROWSER_LOGIN", "${ envMap.value.LOGIN_VIA_BROWSER }" resValue "string", "google_app_id", firebase.appId resValue "string", "platform_name", config.platformName resValue "string", "platform_full_name", config.platformFullName @@ -90,6 +93,7 @@ android { buildConfigField "String", "FIREBASE_PROJECT_ID", "\"${firebase.projectId}\"" buildConfigField "String", "FIREBASE_API_KEY", "\"${firebase.apiKey}\"" buildConfigField "String", "FIREBASE_GCM_SENDER_ID", "\"${firebase.gcmSenderId}\"" + buildConfigField "boolean", "BROWSER_LOGIN", "${ envMap.value.LOGIN_VIA_BROWSER }" resValue "string", "google_app_id", firebase.appId resValue "string", "platform_name", config.platformName resValue "string", "platform_full_name", config.platformFullName @@ -100,6 +104,27 @@ android { } } + sourceSets { + prod { + def envMap = config.environments.find { it.key == "PROD" } + if (envMap.value.RES_DIR) { + res.srcDirs = [envMap.value.RES_DIR] + } + } + develop { + def envMap = config.environments.find { it.key == "DEV" } + if (envMap.value.RES_DIR) { + res.srcDirs = [envMap.value.RES_DIR] + } + } + stage { + def envMap = config.environments.find { it.key == "STAGE" } + if (envMap.value.RES_DIR) { + res.srcDirs = [envMap.value.RES_DIR] + } + } + } + buildTypes { release { minifyEnabled true diff --git a/core/proguard-rules.pro b/core/proguard-rules.pro index 481bb4348..8dcc9ec0d 100644 --- a/core/proguard-rules.pro +++ b/core/proguard-rules.pro @@ -18,4 +18,6 @@ # 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 + +-dontwarn java.lang.invoke.StringConcatFactory diff --git a/core/src/main/java/org/openedx/core/ApiConstants.kt b/core/src/main/java/org/openedx/core/ApiConstants.kt index 82486f593..948a1c40c 100644 --- a/core/src/main/java/org/openedx/core/ApiConstants.kt +++ b/core/src/main/java/org/openedx/core/ApiConstants.kt @@ -11,6 +11,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_REFRESH = "refresh_token" diff --git a/course/proguard-rules.pro b/course/proguard-rules.pro index 481bb4348..3a6da835e 100644 --- a/course/proguard-rules.pro +++ b/course/proguard-rules.pro @@ -18,4 +18,7 @@ # 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 + +-dontwarn java.lang.invoke.StringConcatFactory +-dontwarn org.openedx.core.R$string diff --git a/dashboard/proguard-rules.pro b/dashboard/proguard-rules.pro index 481bb4348..3a6da835e 100644 --- a/dashboard/proguard-rules.pro +++ b/dashboard/proguard-rules.pro @@ -18,4 +18,7 @@ # 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 + +-dontwarn java.lang.invoke.StringConcatFactory +-dontwarn org.openedx.core.R$string diff --git a/discussion/proguard-rules.pro b/discussion/proguard-rules.pro index 481bb4348..71cd5111b 100644 --- a/discussion/proguard-rules.pro +++ b/discussion/proguard-rules.pro @@ -18,4 +18,7 @@ # 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 + +-dontwarn java.lang.invoke.StringConcatFactory +-dontwarn org.openedx.core.R$string \ No newline at end of file diff --git a/profile/build.gradle b/profile/build.gradle index 1c3c6f301..9c8ad6ed1 100644 --- a/profile/build.gradle +++ b/profile/build.gradle @@ -55,6 +55,7 @@ android { dependencies { implementation project(path: ":core") + implementation 'androidx.browser:browser:1.6.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' diff --git a/profile/proguard-rules.pro b/profile/proguard-rules.pro index 481bb4348..71cd5111b 100644 --- a/profile/proguard-rules.pro +++ b/profile/proguard-rules.pro @@ -18,4 +18,7 @@ # 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 + +-dontwarn java.lang.invoke.StringConcatFactory +-dontwarn org.openedx.core.R$string \ No newline at end of file diff --git a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt index 96ee13059..3fe2ba3f0 100644 --- a/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt +++ b/profile/src/main/java/org/openedx/profile/presentation/profile/ProfileFragment.kt @@ -2,9 +2,11 @@ package org.openedx.profile.presentation.profile import android.content.res.Configuration.UI_MODE_NIGHT_NO import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup +import androidx.browser.customtabs.CustomTabsIntent import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -41,6 +43,7 @@ import coil.compose.AsyncImage import coil.request.ImageRequest import org.koin.android.ext.android.inject import org.koin.androidx.viewmodel.ext.android.viewModel +import org.openedx.core.BuildConfig import org.openedx.core.R import org.openedx.core.UIMessage import org.openedx.profile.domain.model.Account @@ -87,6 +90,16 @@ class ProfileFragment : Fragment() { appData = (requireActivity() as AppDataHolder).appData, refreshing = refreshing, logout = { + if (BuildConfig.BROWSER_LOGIN) { + val uri = Uri.parse("${BuildConfig.BASE_URL}logout") + .buildUpon() + .appendQueryParameter("next", "/custom-logout-page").build() + val intent = CustomTabsIntent.Builder() + .setUrlBarHidingEnabled(true) + .setShowTitle(true) + .build() + intent.launchUrl(context, uri) + } viewModel.logout() }, editAccountClicked = { diff --git a/settings.gradle b/settings.gradle index 1bb570281..1a0980258 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,4 +1,15 @@ pluginManagement { + buildscript { + repositories { + mavenCentral() + maven { + url = uri("https://storage.googleapis.com/r8-releases/raw") + } + } + dependencies { + classpath("com.android.tools:r8:8.2.24") + } + } repositories { gradlePluginPortal() google()