Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
45 changes: 44 additions & 1 deletion app/build.gradle
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Allow customising the applicationId via the config file, but fall back to org.openedx.app as before.

minSdk 24
targetSdk 34
versionCode 1
Expand All @@ -30,9 +43,31 @@ android {
}
develop {
dimension 'env'
applicationIdSuffix '.dev'
}
stage {
dimension 'env'
applicationIdSuffix '.stage'
Comment on lines +46 to +50
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added suffixes for each environment since AFAIK this is also done for iOS.

}
}
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]
}
}
}

Expand Down Expand Up @@ -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")
}
8 changes: 8 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@

<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="${applicationId}" />
</intent-filter>
Comment on lines +38 to +43
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will have the app handle the scheme matching the applicationId.

i.e. if the application id is "org.openedx.app" then this will handle: com.openedx.app://

</activity>

<provider
Expand Down
16 changes: 15 additions & 1 deletion app/src/main/java/org/openedx/app/AppActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.openedx.app
import android.content.pm.ActivityInfo
import android.content.res.Configuration
import android.graphics.Color
import android.net.Uri
import android.os.Bundle
import android.view.View
import android.view.WindowManager
Expand Down Expand Up @@ -49,6 +50,15 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataH

private var _windowSize = WindowSize(WindowType.Compact, WindowType.Compact)

private val authCode: String?
get() {
val data = intent?.data
if (data is Uri && data.scheme == BuildConfig.APPLICATION_ID && data.host == "oauth2Callback") {
return data.getQueryParameter("code")
}
return null
}

Comment on lines +53 to +61
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the application was opened using the auth intent filter,
<applicationid>://oauth2Callback?code=<some_code> it will get the code out of the URL and return that.

override fun onSaveInstanceState(outState: Bundle) {
outState.putInt(TOP_INSET, topInset)
outState.putInt(BOTTOM_INSET, bottomInset)
Expand Down Expand Up @@ -103,8 +113,12 @@ class AppActivity : AppCompatActivity(), InsetHolder, WindowSizeHolder, AppDataH
.add(R.id.container, MainFragment())
.commit()
} else {
val fragment = SignInFragment()
val bundle = Bundle()
bundle.putString("auth_code", authCode)
fragment.arguments = bundle
supportFragmentManager.beginTransaction()
.add(R.id.container, SignInFragment())
.add(R.id.container, fragment)
Comment on lines +116 to +121
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass the code to the login fragment if available.

.commit()
}
}
Expand Down
1 change: 1 addition & 0 deletions auth/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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'
Expand Down
11 changes: 11 additions & 0 deletions auth/src/main/java/org/openedx/auth/data/api/AuthApi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@ interface AuthApi {
password: String,
): AuthResponse

@FormUrlEncoded
@POST(ApiConstants.URL_ACCESS_TOKEN)
suspend fun getAccessToken(
@Field("grant_type")
grantType: String,
@Field("client_id")
clientId: String,
@Field("code")
username: String,
): AuthResponse

@FormUrlEncoded
@POST(ApiConstants.URL_ACCESS_TOKEN)
fun refreshAccessToken(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package org.openedx.auth.data.repository

import org.openedx.auth.data.api.AuthApi
import org.openedx.auth.data.model.AuthResponse
import org.openedx.auth.data.model.ValidationFields
import org.openedx.core.ApiConstants
import org.openedx.core.data.storage.CorePreferences
Expand All @@ -12,6 +13,16 @@ class AuthRepository(
private val preferencesManager: CorePreferences,
) {

private suspend fun processAuthResponse(authResponse: AuthResponse) {
if (authResponse.error != null) {
throw EdxError.UnknownException(authResponse.error!!)
}
preferencesManager.accessToken = authResponse.accessToken ?: ""
preferencesManager.refreshToken = authResponse.refreshToken ?: ""
val user = api.getProfile().mapToDomain()
preferencesManager.user = user
}
Comment on lines +16 to +24
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extracted out the common code since both login functions only have a slightly different API call but the same response.


suspend fun login(
username: String,
password: String,
Expand All @@ -22,13 +33,16 @@ class AuthRepository(
username,
password
)
if (authResponse.error != null) {
throw EdxError.UnknownException(authResponse.error!!)
}
preferencesManager.accessToken = authResponse.accessToken ?: ""
preferencesManager.refreshToken = authResponse.refreshToken ?: ""
val user = api.getProfile().mapToDomain()
preferencesManager.user = user
processAuthResponse(authResponse)
}

suspend fun login(code: String) {
val authResponse = api.getAccessToken(
ApiConstants.GRANT_TYPE_CODE,
org.openedx.core.BuildConfig.CLIENT_ID,
code,
)
processAuthResponse(authResponse)
}

suspend fun getRegistrationFields(): List<RegistrationField> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<RegistrationField> {
return repository.getRegistrationFields()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.*
Expand Down Expand Up @@ -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() {

Expand All @@ -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)
}
Comment on lines +60 to +63
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this view was passed the auth_code then we can just log in straight away.

setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
OpenEdXTheme {
Expand All @@ -66,6 +74,7 @@ class SignInFragment : Fragment() {
windowSize = windowSize,
showProgress = showProgress,
uiMessage = uiMessage,
browserLogin = BuildConfig.BROWSER_LOGIN,
onLoginClick = { login, password ->
viewModel.login(login, password)
},
Expand All @@ -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)
}
)

Expand All @@ -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
) {
Expand Down Expand Up @@ -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,
)
}
}
}
}
Expand Down Expand Up @@ -334,9 +373,11 @@ private fun SignInScreenPreview() {
windowSize = WindowSize(WindowType.Compact, WindowType.Compact),
showProgress = false,
uiMessage = null,
browserLogin = true,
onLoginClick = { _, _ ->

},
onLoginClickOauth = {},
onRegisterClick = {},
onForgotPasswordClick = {}
)
Expand All @@ -353,9 +394,11 @@ private fun SignInScreenTabletPreview() {
windowSize = WindowSize(WindowType.Expanded, WindowType.Expanded),
showProgress = false,
uiMessage = null,
browserLogin = true,
onLoginClick = { _, _ ->

},
onLoginClickOauth = {},
onRegisterClick = {},
onForgotPasswordClick = {}
)
Expand Down
Loading