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()