diff --git a/android/build.gradle b/android/build.gradle
index 79a6ff30ba..dc07d7c96d 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -8,8 +8,8 @@ buildscript {
}
dependencies {
- classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.10'
- classpath "org.jetbrains.kotlin:kotlin-serialization:1.8.10"
+ classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:1.8.22'
+ classpath "org.jetbrains.kotlin:kotlin-serialization:1.8.22"
classpath 'com.google.dagger:hilt-android-gradle-plugin:2.45'
classpath deps.spotless
classpath deps.kotlin_coveralls_plugin
diff --git a/android/deps.gradle b/android/deps.gradle
index e3ad8d3a47..f0b60d3e6c 100644
--- a/android/deps.gradle
+++ b/android/deps.gradle
@@ -4,7 +4,7 @@
// Entries in each section of this file should be sorted alphabetically.
def sdk_versions = [:]
sdk_versions.compile_sdk = 33
-sdk_versions.min_sdk = 24
+sdk_versions.min_sdk = 26
sdk_versions.target_sdk = 33
ext.sdk_versions = sdk_versions
@@ -15,14 +15,14 @@ def versions = [:]
versions.activity = '1.2.1'
versions.android_gradle_plugin = '7.1.2'
versions.appcompat = '1.4.1'
-versions.atsl_core = '1.4.1-alpha04'
-versions.atsl_expresso = '3.5.0'
-versions.atsl_junit = '1.1.3'
-versions.atsl_rules = '1.4.0'
-versions.atsl_runner = '1.4.0'
+versions.atsl_core = '1.5.0'
+versions.atsl_expresso = '3.5.1'
+versions.atsl_junit = '1.1.5'
+versions.atsl_rules = '1.5.0'
+versions.atsl_runner = '1.5.2'
versions.caffeine = '2.9.0'
versions.constraint_layout = '1.1.3'
-versions.coroutines = '1.6.4'
+versions.coroutines = '1.7.3'
versions.core = '1.7.0'
versions.cql_engine = '1.3.14-SNAPSHOT'
versions.desugar = '1.1.5'
@@ -31,13 +31,13 @@ versions.fhir_protos = '0.6.1'
versions.guava = '28.2-android'
versions.hapi_r4 = '5.3.0'
versions.junit5_api = '5.9.3'
-versions.kotlin = '1.8.10'
+versions.kotlin = '1.8.22'
versions.lifecycle = '2.2.0'
versions.material = '1.5.0'
versions.okhttp_logging_interceptor = '4.0.0'
versions.recyclerview = '1.1.0'
versions.retrofit = '2.7.2'
-versions.robolectric = '4.9-alpha-1'
+versions.robolectric = '4.9.2'
versions.room = '2.4.2'
versions.spotless = '5.11.0'
versions.truth = '1.0.1'
@@ -48,11 +48,11 @@ versions.jacoco_tool = '0.8.7'
versions.ktlint = '0.41.0'
versions.joda_time = '2.10.5'
versions.timber = '4.7.1'
-versions.mockk = '1.12.4'
+versions.mockk = '1.13.5'
versions.dokka = '1.5.0'
-versions.androidx_test = '2.1.0'
+versions.androidx_test = '2.2.0'
versions.accompanist_swiperefresh = '0.26.4-beta'
-versions.compose = '1.3.3'
+versions.compose = '1.4.3'
ext.versions = versions
def deps = [:]
diff --git a/android/engine/build.gradle b/android/engine/build.gradle
index 5d2753d6e9..2421571c42 100644
--- a/android/engine/build.gradle
+++ b/android/engine/build.gradle
@@ -48,7 +48,7 @@ android {
viewBinding true
}
composeOptions {
- kotlinCompilerExtensionVersion '1.4.2'
+ kotlinCompilerExtensionVersion '1.4.8'
}
//CQL
@@ -87,6 +87,23 @@ android {
includeAndroidResources = true
returnDefaultValues = true
all {
+ testLogging {
+ // set options for log level LIFECYCLE
+ events "failed"
+ exceptionFormat "full"
+
+ // set options for log level DEBUG
+ debug {
+ events "started", "skipped", "failed"
+ exceptionFormat "full"
+ }
+
+
+ // remove standard output/error logging from --info builds
+ // by assigning only 'failed' and 'skipped' events
+ info.events = ["failed", "skipped"]
+ }
+
minHeapSize = "4608m"
maxHeapSize = "4608m"
beforeTest { testDescriptor ->
@@ -183,7 +200,7 @@ dependencies {
api("androidx.work:work-runtime-ktx:2.8.0")
testApi 'androidx.work:work-testing:2.8.0'
- def coroutineVersion = '1.6.4'
+ def coroutineVersion = '1.7.3'
api("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutineVersion")
api("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutineVersion")
api("org.smartregister:contrib-barcode:0.1.0-beta3-preview5-SNAPSHOT"){
@@ -218,7 +235,7 @@ dependencies {
exclude group: 'com.google.android.fhir', module: 'common'
}
- api 'com.google.code.gson:gson:2.9.0'
+ api 'com.google.code.gson:gson:2.9.1'
api 'com.jakewharton.timber:timber:5.0.1'
def retrofitVersion = '2.9.0'
@@ -243,6 +260,7 @@ dependencies {
testRuntimeOnly deps.junit5_engine
testRuntimeOnly deps.junit5_engine_vintage
testImplementation deps.robolectric
+ testImplementation deps.atsl.core
testImplementation deps.atsl.ext_junit
testImplementation deps.atsl.ext_junit_ktx
testImplementation deps.coroutines.test
@@ -254,7 +272,7 @@ dependencies {
androidTestImplementation deps.atsl.ext_junit
androidTestImplementation deps.atsl.espresso
testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion"
- androidTestImplementation 'androidx.test.ext:junit:1.1.3'
+ androidTestImplementation deps.atsl.ext_junit
androidTestImplementation "androidx.test.espresso:espresso-core:$versions.atsl_expresso"
implementation deps.work.runtime
testImplementation group: 'org.json', name: 'json', version: '20210307'
@@ -269,4 +287,4 @@ kapt {
hilt {
enableAggregatingTask = true
-}
+}
\ No newline at end of file
diff --git a/android/engine/src/main/AndroidManifest.xml b/android/engine/src/main/AndroidManifest.xml
index c97ead2959..555580c2e2 100644
--- a/android/engine/src/main/AndroidManifest.xml
+++ b/android/engine/src/main/AndroidManifest.xml
@@ -4,19 +4,12 @@
package="org.smartregister.fhircore.engine">
+
-
-
-
-
+
+
+
+
diff --git a/android/engine/src/main/assets/configs/default/config_application.json b/android/engine/src/main/assets/configs/default/config_application.json
index 1d93fc6fd9..cfcafad0fe 100644
--- a/android/engine/src/main/assets/configs/default/config_application.json
+++ b/android/engine/src/main/assets/configs/default/config_application.json
@@ -8,5 +8,6 @@
],
"applicationName": "Sample App",
"appLogoIconResourceFile": "ic_launcher",
- "count": "100"
+ "count": "100",
+ "syncStrategies": ["Organization", "Location", "CareTeam", "Practitioner"]
}
\ No newline at end of file
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AccountAuthenticator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AccountAuthenticator.kt
index d6d03b94c4..accbf2b274 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AccountAuthenticator.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AccountAuthenticator.kt
@@ -21,294 +21,159 @@ import android.accounts.Account
import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
import android.accounts.AccountManagerCallback
-import android.accounts.NetworkErrorException
import android.content.Context
import android.content.Intent
-import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.core.os.bundleOf
import dagger.hilt.android.qualifiers.ApplicationContext
+import java.net.UnknownHostException
import java.util.Locale
import javax.inject.Inject
-import javax.inject.Singleton
-import kotlinx.coroutines.CoroutineExceptionHandler
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import okhttp3.ResponseBody
-import org.smartregister.fhircore.engine.R
-import org.smartregister.fhircore.engine.configuration.app.ConfigService
-import org.smartregister.fhircore.engine.data.remote.auth.OAuthService
-import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse
-import org.smartregister.fhircore.engine.ui.appsetting.AppSettingActivity
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator.Companion.AUTH_TOKEN_TYPE
import org.smartregister.fhircore.engine.ui.login.LoginActivity
-import org.smartregister.fhircore.engine.util.DispatcherProvider
-import org.smartregister.fhircore.engine.util.IS_LOGGED_IN
import org.smartregister.fhircore.engine.util.SecureSharedPreference
-import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
-import org.smartregister.fhircore.engine.util.extension.showToast
-import org.smartregister.fhircore.engine.util.toSha1
-import retrofit2.Call
-import retrofit2.Response
+import retrofit2.HttpException
import timber.log.Timber
-@Singleton
class AccountAuthenticator
@Inject
constructor(
@ApplicationContext val context: Context,
val accountManager: AccountManager,
- val oAuthService: OAuthService,
- val configService: ConfigService,
- val secureSharedPreference: SecureSharedPreference,
- val tokenManagerService: TokenManagerService,
- val sharedPreference: SharedPreferencesHelper,
- val dispatcherProvider: DispatcherProvider
+ val tokenAuthenticator: TokenAuthenticator,
+ val secureSharedPreference: SecureSharedPreference
) : AbstractAccountAuthenticator(context) {
+ override fun editProperties(
+ response: AccountAuthenticatorResponse?,
+ accountType: String?
+ ): Bundle = bundleOf()
+
override fun addAccount(
- response: AccountAuthenticatorResponse,
- accountType: String,
+ response: AccountAuthenticatorResponse?,
+ accountType: String?,
authTokenType: String?,
requiredFeatures: Array?,
- options: Bundle
+ options: Bundle?
): Bundle {
- Timber.i("Adding account of type $accountType with auth token of type $authTokenType")
-
- val intent =
- Intent(context, getLoginActivityClass()).apply {
- putExtra(AccountManager.KEY_ACCOUNT_TYPE, getAccountType())
- putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
- putExtra(AUTH_TOKEN_TYPE, authTokenType)
- putExtra(IS_NEW_ACCOUNT, true)
- }
+ val intent = loginIntent(accountType, authTokenType, response)
+ return bundleOf(AccountManager.KEY_INTENT to intent)
+ }
- return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
+ override fun confirmCredentials(
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ options: Bundle?
+ ): Bundle {
+ return bundleOf()
}
override fun getAuthToken(
- response: AccountAuthenticatorResponse,
+ response: AccountAuthenticatorResponse?,
account: Account,
- authTokenType: String,
+ authTokenType: String?,
options: Bundle?
): Bundle {
- var accessToken = tokenManagerService.getLocalSessionToken()
-
- Timber.i(
- "Access token for user ${account.name}, account type ${account.type}, token type $authTokenType is available:${accessToken?.isNotBlank()}"
- )
-
- if (accessToken.isNullOrBlank()) {
- // Use saved refresh token to try to get new access token. Logout user otherwise
- getRefreshToken()?.let {
- Timber.i("Saved active refresh token is available")
-
- runCatching {
- refreshToken(it)?.let { newTokenResponse ->
- accessToken = newTokenResponse.accessToken!!
- updateSession(newTokenResponse)
- }
- }
- .onFailure {
- Timber.e("Refresh token expired before it was used", it.stackTraceToString())
+ var authToken = accountManager.peekAuthToken(account, authTokenType)
+
+ // If token is null or empty or expired attempt to refresh the token
+ if (authToken.isNullOrEmpty()) {
+ val refreshToken = accountManager.getPassword(account)
+ if (!refreshToken.isNullOrEmpty()) {
+ authToken =
+ try {
+ tokenAuthenticator.refreshToken(refreshToken)
+ } catch (ex: Exception) {
+ Timber.e(ex)
+ when (ex) {
+ is HttpException, is UnknownHostException -> ""
+ else -> throw ex
+ }
}
- .onSuccess { Timber.i("Got new accessToken") }
}
}
- if (accessToken?.isNotBlank() == true) {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- accountManager.notifyAccountAuthenticated(account)
- }
-
+ // Auth token exists so return it
+ if (!authToken.isNullOrEmpty()) {
return bundleOf(
- Pair(AccountManager.KEY_ACCOUNT_NAME, account.name),
- Pair(AccountManager.KEY_ACCOUNT_TYPE, account.type),
- Pair(AccountManager.KEY_AUTHTOKEN, accessToken)
+ AccountManager.KEY_ACCOUNT_NAME to account.name,
+ AccountManager.KEY_ACCOUNT_TYPE to account.type,
+ AccountManager.KEY_AUTHTOKEN to authToken
)
}
- // failed to validate any token. now update credentials using auth activity
- return updateCredentials(response, account, authTokenType, options)
+ // Auth token does not exist beyond this point so redirect user to login
+ val intent = loginIntent(account.type, authTokenType, response)
+ return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
}
- override fun editProperties(
- response: AccountAuthenticatorResponse?,
- accountType: String?
- ): Bundle = Bundle()
-
- override fun confirmCredentials(
- response: AccountAuthenticatorResponse,
- account: Account,
- options: Bundle?
- ): Bundle {
- Timber.i("Confirming credentials for ${account.name}")
- val intent =
- Intent(context, getLoginActivityClass()).apply {
- putExtra(AccountManager.KEY_ACCOUNT_TYPE, account.type)
- putExtra(AccountManager.KEY_ACCOUNT_NAME, account.name)
- putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
- }
- return bundleOf(AccountManager.KEY_INTENT to intent)
+ private fun loginIntent(
+ accountType: String?,
+ authTokenType: String?,
+ response: AccountAuthenticatorResponse?
+ ): Intent {
+ return Intent(context, LoginActivity::class.java).apply {
+ putExtra(ACCOUNT_TYPE, accountType)
+ putExtra(AUTH_TOKEN_TYPE, authTokenType)
+ putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ }
}
- override fun getAuthTokenLabel(authTokenType: String): String {
- return authTokenType.uppercase(Locale.ROOT)
- }
+ override fun getAuthTokenLabel(authTokenType: String): String =
+ authTokenType.uppercase(Locale.getDefault())
override fun updateCredentials(
- response: AccountAuthenticatorResponse,
- account: Account,
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
authTokenType: String?,
options: Bundle?
- ): Bundle {
- Timber.i("Updating credentials for ${account.name} from auth activity")
-
- val intent =
- Intent(context, getLoginActivityClass()).apply {
- putExtra(AccountManager.KEY_ACCOUNT_TYPE, account.type)
- putExtra(AccountManager.KEY_ACCOUNT_NAME, account.name)
- putExtra(AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE, response)
- putExtra(AUTH_TOKEN_TYPE, authTokenType)
- }
- return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) }
- }
+ ): Bundle = bundleOf()
override fun hasFeatures(
- response: AccountAuthenticatorResponse,
- account: Account,
- features: Array
- ): Bundle {
- return bundleOf(Pair(AccountManager.KEY_BOOLEAN_RESULT, false))
- }
-
- fun getUserInfo(): Call = oAuthService.userInfo()
-
- @Throws(NetworkErrorException::class)
- fun refreshToken(refreshToken: String): OAuthResponse? {
- val data = buildOAuthPayload(REFRESH_TOKEN)
- data[REFRESH_TOKEN] = refreshToken
- return try {
- oAuthService.fetchToken(data).execute().body()
- } catch (exception: Exception) {
- Timber.e("Failed to refresh token, refresh token may have expired", exception)
- throw NetworkErrorException(exception)
- }
- }
+ response: AccountAuthenticatorResponse?,
+ account: Account?,
+ features: Array?
+ ): Bundle = bundleOf()
- @Throws(NetworkErrorException::class)
- fun fetchToken(username: String, password: CharArray): Call {
- val data = buildOAuthPayload(PASSWORD)
- data[USERNAME] = username
- data[PASSWORD] = password.concatToString()
- return try {
- oAuthService.fetchToken(data)
- } catch (exception: Exception) {
- throw NetworkErrorException(exception)
+ fun logout(onLogout: () -> Unit) {
+ tokenAuthenticator.logout().onSuccess { loggedOut -> if (loggedOut) onLogout() }.onFailure {
+ onLogout()
}
}
- private fun buildOAuthPayload(grantType: String) =
- mutableMapOf(
- GRANT_TYPE to grantType,
- CLIENT_ID to clientId(),
- CLIENT_SECRET to clientSecret(),
- SCOPE to providerScope()
- )
-
- fun getRefreshToken(): String? {
- Timber.v("Checking local storage for refresh token")
- val token = secureSharedPreference.retrieveCredentials()?.refreshToken
- return if (tokenManagerService.isTokenActive(token)) token else null
- }
-
- fun hasActiveSession(): Boolean {
- Timber.v("Checking for an active session")
- return tokenManagerService.getLocalSessionToken()?.isNotBlank() == true
- }
+ fun validateLoginCredentials(username: String, password: CharArray) =
+ tokenAuthenticator.validateSavedLoginCredentials(username, password)
- fun hasActivePin(): Boolean {
- Timber.v("Checking for an active PIN")
- return secureSharedPreference.retrieveSessionPin()?.isNotBlank() == true
+ fun invalidateSession(onSessionInvalidated: () -> Unit) {
+ tokenAuthenticator.invalidateSession(onSessionInvalidated)
}
- fun retrieveLastLoggedInUsername(): String? = secureSharedPreference.retrieveSessionUsername()
-
- fun validLocalCredentials(username: String, password: CharArray): Boolean {
- Timber.v("Validating credentials with local storage")
- return secureSharedPreference.retrieveCredentials()?.let {
- it.username.contentEquals(username) &&
- it.password.contentEquals(password.concatToString().toSha1())
+ fun refreshSessionAuthToken(): Bundle? {
+ val account = tokenAuthenticator.findAccount()
+ return if (account != null) {
+ getAuthToken(null, account, AUTH_TOKEN_TYPE, null)
+ } else {
+ null
}
- ?: false
- }
-
- fun updateSession(successResponse: OAuthResponse) {
- Timber.v("Updating tokens on local storage")
- val credentials =
- secureSharedPreference.retrieveCredentials()!!.apply {
- this.sessionToken = successResponse.accessToken!!
- this.refreshToken = successResponse.refreshToken!!
- }
- secureSharedPreference.saveCredentials(credentials)
}
- fun addAuthenticatedAccount(
- successResponse: Response,
- username: String,
- password: CharArray
- ) {
- Timber.i("Adding authenticated account %s of type %s", username, getAccountType())
-
- val accessToken = successResponse.body()!!.accessToken!!
- val refreshToken = successResponse.body()!!.refreshToken!!
-
- val account = Account(username, getAccountType())
-
- accountManager.addAccountExplicitly(account, null, null)
- secureSharedPreference.saveCredentials(
- AuthCredentials(username, password.concatToString().toSha1(), accessToken, refreshToken)
- )
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- accountManager.notifyAccountAuthenticated(account)
- }
- }
-
- fun loadActiveAccount(
- onActiveAuthTokenFound: (String) -> Unit,
- onValidTokenMissing: (Intent) -> Unit
+ private fun confirmAccount(
+ account: Account,
+ callback: AccountManagerCallback,
+ errorHandler: Handler = Handler(Looper.getMainLooper(), DefaultErrorHandler)
) {
- tokenManagerService.getActiveAccount()?.run {
- val accountType = getAccountType()
- val authToken = accountManager.peekAuthToken(this, accountType)
- if (!tokenManagerService.isTokenActive(authToken)) {
- accountManager.invalidateAuthToken(accountType, authToken)
- }
-
- loadAccount(
- this,
- callback = { accountBundleFuture ->
- val bundle = accountBundleFuture.result
- bundle.getParcelable(AccountManager.KEY_INTENT).let { logInIntent ->
- if (logInIntent == null && bundle.containsKey(AccountManager.KEY_AUTHTOKEN)) {
- onActiveAuthTokenFound(bundle.getString(AccountManager.KEY_AUTHTOKEN)!!)
- return@let
- }
-
- logInIntent!!
- logInIntent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP
- onValidTokenMissing(logInIntent)
- }
- },
- errorHandler = Handler(Looper.getMainLooper(), DefaultErrorHandler)
- )
- }
+ accountManager.confirmCredentials(account, Bundle(), null, callback, errorHandler)
}
fun confirmActiveAccount(onResult: (Intent) -> Unit) {
- tokenManagerService.getActiveAccount()?.run {
+ tokenAuthenticator.findAccount()?.run {
confirmAccount(
this,
callback = {
@@ -322,144 +187,43 @@ constructor(
}
}
- fun invalidateAccount() {
- tokenManagerService.getActiveAccount()?.run {
- accountManager.invalidateAuthToken(
- getAccountType(),
- tokenManagerService.getLocalSessionToken()
- )
- secureSharedPreference.deleteSession()
- }
- }
-
- fun loadActiveAccount(
- callback: AccountManagerCallback,
- errorHandler: Handler = Handler(Looper.getMainLooper(), DefaultErrorHandler)
- ) {
- tokenManagerService.getActiveAccount()?.let { loadAccount(it, callback, errorHandler) }
- }
-
- fun loadAccount(
- account: Account,
- callback: AccountManagerCallback,
- errorHandler: Handler = Handler(Looper.getMainLooper(), DefaultErrorHandler)
- ) {
- Timber.i("Trying to load from getAuthToken for account %s", account.name)
- accountManager.getAuthToken(account, getAccountType(), Bundle(), false, callback, errorHandler)
- }
-
- fun confirmAccount(
- account: Account,
- callback: AccountManagerCallback,
- errorHandler: Handler = Handler(Looper.getMainLooper(), DefaultErrorHandler)
- ) {
- accountManager.confirmCredentials(account, Bundle(), null, callback, errorHandler)
- }
+ fun loadActiveAccount(onValidTokenMissing: (Intent) -> Unit) {
+ tokenAuthenticator.findAccount()?.let {
+ val accountType = tokenAuthenticator.getAccountType()
+ val authToken = accountManager.peekAuthToken(it, AUTH_TOKEN_TYPE)
+ if (!tokenAuthenticator.isTokenActive(authToken)) {
+ accountManager.invalidateAuthToken(accountType, authToken)
+ }
- fun logout() {
- getRefreshToken()?.run {
- val logoutService = oAuthService.logout(clientId(), clientSecret(), this)
- kotlin
- .runCatching {
- CoroutineScope(dispatcherProvider.io() + coroutineExceptionHandler).launch {
- logoutService.execute().run {
- if (!this.isSuccessful) {
- Timber.w(this.errorBody()?.toString())
- context.showToast(
- context.getString(R.string.error_contacting_server, this.code().toString())
- )
+ tokenAuthenticator.findAccount()?.let { account ->
+ accountManager.getAuthToken(
+ account,
+ accountType,
+ Bundle(),
+ false,
+ { accountBundleFuture ->
+ val bundle = accountBundleFuture.result
+ bundle.getParcelable(AccountManager.KEY_INTENT).let { logInIntent ->
+ if (logInIntent == null && bundle.containsKey(AccountManager.KEY_AUTHTOKEN)) {
+ return@getAuthToken
}
- }
- }
- }
- .onFailure {
- Timber.w(it)
- context.showToast(context.getString(R.string.error_contacting_server, it.message ?: ""))
- }
- }
-
- sharedPreference.write(IS_LOGGED_IN, false)
- launchScreen(AppSettingActivity::class.java)
- }
-
- fun refreshSessionAuthToken(callback: AccountManagerCallback) {
- tokenManagerService.getActiveAccount()?.run {
- val accountType = getAccountType()
- var authToken = accountManager.peekAuthToken(this, accountType)
- if (!tokenManagerService.isTokenActive(authToken)) {
- // Attempt to refresh token
- getRefreshToken()?.let {
- Timber.i("Saved active refresh token is available")
- runCatching {
- refreshToken(it)?.let { newTokenResponse ->
- authToken = newTokenResponse.accessToken!!
- updateSession(newTokenResponse)
- }
- }
- .onFailure {
- // Reset session and refresh tokens to null to force re-login?
- accountManager.invalidateAuthToken(accountType, authToken)
- Timber.e("Refresh token expired before it was used", it.stackTraceToString())
+ logInIntent!!
+ logInIntent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP
+ onValidTokenMissing(logInIntent)
}
- .onSuccess {
- Timber.i("Got new accessToken")
- tokenManagerService.getActiveAccount()?.let {
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
- accountManager.notifyAccountAuthenticated(it)
- }
- }
- }
- }
+ },
+ Handler(Looper.getMainLooper(), DefaultErrorHandler)
+ )
}
- loadAccount(
- this,
- callback,
- errorHandler = Handler(Looper.getMainLooper(), DefaultErrorHandler)
- )
}
}
- val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
- throwable.printStackTrace()
- }
-
- fun launchLoginScreen() {
- launchScreen(getLoginActivityClass())
- }
-
- fun launchScreen(clazz: Class<*>) {
- context.startActivity(
- Intent(Intent.ACTION_MAIN).apply {
- setClassName(context.packageName, clazz.name)
- addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
- addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
- addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
- addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
- addCategory(Intent.CATEGORY_LAUNCHER)
- }
- )
- }
-
- fun getLoginActivityClass(): Class<*> = LoginActivity::class.java
+ fun hasActiveSession() = secureSharedPreference.retrieveSessionPin().isNullOrEmpty()
- fun getAccountType(): String = configService.provideAuthConfiguration().accountType
-
- fun clientSecret(): String = configService.provideAuthConfiguration().clientSecret
-
- fun clientId(): String = configService.provideAuthConfiguration().clientId
-
- fun providerScope(): String = configService.provideAuthConfiguration().scope
+ fun retrieveLastLoggedInUsername(): String? = secureSharedPreference.retrieveSessionUsername()
companion object {
- const val AUTH_TOKEN_TYPE = "AUTH_TOKEN_TYPE"
- const val IS_NEW_ACCOUNT = "IS_NEW_ACCOUNT"
- const val GRANT_TYPE = "grant_type"
- const val CLIENT_ID = "client_id"
- const val CLIENT_SECRET = "client_secret"
- const val SCOPE = "scope"
- const val USERNAME = "username"
- const val PASSWORD = "password"
- const val REFRESH_TOKEN = "refresh_token"
+ const val ACCOUNT_TYPE = "ACCOUNT_TYPE"
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AuthCredentials.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AuthCredentials.kt
index 12c0e22917..c9ff44849a 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AuthCredentials.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AuthCredentials.kt
@@ -21,7 +21,8 @@ import kotlinx.serialization.Serializable
@Serializable
data class AuthCredentials(
val username: String,
- val password: String,
+ val salt: String,
+ val passwordHash: String,
var sessionToken: String? = null,
var refreshToken: String? = null
)
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/TokenManagerService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/TokenManagerService.kt
deleted file mode 100644
index 3fca83bbee..0000000000
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/TokenManagerService.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * Copyright 2021 Ona Systems, Inc
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.smartregister.fhircore.engine.auth
-
-import android.accounts.Account
-import android.accounts.AccountManager
-import android.content.Context
-import dagger.hilt.android.qualifiers.ApplicationContext
-import io.jsonwebtoken.ExpiredJwtException
-import io.jsonwebtoken.Jwts
-import io.jsonwebtoken.MalformedJwtException
-import io.jsonwebtoken.UnsupportedJwtException
-import java.util.Date
-import javax.inject.Inject
-import javax.inject.Singleton
-import org.smartregister.fhircore.engine.configuration.app.ConfigService
-import org.smartregister.fhircore.engine.util.SecureSharedPreference
-import timber.log.Timber
-
-@Singleton
-class TokenManagerService
-@Inject
-constructor(
- @ApplicationContext val context: Context,
- val accountManager: AccountManager,
- val configService: ConfigService,
- val secureSharedPreference: SecureSharedPreference
-) {
-
- fun getBlockingActiveAuthToken(): String? {
- getLocalSessionToken()?.let {
- return it
- }
- Timber.v("Trying to get blocking auth token from account manager")
- return getActiveAccount()?.let {
- accountManager.blockingGetAuthToken(it, getAccountType(), false)
- }
- }
-
- fun getAccountType(): String = configService.provideAuthConfiguration().accountType
-
- fun getActiveAccount(): Account? {
- Timber.v("Checking for an active account stored")
- return secureSharedPreference.retrieveSessionUsername()?.let { username ->
- accountManager.getAccountsByType(configService.provideAuthConfiguration().accountType).find {
- it.name.equals(username)
- }
- }
- }
-
- fun getLocalSessionToken(): String? {
- Timber.v("Checking local storage for access token")
- val token = secureSharedPreference.retrieveSessionToken()
- return if (isTokenActive(token)) token else null
- }
-
- fun isTokenActive(token: String?): Boolean {
- if (token.isNullOrEmpty()) return false
- return try {
- val tokenOnly = token.substring(0, token.lastIndexOf('.') + 1)
- Jwts.parser().parseClaimsJwt(tokenOnly).body.expiration.after(Date())
- } catch (expiredJwtException: ExpiredJwtException) {
- Timber.w("Token is expired", expiredJwtException)
- false
- } catch (unsupportedJwtException: UnsupportedJwtException) {
- Timber.w("JWT format not recognized", unsupportedJwtException)
- false
- } catch (malformedJwtException: MalformedJwtException) {
- Timber.w(malformedJwtException)
- false
- }
- }
-}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt
index 9349bc29f6..78ef18c301 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistry.kt
@@ -17,25 +17,35 @@
package org.smartregister.fhircore.engine.configuration
import android.content.Context
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.db.ResourceNotFoundException
+import com.google.android.fhir.get
+import com.google.android.fhir.logicalId
+import com.google.android.fhir.search.search
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import org.hl7.fhir.r4.model.Binary
import org.hl7.fhir.r4.model.Composition
+import org.hl7.fhir.r4.model.Identifier
+import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
import org.smartregister.fhircore.engine.configuration.app.AppConfigClassification
import org.smartregister.fhircore.engine.configuration.view.DataFiltersConfiguration
-import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
import org.smartregister.fhircore.engine.util.DispatcherProvider
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.decodeJson
import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString
import org.smartregister.fhircore.engine.util.extension.extractId
+import org.smartregister.fhircore.engine.util.extension.generateMissingId
+import org.smartregister.fhircore.engine.util.extension.updateFrom
+import org.smartregister.fhircore.engine.util.extension.updateLastUpdated
import timber.log.Timber
/**
@@ -50,10 +60,10 @@ class ConfigurationRegistry
@Inject
constructor(
@ApplicationContext val context: Context,
+ val fhirEngine: FhirEngine,
val fhirResourceDataSource: FhirResourceDataSource,
val sharedPreferencesHelper: SharedPreferencesHelper,
- val dispatcherProvider: DispatcherProvider,
- val repository: DefaultRepository
+ val dispatcherProvider: DispatcherProvider
) {
val configurationsMap = mutableMapOf()
@@ -124,8 +134,7 @@ constructor(
suspend fun loadConfigurations(appId: String, configsLoadedCallback: (Boolean) -> Unit) {
this.appId = appId
- repository
- .searchCompositionByIdentifier(appId)
+ searchCompositionByIdentifier(appId)
.also { if (it == null) configsLoadedCallback(false) }
?.section
?.filter { isWorkflowPoint(it) }
@@ -135,7 +144,7 @@ constructor(
WorkflowPoint(
classification = it.focus.identifier.value,
description = it.title,
- resource = repository.getBinary(it.focus.extractId()),
+ resource = getBinary(it.focus.extractId()),
workflowPoint = it.focus.identifier.value
)
workflowPointsMap[workflowPointName] = workflowPoint
@@ -207,8 +216,8 @@ constructor(
CoroutineScope(ioDispatcher).launch {
try {
Timber.i("Fetching non-workflow resources for app $appId")
- repository
- .searchCompositionByIdentifier(appId)
+
+ searchCompositionByIdentifier(appId)
?.section
?.groupBy { it.focus.reference?.split(TYPE_REFERENCE_DELIMITER)?.get(0) ?: "" }
?.entries
@@ -221,9 +230,7 @@ constructor(
sectionComponent.focus.extractId()
}
val searchPath = resourceGroup.key + "?${Composition.SP_RES_ID}=$resourceIds"
- fhirResourceDataSource.loadData(searchPath).entry.forEach {
- repository.addOrUpdate(it.resource)
- }
+ fhirResourceDataSource.loadData(searchPath).entry.forEach { addOrUpdate(it.resource) }
}
} catch (exception: Exception) {
Timber.e("Error fetching non-workflow resources for app $appId")
@@ -242,6 +249,45 @@ constructor(
}
}
+ suspend fun searchCompositionByIdentifier(identifier: String): Composition? =
+ fhirEngine
+ .search {
+ filter(Composition.IDENTIFIER, { value = of(Identifier().apply { value = identifier }) })
+ }
+ .firstOrNull()
+
+ suspend fun getBinary(id: String): Binary = fhirEngine.get(id)
+
+ /**
+ * Using this [FhirEngine] and [DispatcherProvider], update this stored resources with the passed
+ * resource, or create it if not found.
+ */
+ suspend fun addOrUpdate(resource: R) {
+ withContext(dispatcherProvider.io()) {
+ resource.updateLastUpdated()
+ try {
+ fhirEngine.get(resource.resourceType, resource.logicalId).run {
+ fhirEngine.update(updateFrom(resource))
+ }
+ } catch (resourceNotFoundException: ResourceNotFoundException) {
+ create(resource)
+ }
+ }
+ }
+
+ /**
+ * Using this [FhirEngine] and [DispatcherProvider], for all passed resources, make sure they all
+ * have IDs or generate if they don't, then pass them to create.
+ *
+ * @param resources vararg of resources
+ */
+ suspend fun create(vararg resources: Resource): List {
+ return withContext(dispatcherProvider.io()) {
+ resources.onEach { it.generateMissingId() }
+ fhirEngine.create(*resources)
+ }
+ }
+
companion object {
const val DEFAULT_APP_ID = "appId"
const val BASE_CONFIG_PATH = "configs/$DEFAULT_APP_ID"
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt
index 3cad56723d..62d48d4e1e 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ConfigService.kt
@@ -21,17 +21,14 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import java.util.concurrent.TimeUnit
-import org.hl7.fhir.r4.model.Parameters
-import org.hl7.fhir.r4.model.ResourceType
-import org.hl7.fhir.r4.model.SearchParameter
+import org.hl7.fhir.r4.model.Coding
import org.smartregister.fhircore.engine.appointment.MissedFHIRAppointmentsWorker
import org.smartregister.fhircore.engine.appointment.ProposedWelcomeServiceAppointmentsWorker
-import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
-import org.smartregister.fhircore.engine.configuration.FhirConfiguration
-import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo
+import org.smartregister.fhircore.engine.sync.ResourceTag
import org.smartregister.fhircore.engine.task.FhirTaskPlanWorker
import org.smartregister.fhircore.engine.task.WelcomeServiceBackToCarePlanWorker
-import timber.log.Timber
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
+import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid
/** An interface that provides the application configurations. */
interface ConfigService {
@@ -48,6 +45,41 @@ interface ConfigService {
)
}
+ fun defineResourceTags(): List
+
+ /**
+ * Provide a list of [Coding] that represents [ResourceTag]. [Coding] can be directly appended to
+ * a FHIR resource.
+ */
+ fun provideResourceTags(sharedPreferencesHelper: SharedPreferencesHelper): List {
+ val tags = mutableListOf()
+ defineResourceTags().forEach { strategy ->
+ if (strategy.isResource.not()) {
+ val id = sharedPreferencesHelper.read(strategy.type, null)
+ if (id.isNullOrBlank()) {
+ strategy.tag.let { tag -> tags.add(tag.copy().apply { code = "Not defined" }) }
+ } else {
+ strategy.tag.let { tag ->
+ tags.add(tag.copy().apply { code = id.extractLogicalIdUuid() })
+ }
+ }
+ } else {
+ val ids = sharedPreferencesHelper.read>(strategy.type)
+ if (ids.isNullOrEmpty()) {
+ strategy.tag.let { tag -> tags.add(tag.copy().apply { code = "Not defined" }) }
+ } else {
+ ids.forEach { id ->
+ strategy.tag.let { tag ->
+ tags.add(tag.copy().apply { code = id.extractLogicalIdUuid() })
+ }
+ }
+ }
+ }
+ }
+
+ return tags
+ }
+
fun unschedulePlan(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(FhirTaskPlanWorker.WORK_ID)
}
@@ -87,70 +119,4 @@ interface ConfigService {
workRequest
)
}
-
- /** Retrieve registry sync params */
- fun loadRegistrySyncParams(
- configurationRegistry: ConfigurationRegistry,
- authenticatedUserInfo: UserInfo?,
- ): Map> {
- val pairs = mutableListOf>>()
-
- val syncConfig =
- configurationRegistry.retrieveConfiguration>(
- AppConfigClassification.SYNC
- )
-
- val appConfig =
- configurationRegistry.retrieveConfiguration(
- AppConfigClassification.APPLICATION
- )
-
- // TODO Does not support nested parameters i.e. parameters.parameters...
- // TODO: expressionValue supports for Organization and Publisher literals for now
- syncConfig.resource.parameter.map { it.resource as SearchParameter }.forEach { sp ->
- val paramName = sp.name // e.g. organization
- val paramLiteral = "#$paramName" // e.g. #organization in expression for replacement
- val paramExpression = sp.expression
- val expressionValue =
- when (paramName) {
- ConfigurationRegistry.ORGANIZATION -> authenticatedUserInfo?.organization
- ConfigurationRegistry.PUBLISHER -> authenticatedUserInfo?.questionnairePublisher
- ConfigurationRegistry.ID -> paramExpression
- ConfigurationRegistry.COUNT -> appConfig.count
- else -> null
- }?.let {
- // replace the evaluated value into expression for complex expressions
- // e.g. #organization -> 123
- // e.g. patient.organization eq #organization -> patient.organization eq 123
- paramExpression.replace(paramLiteral, it)
- }
-
- // for each entity in base create and add param map
- // [Patient=[ name=Abc, organization=111 ], Encounter=[ type=MyType, location=MyHospital ],..]
- sp.base.forEach { base ->
- val resourceType = ResourceType.fromCode(base.code)
- val pair = pairs.find { it.first == resourceType }
- if (pair == null) {
- pairs.add(
- Pair(
- resourceType,
- expressionValue?.let { mapOf(sp.code to expressionValue) } ?: mapOf()
- )
- )
- } else {
- expressionValue?.let {
- // add another parameter if there is a matching resource type
- // e.g. [(Patient, {organization=105})] to [(Patient, {organization=105, _count=100})]
- val updatedPair = pair.second.toMutableMap().apply { put(sp.code, expressionValue) }
- val index = pairs.indexOfFirst { it.first == resourceType }
- pairs.set(index, Pair(resourceType, updatedPair))
- }
- }
- }
- }
-
- Timber.i("SYNC CONFIG $pairs")
-
- return mapOf(*pairs.toTypedArray())
- }
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt
index 9c3d4b7d4d..33c95a3145 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt
@@ -42,9 +42,13 @@ import org.hl7.fhir.r4.model.Reference
import org.hl7.fhir.r4.model.RelatedPerson
import org.hl7.fhir.r4.model.Resource
import org.hl7.fhir.r4.model.ResourceType
+import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.configuration.view.SearchFilter
import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireConfig
import org.smartregister.fhircore.engine.util.DispatcherProvider
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
+import org.smartregister.fhircore.engine.util.extension.addTags
import org.smartregister.fhircore.engine.util.extension.filterBy
import org.smartregister.fhircore.engine.util.extension.filterByResourceTypeId
import org.smartregister.fhircore.engine.util.extension.generateMissingId
@@ -58,7 +62,13 @@ import org.smartregister.fhircore.engine.util.extension.updateLastUpdated
@Singleton
open class DefaultRepository
@Inject
-constructor(open val fhirEngine: FhirEngine, open val dispatcherProvider: DispatcherProvider) {
+constructor(
+ open val fhirEngine: FhirEngine,
+ open val dispatcherProvider: DispatcherProvider,
+ open val sharedPreferencesHelper: SharedPreferencesHelper,
+ open val configurationRegistry: ConfigurationRegistry,
+ open val configService: ConfigService
+) {
suspend inline fun loadResource(resourceId: String): T? {
return withContext(dispatcherProvider.io()) { fhirEngine.loadResource(resourceId) }
@@ -171,11 +181,25 @@ constructor(open val fhirEngine: FhirEngine, open val dispatcherProvider: Dispat
}
}
+ suspend fun create(addResourceTags: Boolean = true, vararg resource: Resource): List {
+ return withContext(dispatcherProvider.io()) {
+ resource.onEach {
+ it.generateMissingId()
+ it.generateMissingVersionId()
+ if (addResourceTags) {
+ it.addTags(configService.provideResourceTags(sharedPreferencesHelper))
+ }
+ }
+
+ fhirEngine.create(*resource)
+ }
+ }
+
suspend fun delete(resource: Resource) {
return withContext(dispatcherProvider.io()) { fhirEngine.delete(resource.logicalId) }
}
- suspend fun addOrUpdate(resource: R) {
+ suspend fun addOrUpdate(addMandatoryTags: Boolean = true, resource: R) {
return withContext(dispatcherProvider.io()) {
resource.updateLastUpdated()
try {
@@ -183,9 +207,7 @@ constructor(open val fhirEngine: FhirEngine, open val dispatcherProvider: Dispat
fhirEngine.update(updateFrom(resource))
}
} catch (resourceNotFoundException: ResourceNotFoundException) {
- resource.generateMissingId()
- resource.generateMissingVersionId()
- fhirEngine.create(resource)
+ create(addMandatoryTags, resource)
}
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/AppRegisterRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/AppRegisterRepository.kt
index 33f086c993..b5126e37f0 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/AppRegisterRepository.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/AppRegisterRepository.kt
@@ -22,6 +22,8 @@ import kotlinx.coroutines.withContext
import org.hl7.fhir.r4.model.Patient
import org.hl7.fhir.r4.model.Resource
import org.smartregister.fhircore.engine.appfeature.model.HealthModule
+import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.data.local.AppointmentRegisterFilter
import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.data.local.RegisterFilter
@@ -34,17 +36,27 @@ import org.smartregister.fhircore.engine.domain.model.RegisterData
import org.smartregister.fhircore.engine.domain.repository.RegisterRepository
import org.smartregister.fhircore.engine.trace.PerformanceReporter
import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
class AppRegisterRepository
@Inject
constructor(
override val fhirEngine: FhirEngine,
override val dispatcherProvider: DefaultDispatcherProvider,
+ override val sharedPreferencesHelper: SharedPreferencesHelper,
+ override val configurationRegistry: ConfigurationRegistry,
val registerDaoFactory: RegisterDaoFactory,
+ override val configService: ConfigService,
val tracer: PerformanceReporter
) :
RegisterRepository,
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider) {
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = dispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ ) {
override suspend fun loadRegisterData(
currentPage: Int,
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDao.kt
index 5d6aad4edd..318c08672e 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDao.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDao.kt
@@ -65,10 +65,7 @@ constructor(
) : RegisterDao {
private val currentPractitioner by lazy {
- sharedPreferencesHelper.read(
- key = LOGGED_IN_PRACTITIONER,
- decodeFhirResource = true
- )
+ sharedPreferencesHelper.read(key = LOGGED_IN_PRACTITIONER, decodeWithGson = true)
}
private fun Appointment.patientRef() =
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/FamilyRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/FamilyRegisterDao.kt
index ebfce9f4cf..eac085f44a 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/FamilyRegisterDao.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/FamilyRegisterDao.kt
@@ -213,7 +213,7 @@ constructor(
family.member.map { member ->
defaultRepository.loadResource(member.entity.extractId())?.let { patient ->
patient.active = false
- defaultRepository.addOrUpdate(patient)
+ defaultRepository.addOrUpdate(true, patient)
}
}
}
@@ -221,7 +221,7 @@ constructor(
family.member.clear()
family.active = false
- defaultRepository.addOrUpdate(family)
+ defaultRepository.addOrUpdate(true, family)
}
}
@@ -253,7 +253,7 @@ constructor(
}
}
}
- defaultRepository.addOrUpdate(patient)
+ defaultRepository.addOrUpdate(true, patient)
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDao.kt
index f4b0097382..7adb429b6f 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDao.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDao.kt
@@ -380,7 +380,7 @@ constructor(
if (!this.active) throw IllegalStateException("Patient already deleted")
this.active = false
}
- defaultRepository.addOrUpdate(patient)
+ defaultRepository.addOrUpdate(true, patient)
}
suspend fun transformChildrenPatientToRegisterData(patients: List): List {
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDao.kt
index bbc757f922..23c8f421bf 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDao.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDao.kt
@@ -96,10 +96,7 @@ constructor(
patient.extractOfficialIdentifier() ?: HivRegisterDao.ResourceValue.BLANK
private val currentPractitioner by lazy {
- sharedPreferencesHelper.read(
- key = LOGGED_IN_PRACTITIONER,
- decodeFhirResource = true
- )
+ sharedPreferencesHelper.read(key = LOGGED_IN_PRACTITIONER, decodeWithGson = true)
}
private fun Search.validTasksFilters() {
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/auth/KeycloakService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/auth/KeycloakService.kt
new file mode 100644
index 0000000000..f8c38a402f
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/auth/KeycloakService.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.data.remote.auth
+
+import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo
+import retrofit2.Response
+import retrofit2.http.GET
+
+interface KeycloakService {
+ @GET("protocol/openid-connect/userinfo") suspend fun fetchUserInfo(): Response
+}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/auth/OAuthService.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/auth/OAuthService.kt
index 2052dbe6d3..8e54545d91 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/auth/OAuthService.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/auth/OAuthService.kt
@@ -18,26 +18,23 @@ package org.smartregister.fhircore.engine.data.remote.auth
import okhttp3.ResponseBody
import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse
-import retrofit2.Call
+import retrofit2.Response
import retrofit2.http.Field
import retrofit2.http.FieldMap
import retrofit2.http.FormUrlEncoded
-import retrofit2.http.GET
import retrofit2.http.POST
interface OAuthService {
@FormUrlEncoded
@POST("protocol/openid-connect/token")
- fun fetchToken(@FieldMap(encoded = false) body: Map): Call
-
- @GET("protocol/openid-connect/userinfo") fun userInfo(): Call
+ suspend fun fetchToken(@FieldMap(encoded = false) body: Map): OAuthResponse
@FormUrlEncoded
@POST("protocol/openid-connect/logout")
- fun logout(
+ suspend fun logout(
@Field("client_id") clientId: String,
@Field("client_secret") clientSecret: String,
@Field("refresh_token") refreshToken: String
- ): Call
+ ): Response
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/FhirResourceDataSource.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/FhirResourceDataSource.kt
index 71224e60dd..6d1c4d5be8 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/FhirResourceDataSource.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/FhirResourceDataSource.kt
@@ -26,6 +26,10 @@ import org.hl7.fhir.r4.model.Resource
/** Interact with HAPI FHIR server */
class FhirResourceDataSource @Inject constructor(private val resourceService: FhirResourceService) {
+ suspend fun getResource(path: String): Bundle {
+ return resourceService.getResource(path)
+ }
+
suspend fun loadData(path: String): Bundle {
return resourceService.getResource(path)
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt
new file mode 100644
index 0000000000..be746a28f1
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt
@@ -0,0 +1,279 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.data.remote.shared
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.accounts.AccountManagerFuture
+import android.accounts.AuthenticatorException
+import android.accounts.OperationCanceledException
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.os.Message
+import androidx.core.os.bundleOf
+import com.google.android.fhir.sync.Authenticator as FhirAuthenticator
+import dagger.hilt.android.qualifiers.ApplicationContext
+import io.jsonwebtoken.JwtException
+import io.jsonwebtoken.Jwts
+import java.io.IOException
+import java.net.UnknownHostException
+import java.util.Base64
+import javax.inject.Inject
+import javax.inject.Singleton
+import javax.net.ssl.SSLHandshakeException
+import kotlinx.coroutines.runBlocking
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
+import org.smartregister.fhircore.engine.data.remote.auth.OAuthService
+import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse
+import org.smartregister.fhircore.engine.util.DispatcherProvider
+import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.extension.today
+import org.smartregister.fhircore.engine.util.toPasswordHash
+import retrofit2.HttpException
+import timber.log.Timber
+
+@Singleton
+class TokenAuthenticator
+@Inject
+constructor(
+ val secureSharedPreference: SecureSharedPreference,
+ val configService: ConfigService,
+ val oAuthService: OAuthService,
+ val dispatcherProvider: DispatcherProvider,
+ val accountManager: AccountManager,
+ @ApplicationContext val context: Context
+) : FhirAuthenticator {
+
+ private val jwtParser = Jwts.parser()
+ private val authConfiguration by lazy { configService.provideAuthConfiguration() }
+ private var isLoginPageRendered = false
+
+ override fun getAccessToken(): String {
+ val account = findAccount()
+ return if (account != null) {
+ val accessToken = accountManager.peekAuthToken(account, AUTH_TOKEN_TYPE) ?: ""
+ if (!isTokenActive(accessToken)) {
+ accountManager.run {
+ invalidateAuthToken(account.type, accessToken)
+ try {
+ getAuthToken(
+ account,
+ AUTH_TOKEN_TYPE,
+ bundleOf(),
+ true,
+ handleAccountManagerFutureCallback(account),
+ Handler(Looper.getMainLooper()) { message: Message ->
+ Timber.e(message.toString())
+ true
+ }
+ )
+ } catch (operationCanceledException: OperationCanceledException) {
+ Timber.e(operationCanceledException)
+ } catch (ioException: IOException) {
+ Timber.e(ioException)
+ } catch (authenticatorException: AuthenticatorException) {
+ Timber.e(authenticatorException)
+ // TODO: Should we cancel the sync job to avoid retries when offline?
+ }
+ }
+ } else {
+ isLoginPageRendered = false
+ }
+ accessToken
+ } else ""
+ }
+
+ private fun AccountManager.handleAccountManagerFutureCallback(account: Account?) =
+ { result: AccountManagerFuture ->
+ val bundle = result.result
+ when {
+ bundle.containsKey(AccountManager.KEY_AUTHTOKEN) -> {
+ val token = bundle.getString(AccountManager.KEY_AUTHTOKEN)
+ setAuthToken(account, AUTH_TOKEN_TYPE, token)
+ }
+ bundle.containsKey(AccountManager.KEY_INTENT) -> {
+ val launchIntent = bundle.get(AccountManager.KEY_INTENT) as? Intent
+
+ // Deletes session PIN to allow reset
+ secureSharedPreference.deleteSessionPin()
+
+ if (launchIntent != null && !isLoginPageRendered) {
+ context.startActivity(launchIntent.putExtra(CANCEL_BACKGROUND_SYNC, true))
+ isLoginPageRendered = true
+ }
+ }
+ }
+ }
+
+ /** This function checks if token is null or empty or expired */
+ fun isTokenActive(authToken: String?): Boolean {
+ if (authToken.isNullOrEmpty()) return false
+ val tokenPart = authToken.substringBeforeLast('.').plus(".")
+ return try {
+ val body = jwtParser.parseClaimsJwt(tokenPart).body
+ body.expiration.after(today())
+ } catch (jwtException: JwtException) {
+ false
+ }
+ }
+
+ private fun buildOAuthPayload(grantType: String) =
+ mutableMapOf(
+ GRANT_TYPE to grantType,
+ CLIENT_ID to authConfiguration.clientId,
+ CLIENT_SECRET to authConfiguration.clientSecret,
+ SCOPE to authConfiguration.scope
+ )
+
+ /**
+ * This function fetches new access token from the authentication server and then creates a new
+ * account if none exists; otherwise it updates the existing account.
+ */
+ suspend fun fetchAccessToken(username: String, password: CharArray): Result {
+ val body =
+ buildOAuthPayload(PASSWORD).apply {
+ put(USERNAME, username)
+ put(PASSWORD, password.concatToString())
+ }
+ return try {
+ val oAuthResponse = oAuthService.fetchToken(body)
+ saveToken(username = username, password = password, oAuthResponse = oAuthResponse)
+ Result.success(oAuthResponse)
+ } catch (httpException: HttpException) {
+ Result.failure(httpException)
+ } catch (unknownHostException: UnknownHostException) {
+ Result.failure(unknownHostException)
+ } catch (sslHandShakeException: SSLHandshakeException) {
+ Result.failure(sslHandShakeException)
+ }
+ }
+
+ fun logout(): Result {
+ val account = findAccount() ?: return Result.success(false)
+ return runBlocking {
+ try {
+ // Logout remotely then invalidate token
+ val responseBody =
+ oAuthService.logout(
+ clientId = authConfiguration.clientId,
+ clientSecret = authConfiguration.clientSecret,
+ refreshToken = accountManager.getPassword(account)
+ )
+
+ if (responseBody.isSuccessful) {
+ accountManager.invalidateAuthToken(
+ account.type,
+ accountManager.peekAuthToken(account, AUTH_TOKEN_TYPE)
+ )
+ Result.success(true)
+ } else Result.success(false)
+ } catch (httpException: HttpException) {
+ Result.failure(httpException)
+ } catch (unknownHostException: UnknownHostException) {
+ Result.failure(unknownHostException)
+ }
+ }
+ }
+
+ private fun saveToken(
+ username: String,
+ password: CharArray,
+ oAuthResponse: OAuthResponse,
+ ) {
+ accountManager.run {
+ val account =
+ accountManager.getAccountsByType(authConfiguration.accountType).find { it.name == username }
+ if (account != null) {
+ setPassword(account, oAuthResponse.refreshToken)
+ setAuthToken(account, AUTH_TOKEN_TYPE, oAuthResponse.accessToken)
+ } else {
+ val newAccount = Account(username, authConfiguration.accountType)
+ addAccountExplicitly(newAccount, oAuthResponse.refreshToken, null)
+ setAuthToken(newAccount, AUTH_TOKEN_TYPE, oAuthResponse.accessToken)
+ }
+ // Save credentials
+ secureSharedPreference.saveCredentials(username, password)
+ }
+ }
+
+ /**
+ * This function uses the provided [currentRefreshToken] to get a new auth token or throws
+ * [HttpException] or [UnknownHostException] exceptions
+ */
+ @Throws(HttpException::class, UnknownHostException::class)
+ fun refreshToken(currentRefreshToken: String): String {
+ return runBlocking {
+ val oAuthResponse =
+ oAuthService.fetchToken(
+ buildOAuthPayload(REFRESH_TOKEN).apply { put(REFRESH_TOKEN, currentRefreshToken) }
+ )
+
+ // Returns valid token or throws exception, NullPointerException not expected
+ oAuthResponse.accessToken!!
+ }
+ }
+
+ fun validateSavedLoginCredentials(username: String, enteredPassword: CharArray): Boolean {
+ val credentials = secureSharedPreference.retrieveCredentials()
+ return if (username.equals(credentials?.username, ignoreCase = true)) {
+ val generatedHash =
+ enteredPassword.toPasswordHash(Base64.getDecoder().decode(credentials!!.salt))
+ generatedHash == credentials.passwordHash
+ } else false
+ }
+
+ fun getAccountType(): String = authConfiguration.accountType
+
+ fun findAccount(): Account? {
+ val credentials = secureSharedPreference.retrieveCredentials()
+ return accountManager.getAccountsByType(authConfiguration.accountType).find {
+ it.name == credentials?.username
+ }
+ }
+
+ fun sessionActive(): Boolean =
+ findAccount()?.let { isTokenActive(accountManager.peekAuthToken(it, AUTH_TOKEN_TYPE)) } ?: false
+
+ fun invalidateSession(onSessionInvalidated: () -> Unit) {
+ findAccount()?.let { account ->
+ accountManager.run {
+ invalidateAuthToken(account.type, AUTH_TOKEN_TYPE)
+ runCatching { removeAccountExplicitly(account) }
+ .onSuccess { onSessionInvalidated() }
+ .onFailure {
+ Timber.e(it)
+ onSessionInvalidated()
+ }
+ }
+ }
+ }
+
+ companion object {
+ const val GRANT_TYPE = "grant_type"
+ const val CLIENT_ID = "client_id"
+ const val CLIENT_SECRET = "client_secret"
+ const val SCOPE = "scope"
+ const val USERNAME = "username"
+ const val PASSWORD = "password"
+ const val REFRESH_TOKEN = "refresh_token"
+ const val AUTH_TOKEN_TYPE = "provider"
+ const val CANCEL_BACKGROUND_SYNC = "cancelBackgroundSync"
+ }
+}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptor.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptor.kt
deleted file mode 100644
index d6d5bf8cc5..0000000000
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptor.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
- * Copyright 2021 Ona Systems, Inc
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.smartregister.fhircore.engine.data.remote.shared.interceptor
-
-import android.content.Context
-import dagger.hilt.android.qualifiers.ApplicationContext
-import javax.inject.Inject
-import okhttp3.Interceptor
-import okhttp3.Request
-import org.hl7.fhir.r4.model.ResourceType
-import org.smartregister.fhircore.engine.auth.TokenManagerService
-import timber.log.Timber
-
-class OAuthInterceptor
-@Inject
-constructor(
- @ApplicationContext val context: Context,
- val tokenManagerService: TokenManagerService
-) : Interceptor {
-
- override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
- var request = chain.request()
- val segments = mutableListOf("protocol", "openid-connect", "token")
- if (!request.hasPaths(segments) && !request.hasOpenResources()) {
- tokenManagerService.runCatching { getBlockingActiveAuthToken() }.getOrNull()?.let { token ->
- Timber.d("Passing auth token for %s", request.url.toString())
- request = request.newBuilder().addHeader("Authorization", "Bearer $token").build()
- }
- }
- return chain.proceed(request)
- }
-
- fun Request.hasPaths(paths: List) = this.url.pathSegments.containsAll(paths)
-
- fun Request.hasOpenResources() =
- this.method.contentEquals(REQUEST_METHOD_GET) &&
- this.url.pathSegments.any {
- it.contentEquals(ResourceType.Composition.name) ||
- it.contentEquals(ResourceType.Binary.name)
- }
-
- companion object {
- const val REQUEST_METHOD_GET = "GET"
- }
-}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt
index 9b83080a87..1dec4acc15 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/FhirEngineModule.kt
@@ -23,7 +23,6 @@ import com.google.android.fhir.FhirEngineConfiguration
import com.google.android.fhir.FhirEngineProvider
import com.google.android.fhir.NetworkConfiguration
import com.google.android.fhir.ServerConfiguration
-import com.google.android.fhir.sync.Authenticator
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
@@ -31,8 +30,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import org.smartregister.fhircore.engine.BuildConfig
-import org.smartregister.fhircore.engine.auth.TokenManagerService
import org.smartregister.fhircore.engine.configuration.app.ConfigService
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
+import org.smartregister.fhircore.engine.di.NetworkModule.Companion.TIMEOUT_DURATION
/**
* Provide [FhirEngine] dependency in isolation so we can replace it with a fake dependency in test
@@ -45,7 +45,7 @@ class FhirEngineModule {
@Provides
fun provideFhirEngine(
@ApplicationContext context: Context,
- tokenManagerService: TokenManagerService,
+ tokenAuthenticator: TokenAuthenticator,
configService: ConfigService
): FhirEngine {
FhirEngineProvider.init(
@@ -54,11 +54,9 @@ class FhirEngineModule {
DatabaseErrorStrategy.UNSPECIFIED,
ServerConfiguration(
baseUrl = configService.provideAuthConfiguration().fhirServerBaseUrl,
- authenticator =
- object : Authenticator {
- override fun getAccessToken() = tokenManagerService.getBlockingActiveAuthToken() ?: ""
- },
- networkConfiguration = NetworkConfiguration(120, 120, 120)
+ authenticator = tokenAuthenticator,
+ networkConfiguration =
+ NetworkConfiguration(TIMEOUT_DURATION, TIMEOUT_DURATION, TIMEOUT_DURATION)
)
)
)
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt
index c1a3980ee6..f2b3572999 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/NetworkModule.kt
@@ -20,18 +20,26 @@ import ca.uhn.fhir.context.FhirContext
import ca.uhn.fhir.parser.IParser
import com.google.gson.Gson
import com.google.gson.GsonBuilder
+import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.json.Json
+import okhttp3.Interceptor
+import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.smartregister.fhircore.engine.configuration.app.ConfigService
+import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService
import org.smartregister.fhircore.engine.data.remote.auth.OAuthService
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirConverterFactory
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService
-import org.smartregister.fhircore.engine.data.remote.shared.interceptor.OAuthInterceptor
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
+import org.smartregister.fhircore.engine.util.extension.getCustomJsonParser
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
@@ -39,58 +47,122 @@ import retrofit2.converter.gson.GsonConverterFactory
@Module
class NetworkModule {
- @Provides fun provideGson(): Gson = GsonBuilder().setLenient().create()
-
@Provides
- @AuthOkHttpClientQualifier
- fun provideAuthOkHttpClient(oAuthInterceptor: OAuthInterceptor) =
+ @NoAuthorizationOkHttpClientQualifier
+ fun provideAuthOkHttpClient() =
OkHttpClient.Builder()
- .addInterceptor(oAuthInterceptor)
- .addInterceptor(HttpLoggingInterceptor().apply { HttpLoggingInterceptor.Level.BASIC })
+ .addInterceptor(
+ HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BASIC
+ redactHeader(AUTHORIZATION)
+ redactHeader(COOKIE)
+ }
+ )
+ .connectTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
+ .readTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
+ .callTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
.build()
@Provides
- @OkHttpClientQualifier
- fun provideOkHttpClient(interceptor: OAuthInterceptor) =
+ @WithAuthorizationOkHttpClientQualifier
+ fun provideOkHttpClient(tokenAuthenticator: TokenAuthenticator) =
OkHttpClient.Builder()
- .addInterceptor(interceptor)
- .addInterceptor(HttpLoggingInterceptor().apply { HttpLoggingInterceptor.Level.BODY })
+ .addInterceptor(
+ Interceptor { chain: Interceptor.Chain ->
+ val accessToken = tokenAuthenticator.getAccessToken()
+ // NB: Build new request before setting Auth header; otherwise the header will be bypassed
+ val request = chain.request().newBuilder()
+ if (accessToken.isNotEmpty()) {
+ request.addHeader(AUTHORIZATION, "Bearer $accessToken")
+ }
+ chain.proceed(request.build())
+ }
+ )
+ .addInterceptor(
+ HttpLoggingInterceptor().apply {
+ level = HttpLoggingInterceptor.Level.BASIC
+ redactHeader(AUTHORIZATION)
+ redactHeader(COOKIE)
+ }
+ )
.connectTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
.readTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
.callTimeout(TIMEOUT_DURATION, TimeUnit.SECONDS)
+ .retryOnConnectionFailure(false) // Avoid silent retries sometimes before token is provided
.build()
+ @Provides fun provideGson(): Gson = GsonBuilder().setLenient().create()
+
+ @Provides fun provideParser(): IParser = FhirContext.forR4Cached().getCustomJsonParser()
+
@Provides
- fun provideOauthService(
- @AuthOkHttpClientQualifier okHttpClient: OkHttpClient,
+ @Singleton
+ fun provideKotlinJson() = Json {
+ encodeDefaults = true
+ ignoreUnknownKeys = true
+ isLenient = true
+ useAlternativeNames = true
+ }
+
+ @Provides
+ @AuthenticationRetrofit
+ fun provideAuthRetrofit(
+ @NoAuthorizationOkHttpClientQualifier okHttpClient: OkHttpClient,
configService: ConfigService,
gson: Gson
- ): OAuthService =
+ ): Retrofit =
Retrofit.Builder()
.baseUrl(configService.provideAuthConfiguration().oauthServerBaseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
- .create(OAuthService::class.java)
- @Provides fun provideParser(): IParser = FhirContext.forR4Cached().newJsonParser()
+ @OptIn(ExperimentalSerializationApi::class)
+ @Provides
+ @KeycloakRetrofit
+ fun provideKeycloakRetrofit(
+ @WithAuthorizationOkHttpClientQualifier okHttpClient: OkHttpClient,
+ configService: ConfigService,
+ json: Json
+ ): Retrofit =
+ Retrofit.Builder()
+ .baseUrl(configService.provideAuthConfiguration().oauthServerBaseUrl)
+ .client(okHttpClient)
+ .addConverterFactory(json.asConverterFactory(JSON_MEDIA_TYPE))
+ .build()
@Provides
- fun provideFhirResourceService(
- parser: IParser,
- @OkHttpClientQualifier okHttpClient: OkHttpClient,
+ @RegularRetrofit
+ fun provideRegularRetrofit(
+ @WithAuthorizationOkHttpClientQualifier okHttpClient: OkHttpClient,
configService: ConfigService,
- gson: Gson
- ): FhirResourceService =
+ gson: Gson,
+ parser: IParser
+ ): Retrofit =
Retrofit.Builder()
.baseUrl(configService.provideAuthConfiguration().fhirServerBaseUrl)
.client(okHttpClient)
.addConverterFactory(FhirConverterFactory(parser))
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
- .create(FhirResourceService::class.java)
+
+ @Provides
+ fun provideOauthService(
+ @AuthenticationRetrofit retrofit: Retrofit,
+ ): OAuthService = retrofit.create(OAuthService::class.java)
+
+ @Provides
+ fun provideKeycloakService(@KeycloakRetrofit retrofit: Retrofit): KeycloakService =
+ retrofit.create(KeycloakService::class.java)
+
+ @Provides
+ fun provideFhirResourceService(@RegularRetrofit retrofit: Retrofit): FhirResourceService =
+ retrofit.create(FhirResourceService::class.java)
companion object {
const val TIMEOUT_DURATION = 120L
+ const val AUTHORIZATION = "Authorization"
+ const val COOKIE = "Cookie"
+ val JSON_MEDIA_TYPE = "application/json".toMediaType()
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/Qualifiers.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/Qualifiers.kt
index d1215116b8..f19a26f585 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/di/Qualifiers.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/di/Qualifiers.kt
@@ -22,12 +22,27 @@ import org.smartregister.fhircore.engine.util.annotation.ExcludeFromJacocoGenera
@ExcludeFromJacocoGeneratedReport
@Qualifier
@Retention(AnnotationRetention.BINARY)
-annotation class AuthOkHttpClientQualifier
+annotation class NoAuthorizationOkHttpClientQualifier
@ExcludeFromJacocoGeneratedReport
@Qualifier
@Retention(AnnotationRetention.BINARY)
-annotation class OkHttpClientQualifier
+annotation class WithAuthorizationOkHttpClientQualifier
+
+@ExcludeFromJacocoGeneratedReport
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class AuthenticationRetrofit
+
+@ExcludeFromJacocoGeneratedReport
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class KeycloakRetrofit
+
+@ExcludeFromJacocoGeneratedReport
+@Qualifier
+@Retention(AnnotationRetention.BINARY)
+annotation class RegularRetrofit
@ExcludeFromJacocoGeneratedReport
@Qualifier
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt
index 5d04da9883..129fc52b51 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/AppSyncWorker.kt
@@ -36,7 +36,7 @@ class AppSyncWorker
constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
- val syncParametersManager: SyncParametersManager,
+ private val syncListenerManager: SyncListenerManager,
val engine: FhirEngine,
val dataStore: AppDataStore
) : FhirSyncWorker(appContext, workerParams) {
@@ -44,7 +44,7 @@ constructor(
override fun getDownloadWorkManager(): DownloadWorkManager =
ResourceParamsBasedDownloadWorkManager(
- syncParams = syncParametersManager.getSyncParams(),
+ syncParams = syncListenerManager.loadSyncParams(),
context =
object : ResourceParamsBasedDownloadWorkManager.TimestampContext {
override suspend fun getLasUpdateTimestamp(resourceType: ResourceType): String =
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/ResourceTag.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/ResourceTag.kt
new file mode 100644
index 0000000000..d8cdc7bd53
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/ResourceTag.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.sync
+
+import org.hl7.fhir.r4.model.Coding
+
+data class ResourceTag(val type: String, var tag: Coding, val isResource: Boolean = true)
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt
index 36951d2760..4d5ff648b9 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt
@@ -45,7 +45,7 @@ import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.trace.PerformanceReporter
import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider
import org.smartregister.fhircore.engine.util.DispatcherProvider
-import org.smartregister.fhircore.engine.util.LAST_SYNC_TIMESTAMP
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import timber.log.Timber
@@ -59,6 +59,7 @@ constructor(
val configurationRegistry: ConfigurationRegistry,
val configService: ConfigService,
val fhirEngine: FhirEngine,
+ // TODO: Move this to the SyncListenerManager
val sharedSyncStatus: MutableSharedFlow = MutableSharedFlow(),
val dispatcherProvider: DispatcherProvider = DefaultDispatcherProvider(),
val tracer: PerformanceReporter,
@@ -125,7 +126,8 @@ constructor(
}
}
- fun isInitialSync() = sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, null).isNullOrBlank()
+ fun isInitialSync() =
+ sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank()
fun registerSyncListener(onSyncListener: OnSyncListener, scope: CoroutineScope) {
scope.launch { sharedSyncStatus.collect { onSyncListener.onSync(state = it) } }
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt
new file mode 100644
index 0000000000..86367c97ad
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt
@@ -0,0 +1,151 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.sync
+
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import com.google.android.fhir.sync.SyncJobStatus
+import java.lang.ref.WeakReference
+import javax.inject.Inject
+import javax.inject.Singleton
+import org.hl7.fhir.r4.model.Parameters
+import org.hl7.fhir.r4.model.ResourceType
+import org.hl7.fhir.r4.model.SearchParameter
+import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
+import org.smartregister.fhircore.engine.configuration.FhirConfiguration
+import org.smartregister.fhircore.engine.configuration.app.AppConfigClassification
+import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
+import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
+import org.smartregister.fhircore.engine.util.USER_INFO_SHARED_PREFERENCE_KEY
+import org.smartregister.fhircore.engine.util.extension.decodeJson
+import timber.log.Timber
+
+/**
+ * A singleton class that maintains a list of [OnSyncListener] that have been registered to listen
+ * to [SyncJobStatus] emitted to indicate sync progress.
+ */
+@Singleton
+class SyncListenerManager
+@Inject
+constructor(
+ val configService: ConfigService,
+ val configurationRegistry: ConfigurationRegistry,
+ val sharedPreferencesHelper: SharedPreferencesHelper,
+) {
+
+ private val syncConfig by lazy {
+ configurationRegistry.retrieveConfiguration>(
+ AppConfigClassification.SYNC
+ )
+ }
+
+ private val _onSyncListeners = mutableListOf>()
+ val onSyncListeners: List
+ get() = _onSyncListeners.mapNotNull { it.get() }
+
+ /**
+ * Register [OnSyncListener] for [SyncJobStatus]. Typically the [OnSyncListener] will be
+ * implemented in a [Lifecycle](an Activity/Fragment). This function ensures the [OnSyncListener]
+ * is removed for the [_onSyncListeners] list when the [Lifecycle] changes to
+ * [Lifecycle.State.DESTROYED]
+ */
+ fun registerSyncListener(onSyncListener: OnSyncListener, lifecycle: Lifecycle) {
+ _onSyncListeners.add(WeakReference(onSyncListener))
+ Timber.w("${onSyncListener::class.simpleName} registered to receive sync state events")
+ lifecycle.addObserver(
+ object : DefaultLifecycleObserver {
+ override fun onStop(owner: LifecycleOwner) {
+ super.onStop(owner)
+ deregisterSyncListener(onSyncListener)
+ }
+ }
+ )
+ }
+
+ /**
+ * This function removes [onSyncListener] from the list of registered [OnSyncListener]'s to stop
+ * receiving sync state events.
+ */
+ fun deregisterSyncListener(onSyncListener: OnSyncListener) {
+ val removed = _onSyncListeners.removeIf { it.get() == onSyncListener }
+ if (removed)
+ Timber.w("De-registered ${onSyncListener::class.simpleName} from receiving sync state...")
+ }
+
+ /** Retrieve registry sync params */
+ fun loadSyncParams(): Map> {
+ val authenticatedUserInfo =
+ sharedPreferencesHelper.read(USER_INFO_SHARED_PREFERENCE_KEY, null)?.decodeJson()
+ val pairs = mutableListOf>>()
+
+ val appConfig =
+ configurationRegistry.retrieveConfiguration(
+ AppConfigClassification.APPLICATION
+ )
+
+ // TODO Does not support nested parameters i.e. parameters.parameters...
+ // TODO: expressionValue supports for Organization and Publisher literals for now
+ syncConfig.resource.parameter.map { it.resource as SearchParameter }.forEach { sp ->
+ val paramName = sp.name // e.g. organization
+ val paramLiteral = "#$paramName" // e.g. #organization in expression for replacement
+ val paramExpression = sp.expression
+ val expressionValue =
+ when (paramName) {
+ ConfigurationRegistry.ORGANIZATION -> authenticatedUserInfo?.organization
+ ConfigurationRegistry.PUBLISHER -> authenticatedUserInfo?.questionnairePublisher
+ ConfigurationRegistry.ID -> paramExpression
+ ConfigurationRegistry.COUNT -> appConfig.count
+ else -> null
+ }?.let {
+ // replace the evaluated value into expression for complex expressions
+ // e.g. #organization -> 123
+ // e.g. patient.organization eq #organization -> patient.organization eq 123
+ paramExpression.replace(paramLiteral, it)
+ }
+
+ // for each entity in base create and add param map
+ // [Patient=[ name=Abc, organization=111 ], Encounter=[ type=MyType, location=MyHospital ],..]
+ sp.base.forEach { base ->
+ val resourceType = ResourceType.fromCode(base.code)
+ val pair = pairs.find { it.first == resourceType }
+ if (pair == null) {
+ pairs.add(
+ Pair(
+ resourceType,
+ expressionValue?.let { mapOf(sp.code to expressionValue) } ?: mapOf()
+ )
+ )
+ } else {
+ expressionValue?.let {
+ // add another parameter if there is a matching resource type
+ // e.g. [(Patient, {organization=105})] to [(Patient, {organization=105, _count=100})]
+ val updatedPair = pair.second.toMutableMap().apply { put(sp.code, expressionValue) }
+ val index = pairs.indexOfFirst { it.first == resourceType }
+ pairs.set(index, Pair(resourceType, updatedPair))
+ }
+ }
+ }
+ }
+
+ Timber.i("SYNC CONFIG $pairs")
+
+ return mapOf(*pairs.toTypedArray())
+ }
+}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncParametersManager.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncParametersManager.kt
deleted file mode 100644
index a22644d9ca..0000000000
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncParametersManager.kt
+++ /dev/null
@@ -1,45 +0,0 @@
-/*
- * Copyright 2021 Ona Systems, Inc
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.smartregister.fhircore.engine.sync
-
-import javax.inject.Inject
-import javax.inject.Singleton
-import org.hl7.fhir.r4.model.ResourceType
-import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
-import org.smartregister.fhircore.engine.configuration.app.ConfigService
-import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo
-import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
-import org.smartregister.fhircore.engine.util.USER_INFO_SHARED_PREFERENCE_KEY
-import org.smartregister.fhircore.engine.util.extension.decodeJson
-
-@Singleton
-class SyncParametersManager
-@Inject
-constructor(
- val configService: ConfigService,
- val configurationRegistry: ConfigurationRegistry,
- val sharedPreferencesHelper: SharedPreferencesHelper
-) {
- /** Retrieve registry sync params */
- fun getSyncParams(): Map> =
- configService
- .loadRegistrySyncParams(
- configurationRegistry,
- sharedPreferencesHelper.read(USER_INFO_SHARED_PREFERENCE_KEY, null)?.decodeJson()
- )
- .toMap()
-}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivity.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivity.kt
index 50242d6274..1c41b06983 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivity.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivity.kt
@@ -23,19 +23,18 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.app.AppCompatDelegate
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.res.stringResource
import androidx.lifecycle.lifecycleScope
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.launch
-import org.smartregister.fhircore.engine.BuildConfig
import org.smartregister.fhircore.engine.R
import org.smartregister.fhircore.engine.auth.AccountAuthenticator
-import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
-import org.smartregister.fhircore.engine.ui.login.LoginService
+import org.smartregister.fhircore.engine.cql.LibraryEvaluator
+import org.smartregister.fhircore.engine.ui.components.register.LoaderDialog
import org.smartregister.fhircore.engine.ui.theme.AppTheme
-import org.smartregister.fhircore.engine.util.APP_ID_CONFIG
import org.smartregister.fhircore.engine.util.DispatcherProvider
-import org.smartregister.fhircore.engine.util.IS_LOGGED_IN
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.showToast
@@ -43,111 +42,45 @@ import org.smartregister.fhircore.engine.util.extension.showToast
class AppSettingActivity : AppCompatActivity() {
@Inject lateinit var accountAuthenticator: AccountAuthenticator
- @Inject lateinit var configurationRegistry: ConfigurationRegistry
@Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper
@Inject lateinit var dispatcherProvider: DispatcherProvider
- @Inject lateinit var loginService: LoginService
-
- val appSettingViewModel: AppSettingViewModel by viewModels()
+ @Inject lateinit var libraryEvaluator: LibraryEvaluator
+ private val appSettingViewModel: AppSettingViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
super.onCreate(savedInstanceState)
-
- val isLoggedIn =
- sharedPreferencesHelper.read(IS_LOGGED_IN, false) && accountAuthenticator.hasActiveSession()
-
- with(appSettingViewModel) {
- loadConfigs.observe(this@AppSettingActivity) { loadConfigs ->
- if (loadConfigs == false) {
- showToast(getString(R.string.application_not_supported, appId.value))
- return@observe
- }
-
- if (appId.value.isNullOrBlank()) return@observe
-
- val appId = appId.value!!.trimEnd()
-
- if (hasDebugSuffix() == true && BuildConfig.DEBUG) {
- lifecycleScope.launch(dispatcherProvider.io()) {
- configurationRegistry.loadConfigurationsLocally(appId) { loadSuccessful: Boolean ->
- if (loadSuccessful) {
- sharedPreferencesHelper.write(APP_ID_CONFIG, appId)
- if (!isLoggedIn) {
- accountAuthenticator.launchLoginScreen()
- } else {
- loginService.loginActivity = this@AppSettingActivity
- loginService.navigateToHome()
- }
- finish()
- } else {
- launch(dispatcherProvider.main()) {
- showToast(getString(R.string.application_not_supported, appId))
- }
- }
- }
- }
- return@observe
- }
-
- lifecycleScope.launch(dispatcherProvider.io()) {
- configurationRegistry.loadConfigurations(appId) { loadSuccessful: Boolean ->
- if (loadSuccessful) {
- sharedPreferencesHelper.write(APP_ID_CONFIG, appId)
- accountAuthenticator.launchLoginScreen()
- finish()
- } else {
- launch(dispatcherProvider.main()) {
- showToast(getString(R.string.application_not_supported, appId))
- }
- }
- }
- }
- }
-
- fetchConfigs.observe(this@AppSettingActivity) { fetchConfigs ->
- if (fetchConfigs == false) {
- loadConfigurations(true)
- return@observe
- }
-
- if (hasDebugSuffix() == true && BuildConfig.DEBUG) {
- loadConfigurations(true)
- return@observe
- }
-
- if (appId.value.isNullOrBlank()) return@observe
-
- lifecycleScope.launch(dispatcherProvider.io()) {
- fetchConfigurations(appId.value!!, this@AppSettingActivity)
- }
- }
-
- error.observe(this@AppSettingActivity) { error ->
- if (error.isNotBlank()) showToast(getString(R.string.error_loading_config, error))
- }
+ val appSettingActivity = this@AppSettingActivity
+ setContent { AppTheme { LoaderDialog(dialogMessage = stringResource(R.string.initializing)) } }
+ lifecycleScope.launch(dispatcherProvider.io()) { libraryEvaluator.initialize() }
+ val existingAppId =
+ sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)?.trimEnd()
+
+ appSettingViewModel.error.observe(appSettingActivity) { error ->
+ if (!error.isNullOrEmpty()) showToast(error)
}
- val lastAppId = sharedPreferencesHelper.read(APP_ID_CONFIG, null)?.trimEnd()
- lastAppId?.let {
- with(appSettingViewModel) {
- onApplicationIdChanged(it)
- fetchConfigurations(!isLoggedIn)
+ // If app exists load the configs otherwise fetch from the server
+ if (!existingAppId.isNullOrEmpty()) {
+ appSettingViewModel.run {
+ onApplicationIdChanged(existingAppId)
+ loadConfigurations(appSettingActivity)
}
- }
- ?: run {
- setContent {
- AppTheme {
- val appId by appSettingViewModel.appId.observeAsState("")
- val showProgressBar by appSettingViewModel.showProgressBar.observeAsState(false)
- AppSettingScreen(
- appId = appId,
- onAppIdChanged = appSettingViewModel::onApplicationIdChanged,
- onLoadConfigurations = appSettingViewModel::fetchConfigurations,
- showProgressBar = showProgressBar
- )
- }
+ } else {
+ setContent {
+ AppTheme {
+ val appId by appSettingViewModel.appId.observeAsState("")
+ val showProgressBar by appSettingViewModel.showProgressBar.observeAsState(false)
+ val error by appSettingViewModel.error.observeAsState("")
+ AppSettingScreen(
+ appId = appId,
+ onAppIdChanged = appSettingViewModel::onApplicationIdChanged,
+ fetchConfiguration = appSettingViewModel::fetchConfigurations,
+ showProgressBar = showProgressBar,
+ error = error
+ )
}
}
+ }
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreen.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreen.kt
index 55f7b17f68..561353702d 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreen.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreen.kt
@@ -16,6 +16,8 @@
package org.smartregister.fhircore.engine.ui.appsetting
+import android.content.Context
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -24,92 +26,173 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentWidth
+import androidx.compose.foundation.relocation.BringIntoViewRequester
+import androidx.compose.foundation.relocation.bringIntoViewRequester
import androidx.compose.material.Button
+import androidx.compose.material.ButtonDefaults
+import androidx.compose.material.CircularProgressIndicator
+import androidx.compose.material.MaterialTheme
import androidx.compose.material.OutlinedTextField
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.focus.onFocusEvent
import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
-import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import org.smartregister.fhircore.engine.R
-import org.smartregister.fhircore.engine.ui.components.CircularProgressBar
-import org.smartregister.fhircore.engine.util.annotation.ExcludeFromJacocoGeneratedReport
+import org.smartregister.fhircore.engine.ui.login.LOGIN_ERROR_TEXT_TAG
+import org.smartregister.fhircore.engine.util.annotation.PreviewWithBackgroundExcludeGenerated
+import org.smartregister.fhircore.engine.util.extension.appVersion
const val APP_ID_TEXT_INPUT_TAG = "appIdTextInputTag"
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun AppSettingScreen(
modifier: Modifier = Modifier,
appId: String,
onAppIdChanged: (String) -> Unit,
- onLoadConfigurations: (Boolean) -> Unit,
- showProgressBar: Boolean = false
+ fetchConfiguration: (Context) -> Unit,
+ showProgressBar: Boolean = false,
+ error: String,
+ appVersionPair: Pair? = null,
) {
+ val context = LocalContext.current
+ val (versionCode, versionName) = remember { appVersionPair ?: context.appVersion() }
+ val coroutineScope = rememberCoroutineScope()
+ val bringIntoViewRequester = BringIntoViewRequester()
+ val focusRequester = remember { FocusRequester() }
- Column(
- verticalArrangement = Arrangement.Center,
- modifier = modifier.fillMaxSize().padding(horizontal = 20.dp)
- ) {
- Text(
- text = stringResource(R.string.fhir_core_app),
- fontWeight = FontWeight.Bold,
- textAlign = TextAlign.Center,
- fontSize = 32.sp,
- modifier = modifier.padding(vertical = 8.dp).align(Alignment.CenterHorizontally)
- )
- Spacer(modifier = modifier.height(20.dp))
- Text(
- text = stringResource(R.string.application_id),
- modifier = modifier.padding(vertical = 8.dp)
- )
- OutlinedTextField(
- onValueChange = onAppIdChanged,
- value = appId,
- maxLines = 1,
- singleLine = true,
- placeholder = {
+ LaunchedEffect(Unit) {
+ delay(300)
+ focusRequester.requestFocus()
+ }
+
+ Column(modifier = modifier.fillMaxSize()) {
+ Column(
+ verticalArrangement = Arrangement.Center,
+ modifier = modifier.weight(1f).padding(horizontal = 20.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.fhir_core_app),
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center,
+ fontSize = 32.sp,
+ modifier = modifier.padding(vertical = 8.dp).align(Alignment.CenterHorizontally)
+ )
+ Spacer(modifier = modifier.height(80.dp))
+ Text(
+ text = stringResource(R.string.application_id),
+ modifier = modifier.padding(vertical = 4.dp)
+ )
+ OutlinedTextField(
+ onValueChange = onAppIdChanged,
+ value = appId,
+ maxLines = 1,
+ singleLine = true,
+ placeholder = {
+ Text(
+ color = Color.LightGray,
+ text = stringResource(R.string.app_id_sample),
+ )
+ },
+ modifier =
+ modifier
+ .testTag(APP_ID_TEXT_INPUT_TAG)
+ .fillMaxWidth()
+ .padding(vertical = 2.dp)
+ .onFocusEvent { event ->
+ if (event.isFocused) coroutineScope.launch { bringIntoViewRequester.bringIntoView() }
+ }
+ .focusRequester(focusRequester)
+ )
+ if (error.isNotEmpty())
Text(
- color = Color.LightGray,
- text = stringResource(R.string.enter_app_id),
+ fontSize = 14.sp,
+ color = MaterialTheme.colors.error,
+ text = error,
+ modifier =
+ modifier
+ .wrapContentWidth()
+ .padding(vertical = 10.dp)
+ .align(Alignment.Start)
+ .testTag(LOGIN_ERROR_TEXT_TAG)
)
- },
- modifier = modifier.testTag(APP_ID_TEXT_INPUT_TAG).fillMaxWidth().padding(vertical = 2.dp)
- )
- Text(
- text = stringResource(R.string.app_id_sample),
- fontSize = 12.sp,
- modifier = modifier.padding(vertical = 8.dp)
- )
- Spacer(modifier = modifier.height(20.dp))
- Box(contentAlignment = Alignment.Center, modifier = modifier.fillMaxWidth()) {
- Button(
- onClick = { onLoadConfigurations(true) },
- enabled = !showProgressBar && appId.isNotEmpty(),
- modifier = modifier.fillMaxWidth()
+ Spacer(modifier = modifier.height(30.dp))
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier.bringIntoViewRequester(bringIntoViewRequester).fillMaxWidth()
) {
- Text(
- color = Color.White,
- text = stringResource(id = R.string.load_configurations),
- modifier = modifier.padding(8.dp)
- )
- }
- if (showProgressBar) {
- CircularProgressBar(modifier = modifier.matchParentSize().padding(4.dp))
+ Button(
+ onClick = { fetchConfiguration(context) },
+ enabled = !showProgressBar && appId.isNotEmpty(),
+ modifier = modifier.fillMaxWidth(),
+ colors =
+ ButtonDefaults.buttonColors(
+ disabledContentColor = Color.Gray,
+ contentColor = Color.White
+ ),
+ elevation = null
+ ) {
+ Text(
+ text = if (!showProgressBar) stringResource(id = R.string.load_configurations) else "",
+ modifier = modifier.padding(8.dp)
+ )
+ }
+ if (showProgressBar) {
+ CircularProgressIndicator(
+ modifier = modifier.align(Alignment.Center).size(18.dp),
+ strokeWidth = 1.6.dp,
+ color = Color.White
+ )
+ }
}
}
+ Text(
+ color = Color.Gray,
+ fontSize = 16.sp,
+ text = stringResource(id = R.string.app_version, versionCode, versionName),
+ modifier = modifier.padding(16.dp).wrapContentWidth().align(Alignment.End),
+ )
}
}
@Composable
-@Preview(showBackground = true)
-@ExcludeFromJacocoGeneratedReport
-private fun AppSettingScreenPreview() {
- AppSettingScreen(appId = "", onAppIdChanged = {}, onLoadConfigurations = {})
+@PreviewWithBackgroundExcludeGenerated
+private fun AppSettingScreenWithErrorPreview() {
+ AppSettingScreen(
+ appId = "",
+ onAppIdChanged = {},
+ fetchConfiguration = {},
+ appVersionPair = Pair(1, "0.0.1"),
+ error = "Application not found"
+ )
+}
+
+@Composable
+@PreviewWithBackgroundExcludeGenerated
+private fun AppSettingScreenWithNoErrorPreview() {
+ AppSettingScreen(
+ appId = "",
+ onAppIdChanged = {},
+ fetchConfiguration = {},
+ appVersionPair = Pair(1, "0.0.1"),
+ error = ""
+ )
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModel.kt
index bba163f6ee..72e35f2e95 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModel.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModel.kt
@@ -20,15 +20,27 @@ import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
+import java.net.UnknownHostException
import javax.inject.Inject
+import kotlinx.coroutines.launch
import org.hl7.fhir.r4.model.Composition
import org.hl7.fhir.r4.model.ResourceType
+import org.smartregister.fhircore.engine.BuildConfig
import org.smartregister.fhircore.engine.R
+import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry.Companion.DEBUG_SUFFIX
import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
+import org.smartregister.fhircore.engine.ui.login.LoginActivity
+import org.smartregister.fhircore.engine.util.DispatcherProvider
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.extractId
+import org.smartregister.fhircore.engine.util.extension.getActivity
+import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory
+import retrofit2.HttpException
import timber.log.Timber
@HiltViewModel
@@ -36,13 +48,12 @@ class AppSettingViewModel
@Inject
constructor(
val fhirResourceDataSource: FhirResourceDataSource,
- val defaultRepository: DefaultRepository
+ val defaultRepository: DefaultRepository,
+ val sharedPreferencesHelper: SharedPreferencesHelper,
+ val configurationRegistry: ConfigurationRegistry,
+ val dispatcherProvider: DispatcherProvider
) : ViewModel() {
- val loadConfigs: MutableLiveData = MutableLiveData(null)
-
- val fetchConfigs: MutableLiveData = MutableLiveData(null)
-
private val _appId = MutableLiveData("")
val appId
get() = _appId
@@ -59,20 +70,35 @@ constructor(
_appId.value = appId
}
- fun loadConfigurations(loadConfigs: Boolean) {
- this.loadConfigs.postValue(loadConfigs)
+ fun loadConfigurations(context: Context) {
+ viewModelScope.launch(dispatcherProvider.io()) {
+ appId.value?.let { thisAppId ->
+ configurationRegistry.loadConfigurations(thisAppId) {
+ showProgressBar.postValue(false)
+ sharedPreferencesHelper.write(SharedPreferenceKey.APP_ID.name, thisAppId)
+ launchLoginScreen(context)
+ }
+ }
+ }
}
- fun fetchConfigurations(fetchConfigs: Boolean) {
- this.fetchConfigs.postValue(fetchConfigs)
+ fun fetchConfigurations(context: Context) {
+ showProgressBar.postValue(true)
+ val appId = appId.value
+ if (!appId.isNullOrEmpty()) {
+ when {
+ hasDebugSuffix() -> loadConfigurations(context)
+ else -> fetchRemoteConfigurations(appId, context)
+ }
+ }
}
- suspend fun fetchConfigurations(appId: String, context: Context) {
- kotlin
- .runCatching {
+ fun fetchRemoteConfigurations(appId: String, context: Context) {
+ viewModelScope.launch {
+ try {
Timber.i("Fetching configs for app $appId")
- this._showProgressBar.postValue(true)
+ _showProgressBar.postValue(true)
val cPath = "${ResourceType.Composition.name}?${Composition.SP_IDENTIFIER}=$appId"
val data =
fhirResourceDataSource.loadData(cPath).entryFirstRep.also {
@@ -80,7 +106,7 @@ constructor(
Timber.w("No response for composition resource on path $cPath")
_showProgressBar.postValue(false)
_error.postValue(context.getString(R.string.application_not_supported, appId))
- return
+ return@launch
}
}
@@ -100,19 +126,24 @@ constructor(
}
}
- loadConfigurations(true)
+ loadConfigurations(context)
_showProgressBar.postValue(false)
+ } catch (unknownHostException: UnknownHostException) {
+ _error.postValue(context.getString(R.string.error_loading_config_no_internet))
+ showProgressBar.postValue(false)
+ } catch (httpException: HttpException) {
+ if ((400..503).contains(httpException.response()!!.code()))
+ _error.postValue(context.getString(R.string.error_loading_config_general))
+ else _error.postValue(context.getString(R.string.error_loading_config_http_error))
+ showProgressBar.postValue(false)
}
- .onFailure {
- Timber.w(it)
- _showProgressBar.postValue(false)
- _error.postValue("${it.message}")
- }
+ }
}
- fun hasDebugSuffix(): Boolean? {
- return if (!appId.value.isNullOrBlank())
- appId.value!!.split("/").last().contentEquals(DEBUG_SUFFIX)
- else null
+ fun hasDebugSuffix(): Boolean =
+ appId.value?.endsWith(DEBUG_SUFFIX, ignoreCase = true) == true && BuildConfig.DEBUG
+
+ fun launchLoginScreen(context: Context) {
+ context.getActivity()?.launchActivityWithNoBackStackHistory()
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/BaseMultiLanguageActivity.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/BaseMultiLanguageActivity.kt
index b7dfb8519a..1de01f9f96 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/BaseMultiLanguageActivity.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/base/BaseMultiLanguageActivity.kt
@@ -22,6 +22,7 @@ import androidx.appcompat.app.AppCompatActivity
import java.lang.UnsupportedOperationException
import java.util.Locale
import javax.inject.Inject
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.setAppLocale
@@ -33,7 +34,7 @@ abstract class BaseMultiLanguageActivity : AppCompatActivity() {
inject()
super.onCreate(savedInstanceState)
val themePref =
- sharedPreferencesHelper.read(key = SharedPreferencesHelper.THEME, defaultValue = "")!!
+ sharedPreferencesHelper.read(key = SharedPreferenceKey.THEME.name, defaultValue = "")!!
if (themePref.isNotEmpty()) {
val resourceId = this.resources.getIdentifier(themePref, "style", packageName)
@@ -45,7 +46,7 @@ abstract class BaseMultiLanguageActivity : AppCompatActivity() {
val lang =
baseContext
.getSharedPreferences(SharedPreferencesHelper.PREFS_NAME, Context.MODE_PRIVATE)
- .getString(SharedPreferencesHelper.LANG, Locale.UK.toLanguageTag())!!
+ .getString(SharedPreferenceKey.LANG.name, Locale.UK.toLanguageTag())!!
baseContext.setAppLocale(lang).run {
super.attachBaseContext(baseContext)
applyOverrideConfiguration(this)
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginActivity.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginActivity.kt
index c060068f1b..ffaf85c762 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginActivity.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginActivity.kt
@@ -58,65 +58,73 @@ class LoginActivity :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+
+ navigateToScreen()
+
+ setContent { AppTheme { LoginScreen(loginViewModel = loginViewModel) } }
+
+ if (!intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME).isNullOrBlank() &&
+ loginViewModel.username.value.isNullOrBlank()
+ ) {
+ loginViewModel.onUsernameUpdated(intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)!!)
+ this@LoginActivity.showToast(getString(R.string.auth_token_expired), Toast.LENGTH_SHORT)
+ }
+ }
+
+ private fun navigateToScreen() {
loginService.loginActivity = this
loginViewModel.apply {
loadLastLoggedInUsername()
- navigateToHome.observe(this@LoginActivity) {
- val isUpdatingCurrentAccount =
- intent.hasExtra(AccountManager.KEY_ACCOUNT_NAME) &&
- intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)!!.trim() ==
- loginViewModel.username.value?.trim()
-
- if (loginViewModel.loginViewConfiguration.value?.enablePin == true) {
- val lastPinExist = loginViewModel.accountAuthenticator.hasActivePin()
- val forceLoginViaUsernamePinSetup =
- loginViewModel.sharedPreferences.read(FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP, false)
- when {
- lastPinExist -> {
- goToHomeScreen(FORCE_LOGIN_VIA_USERNAME, false)
- }
- forceLoginViaUsernamePinSetup -> {
- goToHomeScreen(FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP, false)
- }
- else -> {
- loginService.navigateToPinLogin(goForSetup = true)
+ navigateToHome.observe(this@LoginActivity) { isNavigate ->
+ if (isNavigate) {
+
+ val isUpdatingCurrentAccount =
+ intent.hasExtra(AccountManager.KEY_ACCOUNT_NAME) &&
+ intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)!!.trim() ==
+ loginViewModel.username.value?.trim()
+
+ if (loginViewModel.loginViewConfiguration.value?.enablePin == true) {
+ val lastPinExist = !secureSharedPreference.retrieveSessionPin().isNullOrEmpty()
+ val forceLoginViaUsernamePinSetup =
+ loginViewModel.sharedPreferences.read(FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP, false)
+ when {
+ lastPinExist -> {
+ goToHomeScreen(FORCE_LOGIN_VIA_USERNAME, false)
+ }
+ forceLoginViaUsernamePinSetup -> {
+ goToHomeScreen(FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP, false)
+ }
+ else -> {
+ loginService.navigateToPinLogin(goForSetup = true)
+ }
}
+ } else if (isUpdatingCurrentAccount) {
+ configurationRegistry.fetchNonWorkflowConfigResources()
+ syncBroadcaster.get().runSync() // restart/resume sync
+ setResult(Activity.RESULT_OK)
+ finish() // Return to the previous activity
+ } else {
+ configurationRegistry.fetchNonWorkflowConfigResources()
+ syncBroadcaster.get().runSync()
+ loginService.navigateToHome()
}
- } else if (isUpdatingCurrentAccount) {
- configurationRegistry.fetchNonWorkflowConfigResources()
- syncBroadcaster.get().runSync() // restart/resume sync
- setResult(Activity.RESULT_OK)
- finish() // Return to the previous activity
- } else {
- configurationRegistry.fetchNonWorkflowConfigResources()
- syncBroadcaster.get().runSync()
- loginService.navigateToHome()
}
}
launchDialPad.observe(this@LoginActivity) { if (!it.isNullOrEmpty()) launchDialPad(it) }
- }
-
- if (configurationRegistry.isAppIdInitialized()) {
- configureViews(configurationRegistry.retrieveConfiguration(AppConfigClassification.LOGIN))
- }
- // Check if Pin enabled and stored then move to Pin login
- val isPinEnabled = loginViewModel.loginViewConfiguration.value?.enablePin ?: false
- val forceLoginViaUsername =
- loginViewModel.sharedPreferences.read(FORCE_LOGIN_VIA_USERNAME, false)
- val lastPinExist = loginViewModel.accountAuthenticator.hasActivePin()
- if (isPinEnabled && lastPinExist && !forceLoginViaUsername) {
- loginViewModel.sharedPreferences.write(FORCE_LOGIN_VIA_USERNAME, false)
- loginService.navigateToPinLogin()
- }
-
- setContent { AppTheme { LoginScreen(loginViewModel = loginViewModel) } }
+ if (configurationRegistry.isAppIdInitialized()) {
+ configureViews(configurationRegistry.retrieveConfiguration(AppConfigClassification.LOGIN))
+ }
- if (!intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME).isNullOrBlank() &&
- loginViewModel.username.value.isNullOrBlank()
- ) {
- loginViewModel.onUsernameUpdated(intent.getStringExtra(AccountManager.KEY_ACCOUNT_NAME)!!)
- this@LoginActivity.showToast(getString(R.string.auth_token_expired), Toast.LENGTH_SHORT)
+ // Check if Pin enabled and stored then move to Pin login
+ val isPinEnabled = loginViewConfiguration.value?.enablePin ?: false
+ val forceLoginViaUsername =
+ loginViewModel.sharedPreferences.read(FORCE_LOGIN_VIA_USERNAME, false)
+ val lastPinExist = !secureSharedPreference.retrieveSessionPin().isNullOrEmpty()
+ if (isPinEnabled && lastPinExist && !forceLoginViaUsername) {
+ loginViewModel.sharedPreferences.write(FORCE_LOGIN_VIA_USERNAME, false)
+ loginService.navigateToPinLogin()
+ }
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginErrorState.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginErrorState.kt
index c8a5152495..367031938c 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginErrorState.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginErrorState.kt
@@ -18,6 +18,7 @@ package org.smartregister.fhircore.engine.ui.login
enum class LoginErrorState {
UNKNOWN_HOST,
- NETWORK_ERROR,
- INVALID_CREDENTIALS
+ INVALID_CREDENTIALS,
+ MULTI_USER_LOGIN_ATTEMPT,
+ ERROR_FETCHING_USER
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginScreen.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginScreen.kt
index 366310eac9..b11e0511a8 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginScreen.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginScreen.kt
@@ -74,6 +74,7 @@ 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.VisualTransformation
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -109,6 +110,8 @@ fun LoginScreen(loginViewModel: LoginViewModel) {
val password by loginViewModel.password.observeAsState("")
val loginErrorState by loginViewModel.loginErrorState.observeAsState(null)
val showProgressBar by loginViewModel.showProgressBar.observeAsState(false)
+ val context = LocalContext.current
+
AnimatedContent(targetState = loadingConfig) {
if (!loadingConfig) {
LoginPage(
@@ -118,7 +121,7 @@ fun LoginScreen(loginViewModel: LoginViewModel) {
password = password,
onPasswordChanged = { loginViewModel.onPasswordUpdated(it) },
forgotPassword = { loginViewModel.forgotPassword() },
- onLoginButtonClicked = { loginViewModel.attemptRemoteLogin() },
+ onLoginButtonClicked = { loginViewModel.login(context = context) },
loginErrorState = loginErrorState,
showProgressBar = showProgressBar,
)
@@ -191,6 +194,7 @@ fun LoginPage(
text = viewConfiguration.applicationName,
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
+ textAlign = TextAlign.Center,
modifier =
modifier
.wrapContentWidth()
@@ -276,7 +280,7 @@ fun LoginPage(
color = MaterialTheme.colors.error,
text =
when (loginErrorState) {
- LoginErrorState.UNKNOWN_HOST, LoginErrorState.NETWORK_ERROR ->
+ LoginErrorState.UNKNOWN_HOST ->
stringResource(
id = R.string.login_error,
stringResource(R.string.login_call_fail_error_message)
@@ -287,6 +291,16 @@ fun LoginPage(
stringResource(R.string.invalid_login_credentials)
)
null -> ""
+ LoginErrorState.MULTI_USER_LOGIN_ATTEMPT ->
+ stringResource(
+ id = R.string.login_error,
+ stringResource(R.string.multi_user_login_attempt)
+ )
+ LoginErrorState.ERROR_FETCHING_USER ->
+ stringResource(
+ id = R.string.login_error,
+ stringResource(R.string.error_fetching_user_details)
+ )
},
modifier =
modifier
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt
index 500db89ca8..d55d545353 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt
@@ -16,41 +16,38 @@
package org.smartregister.fhircore.engine.ui.login
-import android.accounts.AccountManager
-import android.accounts.AccountManagerCallback
-import android.accounts.AccountManagerFuture
-import android.os.Bundle
-import androidx.core.os.bundleOf
+import android.content.Context
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
-import java.io.IOException
-import java.net.UnknownHostException
import javax.inject.Inject
import kotlinx.coroutines.launch
-import okhttp3.ResponseBody
-import org.hl7.fhir.r4.model.Practitioner
+import kotlinx.coroutines.runBlocking
+import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.ResourceType
import org.jetbrains.annotations.TestOnly
import org.smartregister.fhircore.engine.auth.AccountAuthenticator
import org.smartregister.fhircore.engine.configuration.view.LoginViewConfiguration
import org.smartregister.fhircore.engine.configuration.view.loginViewConfigurationOf
+import org.smartregister.fhircore.engine.data.local.DefaultRepository
+import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
-import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse
+import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService
import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo
-import org.smartregister.fhircore.engine.data.remote.shared.ResponseCallback
-import org.smartregister.fhircore.engine.data.remote.shared.ResponseHandler
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
import org.smartregister.fhircore.engine.util.DispatcherProvider
-import org.smartregister.fhircore.engine.util.LOGGED_IN_PRACTITIONER
+import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
-import org.smartregister.fhircore.engine.util.USER_INFO_SHARED_PREFERENCE_KEY
-import org.smartregister.fhircore.engine.util.extension.decodeJson
-import org.smartregister.fhircore.engine.util.extension.encodeJson
-import org.smartregister.fhircore.engine.util.extension.encodeResourceToString
-import retrofit2.Call
-import retrofit2.Response
+import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid
+import org.smartregister.fhircore.engine.util.extension.getActivity
+import org.smartregister.fhircore.engine.util.extension.isDeviceOnline
+import org.smartregister.fhircore.engine.util.extension.practitionerEndpointUrl
+import org.smartregister.fhircore.engine.util.extension.valueToString
+import org.smartregister.model.practitioner.PractitionerDetails
+import retrofit2.HttpException
import timber.log.Timber
@HiltViewModel
@@ -58,96 +55,21 @@ class LoginViewModel
@Inject
constructor(
val accountAuthenticator: AccountAuthenticator,
- val dispatcher: DispatcherProvider,
val sharedPreferences: SharedPreferencesHelper,
- val fhirResourceDataSource: FhirResourceDataSource
-) : ViewModel(), AccountManagerCallback {
+ val secureSharedPreference: SecureSharedPreference,
+ val dispatcherProvider: DispatcherProvider,
+ val fhirResourceDataSource: FhirResourceDataSource,
+ val defaultRepository: DefaultRepository,
+ val tokenAuthenticator: TokenAuthenticator,
+ val keycloakService: KeycloakService,
+ val fhirResourceService: FhirResourceService,
+) : ViewModel() {
private val _launchDialPad: MutableLiveData = MutableLiveData(null)
val launchDialPad
get() = _launchDialPad
- /**
- * Fetch the user info after verifying credentials with flow.
- *
- * On user-resp (failure) show-error. On user-resp (success) store user info and goto home.
- */
- val responseBodyHandler =
- object : ResponseHandler {
- override fun handleResponse(call: Call, response: Response) {
- if (response.isSuccessful) {
- response.body()?.let {
- with(it.string().decodeJson()) {
- sharedPreferences.write(USER_INFO_SHARED_PREFERENCE_KEY, this.encodeJson())
- fetchLoggedInPractitioner(this)
- }
- }
- } else {
- handleFailure(call, IOException("Network call failed with $response"))
- }
- Timber.i(response.errorBody()?.toString() ?: "No error")
- }
-
- override fun handleFailure(call: Call, throwable: Throwable) {
- Timber.e(throwable)
- handleErrorMessage(throwable)
- _showProgressBar.postValue(false)
- }
- }
-
- private val userInfoResponseCallback: ResponseCallback by lazy {
- object : ResponseCallback(responseBodyHandler) {}
- }
-
- /**
- * Call after remote login and subsequently fetch userinfo, handles network failures incase
- * previous successful attempt exists.
- *
- * On auth-resp (failure) show error, attempt local login (true), and goto home.
- *
- * On auth-resp success, fetch userinfo #LoginViewModel.responseBodyHandler. On subsequent
- * user-resp (failure) show-error, otherwise on user-resp (success) store user info, and goto
- * home.
- * ```
- * ```
- */
- val oauthResponseHandler =
- object : ResponseHandler {
- override fun handleResponse(call: Call, response: Response) {
- if (!response.isSuccessful) {
- if (response.code() == 401) {
- handleFailure(
- call,
- InvalidCredentialsException(Throwable(response.errorBody()?.toString()))
- )
- } else {
- handleFailure(call, LoginNetworkException(Throwable(response.errorBody()?.toString())))
- }
- } else {
- accountAuthenticator.run {
- addAuthenticatedAccount(
- response,
- username.value!!.trim(),
- password.value?.trim()?.toCharArray()!!
- )
- getUserInfo().enqueue(userInfoResponseCallback)
- }
- }
- }
-
- override fun handleFailure(call: Call, throwable: Throwable) {
- Timber.e(throwable.stackTraceToString())
- if (attemptLocalLogin()) {
- _navigateToHome.postValue(true)
- _showProgressBar.postValue(false)
- return
- }
- handleErrorMessage(throwable)
- _showProgressBar.postValue(false)
- }
- }
-
- private val _navigateToHome = MutableLiveData()
+ private val _navigateToHome = MutableLiveData(false)
val navigateToHome: LiveData
get() = _navigateToHome
@@ -175,52 +97,28 @@ constructor(
val loadingConfig: LiveData
get() = _loadingConfig
- fun fetchLoggedInPractitioner(userInfo: UserInfo) {
- if (!userInfo.keycloakUuid.isNullOrEmpty() &&
- sharedPreferences.read(LOGGED_IN_PRACTITIONER, null) == null
- ) {
- viewModelScope.launch(dispatcher.io()) {
+ private suspend fun fetchPractitioner(
+ onFetchUserInfo: (Result) -> Unit,
+ onFetchPractitioner: (Result) -> Unit
+ ) {
+ try {
+ val userInfo = keycloakService.fetchUserInfo().body()
+ if (userInfo != null && !userInfo.keycloakUuid.isNullOrEmpty()) {
+ onFetchUserInfo(Result.success(userInfo))
try {
- fhirResourceDataSource.search(
- ResourceType.Practitioner.name,
- mapOf(IDENTIFIER to userInfo.keycloakUuid!!)
- )
- .run {
- if (!this.entry.isNullOrEmpty()) {
- sharedPreferences.write(
- LOGGED_IN_PRACTITIONER,
- (this.entryFirstRep.resource as Practitioner).encodeResourceToString()
- )
- }
- }
- } catch (throwable: Throwable) {
- Timber.e("Error fetching practitioner details", throwable)
- } finally {
- _showProgressBar.postValue(false)
- _navigateToHome.postValue(true)
+ val bundle =
+ fhirResourceService.getResource(url = userInfo.keycloakUuid!!.practitionerEndpointUrl())
+ onFetchPractitioner(Result.success(bundle))
+ } catch (httpException: HttpException) {
+ onFetchPractitioner(Result.failure(httpException))
}
- }
- } else {
- _showProgressBar.postValue(false)
- _navigateToHome.postValue(true)
- }
- }
-
- fun attemptLocalLogin(): Boolean {
- return accountAuthenticator.validLocalCredentials(
- username.value!!.trim(),
- password.value!!.trim().toCharArray()
- )
- }
-
- fun loginUser() {
- viewModelScope.launch(dispatcher.io()) {
- if (accountAuthenticator.hasActiveSession()) {
- Timber.v("Login not needed .. navigating to home directly")
- _navigateToHome.postValue(true)
} else {
- accountAuthenticator.loadActiveAccount(this@LoginViewModel)
+ onFetchPractitioner(
+ Result.failure(NullPointerException("Keycloak user is null. Failed to fetch user."))
+ )
}
+ } catch (httpException: HttpException) {
+ onFetchUserInfo(Result.failure(httpException))
}
}
@@ -239,24 +137,94 @@ constructor(
_password.value = password
}
- override fun run(future: AccountManagerFuture) {
- val bundle = future.result ?: bundleOf()
- bundle.getString(AccountManager.KEY_AUTHTOKEN)?.run {
- if (this.isNotEmpty() && accountAuthenticator.tokenManagerService.isTokenActive(this)) {
- _navigateToHome.postValue(true)
+ fun login(context: Context) {
+ val usernameValue = _username.value
+ val passwordValue = _password.value
+ if (!usernameValue.isNullOrBlank() && !passwordValue.isNullOrBlank()) {
+ _loginErrorState.postValue(null)
+ _showProgressBar.postValue(true)
+
+ val trimmedUsername = _username.value!!.trim()
+ val passwordAsCharArray = _password.value!!.toCharArray()
+ viewModelScope.launch(dispatcherProvider.io()) {
+ if (context.getActivity()!!.isDeviceOnline()) {
+ fetchToken(
+ username = trimmedUsername,
+ password = passwordAsCharArray,
+ onFetchUserInfo = {
+ if (it.isFailure) {
+ Timber.e(it.exceptionOrNull())
+ _showProgressBar.postValue(false)
+ _loginErrorState.postValue(LoginErrorState.ERROR_FETCHING_USER)
+ }
+ },
+ onFetchPractitioner = { bundleResult ->
+ _showProgressBar.postValue(false)
+ if (bundleResult.isSuccess) {
+ updateNavigateHome(true)
+ val bundle = bundleResult.getOrDefault(Bundle())
+ savePractitionerDetails(bundle) {
+ _showProgressBar.postValue(false)
+ updateNavigateHome(true)
+ }
+ } else {
+ _showProgressBar.postValue(false)
+ Timber.e(bundleResult.exceptionOrNull())
+ Timber.e(bundleResult.getOrNull().valueToString())
+ _loginErrorState.postValue(LoginErrorState.ERROR_FETCHING_USER)
+ }
+ }
+ )
+ } else {
+ if (accountAuthenticator.validateLoginCredentials(trimmedUsername, passwordAsCharArray)) {
+ _showProgressBar.postValue(false)
+ updateNavigateHome(true)
+ } else {
+ _showProgressBar.postValue(false)
+ _loginErrorState.postValue(LoginErrorState.INVALID_CREDENTIALS)
+ }
+ }
}
}
}
- fun attemptRemoteLogin() {
- if (!username.value.isNullOrBlank() && !password.value.isNullOrBlank()) {
- _loginErrorState.postValue(null)
- _showProgressBar.postValue(true)
- accountAuthenticator
- .fetchToken(username.value!!.trim(), password.value!!.trim().toCharArray())
- .enqueue(object : ResponseCallback(oauthResponseHandler) {})
+ /**
+ * This function checks first if the existing token is active otherwise fetches a new token, then
+ * gets the user information from the authentication server. The id of the retrieved user is used
+ * to obtain the [PractitionerDetails] from the FHIR server.
+ */
+ private suspend fun fetchToken(
+ username: String,
+ password: CharArray,
+ onFetchUserInfo: (Result) -> Unit,
+ onFetchPractitioner: (Result) -> Unit
+ ) {
+ val practitionerDetails =
+ sharedPreferences.read(key = SharedPreferenceKey.PRACTITIONER_ID.name, defaultValue = null)
+ if (tokenAuthenticator.sessionActive() && practitionerDetails != null) {
+ _showProgressBar.postValue(false)
+ updateNavigateHome(true)
+ } else {
+ // Prevent user from logging in with different credentials
+ val existingCredentials = secureSharedPreference.retrieveCredentials()
+ if (existingCredentials != null && !username.equals(existingCredentials.username, true)) {
+ _showProgressBar.postValue(false)
+ _loginErrorState.postValue(LoginErrorState.MULTI_USER_LOGIN_ATTEMPT)
+ } else {
+ tokenAuthenticator
+ .fetchAccessToken(username, password)
+ .onSuccess { fetchPractitioner(onFetchUserInfo, onFetchPractitioner) }
+ .onFailure {
+ _showProgressBar.postValue(false)
+ _loginErrorState.postValue(LoginErrorState.UNKNOWN_HOST)
+ Timber.e(it)
+ }
+ }
}
}
+ fun updateNavigateHome(navigateHome: Boolean = true) {
+ _navigateToHome.postValue(navigateHome)
+ }
fun forgotPassword() {
// TODO load supervisor contact e.g.
@@ -269,12 +237,43 @@ constructor(
_navigateToHome.postValue(navigateHome)
}
- private fun handleErrorMessage(throwable: Throwable) {
- when (throwable) {
- is UnknownHostException -> _loginErrorState.postValue(LoginErrorState.UNKNOWN_HOST)
- is InvalidCredentialsException ->
- _loginErrorState.postValue(LoginErrorState.INVALID_CREDENTIALS)
- else -> _loginErrorState.postValue(LoginErrorState.NETWORK_ERROR)
+ fun savePractitionerDetails(bundle: Bundle, postProcess: () -> Unit) {
+ if (bundle.entry.isNullOrEmpty()) return
+ runBlocking {
+ val practitionerDetails = bundle.entry.first().resource as PractitionerDetails
+
+ val careTeams = practitionerDetails.fhirPractitionerDetails?.careTeams ?: listOf()
+ val organizations = practitionerDetails.fhirPractitionerDetails?.organizations ?: listOf()
+ val locations = practitionerDetails.fhirPractitionerDetails?.locations ?: listOf()
+ val locationHierarchies =
+ practitionerDetails.fhirPractitionerDetails?.locationHierarchyList ?: listOf()
+
+ val careTeamIds =
+ defaultRepository.create(false, *careTeams.toTypedArray()).map { it.extractLogicalIdUuid() }
+ sharedPreferences.write(ResourceType.CareTeam.name, careTeamIds)
+
+ val organizationIds =
+ defaultRepository.create(false, *organizations.toTypedArray()).map {
+ it.extractLogicalIdUuid()
+ }
+ sharedPreferences.write(ResourceType.Organization.name, organizationIds)
+
+ val locationIds =
+ defaultRepository.create(false, *locations.toTypedArray()).map { it.extractLogicalIdUuid() }
+ sharedPreferences.write(ResourceType.Location.name, locationIds)
+
+ sharedPreferences.write(
+ SharedPreferenceKey.PRACTITIONER_LOCATION_HIERARCHIES.name,
+ locationHierarchies
+ )
+ sharedPreferences.write(
+ key = SharedPreferenceKey.PRACTITIONER_ID.name,
+ value = practitionerDetails.fhirPractitionerDetails?.practitionerId.valueToString()
+ )
+
+ sharedPreferences.write(SharedPreferenceKey.PRACTITIONER_DETAILS.name, practitionerDetails)
+
+ postProcess()
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/pin/PinViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/pin/PinViewModel.kt
index 163235556f..3a173f519b 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/pin/PinViewModel.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/pin/PinViewModel.kt
@@ -27,10 +27,10 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
import org.smartregister.fhircore.engine.configuration.app.AppConfigClassification
import org.smartregister.fhircore.engine.configuration.view.PinViewConfiguration
import org.smartregister.fhircore.engine.ui.components.PIN_INPUT_MAX_THRESHOLD
-import org.smartregister.fhircore.engine.util.APP_ID_CONFIG
import org.smartregister.fhircore.engine.util.DispatcherProvider
import org.smartregister.fhircore.engine.util.IS_LOGGED_IN
import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
@HiltViewModel
@@ -109,7 +109,7 @@ constructor(
fun getPinConfiguration(): PinViewConfiguration =
configurationRegistry.retrieveConfiguration(AppConfigClassification.PIN)
- fun retrieveAppId(): String = sharedPreferences.read(APP_ID_CONFIG, "")!!
+ fun retrieveAppId(): String = sharedPreferences.read(SharedPreferenceKey.APP_ID.name, "")!!
fun retrieveAppName(): String = pinViewConfiguration.applicationName
@@ -157,7 +157,7 @@ constructor(
}
fun onMenuSettingClicked() {
- sharedPreferences.remove(APP_ID_CONFIG)
+ sharedPreferences.remove(SharedPreferenceKey.APP_ID.name)
_navigateToSettings.value = true
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivity.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivity.kt
index 3315aba068..9997193314 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivity.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivity.kt
@@ -154,7 +154,7 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList
private suspend fun renderFragment() {
tracer.startTrace(QUESTIONNAIRE_TRACE)
val questionnaireString = parser.encodeResourceToString(questionnaire)
- val questionnaireResponse: QuestionnaireResponse?
+ var questionnaireResponse: QuestionnaireResponse?
if (clientIdentifier != null) {
setBarcode(questionnaire, clientIdentifier!!, true)
questionnaireResponse =
@@ -165,7 +165,12 @@ open class QuestionnaireActivity : BaseMultiLanguageActivity(), View.OnClickList
.getStringExtra(QUESTIONNAIRE_RESPONSE)
?.decodeResourceFromString()
?.apply { generateMissingItems(this@QuestionnaireActivity.questionnaire) }
- if (questionnaireType.isReadOnly()) requireNotNull(questionnaireResponse)
+ if (questionnaireType.isReadOnly()) {
+ requireNotNull(questionnaireResponse)
+ } else {
+ questionnaireResponse =
+ questionnaireViewModel.generateQuestionnaireResponse(questionnaire, intent)
+ }
}
val questionnaireFragmentBuilder =
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt
index b17886eea6..197ff0fcd3 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt
@@ -55,7 +55,6 @@ import org.hl7.fhir.r4.model.Identifier
import org.hl7.fhir.r4.model.ListResource
import org.hl7.fhir.r4.model.Observation
import org.hl7.fhir.r4.model.Patient
-import org.hl7.fhir.r4.model.Practitioner
import org.hl7.fhir.r4.model.Questionnaire
import org.hl7.fhir.r4.model.QuestionnaireResponse
import org.hl7.fhir.r4.model.Reference
@@ -75,7 +74,7 @@ import org.smartregister.fhircore.engine.task.FhirCarePlanGenerator
import org.smartregister.fhircore.engine.trace.PerformanceReporter
import org.smartregister.fhircore.engine.util.AssetUtil
import org.smartregister.fhircore.engine.util.DispatcherProvider
-import org.smartregister.fhircore.engine.util.LOGGED_IN_PRACTITIONER
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.TracingHelpers
import org.smartregister.fhircore.engine.util.USER_INFO_SHARED_PREFERENCE_KEY
@@ -85,6 +84,7 @@ import org.smartregister.fhircore.engine.util.extension.assertSubject
import org.smartregister.fhircore.engine.util.extension.cqfLibraryIds
import org.smartregister.fhircore.engine.util.extension.deleteRelatedResources
import org.smartregister.fhircore.engine.util.extension.extractId
+import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid
import org.smartregister.fhircore.engine.util.extension.filterByResourceTypeId
import org.smartregister.fhircore.engine.util.extension.find
import org.smartregister.fhircore.engine.util.extension.findSubject
@@ -130,11 +130,10 @@ constructor(
sharedPreferencesHelper.read(USER_INFO_SHARED_PREFERENCE_KEY)
}
- private val loggedInPractitioner by lazy {
- sharedPreferencesHelper.read(
- key = LOGGED_IN_PRACTITIONER,
- decodeFhirResource = true
- )
+ private val practitionerId: String? by lazy {
+ sharedPreferencesHelper
+ .read(SharedPreferenceKey.PRACTITIONER_ID.name, null)
+ ?.extractLogicalIdUuid()
}
suspend fun loadQuestionnaire(id: String, type: QuestionnaireType): Questionnaire? =
@@ -224,8 +223,8 @@ constructor(
}
fun appendPractitionerInfo(resource: Resource) {
- loggedInPractitioner?.id?.let {
- val practitionerRef = Reference().apply { reference = it }
+ practitionerId?.let {
+ val practitionerRef = it.asReference(ResourceType.Practitioner)
if (resource is Encounter)
resource.participant =
@@ -257,7 +256,7 @@ constructor(
reference = "${ResourceType.RelatedPerson.name}/${resource.logicalId}"
}
}
- defaultRepository.addOrUpdate(this)
+ defaultRepository.addOrUpdate(true, this)
}
}
@@ -467,7 +466,7 @@ constructor(
it.valueCodeableConcept.coding.forEach { questionnaireResponse.meta.addTag(it) }
}
- defaultRepository.addOrUpdate(questionnaireResponse)
+ defaultRepository.addOrUpdate(true, questionnaireResponse)
}
suspend fun performExtraction(
@@ -489,7 +488,7 @@ constructor(
suspend fun saveBundleResources(bundle: Bundle) {
if (!bundle.isEmpty) {
- bundle.entry.forEach { defaultRepository.addOrUpdate(it.resource) }
+ bundle.entry.forEach { defaultRepository.addOrUpdate(true, it.resource) }
}
}
@@ -625,10 +624,10 @@ constructor(
return bundle
}
- open suspend fun getPopulationResources(
+ fun getPopulationResourcesFromIntent(
intent: Intent,
questionnaireLogicalId: String
- ): Array {
+ ): List {
val resourcesList = mutableListOf()
intent.getStringArrayListExtra(QuestionnaireActivity.QUESTIONNAIRE_POPULATION_RESOURCES)?.run {
@@ -644,6 +643,16 @@ constructor(
resourcesList.add(bundle)
}
+ return resourcesList
+ }
+
+ open suspend fun getPopulationResources(
+ intent: Intent,
+ questionnaireLogicalId: String
+ ): Array {
+ val resourcesList =
+ getPopulationResourcesFromIntent(intent, questionnaireLogicalId).toMutableList()
+
intent.getStringExtra(QuestionnaireActivity.QUESTIONNAIRE_ARG_PATIENT_KEY)?.let { patientId ->
loadPatient(patientId)?.apply {
if (identifier.isEmpty()) {
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/register/BaseRegisterActivity.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/register/BaseRegisterActivity.kt
index 03a184929d..c32fdaf73e 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/register/BaseRegisterActivity.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/register/BaseRegisterActivity.kt
@@ -67,20 +67,21 @@ import org.smartregister.fhircore.engine.navigation.NavigationBottomSheet
import org.smartregister.fhircore.engine.sync.OnSyncListener
import org.smartregister.fhircore.engine.sync.SyncBroadcaster
import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity
+import org.smartregister.fhircore.engine.ui.login.LoginActivity
import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity
import org.smartregister.fhircore.engine.ui.register.model.NavigationMenuOption
import org.smartregister.fhircore.engine.ui.register.model.RegisterFilterType
import org.smartregister.fhircore.engine.ui.register.model.RegisterItem
import org.smartregister.fhircore.engine.ui.register.model.SideMenuOption
import org.smartregister.fhircore.engine.util.DateUtils
-import org.smartregister.fhircore.engine.util.LAST_SYNC_TIMESTAMP
import org.smartregister.fhircore.engine.util.SecureSharedPreference
-import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.extension.DrawablePosition
import org.smartregister.fhircore.engine.util.extension.addOnDrawableClickListener
import org.smartregister.fhircore.engine.util.extension.asString
import org.smartregister.fhircore.engine.util.extension.getDrawable
import org.smartregister.fhircore.engine.util.extension.hide
+import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory
import org.smartregister.fhircore.engine.util.extension.refresh
import org.smartregister.fhircore.engine.util.extension.setAppLocale
import org.smartregister.fhircore.engine.util.extension.show
@@ -165,7 +166,10 @@ abstract class BaseRegisterActivity :
is SyncJobStatus.Glitch -> {
progressSync.hide()
val lastSyncTimestamp =
- sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, getString(R.string.syncing_retry))
+ sharedPreferencesHelper.read(
+ SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name,
+ getString(R.string.syncing_retry)
+ )
tvLastSyncTimestamp.text = lastSyncTimestamp?.formatSyncDate() ?: ""
containerProgressSync.apply {
background = this.getDrawable(R.drawable.ic_sync)
@@ -452,7 +456,7 @@ abstract class BaseRegisterActivity :
when (item.itemId) {
R.id.menu_item_language -> renderSelectLanguageDialog(this)
R.id.menu_item_logout -> {
- accountAuthenticator.logout()
+ accountAuthenticator.logout { launchActivityWithNoBackStackHistory() }
manipulateDrawer(open = false)
}
else -> {
@@ -491,7 +495,7 @@ abstract class BaseRegisterActivity :
private fun refreshSelectedLanguage(language: Language, context: Activity) {
updateLanguage(language)
context.setAppLocale(language.tag)
- sharedPreferencesHelper.write(SharedPreferencesHelper.LANG, language.tag)
+ sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, language.tag)
context.refresh()
}
@@ -708,7 +712,7 @@ abstract class BaseRegisterActivity :
401
) {
showToast(getString(R.string.session_expired))
- accountAuthenticator.logout()
+ accountAuthenticator.logout { launchActivityWithNoBackStackHistory() }
} else {
if (exceptions.map { it.exception }.any {
it is InterruptedIOException || it is UnknownHostException
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/register/RegisterViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/register/RegisterViewModel.kt
index 8770ae3447..4cc0602833 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/register/RegisterViewModel.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/register/RegisterViewModel.kt
@@ -37,7 +37,7 @@ import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceD
import org.smartregister.fhircore.engine.domain.model.Language
import org.smartregister.fhircore.engine.ui.register.model.RegisterFilterType
import org.smartregister.fhircore.engine.util.DispatcherProvider
-import org.smartregister.fhircore.engine.util.LAST_SYNC_TIMESTAMP
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
/**
@@ -63,7 +63,7 @@ constructor(
)
private val _lastSyncTimestamp =
- MutableLiveData(sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, ""))
+ MutableLiveData(sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, ""))
val lastSyncTimestamp
get() = _lastSyncTimestamp
@@ -79,7 +79,7 @@ constructor(
var selectedLanguage =
MutableLiveData(
- sharedPreferencesHelper.read(SharedPreferencesHelper.LANG, Locale.UK.toLanguageTag())
+ sharedPreferencesHelper.read(SharedPreferenceKey.LANG.name, Locale.UK.toLanguageTag())
)
val registerViewConfiguration: MutableLiveData = MutableLiveData()
@@ -111,7 +111,7 @@ constructor(
fun setLastSyncTimestamp(lastSyncTimestamp: String) {
if (lastSyncTimestamp.isNotEmpty()) {
- sharedPreferencesHelper.write(LAST_SYNC_TIMESTAMP, lastSyncTimestamp)
+ sharedPreferencesHelper.write(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, lastSyncTimestamp)
}
_lastSyncTimestamp.value = lastSyncTimestamp
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileScreen.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileScreen.kt
index a8bceabc67..4e2625c3a3 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileScreen.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileScreen.kt
@@ -48,6 +48,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.capitalize
@@ -70,6 +71,7 @@ fun UserProfileScreen(
val username by remember { mutableStateOf(userProfileViewModel.retrieveUsername()) }
var expanded by remember { mutableStateOf(false) }
+ val context = LocalContext.current
Column(modifier = modifier.padding(vertical = 20.dp)) {
if (!username.isNullOrEmpty()) {
@@ -154,7 +156,7 @@ fun UserProfileScreen(
UserProfileRow(
icon = Icons.Rounded.Logout,
text = stringResource(id = R.string.logout),
- clickListener = userProfileViewModel::logoutUser,
+ clickListener = { userProfileViewModel.logoutUser(context) },
modifier = modifier
)
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileViewModel.kt
index 2601ff2c7e..88409bad9f 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileViewModel.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileViewModel.kt
@@ -16,6 +16,7 @@
package org.smartregister.fhircore.engine.ui.userprofile
+import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -25,9 +26,13 @@ import org.smartregister.fhircore.engine.auth.AccountAuthenticator
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
import org.smartregister.fhircore.engine.domain.model.Language
import org.smartregister.fhircore.engine.sync.SyncBroadcaster
+import org.smartregister.fhircore.engine.ui.login.LoginActivity
import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.fetchLanguages
+import org.smartregister.fhircore.engine.util.extension.getActivity
+import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory
@HiltViewModel
class UserProfileViewModel
@@ -50,9 +55,11 @@ constructor(
syncBroadcaster.runSync()
}
- fun logoutUser() {
+ fun logoutUser(context: Context) {
onLogout.postValue(true)
- accountAuthenticator.logout()
+ accountAuthenticator.logout {
+ context.getActivity()?.launchActivityWithNoBackStackHistory()
+ }
}
fun retrieveUsername(): String? = secureSharedPreference.retrieveSessionUsername()
@@ -61,12 +68,12 @@ constructor(
fun loadSelectedLanguage(): String =
Locale.forLanguageTag(
- sharedPreferencesHelper.read(SharedPreferencesHelper.LANG, Locale.UK.toLanguageTag())!!
+ sharedPreferencesHelper.read(SharedPreferenceKey.LANG.name, Locale.UK.toLanguageTag())!!
)
.displayName
fun setLanguage(language: Language) {
- sharedPreferencesHelper.write(SharedPreferencesHelper.LANG, language.tag)
+ sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, language.tag)
this.language.postValue(language)
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecureSharedPreference.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecureSharedPreference.kt
index 6bff1c26e6..481bfc5bc5 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecureSharedPreference.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecureSharedPreference.kt
@@ -21,8 +21,10 @@ import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
+import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
+import org.jetbrains.annotations.VisibleForTesting
import org.smartregister.fhircore.engine.auth.AuthCredentials
import org.smartregister.fhircore.engine.util.extension.decodeJson
import org.smartregister.fhircore.engine.util.extension.encodeJson
@@ -42,32 +44,33 @@ class SecureSharedPreference @Inject constructor(@ApplicationContext val context
private fun getMasterKey() =
MasterKey.Builder(context).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
- fun saveCredentials(authCredentials: AuthCredentials) {
+ fun saveCredentials(username: String, password: CharArray) {
+ val randomSaltBytes = get256RandomBytes()
+
secureSharedPreferences.edit {
- putString(KEY_LATEST_CREDENTIALS_PREFERENCE, authCredentials.encodeJson())
+ putString(
+ SharedPreferenceKey.LOGIN_CREDENTIAL_KEY.name,
+ AuthCredentials(
+ username = username,
+ salt = Base64.getEncoder().encodeToString(randomSaltBytes),
+ passwordHash = password.toPasswordHash(randomSaltBytes),
+ )
+ .encodeJson()
+ )
}
}
fun deleteCredentials() {
- secureSharedPreferences.edit { remove(KEY_LATEST_CREDENTIALS_PREFERENCE) }
+ secureSharedPreferences.edit { remove(SharedPreferenceKey.LOGIN_CREDENTIAL_KEY.name) }
}
fun retrieveSessionToken() = retrieveCredentials()?.sessionToken
fun retrieveSessionUsername() = retrieveCredentials()?.username
- fun deleteSession() {
- retrieveCredentials()?.run {
- this.sessionToken = null
- this.refreshToken = null
-
- saveCredentials(this)
- }
- }
-
fun retrieveCredentials(): AuthCredentials? {
return secureSharedPreferences
- .getString(KEY_LATEST_CREDENTIALS_PREFERENCE, null)
+ .getString(SharedPreferenceKey.LOGIN_CREDENTIAL_KEY.name, null)
?.decodeJson()
}
@@ -81,9 +84,10 @@ class SecureSharedPreference @Inject constructor(@ApplicationContext val context
secureSharedPreferences.edit { remove(KEY_SESSION_PIN) }
}
+ @VisibleForTesting fun get256RandomBytes() = 256.getRandomBytesOfSize()
+
companion object {
const val SECURE_STORAGE_FILE_NAME = "fhircore_secure_preferences"
- const val KEY_LATEST_CREDENTIALS_PREFERENCE = "LATEST_SUCCESSFUL_SESSION_CREDENTIALS"
const val KEY_SESSION_PIN = "KEY_SESSION_PIN"
}
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecurityUtil.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecurityUtil.kt
index f3b1cd1a61..1764380da9 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecurityUtil.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SecurityUtil.kt
@@ -16,13 +16,29 @@
package org.smartregister.fhircore.engine.util
-import java.security.MessageDigest
-import java.util.Locale
-import javax.xml.bind.DatatypeConverter
+import android.os.Build
+import java.nio.charset.StandardCharsets
+import java.security.SecureRandom
+import javax.crypto.SecretKeyFactory
+import javax.crypto.spec.PBEKeySpec
+import org.jetbrains.annotations.VisibleForTesting
-fun String.toSha1() = hashString("SHA-1", this)
+fun CharArray.toPasswordHash(salt: ByteArray) = passwordHashString(this, salt)
-private fun hashString(type: String, input: String): String {
- val bytes = MessageDigest.getInstance(type).digest(input.toByteArray())
- return DatatypeConverter.printHexBinary(bytes).uppercase(Locale.getDefault())
+@VisibleForTesting
+fun passwordHashString(password: CharArray, salt: ByteArray): String {
+ val pbKeySpec = PBEKeySpec(password, salt, 1000000, 256)
+ val secretKeyFactory =
+ SecretKeyFactory.getInstance(
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) "PBKDF2withHmacSHA256"
+ else "PBKDF2WithHmacSHA1"
+ )
+ return secretKeyFactory.generateSecret(pbKeySpec).encoded.toString(StandardCharsets.UTF_8)
+}
+
+fun Int.getRandomBytesOfSize(): ByteArray {
+ val random = SecureRandom()
+ val randomSaltBytes = ByteArray(this)
+ random.nextBytes(randomSaltBytes)
+ return randomSaltBytes
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPrefConstants.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPrefConstants.kt
index 75c57d07ad..93d3ce81ea 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPrefConstants.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPrefConstants.kt
@@ -16,10 +16,8 @@
package org.smartregister.fhircore.engine.util
-const val LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
const val USER_INFO_SHARED_PREFERENCE_KEY = "user_info"
const val LOGGED_IN_PRACTITIONER = "logged_in_practitioner"
-const val APP_ID_CONFIG = "app_id_config"
const val FORCE_LOGIN_VIA_USERNAME = "force_login_with_username"
const val FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP = "force_login_with_username_from_pin_setup"
const val IS_LOGGED_IN = "is_logged_in"
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt
new file mode 100644
index 0000000000..d4419c8a1a
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.util
+
+enum class SharedPreferenceKey {
+ APP_ID,
+ LAST_SYNC_TIMESTAMP,
+ LANG,
+ PRACTITIONER_ID,
+ PRACTITIONER_DETAILS,
+ PRACTITIONER_LOCATION_HIERARCHIES,
+ THEME,
+ REMOTE_SYNC_RESOURCES,
+ OVERDUE_TASK_LAST_AUTHORED_ON_DATE,
+ LOGIN_CREDENTIAL_KEY,
+ LOGIN_PIN_KEY
+}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt
index 9d78e9721c..1da5a76af4 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelper.kt
@@ -18,26 +18,33 @@ package org.smartregister.fhircore.engine.util
import android.content.Context
import android.content.SharedPreferences
+import com.google.gson.Gson
+import com.google.gson.JsonIOException
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
+import kotlinx.serialization.SerializationException
import org.smartregister.fhircore.engine.util.extension.decodeJson
-import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString
+import org.smartregister.fhircore.engine.util.extension.encodeJson
+import timber.log.Timber
@Singleton
-class SharedPreferencesHelper @Inject constructor(@ApplicationContext val context: Context) {
+class SharedPreferencesHelper
+@Inject
+constructor(@ApplicationContext val context: Context, val gson: Gson) {
- private var prefs: SharedPreferences =
+ val prefs: SharedPreferences by lazy {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
+ }
/** @see [SharedPreferences.getString] */
fun read(key: String, defaultValue: String?) = prefs.getString(key, defaultValue)
/** @see [SharedPreferences.Editor.putString] */
- fun write(key: String, value: String?, async: Boolean = false) {
+ fun write(key: String, value: String?) {
with(prefs.edit()) {
putString(key, value)
- if (async) apply() else commit()
+ commit()
}
}
@@ -45,11 +52,11 @@ class SharedPreferencesHelper @Inject constructor(@ApplicationContext val contex
fun read(key: String, defaultValue: Long) = prefs.getLong(key, defaultValue)
/** @see [SharedPreferences.Editor.putLong] */
- fun write(key: String, value: Long, async: Boolean = false) {
+ fun write(key: String, value: Long) {
val prefsEditor: SharedPreferences.Editor = prefs.edit()
with(prefsEditor) {
putLong(key, value)
- if (async) apply() else commit()
+ commit()
}
}
@@ -57,10 +64,35 @@ class SharedPreferencesHelper @Inject constructor(@ApplicationContext val contex
fun read(key: String, defaultValue: Boolean) = prefs.getBoolean(key, defaultValue)
/** @see [SharedPreferences.Editor.putBoolean] */
- fun write(key: String, value: Boolean, async: Boolean = false) {
+ fun write(key: String, value: Boolean) {
with(prefs.edit()) {
putBoolean(key, value)
- if (async) apply() else commit()
+ commit()
+ }
+ }
+
+ /** Read any JSON object with type T */
+ inline fun read(key: String, decodeWithGson: Boolean = true): T? =
+ if (decodeWithGson)
+ try {
+ gson.fromJson(this.read(key, null), T::class.java)
+ } catch (jsonIoException: JsonIOException) {
+ Timber.e(jsonIoException)
+ null
+ }
+ else
+ try {
+ this.read(key, null)?.decodeJson()
+ } catch (serializationException: SerializationException) {
+ Timber.e(serializationException)
+ null
+ }
+
+ /** Write any object by saving it as JSON */
+ inline fun write(key: String, value: T?, encodeWithGson: Boolean = true) {
+ with(prefs.edit()) {
+ putString(key, if (encodeWithGson) gson.toJson(value) else value.encodeJson())
+ commit()
}
}
@@ -68,13 +100,12 @@ class SharedPreferencesHelper @Inject constructor(@ApplicationContext val contex
prefs.edit().remove(key).apply()
}
- inline fun read(key: String, decodeFhirResource: Boolean = false): T? =
- if (decodeFhirResource) this.read(key, null)?.decodeResourceFromString()
- else this.read(key, null)?.decodeJson()
+ /** This method resets/clears all existing values in the shared preferences asynchronously */
+ fun resetSharedPrefs() {
+ prefs.edit()?.clear()?.apply()
+ }
companion object {
- const val LANG = "shared_pref_lang"
- const val THEME = "shared_pref_theme"
const val PREFS_NAME = "params"
const val MEASURE_RESOURCES_LOADED = "measure_resources_loaded"
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/annotation/PreviewWithBackgroundExcludeGenerated.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/annotation/PreviewWithBackgroundExcludeGenerated.kt
new file mode 100644
index 0000000000..667f614d4f
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/annotation/PreviewWithBackgroundExcludeGenerated.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.util.annotation
+
+import androidx.compose.ui.tooling.preview.Preview
+
+@Preview(showBackground = true)
+@ExcludeFromJacocoGeneratedReport
+annotation class PreviewWithBackgroundExcludeGenerated
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt
index d9dc209594..144bddd3de 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt
@@ -18,15 +18,21 @@ package org.smartregister.fhircore.engine.util.extension
import android.app.Activity
import android.content.Context
+import android.content.ContextWrapper
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.res.Configuration
import android.content.res.Resources
import android.graphics.drawable.Drawable
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
import android.os.Build
+import android.os.Bundle
import android.os.LocaleList
import android.widget.Toast
+import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
+import androidx.core.os.bundleOf
import java.util.Locale
import org.smartregister.fhircore.engine.R
import timber.log.Timber
@@ -86,3 +92,49 @@ fun Context.getDrawable(name: String): Drawable {
fun > Enum.isIn(vararg values: Enum): Boolean {
return values.any { this == it }
}
+
+fun Context.getActivity(): AppCompatActivity? =
+ when (this) {
+ is AppCompatActivity -> this
+ is ContextWrapper -> baseContext.getActivity()
+ else -> null
+ }
+
+/** This function checks if the device is online */
+fun Context.isDeviceOnline(): Boolean {
+ val connectivityManager =
+ this.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork ?: return false
+ val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
+
+ // Device can be connected to the internet through any of these NetworkCapabilities
+ val transports: List =
+ listOf(
+ NetworkCapabilities.TRANSPORT_ETHERNET,
+ NetworkCapabilities.TRANSPORT_CELLULAR,
+ NetworkCapabilities.TRANSPORT_WIFI,
+ NetworkCapabilities.TRANSPORT_VPN
+ )
+
+ return transports.any { capabilities.hasTransport(it) }
+}
+
+/**
+ * This function launches another [Activity] on top of the current. The current [Activity] is
+ * cleared from the back stack for launching the next activity then the current [Activity] is
+ * finished based on [finishLauncherActivity] condition.
+ */
+inline fun Activity.launchActivityWithNoBackStackHistory(
+ finishLauncherActivity: Boolean = true,
+ bundle: Bundle = bundleOf()
+) {
+ startActivity(
+ Intent(this, A::class.java).apply {
+ addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
+ addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
+ addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
+ putExtras(bundle)
+ }
+ )
+ if (finishLauncherActivity) finish()
+}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ApplicationExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ApplicationExtension.kt
index ea13028561..46441d8668 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ApplicationExtension.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ApplicationExtension.kt
@@ -81,14 +81,6 @@ suspend fun FhirEngine.searchActivePatients(
suspend fun FhirEngine.countActivePatients(): Long =
this.count { apply { filter(Patient.ACTIVE, { value = of(true) }) }.getQuery(true) }
-suspend inline fun FhirEngine.loadResource(resourceId: String): T? {
- return try {
- this@loadResource.get(resourceId)
- } catch (resourceNotFoundException: ResourceNotFoundException) {
- null
- }
-}
-
suspend fun FhirEngine.loadRelatedPersons(patientId: String): List? {
return try {
this@loadRelatedPersons.search {
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt
index 48f7f69edc..f9c11069d0 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt
@@ -30,6 +30,8 @@ val SDF_DD_MMM_YYYY = SimpleDateFormat("dd-MMM-yyyy")
val SDF_YYYY_MM_DD = SimpleDateFormat("yyyy-MM-dd")
val SDF_DD_MM_YYYY = SimpleDateFormat("dd/MM/yyyy")
+fun today(): Date = DateTimeType.today().value
+
fun OffsetDateTime.asString(): String {
return this.format(DateTimeFormatter.RFC_1123_DATE_TIME)
}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirContextExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirContextExtension.kt
new file mode 100644
index 0000000000..655248d10c
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirContextExtension.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.util.extension
+
+import ca.uhn.fhir.context.FhirContext
+import ca.uhn.fhir.parser.IParser
+import org.smartregister.model.location.LocationHierarchy
+import org.smartregister.model.practitioner.FhirPractitionerDetails
+import org.smartregister.model.practitioner.PractitionerDetails
+
+fun FhirContext.getCustomJsonParser(): IParser {
+ return this.apply {
+ registerCustomTypes(
+ listOf(
+ PractitionerDetails::class.java,
+ FhirPractitionerDetails::class.java,
+ LocationHierarchy::class.java,
+ // KeycloakUserDetails::class.java,
+ // UserBioData::class.java
+ )
+ )
+ }
+ .newJsonParser()
+}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt
new file mode 100644
index 0000000000..cbf290e63e
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt
@@ -0,0 +1,103 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.util.extension
+
+import android.database.SQLException
+import ca.uhn.fhir.util.UrlUtil
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.db.ResourceNotFoundException
+import com.google.android.fhir.get
+import com.google.android.fhir.search.SearchQuery
+import com.google.android.fhir.search.search
+import com.google.android.fhir.workflow.FhirOperator
+import org.hl7.fhir.r4.model.Composition
+import org.hl7.fhir.r4.model.IdType
+import org.hl7.fhir.r4.model.Identifier
+import org.hl7.fhir.r4.model.Library
+import org.hl7.fhir.r4.model.Measure
+import org.hl7.fhir.r4.model.RelatedArtifact
+import org.hl7.fhir.r4.model.Resource
+import org.hl7.fhir.r4.model.Task
+import timber.log.Timber
+
+suspend inline fun FhirEngine.loadResource(resourceId: String): T? {
+ return try {
+ this.get(resourceId)
+ } catch (resourceNotFoundException: ResourceNotFoundException) {
+ null
+ }
+}
+
+suspend fun FhirEngine.searchCompositionByIdentifier(identifier: String): Composition? =
+ this.search {
+ filter(Composition.IDENTIFIER, { value = of(Identifier().apply { value = identifier }) })
+ }
+ .firstOrNull()
+
+suspend fun FhirEngine.loadLibraryAtPath(fhirOperator: FhirOperator, path: String) {
+ // resource path could be Library/123 OR something like http://fhir.labs.common/Library/123
+ val library =
+ runCatching { get(IdType(path).idPart) }.getOrNull()
+ ?: search { filter(Library.URL, { value = path }) }.firstOrNull()
+
+ library?.let {
+ fhirOperator.loadLib(it)
+
+ it.relatedArtifact.forEach { loadLibraryAtPath(fhirOperator, it) }
+ }
+}
+
+suspend fun FhirEngine.loadLibraryAtPath(
+ fhirOperator: FhirOperator,
+ relatedArtifact: RelatedArtifact
+) {
+ if (relatedArtifact.type.isIn(
+ RelatedArtifact.RelatedArtifactType.COMPOSEDOF,
+ RelatedArtifact.RelatedArtifactType.DEPENDSON
+ )
+ )
+ loadLibraryAtPath(fhirOperator, relatedArtifact.resource)
+}
+
+suspend fun FhirEngine.loadCqlLibraryBundle(fhirOperator: FhirOperator, measurePath: String) =
+ try {
+ // resource path could be Measure/123 OR something like http://fhir.labs.common/Measure/123
+ val measure: Measure? =
+ if (UrlUtil.isValid(measurePath))
+ search { filter(Measure.URL, { value = measurePath }) }.firstOrNull()
+ else get(measurePath)
+
+ measure?.apply {
+ relatedArtifact.forEach { loadLibraryAtPath(fhirOperator, it) }
+ library.map { it.value }.forEach { path -> loadLibraryAtPath(fhirOperator, path) }
+ }
+ } catch (exception: Exception) {
+ Timber.e(exception)
+ }
+
+suspend fun FhirEngine.addDateTimeIndex() {
+ try {
+ val addDateTimeIndexEntityIndexFromIndexQuery =
+ SearchQuery(
+ "CREATE INDEX IF NOT EXISTS `index_DateTimeIndexEntity_index_from` ON `DateTimeIndexEntity` (`index_from`)",
+ emptyList()
+ )
+ search(addDateTimeIndexEntityIndexFromIndexQuery)
+ } catch (ex: SQLException) {
+ Timber.e(ex)
+ }
+}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt
index 3f19095ebd..9a07a5a089 100644
--- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt
@@ -22,6 +22,7 @@ import ca.uhn.fhir.rest.gclient.ReferenceClientParam
import com.google.android.fhir.datacapture.extensions.createQuestionnaireResponseItem
import com.google.android.fhir.logicalId
import java.util.Date
+import java.util.LinkedList
import java.util.UUID
import org.hl7.fhir.r4.model.Base
import org.hl7.fhir.r4.model.BaseDateTimeType
@@ -29,6 +30,7 @@ import org.hl7.fhir.r4.model.Binary
import org.hl7.fhir.r4.model.CarePlan
import org.hl7.fhir.r4.model.CodeableConcept
import org.hl7.fhir.r4.model.Coding
+import org.hl7.fhir.r4.model.Composition
import org.hl7.fhir.r4.model.Condition
import org.hl7.fhir.r4.model.Extension
import org.hl7.fhir.r4.model.HumanName
@@ -48,7 +50,7 @@ import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor
import timber.log.Timber
-private val fhirR4JsonParser = FhirContext.forR4Cached().newJsonParser()
+private val fhirR4JsonParser = FhirContext.forR4Cached().getCustomJsonParser()
fun Base?.valueToString(): String {
return when {
@@ -276,6 +278,42 @@ fun ArrayList.asCarePlanDomainResource(): ArrayList {
return list
}
+/**
+ * A function that extracts only the UUID part of a resource logicalId.
+ *
+ * Examples:
+ *
+ * 1. "Group/0acda8c9-3fa3-40ae-abcd-7d1fba7098b4/_history/2" returns
+ * "0acda8c9-3fa3-40ae-abcd-7d1fba7098b4".
+ *
+ * 2. "Group/0acda8c9-3fa3-40ae-abcd-7d1fba7098b4" returns "0acda8c9-3fa3-40ae-abcd-7d1fba7098b4".
+ */
+fun String.extractLogicalIdUuid() = this.substringAfter("/").substringBefore("/")
+
fun Resource.addTags(tags: List) {
tags.forEach { this.meta.addTag(it) }
}
+
+/**
+ * Composition sections can be nested. This function retrieves all the nested composition sections
+ * and returns a flattened list of all [Composition.SectionComponent] for the given [Composition]
+ * resource
+ */
+fun Composition.retrieveCompositionSections(): List {
+ val sections = mutableListOf()
+ val sectionsQueue = LinkedList()
+ this.section.forEach {
+ if (!it.section.isNullOrEmpty()) {
+ it.section.forEach { sectionComponent -> sectionsQueue.addLast(sectionComponent) }
+ }
+ sections.add(it)
+ }
+ while (sectionsQueue.isNotEmpty()) {
+ val sectionComponent = sectionsQueue.removeFirst()
+ if (!sectionComponent.section.isNullOrEmpty()) {
+ sectionComponent.section.forEach { sectionsQueue.addLast(it) }
+ }
+ sections.add(sectionComponent)
+ }
+ return sections
+}
diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt
new file mode 100644
index 0000000000..7850e51824
--- /dev/null
+++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.util.extension
+
+/**
+ * Get the practitioner endpoint url and append the keycloak-uuid. The original String is assumed to
+ * be a keycloak-uuid.
+ */
+fun String.practitionerEndpointUrl(): String = "practitioner-details?keycloak-uuid=$this"
diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml
index d42c48bd1f..bf1e072cf9 100644
--- a/android/engine/src/main/res/values/strings.xml
+++ b/android/engine/src/main/res/values/strings.xml
@@ -107,6 +107,8 @@
eCBIS
username or password is invalid
"Invalid form id attached"
+ Attempted to login with a different provider
+ Failed to fetch user details
No
Yes
There is no response to the required field.
@@ -138,4 +140,17 @@
Next Appointment Date
New Visit Created
A new visit for the patient has been successfully created.
+ http://smartregister.org/fhir/care-team-tag
+ http://smartregister.org/fhir/location-tag
+ http://smartregister.org/fhir/organization-tag
+ http://smartregister.org/fhir/practitioner-tag
+ http://smartregister.org/fhir/appid-tag
+ Practitioner CareTeam
+ Practitioner Location
+ Practitioner Organization
+ Practitioner
+ Initializing settings …
+ Could not load configuration. Please try again later
+ Could not load configuration. Please check your internet connection
+ Error connecting to the server. Please contact the system administrator
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt
index c06df357cb..c30450fe42 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt
@@ -19,8 +19,12 @@ package org.smartregister.fhircore.engine.app
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
+import org.hl7.fhir.r4.model.Coding
+import org.hl7.fhir.r4.model.ResourceType
import org.smartregister.fhircore.engine.configuration.app.AuthConfiguration
import org.smartregister.fhircore.engine.configuration.app.ConfigService
+import org.smartregister.fhircore.engine.sync.ResourceTag
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
class AppConfigService @Inject constructor(@ApplicationContext val context: Context) :
ConfigService {
@@ -32,4 +36,52 @@ class AppConfigService @Inject constructor(@ApplicationContext val context: Cont
clientSecret = "siri-fake",
accountType = context.packageName
)
+
+ override fun defineResourceTags() =
+ listOf(
+ ResourceTag(
+ type = ResourceType.CareTeam.name,
+ tag =
+ Coding().apply {
+ system = CARETEAM_SYSTEM
+ display = CARETEAM_DISPLAY
+ }
+ ),
+ ResourceTag(
+ type = ResourceType.Location.name,
+ tag =
+ Coding().apply {
+ system = LOCATION_SYSTEM
+ display = LOCATION_DISPLAY
+ }
+ ),
+ ResourceTag(
+ type = ResourceType.Organization.name,
+ tag =
+ Coding().apply {
+ system = ORGANIZATION_SYSTEM
+ display = ORGANIZATION_DISPLAY
+ }
+ ),
+ ResourceTag(
+ type = SharedPreferenceKey.PRACTITIONER_ID.name,
+ tag =
+ Coding().apply {
+ system = PRACTITIONER_SYSTEM
+ display = PRACTITIONER_DISPLAY
+ },
+ isResource = false
+ )
+ )
+
+ companion object {
+ const val CARETEAM_SYSTEM = "http://fake.tag.com/CareTeam#system"
+ const val CARETEAM_DISPLAY = "Practitioner CareTeam"
+ const val ORGANIZATION_SYSTEM = "http://fake.tag.com/Organization#system"
+ const val ORGANIZATION_DISPLAY = "Practitioner Organization"
+ const val LOCATION_SYSTEM = "http://fake.tag.com/Location#system"
+ const val LOCATION_DISPLAY = "Practitioner Location"
+ const val PRACTITIONER_SYSTEM = "http://fake.tag.com/Practitioner#system"
+ const val PRACTITIONER_DISPLAY = "Practitioner"
+ }
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/ConfigServiceTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/ConfigServiceTest.kt
index 283a9525b3..20187853fb 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/ConfigServiceTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/ConfigServiceTest.kt
@@ -16,87 +16,86 @@
package org.smartregister.fhircore.engine.app
+import android.app.Application
import androidx.test.core.app.ApplicationProvider
+import com.google.gson.Gson
+import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
-import io.mockk.mockk
+import javax.inject.Inject
import org.hl7.fhir.r4.model.ResourceType
import org.junit.Assert
+import org.junit.Before
+import org.junit.Rule
import org.junit.Test
-import org.smartregister.fhircore.engine.app.fakes.Faker
-import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
-import org.smartregister.fhircore.engine.util.extension.isIn
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
@HiltAndroidTest
class ConfigServiceTest : RobolectricTest() {
+ @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
+ @Inject lateinit var gson: Gson
+
+ private val application = ApplicationProvider.getApplicationContext()
val configService = AppConfigService(ApplicationProvider.getApplicationContext())
- val configurationRegistry = Faker.buildTestConfigurationRegistry(mockk())
+ private lateinit var sharedPreferencesHelper: SharedPreferencesHelper
+
+ @Before
+ fun setUp() {
+ hiltRule.inject()
+ sharedPreferencesHelper = SharedPreferencesHelper(application, gson)
+ }
+ @Test
+ fun testProvideSyncTagsShouldHaveOrganizationId() {
+ val practitionerId = "practitioner-id"
+ sharedPreferencesHelper.write(SharedPreferenceKey.PRACTITIONER_ID.name, practitionerId)
+
+ val resourceTags = configService.provideResourceTags(sharedPreferencesHelper)
+ val practitionerTag =
+ resourceTags.firstOrNull { it.system == AppConfigService.PRACTITIONER_SYSTEM }
+
+ Assert.assertEquals(practitionerId, practitionerTag?.code)
+ }
@Test
- fun testLoadSyncParamsShouldLoadFromConfiguration() {
- val syncParam =
- configService.loadRegistrySyncParams(configurationRegistry, UserInfo("samplep", "sampleo"))
-
- Assert.assertTrue(syncParam.isNotEmpty())
-
- val resourceTypes =
- arrayOf(
- ResourceType.Library,
- ResourceType.StructureMap,
- ResourceType.PlanDefinition,
- ResourceType.MedicationRequest,
- ResourceType.QuestionnaireResponse,
- ResourceType.Questionnaire,
- ResourceType.Patient,
- ResourceType.Condition,
- ResourceType.Observation,
- ResourceType.Encounter,
- ResourceType.Task
- )
- .sorted()
-
- Assert.assertEquals(resourceTypes, syncParam.keys.toTypedArray().sorted())
-
- syncParam.keys
- .filter {
- it.isIn(ResourceType.Binary, ResourceType.StructureMap, ResourceType.PlanDefinition)
- }
- .forEach { Assert.assertTrue(syncParam[it]!!.containsKey("_count")) }
-
- syncParam.keys.filter { it.isIn(ResourceType.Library) }.forEach {
- Assert.assertTrue(syncParam[it]!!.containsKey("_id"))
- Assert.assertTrue(syncParam[it]!!.containsKey("_count"))
- }
-
- syncParam.keys.filter { it.isIn(ResourceType.Patient) }.forEach {
- Assert.assertTrue(syncParam[it]!!.containsKey("organization"))
- Assert.assertTrue(syncParam[it]!!.containsKey("_count"))
- }
-
- syncParam.keys
- .filter {
- it.isIn(
- ResourceType.Encounter,
- ResourceType.Condition,
- ResourceType.MedicationRequest,
- ResourceType.Task
- )
- }
- .forEach {
- Assert.assertTrue(syncParam[it]!!.containsKey("subject.organization"))
- Assert.assertTrue(syncParam[it]!!.containsKey("_count"))
- }
-
- syncParam.keys
- .filter { it.isIn(ResourceType.Observation, ResourceType.QuestionnaireResponse) }
- .forEach {
- Assert.assertTrue(syncParam[it]!!.containsKey("_filter"))
- Assert.assertTrue(syncParam[it]!!.containsKey("_count"))
- }
-
- syncParam.keys.filter { it.isIn(ResourceType.Questionnaire) }.forEach {
- Assert.assertTrue(syncParam[it]!!.containsKey("publisher"))
- Assert.assertTrue(syncParam[it]!!.containsKey("_count"))
- }
+ fun testProvideSyncTagsShouldHaveLocationIds() {
+ val locationId1 = "location-id1"
+ val locationId2 = "location-id2"
+ sharedPreferencesHelper.write(ResourceType.Location.name, listOf(locationId1, locationId2))
+
+ val resourceTags = configService.provideResourceTags(sharedPreferencesHelper)
+ val locationTags = resourceTags.filter { it.system == AppConfigService.LOCATION_SYSTEM }
+
+ Assert.assertTrue(locationTags.any { it.code == locationId1 })
+ Assert.assertTrue(locationTags.any { it.code == locationId2 })
+ }
+
+ @Test
+ fun testProvideSyncTagsShouldHaveOrganizationIds() {
+ val organizationId1 = "organization-id1"
+ val organizationId2 = "organization-id2"
+ sharedPreferencesHelper.write(
+ ResourceType.Organization.name,
+ listOf(organizationId1, organizationId2)
+ )
+
+ val resourceTags = configService.provideResourceTags(sharedPreferencesHelper)
+ val organizationTags = resourceTags.filter { it.system == AppConfigService.ORGANIZATION_SYSTEM }
+
+ Assert.assertTrue(organizationTags.any { it.code == organizationId1 })
+ Assert.assertTrue(organizationTags.any { it.code == organizationId2 })
+ }
+
+ @Test
+ fun testProvideSyncTagsShouldHaveCareTeamIds() {
+ val careTeamId1 = "careteam-id1"
+ val careTeamId2 = "careteam-id2"
+ sharedPreferencesHelper.write(ResourceType.CareTeam.name, listOf(careTeamId1, careTeamId2))
+
+ val resourceTags = configService.provideResourceTags(sharedPreferencesHelper)
+ val organizationTags = resourceTags.filter { it.system == AppConfigService.CARETEAM_SYSTEM }
+
+ Assert.assertTrue(organizationTags.any { it.code == careTeamId1 })
+ Assert.assertTrue(organizationTags.any { it.code == careTeamId2 })
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/FakeModel.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/FakeModel.kt
index 1cce71065e..bb880def29 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/FakeModel.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/FakeModel.kt
@@ -16,14 +16,18 @@
package org.smartregister.fhircore.engine.app.fakes
+import java.util.Base64
import org.smartregister.fhircore.engine.auth.AuthCredentials
-import org.smartregister.fhircore.engine.util.toSha1
+import org.smartregister.fhircore.engine.util.getRandomBytesOfSize
+import org.smartregister.fhircore.engine.util.toPasswordHash
object FakeModel {
+ var salt = 256.getRandomBytesOfSize()
val authCredentials =
AuthCredentials(
username = "demo",
- password = "51r1K4l1".toSha1(),
+ passwordHash = "51r1K4l1".toCharArray().toPasswordHash(salt),
+ salt = Base64.getEncoder().encodeToString(salt),
sessionToken = "49fad390491a5b547d0f782309b6a5b33f7ac087",
refreshToken = "USrAgmSf5MJ8N_RLQODa7rZ3zNs1Sj1GkSIsTsb4n-Y"
)
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/Faker.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/Faker.kt
index 5dd12a6f67..b5d0538b94 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/Faker.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/fakes/Faker.kt
@@ -16,6 +16,9 @@
package org.smartregister.fhircore.engine.app.fakes
+import androidx.test.core.app.ApplicationProvider
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.search.Search
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.spyk
@@ -24,19 +27,22 @@ import java.util.Calendar
import java.util.Date
import kotlinx.coroutines.runBlocking
import org.hl7.fhir.r4.model.Binary
+import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.Coding
import org.hl7.fhir.r4.model.Composition
import org.hl7.fhir.r4.model.DateType
import org.hl7.fhir.r4.model.Enumerations
import org.hl7.fhir.r4.model.Patient
+import org.hl7.fhir.r4.model.ResourceType
import org.hl7.fhir.r4.model.StringType
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
-import org.smartregister.fhircore.engine.data.local.DefaultRepository
+import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
+import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService
import org.smartregister.fhircore.engine.robolectric.RobolectricTest.Companion.readFile
import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString
object Faker {
-
+ private const val APP_DEBUG = "default/debug"
private val systemPath =
(System.getProperty("user.dir") +
File.separator +
@@ -48,35 +54,68 @@ object Faker {
File.separator)
fun loadTestConfigurationRegistryData(
- defaultRepository: DefaultRepository,
+ fhirEngine: FhirEngine,
configurationRegistry: ConfigurationRegistry
) {
val composition =
getBasePath("composition").readFile(systemPath).decodeResourceFromString() as Composition
- coEvery { defaultRepository.searchCompositionByIdentifier(any()) } returns composition
-
- coEvery { defaultRepository.getBinary(any()) } answers
+ coEvery { fhirEngine.search(any()) } returns listOf(composition)
+ coEvery { fhirEngine.get(ResourceType.Binary, any()) } answers
{
val sectionComponent =
composition.section.find {
- this.args.first().toString() == it.focus.reference.substringAfter("Binary/")
+ this.args[1].toString() == it.focus.reference.substringAfter("Binary/")
}
val configName = sectionComponent!!.focus.identifier.value
Binary().apply { content = getBasePath(configName).readFile(systemPath).toByteArray() }
}
-
- runBlocking { configurationRegistry.loadConfigurations(appId = "default") {} }
+ runBlocking {
+ configurationRegistry.loadConfigurations(
+ appId = APP_DEBUG.substringBefore("/"),
+ ) {}
+ }
}
private fun getBasePath(configName: String): String {
return "/configs/default/config_$configName.json"
}
- fun buildTestConfigurationRegistry(defaultRepository: DefaultRepository): ConfigurationRegistry {
+ fun buildTestConfigurationRegistry(): ConfigurationRegistry {
+ val fhirResourceService = mockk()
+ val fhirResourceDataSource = spyk(FhirResourceDataSource(fhirResourceService))
+ coEvery { fhirResourceService.getResource(any()) } returns Bundle()
+ val fhirEngine: FhirEngine = mockk()
+
+ val composition =
+ getBasePath("composition").readFile(systemPath).decodeResourceFromString() as Composition
+ coEvery { fhirEngine.search(any()) } returns listOf(composition)
+
+ coEvery { fhirEngine.get(ResourceType.Binary, any()) } answers
+ {
+ val sectionComponent =
+ composition.section.find {
+ this.args[1].toString() == it.focus.reference.substringAfter("Binary/")
+ }
+ val configName = sectionComponent!!.focus.identifier.value
+ Binary().apply { content = getBasePath(configName).readFile(systemPath).toByteArray() }
+ }
+
val configurationRegistry =
- spyk(ConfigurationRegistry(mockk(), mockk(), mockk(), mockk(), defaultRepository))
+ spyk(
+ ConfigurationRegistry(
+ fhirEngine = fhirEngine,
+ fhirResourceDataSource = fhirResourceDataSource,
+ sharedPreferencesHelper = mockk(),
+ dispatcherProvider = mockk(),
+ context = ApplicationProvider.getApplicationContext(),
+ )
+ )
- loadTestConfigurationRegistryData(defaultRepository, configurationRegistry)
+ runBlocking {
+ configurationRegistry.loadConfigurations(
+ appId = APP_DEBUG.substringBefore("/"),
+ ) {}
+ }
return configurationRegistry
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/appfeature/AppFeatureManagerTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/appfeature/AppFeatureManagerTest.kt
index bc0d26ed49..9e914e6713 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/appfeature/AppFeatureManagerTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/appfeature/AppFeatureManagerTest.kt
@@ -18,45 +18,21 @@ package org.smartregister.fhircore.engine.appfeature
import android.content.Context
import androidx.test.core.app.ApplicationProvider
-import io.mockk.mockk
-import io.mockk.spyk
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.smartregister.fhircore.engine.app.fakes.Faker
-import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
-import org.smartregister.fhircore.engine.data.local.DefaultRepository
-import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
-import org.smartregister.fhircore.engine.util.DispatcherProvider
-import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
class AppFeatureManagerTest : RobolectricTest() {
val context: Context = ApplicationProvider.getApplicationContext()
- lateinit var dispatcherProvider: DispatcherProvider
lateinit var appFeatureManager: AppFeatureManager
- lateinit var configurationRegistry: ConfigurationRegistry
- lateinit var defaultRepository: DefaultRepository
- lateinit var sharedPreferencesHelper: SharedPreferencesHelper
- lateinit var fhirResourceDataSource: FhirResourceDataSource
@Before
fun setUp() {
- defaultRepository = mockk()
- sharedPreferencesHelper = mockk()
- dispatcherProvider = mockk()
- fhirResourceDataSource = spyk(FhirResourceDataSource(mockk()))
- configurationRegistry =
- ConfigurationRegistry(
- context = context,
- fhirResourceDataSource = fhirResourceDataSource,
- sharedPreferencesHelper = sharedPreferencesHelper,
- dispatcherProvider = dispatcherProvider,
- repository = defaultRepository
- )
- Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry)
- appFeatureManager = AppFeatureManager(configurationRegistry)
+
+ appFeatureManager = AppFeatureManager(Faker.buildTestConfigurationRegistry())
}
@Test
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/AccountAuthenticatorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/AccountAuthenticatorTest.kt
index 3a27d9be14..9d8202390f 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/AccountAuthenticatorTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/AccountAuthenticatorTest.kt
@@ -17,11 +17,7 @@
package org.smartregister.fhircore.engine.auth
import android.accounts.Account
-import android.accounts.AccountAuthenticatorResponse
import android.accounts.AccountManager
-import android.accounts.AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE
-import android.accounts.AccountManager.KEY_ACCOUNT_NAME
-import android.accounts.AccountManager.KEY_ACCOUNT_TYPE
import android.accounts.AccountManager.KEY_AUTHTOKEN
import android.accounts.AccountManager.KEY_INTENT
import android.accounts.AccountManagerCallback
@@ -41,36 +37,24 @@ import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
-import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
+import java.net.UnknownHostException
import java.util.Locale
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
import org.junit.Assert
import org.junit.Before
-import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
-import org.robolectric.Shadows.shadowOf
-import org.robolectric.shadows.ShadowIntent
-import org.smartregister.fhircore.engine.app.fakes.FakeModel
-import org.smartregister.fhircore.engine.auth.AccountAuthenticator.Companion.AUTH_TOKEN_TYPE
-import org.smartregister.fhircore.engine.auth.AccountAuthenticator.Companion.IS_NEW_ACCOUNT
import org.smartregister.fhircore.engine.configuration.app.ConfigService
-import org.smartregister.fhircore.engine.data.remote.auth.OAuthService
-import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
-import org.smartregister.fhircore.engine.ui.appsetting.AppSettingActivity
-import org.smartregister.fhircore.engine.ui.login.LoginActivity
import org.smartregister.fhircore.engine.util.DispatcherProvider
import org.smartregister.fhircore.engine.util.SecureSharedPreference
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
-import org.smartregister.fhircore.engine.util.toSha1
-import retrofit2.Call
-import retrofit2.Response
+import retrofit2.HttpException
@ExperimentalCoroutinesApi
@HiltAndroidTest
@@ -81,13 +65,11 @@ class AccountAuthenticatorTest : RobolectricTest() {
var accountManager: AccountManager = mockk()
- var oAuthService: OAuthService = mockk()
-
@Inject lateinit var configService: ConfigService
@BindValue var secureSharedPreference: SecureSharedPreference = mockk()
- @BindValue var tokenManagerService: TokenManagerService = mockk()
+ @BindValue var tokenAuthenticator: TokenAuthenticator = mockk()
@Inject lateinit var sharedPreference: SharedPreferencesHelper
@@ -107,23 +89,19 @@ class AccountAuthenticatorTest : RobolectricTest() {
AccountAuthenticator(
context = context,
accountManager = accountManager,
- oAuthService = oAuthService,
- configService = configService,
+ tokenAuthenticator = tokenAuthenticator,
secureSharedPreference = secureSharedPreference,
- tokenManagerService = tokenManagerService,
- sharedPreference = sharedPreference,
- dispatcherProvider = dispatcherProvider
)
)
}
@Test
fun testThatAccountIsAddedWithCorrectConfigs() {
-
+ val accountType = configService.provideAuthConfiguration().accountType
val bundle =
accountAuthenticator.addAccount(
response = mockk(relaxed = true),
- accountType = configService.provideAuthConfiguration().accountType,
+ accountType = accountType,
authTokenType = authTokenType,
requiredFeatures = emptyArray(),
options = bundleOf()
@@ -132,15 +110,13 @@ class AccountAuthenticatorTest : RobolectricTest() {
val parcelable = bundle.getParcelable(KEY_INTENT)
Assert.assertNotNull(parcelable)
Assert.assertNotNull(parcelable!!.extras)
+
+ Assert.assertTrue(parcelable.extras!!.containsKey(AccountAuthenticator.ACCOUNT_TYPE))
+ Assert.assertEquals(accountType, parcelable.getStringExtra(AccountAuthenticator.ACCOUNT_TYPE))
Assert.assertEquals(
- configService.provideAuthConfiguration().accountType,
- parcelable.getStringExtra(KEY_ACCOUNT_TYPE)
+ authTokenType,
+ parcelable.getStringExtra(TokenAuthenticator.AUTH_TOKEN_TYPE)
)
-
- Assert.assertTrue(parcelable.extras!!.containsKey(AUTH_TOKEN_TYPE))
- Assert.assertEquals(authTokenType, parcelable.getStringExtra(AUTH_TOKEN_TYPE))
- Assert.assertTrue(parcelable.extras!!.containsKey(IS_NEW_ACCOUNT))
- Assert.assertTrue(parcelable.extras!!.getBoolean(IS_NEW_ACCOUNT))
}
@Test
@@ -153,318 +129,28 @@ class AccountAuthenticatorTest : RobolectricTest() {
)
}
- @Test
- fun testThatConfirmCredentialsReturnsBundleWithKeyIntent() {
- val account = spyk(Account("newAccName", "newAccType"))
-
- val accountAuthenticatorResponse = mockk(relaxed = true)
- val bundle =
- accountAuthenticator.confirmCredentials(
- response = accountAuthenticatorResponse,
- account = account,
- options = bundleOf()
- )
- Assert.assertTrue(bundle.containsKey(KEY_INTENT))
- val parcelable = bundle.getParcelable(KEY_INTENT)
- Assert.assertNotNull(parcelable)
- parcelable!!
- Assert.assertEquals(account.type, parcelable.getStringExtra(KEY_ACCOUNT_TYPE))
- Assert.assertEquals(account.name, parcelable.getStringExtra(KEY_ACCOUNT_NAME))
- Assert.assertEquals(
- accountAuthenticatorResponse,
- parcelable.getParcelableExtra(
- KEY_ACCOUNT_AUTHENTICATOR_RESPONSE
- )
- )
- }
-
@Test
fun testThatAuthTokenLabelIsCapitalized() {
val capitalizedAuthToken = authTokenType.uppercase(Locale.ROOT)
Assert.assertEquals(capitalizedAuthToken, accountAuthenticator.getAuthTokenLabel(authTokenType))
}
- @Test
- fun testThatCredentialsAreUpdated() {
-
- val account = spyk(Account("newAccName", "newAccType"))
-
- val bundle =
- accountAuthenticator.updateCredentials(
- response = mockk(relaxed = true),
- account = account,
- authTokenType = authTokenType,
- options = bundleOf()
- )
- Assert.assertNotNull(bundle)
- val parcelable = bundle.getParcelable(KEY_INTENT)
- Assert.assertNotNull(parcelable)
- Assert.assertNotNull(parcelable!!.extras)
- Assert.assertEquals(account.type, parcelable.getStringExtra(KEY_ACCOUNT_TYPE))
- Assert.assertEquals(account.name, parcelable.getStringExtra(KEY_ACCOUNT_NAME))
- Assert.assertTrue(parcelable.extras!!.containsKey(AUTH_TOKEN_TYPE))
- Assert.assertEquals(authTokenType, parcelable.getStringExtra(AUTH_TOKEN_TYPE))
- }
-
- @Test
- fun testGetAuthToken() {
- every { tokenManagerService.getLocalSessionToken() } returns null
- every { tokenManagerService.isTokenActive(any()) } returns false
- every { secureSharedPreference.retrieveCredentials() } returns AuthCredentials("abc", "123")
- every { accountManager.notifyAccountAuthenticated(any()) } returns false
-
- val account = spyk(Account("newAccName", "newAccType"))
- val authToken = accountAuthenticator.getAuthToken(mockk(), account, authTokenType, bundleOf())
- val parcelable = authToken.getParcelable(KEY_INTENT)
- Assert.assertNotNull(authToken)
- Assert.assertNotNull(parcelable)
- Assert.assertTrue(parcelable!!.hasExtra(KEY_ACCOUNT_NAME))
- Assert.assertTrue(parcelable.hasExtra(KEY_ACCOUNT_TYPE))
- Assert.assertEquals(account.name, parcelable.getStringExtra(KEY_ACCOUNT_NAME))
- Assert.assertEquals(account.type, parcelable.getStringExtra(KEY_ACCOUNT_TYPE))
- }
-
- @Test
- fun testGetAuthTokenWhenAccessTokenIsNullShouldReturnValidAccount() {
- val emptySessionToken = null
- every { tokenManagerService.getLocalSessionToken() } returns emptySessionToken
-
- val accountManager = mockk()
- val isAuthAcknowledged = true
- every { accountManager.notifyAccountAuthenticated(any()) } returns isAuthAcknowledged
-
- val accountAuthenticator =
- spyk(
- AccountAuthenticator(
- context = context,
- accountManager = accountManager,
- oAuthService = spyk(oAuthService),
- configService = configService,
- secureSharedPreference = secureSharedPreference,
- tokenManagerService = tokenManagerService,
- sharedPreference = sharedPreference,
- dispatcherProvider = dispatcherProvider
- )
- )
-
- val refreshToken = "refreshToken"
- every { accountAuthenticator.getRefreshToken() } returns refreshToken
-
- val newAccessToken = "newAccessToken"
- val refreshExpiresIn = 2
- val expiresIn = 1
- val scope = "scope"
-
- val oAuthResponse =
- OAuthResponse(
- accessToken = newAccessToken,
- tokenType = authTokenType,
- refreshToken = refreshToken,
- refreshExpiresIn = refreshExpiresIn,
- expiresIn = expiresIn,
- scope = scope
- )
- every { accountAuthenticator.refreshToken(any()) } returns oAuthResponse
-
- val account = Account("newAccName", "newAccType")
- val authToken = accountAuthenticator.getAuthToken(mockk(), account, authTokenType, bundleOf())
-
- val actualAccountName = authToken[KEY_ACCOUNT_NAME]
- val actualAccountType = authToken[KEY_ACCOUNT_TYPE]
- val actualAccountAuthToken = authToken[KEY_AUTHTOKEN]
-
- Assert.assertEquals(account.name, actualAccountName)
- Assert.assertEquals(account.type, actualAccountType)
- Assert.assertEquals(newAccessToken, actualAccountAuthToken)
- }
-
- @Test
- fun testGetAuthTokenWhenAccessTokenIsBlankAndNewTokenResponseIsNullShouldReturnValidAccountFromAuthActivity() {
- val emptySessionToken = ""
- every { tokenManagerService.getLocalSessionToken() } returns emptySessionToken
-
- val accountManager = mockk()
- val isAuthAcknowledged = true
- every { accountManager.notifyAccountAuthenticated(any()) } returns isAuthAcknowledged
-
- val accountAuthenticator =
- spyk(
- AccountAuthenticator(
- context = context,
- accountManager = accountManager,
- oAuthService = spyk(oAuthService),
- configService = configService,
- secureSharedPreference = secureSharedPreference,
- tokenManagerService = tokenManagerService,
- sharedPreference = sharedPreference,
- dispatcherProvider = dispatcherProvider
- )
- )
-
- val refreshToken = "refreshToken"
- every { accountAuthenticator.getRefreshToken() } returns refreshToken
-
- val oAuthResponse = null
- every { accountAuthenticator.refreshToken(any()) } returns oAuthResponse
-
- val account = Account("newAccName", "newAccType")
- val authToken = accountAuthenticator.getAuthToken(mockk(), account, authTokenType, bundleOf())
- val parcelable = authToken.getParcelable(KEY_INTENT)
-
- val actualAccountName = parcelable?.getStringExtra(KEY_ACCOUNT_NAME)
- val actualAccountType = parcelable?.getStringExtra(KEY_ACCOUNT_TYPE)
-
- Assert.assertNotNull(authToken)
- Assert.assertNotNull(parcelable)
- Assert.assertTrue(parcelable!!.hasExtra(KEY_ACCOUNT_NAME))
- Assert.assertTrue(parcelable.hasExtra(KEY_ACCOUNT_TYPE))
- Assert.assertEquals(account.name, actualAccountName)
- Assert.assertEquals(account.type, actualAccountType)
- }
-
@Test
fun testHasFeatures() {
Assert.assertNotNull(accountAuthenticator.hasFeatures(mockk(), mockk(), arrayOf()))
}
- @Test
- fun testGetUserInfo() {
- every { oAuthService.userInfo() } returns mockk()
- Assert.assertNotNull(accountAuthenticator.getUserInfo())
- }
-
- @Test
- fun testFetchToken() {
- val callMock = mockk>()
- val mockResponse = Response.success(OAuthResponse("testToken"))
- every { callMock.execute() } returns mockResponse
- every { oAuthService.fetchToken(any()) } returns callMock
- val token =
- accountAuthenticator
- .fetchToken(
- FakeModel.authCredentials.username,
- FakeModel.authCredentials.password.toCharArray()
- )
- .execute()
- Assert.assertEquals("testToken", token.body()!!.accessToken)
- }
-
- @Test
- @Ignore("Fix assertion")
- fun testRefreshToken() {
- val callMock = mockk>()
- val mockk = mockk()
- val mockResponse = spyk(Response.success(mockk))
- every { callMock.execute() } returns mockResponse
-
- every { accountAuthenticator.oAuthService.fetchToken(any()) } returns callMock
- val token = accountAuthenticator.refreshToken(FakeModel.authCredentials.refreshToken!!)
- Assert.assertNotNull(token)
- }
-
- @Test
- fun testGetRefreshToken() {
- every { tokenManagerService.isTokenActive(any()) } returns false
-
- every { secureSharedPreference.retrieveCredentials() } returns null
- Assert.assertNull(accountAuthenticator.getRefreshToken())
-
- every { secureSharedPreference.retrieveCredentials() } returns
- AuthCredentials("abc", "123", null, null)
- Assert.assertNull(accountAuthenticator.getRefreshToken())
- }
-
- @Test
- fun testHasActiveSession() {
- every { tokenManagerService.getLocalSessionToken() } returns ""
- Assert.assertFalse(accountAuthenticator.hasActiveSession())
- }
-
- @Test
- fun testValidLocalCredentials() {
- every { secureSharedPreference.retrieveCredentials() } returns
- AuthCredentials("demo", "51r1K4l1".toSha1())
-
- Assert.assertTrue(accountAuthenticator.validLocalCredentials("demo", "51r1K4l1".toCharArray()))
- Assert.assertFalse(
- accountAuthenticator.validLocalCredentials("WrongUsername", "51r1K4l1".toCharArray())
- )
- Assert.assertFalse(
- accountAuthenticator.validLocalCredentials("demo", "WrongPassword".toCharArray())
- )
- }
-
- @Test
- fun testUpdateSession() {
- every { secureSharedPreference.retrieveCredentials() } returns
- AuthCredentials("abc", "123", null, null)
- every { secureSharedPreference.saveCredentials(any()) } just runs
-
- val successResponse: OAuthResponse = mockk()
- every { successResponse.accessToken } returns "newAccessToken"
- every { successResponse.refreshToken } returns "newRefreshToken"
-
- accountAuthenticator.updateSession(successResponse)
-
- val slot = slot()
-
- verify { secureSharedPreference.retrieveCredentials() }
- verify { secureSharedPreference.saveCredentials(capture(slot)) }
-
- val retrieveCredentials = slot.captured
- Assert.assertNotNull(retrieveCredentials)
- Assert.assertEquals(successResponse.accessToken, retrieveCredentials.sessionToken)
- Assert.assertEquals(successResponse.refreshToken, retrieveCredentials.refreshToken)
- }
-
- @Test
- fun testLaunchLoginScreenShouldStartLoginActivity() {
- accountAuthenticator.launchLoginScreen()
- val startedIntent: Intent =
- shadowOf(ApplicationProvider.getApplicationContext()).nextStartedActivity
- val shadowIntent: ShadowIntent = shadowOf(startedIntent)
- Assert.assertEquals(LoginActivity::class.java, shadowIntent.intentClass)
- }
-
- @Test
- fun testLogoutShouldCleanSessionAndStartLoginActivity() = runBlockingTest {
- every { tokenManagerService.isTokenActive(any()) } returns true
- every { secureSharedPreference.retrieveCredentials() } returns
- AuthCredentials("abc", "111", "mystoken", "myrtoken")
- every { oAuthService.logout(any(), any(), any()) } returns mockk()
-
- accountAuthenticator.logout()
-
- val startedIntent: Intent =
- shadowOf(ApplicationProvider.getApplicationContext()).nextStartedActivity
- val shadowIntent: ShadowIntent = shadowOf(startedIntent)
-
- // User will be prompted with AppSettings screen to provide appId to redownload config
- Assert.assertEquals(AppSettingActivity::class.java, shadowIntent.intentClass)
-
- verify { oAuthService.logout(any(), any(), any()) }
- }
-
@Test
fun loadActiveAccountWhenTokenInactiveShouldInvalidateToken() {
- val accountType = "testAccountType"
+ val accountType = TokenAuthenticator.AUTH_TOKEN_TYPE
val account = Account("test", accountType)
- every { tokenManagerService.getActiveAccount() } returns account
- every { tokenManagerService.isTokenActive(any()) } returns false
- every { accountAuthenticator.getAccountType() } returns accountType
+ every { tokenAuthenticator.findAccount() } returns account
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+ every { tokenAuthenticator.getAccountType() } returns accountType
val token = "mystesttoken"
- every { accountManager.peekAuthToken(any(), any()) } returns token
+ every { accountManager.peekAuthToken(account, accountType) } returns token
every { accountManager.invalidateAuthToken(any(), any()) } just runs
- every {
- accountManager.getAuthToken(
- any(),
- any(),
- any(),
- any(),
- any(),
- any()
- )
- } returns
+ every { accountManager.getAuthToken(any(), any(), any(), any(), any(), any()) } returns
object : AccountManagerFuture {
override fun cancel(mayInterruptIfRunning: Boolean): Boolean {
TODO("Not yet implemented")
@@ -487,7 +173,7 @@ class AccountAuthenticatorTest : RobolectricTest() {
}
}
- accountAuthenticator.loadActiveAccount(onActiveAuthTokenFound = {}, onValidTokenMissing = {})
+ accountAuthenticator.loadActiveAccount(onValidTokenMissing = {})
verify { accountManager.peekAuthToken(account, accountType) }
verify { accountManager.invalidateAuthToken(accountType, token) }
@@ -495,8 +181,7 @@ class AccountAuthenticatorTest : RobolectricTest() {
@Test
fun testConfirmActiveAccountCallsOnResultCallback() {
- every { tokenManagerService.getActiveAccount() } returns
- Account("testAccountName", "testAccountType")
+ every { tokenAuthenticator.findAccount() } returns Account("testAccountName", "testAccountType")
every {
accountManager.confirmCredentials(
any(),
@@ -534,37 +219,160 @@ class AccountAuthenticatorTest : RobolectricTest() {
}
@Test
- fun loadRefreshedSessionAccountRefreshesAccessTokenIfExpired() = runBlockingTest {
- val callMock = mockk>()
- val mockResponse = Response.success(OAuthResponse("testToken"))
- every { callMock.execute() } returns mockResponse
- every { oAuthService.fetchToken(any()) } returns callMock
- every { tokenManagerService.getActiveAccount() } returns mockk()
- every { tokenManagerService.isTokenActive(any()) } returns false
- every { accountManager.getAuthToken(any(), any(), any(), any(), any(), any()) } returns
- mockk()
- every { accountManager.peekAuthToken(any(), any()) } returns "auth-token"
- every { accountManager.notifyAccountAuthenticated(any()) } returns true
- every { accountAuthenticator.getRefreshToken() } returns "refresh-token"
- every { accountAuthenticator.updateSession(any()) } returns mockk()
-
- accountAuthenticator.refreshSessionAuthToken(mockk())
- verify { accountAuthenticator.refreshToken(any()) }
+ fun testEditPropertiesShouldReturnEmptyBundle() {
+ Assert.assertTrue(accountAuthenticator.editProperties(null, null).isEmpty)
}
@Test
- fun loadRefreshedSessionAccountInvalidatesAccessTokenIfRefreshTokenExpired() = runBlockingTest {
- every { tokenManagerService.getActiveAccount() } returns mockk()
- every { tokenManagerService.isTokenActive(any()) } returns false
- every { accountManager.getAuthToken(any(), any(), any(), any(), any(), any()) } returns
- null
- every { accountManager.peekAuthToken(any(), any()) } returns "auth-token"
- every { accountManager.invalidateAuthToken(any(), any()) } returns Unit
- every { accountAuthenticator.getRefreshToken() } returns "refresh-token"
- every { accountAuthenticator.refreshToken("refresh-token") } throws
- Exception("Failed to refresh token")
-
- accountAuthenticator.refreshSessionAuthToken(mockk())
- verify { accountManager.invalidateAuthToken(any(), any()) }
+ fun testAddAccountShouldReturnRelevantBundle() {
+ val accountType = "accountType"
+ val accountBundle =
+ accountAuthenticator.addAccount(mockk(), accountType, authTokenType, arrayOf(), bundleOf())
+
+ Assert.assertNotNull(accountBundle)
+ Assert.assertTrue(accountBundle.containsKey(AccountManager.KEY_INTENT))
+
+ val intent = accountBundle.get(AccountManager.KEY_INTENT) as Intent
+
+ Assert.assertEquals(accountType, intent.getStringExtra(AccountAuthenticator.ACCOUNT_TYPE))
+ Assert.assertEquals(authTokenType, intent.getStringExtra(TokenAuthenticator.AUTH_TOKEN_TYPE))
+ }
+
+ @Test
+ fun testConfirmCredentialsShouldReturnEmptyBundle() {
+ Assert.assertTrue(accountAuthenticator.confirmCredentials(mockk(), mockk(), bundleOf()).isEmpty)
+ }
+
+ @Test
+ fun testGetAuthTokenWithoutRefreshToken() {
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+ val account = spyk(Account("newAccName", "newAccType"))
+ every { accountManager.peekAuthToken(account, authTokenType) } returns ""
+ val refreshToken = "refreshToken"
+ every { accountManager.getPassword(account) } returns refreshToken
+
+ every { tokenAuthenticator.refreshToken(refreshToken) } returns ""
+
+ val authToken = accountAuthenticator.getAuthToken(mockk(), account, authTokenType, bundleOf())
+ val parcelable = authToken.get(AccountManager.KEY_INTENT) as Intent
+
+ Assert.assertNotNull(authToken)
+ Assert.assertNotNull(parcelable)
+ Assert.assertTrue(parcelable.hasExtra(AccountAuthenticator.ACCOUNT_TYPE))
+ Assert.assertTrue(parcelable.hasExtra(TokenAuthenticator.AUTH_TOKEN_TYPE))
+ }
+
+ @Test
+ fun testGetAuthTokenWithRefreshToken() {
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+ val account = spyk(Account("newAccName", "newAccType"))
+ every { accountManager.peekAuthToken(account, authTokenType) } returns ""
+
+ val refreshToken = "refreshToken"
+ every { accountManager.getPassword(account) } returns refreshToken
+ every { tokenAuthenticator.refreshToken(refreshToken) } returns "newAccessToken"
+
+ val authTokenBundle: Bundle =
+ accountAuthenticator.getAuthToken(null, account, authTokenType, bundleOf())
+
+ Assert.assertNotNull(authTokenBundle)
+ Assert.assertTrue(authTokenBundle.containsKey(KEY_AUTHTOKEN))
+ Assert.assertEquals("newAccessToken", authTokenBundle.getString(KEY_AUTHTOKEN))
+ }
+
+ @Test
+ fun testGetBundleWithoutAuthInfoWhenCaughtHttpException() {
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+ val account = spyk(Account("newAccName", "newAccType"))
+ every { accountManager.peekAuthToken(account, authTokenType) } returns ""
+
+ val refreshToken = "refreshToken"
+ every { accountManager.getPassword(account) } returns refreshToken
+ every { tokenAuthenticator.refreshToken(refreshToken) } throws
+ HttpException(
+ mockk {
+ every { code() } returns 0
+ every { message() } returns ""
+ }
+ )
+
+ val authTokenBundle: Bundle =
+ accountAuthenticator.getAuthToken(null, account, authTokenType, bundleOf())
+
+ Assert.assertNotNull(authTokenBundle)
+ Assert.assertFalse(authTokenBundle.containsKey(KEY_AUTHTOKEN))
+ }
+
+ @Test
+ fun testGetBundleWithoutAuthInfoWhenCaughtUnknownHostException() {
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+ val account = spyk(Account("newAccName", "newAccType"))
+ every { accountManager.peekAuthToken(account, authTokenType) } returns ""
+
+ val refreshToken = "refreshToken"
+ every { accountManager.getPassword(account) } returns refreshToken
+ every { tokenAuthenticator.refreshToken(refreshToken) } throws UnknownHostException()
+
+ val authTokenBundle: Bundle =
+ accountAuthenticator.getAuthToken(null, account, authTokenType, bundleOf())
+
+ Assert.assertNotNull(authTokenBundle)
+ Assert.assertFalse(authTokenBundle.containsKey(KEY_AUTHTOKEN))
+ }
+
+ @Test(expected = RuntimeException::class)
+ fun testGetBundleWithoutAuthInfoWhenCaughtUnknownHost() {
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+ val account = spyk(Account("newAccName", "newAccType"))
+ every { accountManager.peekAuthToken(account, authTokenType) } returns ""
+
+ val refreshToken = "refreshToken"
+ every { accountManager.getPassword(account) } returns refreshToken
+ every { tokenAuthenticator.refreshToken(refreshToken) } throws RuntimeException()
+
+ accountAuthenticator.getAuthToken(null, account, authTokenType, bundleOf())
+ }
+
+ @Test
+ fun testGetAuthTokenLabel() {
+ val authTokenLabel = "auth_token_label"
+ Assert.assertEquals(
+ authTokenLabel.uppercase(),
+ accountAuthenticator.getAuthTokenLabel(authTokenLabel)
+ )
+ }
+
+ @Test
+ fun testUpdateCredentialsShouldReturnEmptyBundle() {
+ Assert.assertTrue(
+ accountAuthenticator.updateCredentials(mockk(), mockk(), authTokenType, bundleOf()).isEmpty
+ )
+ }
+
+ @Test
+ fun testHasFeaturesShouldReturnEmptyBundle() {
+ Assert.assertNotNull(accountAuthenticator.hasFeatures(mockk(), mockk(), arrayOf()))
+ }
+
+ @Test
+ fun testThatLogoutCallsTokenAuthenticatorLogout() {
+ every { tokenAuthenticator.logout() } returns Result.success(true)
+ val onLogout = {}
+ accountAuthenticator.logout(onLogout)
+ verify { tokenAuthenticator.logout() }
+ }
+
+ @Test
+ fun testValidateLoginCredentials() {
+ every { tokenAuthenticator.validateSavedLoginCredentials(any(), any()) } returns true
+ Assert.assertTrue(accountAuthenticator.validateLoginCredentials("doe", "pswd".toCharArray()))
+ }
+
+ @Test
+ fun testThatInvalidateSessionCallsTokenAuthenticatorInvalidateSession() {
+ every { tokenAuthenticator.invalidateSession(any()) } just runs
+ val onSessionInvalidated = {}
+ accountAuthenticator.invalidateSession(onSessionInvalidated)
+ verify { tokenAuthenticator.invalidateSession(onSessionInvalidated) }
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt
new file mode 100644
index 0000000000..c7c1651fc9
--- /dev/null
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt
@@ -0,0 +1,492 @@
+/*
+ * Copyright 2021 Ona Systems, Inc
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.smartregister.fhircore.engine.auth
+
+import android.accounts.Account
+import android.accounts.AccountManager
+import android.accounts.AuthenticatorException
+import android.accounts.OperationCanceledException
+import android.os.Bundle
+import androidx.test.core.app.ApplicationProvider
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
+import dagger.hilt.android.testing.HiltTestApplication
+import io.jsonwebtoken.JwtException
+import io.jsonwebtoken.UnsupportedJwtException
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.runs
+import io.mockk.slot
+import io.mockk.spyk
+import io.mockk.verifyOrder
+import java.io.IOException
+import java.net.UnknownHostException
+import javax.inject.Inject
+import javax.net.ssl.SSLHandshakeException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import okhttp3.internal.http.RealResponseBody
+import org.junit.After
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
+import org.smartregister.fhircore.engine.data.remote.auth.OAuthService
+import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
+import org.smartregister.fhircore.engine.robolectric.RobolectricTest
+import org.smartregister.fhircore.engine.rule.CoroutineTestRule
+import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.toPasswordHash
+import retrofit2.HttpException
+import retrofit2.Response
+
+@HiltAndroidTest
+class TokenAuthenticatorTest : RobolectricTest() {
+
+ @get:Rule val hiltRule = HiltAndroidRule(this)
+ @ExperimentalCoroutinesApi @get:Rule val coroutineRule = CoroutineTestRule()
+ @Inject lateinit var secureSharedPreference: SecureSharedPreference
+ @Inject lateinit var configService: ConfigService
+ private val oAuthService: OAuthService = mockk()
+ private lateinit var tokenAuthenticator: TokenAuthenticator
+ private val accountManager = mockk()
+ private val context = ApplicationProvider.getApplicationContext()
+ private val sampleUsername = "demo"
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Before
+ fun setUp() {
+ hiltRule.inject()
+ tokenAuthenticator =
+ spyk(
+ TokenAuthenticator(
+ secureSharedPreference = secureSharedPreference,
+ configService = configService,
+ oAuthService = oAuthService,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ accountManager = accountManager,
+ context = context
+ )
+ )
+ }
+
+ @After
+ fun tearDown() {
+ secureSharedPreference.deleteCredentials()
+ }
+
+ @Test
+ fun testIsTokenActiveWithNullToken() {
+ Assert.assertFalse(tokenAuthenticator.isTokenActive(null))
+ }
+
+ @Test
+ @Throws(UnsupportedJwtException::class)
+ fun testIsTokenActiveWithMalformedJwtToken() {
+ Assert.assertFalse(tokenAuthenticator.isTokenActive("gibberish-token"))
+ }
+
+ @Test
+ fun getAccountTypeEqualValueFromConfigService() {
+ Assert.assertEquals(
+ configService.provideAuthConfiguration().accountType,
+ tokenAuthenticator.getAccountType()
+ )
+ }
+
+ @Test
+ @Throws(JwtException::class)
+ fun testIsTokenActiveWithExpiredJwtToken() {
+ Assert.assertFalse(tokenAuthenticator.isTokenActive("expired-token"))
+ }
+
+ @Test
+ fun testGetAccessTokenShouldReturnValidAccessToken() {
+ val account = Account(sampleUsername, PROVIDER)
+ every { tokenAuthenticator.findAccount() } returns account
+ every { tokenAuthenticator.isTokenActive(any()) } returns true
+ val accessToken = "gibberishaccesstoken"
+ every { accountManager.peekAuthToken(account, TokenAuthenticator.AUTH_TOKEN_TYPE) } returns
+ accessToken
+ Assert.assertEquals(accessToken, tokenAuthenticator.getAccessToken())
+ }
+
+ @Test
+ fun testGetAccessTokenShouldInvalidateExpiredToken() {
+ val account = Account(sampleUsername, PROVIDER)
+ val accessToken = "gibberishaccesstoken"
+ every { tokenAuthenticator.findAccount() } returns account
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+ every { accountManager.peekAuthToken(account, TokenAuthenticator.AUTH_TOKEN_TYPE) } returns
+ accessToken
+ every { accountManager.invalidateAuthToken(account.type, accessToken) } just runs
+ every {
+ accountManager.getAuthToken(
+ account,
+ TokenAuthenticator.AUTH_TOKEN_TYPE,
+ any(),
+ true,
+ any(),
+ any()
+ )
+ } returns mockk()
+
+ tokenAuthenticator.getAccessToken()
+
+ verifyOrder {
+ accountManager.invalidateAuthToken(account.type, accessToken)
+ accountManager.getAuthToken(
+ account,
+ TokenAuthenticator.AUTH_TOKEN_TYPE,
+ any(),
+ true,
+ any(),
+ any()
+ )
+ }
+ }
+
+ @Test
+ fun testGetAccessTokenShouldReturnEmptyStringIfAccountNull() {
+ every { tokenAuthenticator.findAccount() } returns null
+ Assert.assertEquals("", tokenAuthenticator.getAccessToken())
+ }
+
+ @Test
+ fun testGetAccessTokenShouldCatchOperationCanceledAndIOAndAuthenticatorExceptions() {
+ val account = Account(sampleUsername, PROVIDER)
+ every { tokenAuthenticator.findAccount() } returns account
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+ val accessToken = "gibberishaccesstoken"
+ every { accountManager.peekAuthToken(account, TokenAuthenticator.AUTH_TOKEN_TYPE) } returns
+ accessToken
+ every { accountManager.invalidateAuthToken(account.type, accessToken) } just runs
+ every {
+ accountManager.getAuthToken(
+ account,
+ TokenAuthenticator.AUTH_TOKEN_TYPE,
+ any(),
+ true,
+ any(),
+ any()
+ )
+ } throws OperationCanceledException()
+ Assert.assertEquals(accessToken, tokenAuthenticator.getAccessToken())
+ every {
+ accountManager.getAuthToken(
+ account,
+ TokenAuthenticator.AUTH_TOKEN_TYPE,
+ any(),
+ true,
+ any(),
+ any()
+ )
+ } throws IOException()
+ Assert.assertEquals(accessToken, tokenAuthenticator.getAccessToken())
+ every {
+ accountManager.getAuthToken(
+ account,
+ TokenAuthenticator.AUTH_TOKEN_TYPE,
+ any(),
+ true,
+ any(),
+ any()
+ )
+ } throws AuthenticatorException()
+ Assert.assertEquals(accessToken, tokenAuthenticator.getAccessToken())
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testFetchTokenShouldRetrieveNewTokenAndCreateAccount() {
+ val token = "goodToken"
+ val refreshToken = "refreshToken"
+ val username = sampleUsername
+ val password = charArrayOf('P', '4', '5', '5', 'W', '4', '0')
+ var passwordSalt = byteArrayOf(-128, 100, 112, 127)
+
+ val secureSharedPreference = spyk(secureSharedPreference)
+ val tokenAuthenticator =
+ spyk(
+ TokenAuthenticator(
+ secureSharedPreference = secureSharedPreference,
+ configService = configService,
+ oAuthService = oAuthService,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ accountManager = accountManager,
+ context = context
+ )
+ )
+
+ val oAuthResponse =
+ OAuthResponse(
+ accessToken = token,
+ refreshToken = refreshToken,
+ tokenType = "",
+ expiresIn = 3600,
+ scope = SCOPE
+ )
+ coEvery { oAuthService.fetchToken(any()) } returns oAuthResponse
+
+ every { secureSharedPreference.get256RandomBytes() } returns passwordSalt
+ every { accountManager.accounts } returns arrayOf()
+
+ val accountSlot = slot()
+ val tokenSlot = slot()
+
+ every { accountManager.addAccountExplicitly(capture(accountSlot), any(), null) } returns true
+ every { accountManager.setAuthToken(any(), any(), capture(tokenSlot)) } just runs
+ every { accountManager.getAccountsByType(any()) } returns arrayOf()
+
+ runTest {
+ tokenAuthenticator.fetchAccessToken(username, password)
+ Assert.assertEquals(username, accountSlot.captured.name)
+ Assert.assertEquals(token, tokenSlot.captured)
+ }
+
+ // Credentials saved
+ val credentials = secureSharedPreference.retrieveCredentials()
+ Assert.assertNotNull(credentials)
+ Assert.assertTrue(username.contentEquals(credentials?.username))
+
+ Assert.assertEquals(
+ charArrayOf('P', '4', '5', '5', 'W', '4', '0').toPasswordHash(passwordSalt),
+ credentials?.passwordHash
+ )
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun testFetchTokenShouldSetPasswordAndAuthTokenForExistingAccount() = runTest {
+ val account = Account(sampleUsername, PROVIDER)
+ val password = charArrayOf('P', '4', '5', '5', 'W', '4', '0')
+ val token = "goodToken"
+ val refreshToken = "refreshToken"
+
+ val oAuthResponse =
+ OAuthResponse(
+ accessToken = token,
+ refreshToken = refreshToken,
+ tokenType = "",
+ expiresIn = 3600,
+ scope = SCOPE
+ )
+ coEvery { oAuthService.fetchToken(any()) } returns oAuthResponse
+ every { accountManager.accounts } returns arrayOf(account)
+ every { accountManager.setPassword(account, oAuthResponse.refreshToken) } just runs
+ every {
+ accountManager.setAuthToken(
+ account,
+ TokenAuthenticator.AUTH_TOKEN_TYPE,
+ oAuthResponse.accessToken
+ )
+ } just runs
+ every { accountManager.getAccountsByType(any()) } returns arrayOf(account)
+
+ tokenAuthenticator.fetchAccessToken(sampleUsername, password)
+
+ verifyOrder {
+ accountManager.setPassword(account, oAuthResponse.refreshToken)
+ accountManager.setAuthToken(
+ account,
+ TokenAuthenticator.AUTH_TOKEN_TYPE,
+ oAuthResponse.accessToken
+ )
+ }
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testFetchTokenShouldShouldCatchHttpAndUnknownHostAndSSLHandshakeExceptions() {
+ val username = sampleUsername
+ val password = charArrayOf('P', '4', '5', '5', 'W', '4', '0')
+
+ val httpException = HttpException(Response.success(null))
+
+ coEvery { oAuthService.fetchToken(any()) }.throws(httpException)
+
+ runTest {
+ var result = tokenAuthenticator.fetchAccessToken(username, password)
+ Assert.assertEquals(Result.failure(httpException), result)
+ }
+
+ val unknownHostException = UnknownHostException()
+
+ coEvery { oAuthService.fetchToken(any()) }.throws(unknownHostException)
+
+ runTest {
+ var result = tokenAuthenticator.fetchAccessToken(username, password)
+ Assert.assertEquals(Result.failure(unknownHostException), result)
+ }
+
+ val sslHandshakeException = SSLHandshakeException("reason")
+
+ coEvery { oAuthService.fetchToken(any()) }.throws(sslHandshakeException)
+
+ runTest {
+ var result = tokenAuthenticator.fetchAccessToken(username, password)
+ Assert.assertEquals(Result.failure(sslHandshakeException), result)
+ }
+ }
+ @Test
+ fun testLogout() {
+ val account = Account(sampleUsername, PROVIDER)
+ val refreshToken = "gibberishaccesstoken"
+ every { tokenAuthenticator.findAccount() } returns account
+ every { accountManager.getPassword(account) } returns refreshToken
+
+ val refreshTokenSlot = slot()
+ coEvery { oAuthService.logout(any(), any(), capture(refreshTokenSlot)) } returns
+ Response.success(200, mockk())
+
+ every { accountManager.invalidateAuthToken(account.type, any()) } just runs
+ every { accountManager.peekAuthToken(account, TokenAuthenticator.AUTH_TOKEN_TYPE) } returns
+ "oldToken"
+ val result = tokenAuthenticator.logout()
+ Assert.assertTrue(result.isSuccess)
+ }
+
+ @Test
+ fun testLogoutShouldShouldCatchHttpAndUnknownHostExceptions() {
+ val account = Account(sampleUsername, PROVIDER)
+ val refreshToken = "gibberishaccesstoken"
+ every { tokenAuthenticator.findAccount() } returns account
+ every { accountManager.getPassword(account) } returns refreshToken
+
+ val httpException = HttpException(Response.success(null))
+
+ coEvery { oAuthService.logout(any(), any(), any()) }.throws(httpException)
+
+ var result = tokenAuthenticator.logout()
+ Assert.assertEquals(Result.failure(httpException), result)
+
+ val unknownHostException = UnknownHostException()
+
+ coEvery { oAuthService.logout(any(), any(), any()) }.throws(unknownHostException)
+
+ result = tokenAuthenticator.logout()
+ Assert.assertEquals(Result.failure(unknownHostException), result)
+ }
+
+ @Test
+ fun testRefreshTokenShouldReturnToken() {
+ val accessToken = "soRefreshingNewToken"
+ val oAuthResponse =
+ OAuthResponse(
+ accessToken = accessToken,
+ refreshToken = "soRefreshingRefreshToken",
+ tokenType = "",
+ expiresIn = 3600,
+ scope = SCOPE
+ )
+ coEvery { oAuthService.fetchToken(any()) } returns oAuthResponse
+
+ val currentRefreshToken = "oldRefreshToken"
+ val newAccessToken = tokenAuthenticator.refreshToken(currentRefreshToken)
+ Assert.assertNotNull(newAccessToken)
+ Assert.assertEquals(accessToken, newAccessToken)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun testValidateSavedLoginCredentialsShouldReturnTrue() {
+ val passwd = "P455W40"
+ val passwordSalt = byteArrayOf(-128, 100, 112, 127)
+
+ val secureSharedPreference = spyk(secureSharedPreference)
+ every { secureSharedPreference.get256RandomBytes() } returns passwordSalt
+ secureSharedPreference.saveCredentials(sampleUsername, passwd.toCharArray())
+ val tokenAuthenticator =
+ spyk(
+ TokenAuthenticator(
+ secureSharedPreference = secureSharedPreference,
+ configService = configService,
+ oAuthService = oAuthService,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ accountManager = accountManager,
+ context = context
+ )
+ )
+
+ val result =
+ tokenAuthenticator.validateSavedLoginCredentials(sampleUsername, passwd.toCharArray())
+ Assert.assertTrue(result)
+ }
+
+ @Test
+ fun testFindAccountShouldReturnAnAccount() {
+ secureSharedPreference.saveCredentials(sampleUsername, "sirikali".toCharArray())
+ val account = Account(sampleUsername, PROVIDER)
+ every { accountManager.getAccountsByType(any()) } returns arrayOf(account)
+ val resultAccount = tokenAuthenticator.findAccount()
+ Assert.assertNotNull(resultAccount)
+ Assert.assertEquals(account.name, resultAccount?.name)
+ Assert.assertEquals(account.type, resultAccount?.type)
+ }
+
+ @Test
+ fun testSessionActiveWithActiveToken() {
+ val account = Account(sampleUsername, PROVIDER)
+ val token = "anotherToken"
+ every { tokenAuthenticator.findAccount() } returns account
+ every { accountManager.peekAuthToken(account, TokenAuthenticator.AUTH_TOKEN_TYPE) } returns
+ token
+ every { tokenAuthenticator.isTokenActive(any()) } returns true
+
+ Assert.assertTrue(tokenAuthenticator.sessionActive())
+ }
+
+ @Test
+ fun testSessionActiveWithInActiveToken() {
+ val account = Account(sampleUsername, PROVIDER)
+ val token = "anotherToken"
+ every { tokenAuthenticator.findAccount() } returns account
+ every { accountManager.peekAuthToken(account, TokenAuthenticator.AUTH_TOKEN_TYPE) } returns
+ token
+ every { tokenAuthenticator.isTokenActive(any()) } returns false
+
+ Assert.assertFalse(tokenAuthenticator.sessionActive())
+ }
+
+ @Test
+ fun testInvalidateSessionShouldInvalidateToken() {
+ val account = Account(sampleUsername, PROVIDER)
+ every { tokenAuthenticator.findAccount() } returns account
+ every {
+ accountManager.invalidateAuthToken(account.type, TokenAuthenticator.AUTH_TOKEN_TYPE)
+ } just runs
+ every { accountManager.removeAccountExplicitly(account) } returns true
+
+ val onSessionInvalidated = {}
+ tokenAuthenticator.invalidateSession(onSessionInvalidated)
+
+ verifyOrder {
+ accountManager.invalidateAuthToken(account.type, TokenAuthenticator.AUTH_TOKEN_TYPE)
+ accountManager.removeAccountExplicitly(account)
+ onSessionInvalidated()
+ }
+ }
+
+ companion object {
+ private const val SCOPE = "openid"
+ private const val PROVIDER = "provider"
+ }
+}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenManagerServiceTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenManagerServiceTest.kt
deleted file mode 100644
index 425bd9f59b..0000000000
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenManagerServiceTest.kt
+++ /dev/null
@@ -1,132 +0,0 @@
-/*
- * Copyright 2021 Ona Systems, Inc
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.smartregister.fhircore.engine.auth
-
-import android.accounts.Account
-import android.accounts.AccountManager
-import androidx.core.os.bundleOf
-import androidx.test.core.app.ApplicationProvider
-import dagger.hilt.android.testing.HiltAndroidRule
-import dagger.hilt.android.testing.HiltAndroidTest
-import dagger.hilt.android.testing.HiltTestApplication
-import io.jsonwebtoken.UnsupportedJwtException
-import io.mockk.every
-import io.mockk.spyk
-import io.mockk.verify
-import javax.inject.Inject
-import org.junit.After
-import org.junit.Assert
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
-import org.smartregister.fhircore.engine.app.fakes.FakeModel.authCredentials
-import org.smartregister.fhircore.engine.configuration.app.ConfigService
-import org.smartregister.fhircore.engine.robolectric.RobolectricTest
-import org.smartregister.fhircore.engine.util.SecureSharedPreference
-
-@HiltAndroidTest
-class TokenManagerServiceTest : RobolectricTest() {
-
- @get:Rule val hiltRule = HiltAndroidRule(this)
-
- @Inject lateinit var accountManager: AccountManager
-
- @Inject lateinit var secureSharedPreference: SecureSharedPreference
-
- @Inject lateinit var configService: ConfigService
-
- private lateinit var tokenManagerService: TokenManagerService
-
- private val context = ApplicationProvider.getApplicationContext()
-
- @Before
- fun setUp() {
- hiltRule.inject()
- tokenManagerService =
- spyk(
- TokenManagerService(
- context = context,
- accountManager = accountManager,
- configService = configService,
- secureSharedPreference = secureSharedPreference
- )
- )
- }
-
- @After
- fun tearDown() {
- secureSharedPreference.deleteCredentials()
- }
-
- @Test
- fun testLocalSessionTokenWithInactiveToken() {
- Assert.assertNull(tokenManagerService.getLocalSessionToken())
- }
-
- @Test
- fun testLocalSessionTokenWithActiveToken() {
- every { tokenManagerService.isTokenActive(authCredentials.sessionToken) } returns true
- secureSharedPreference.saveCredentials(authCredentials)
- Assert.assertEquals(authCredentials.sessionToken, tokenManagerService.getLocalSessionToken())
- }
-
- @Test
- fun testIsTokenActiveWithNullToken() {
- Assert.assertFalse(tokenManagerService.isTokenActive(null))
- }
-
- @Test
- @Throws(UnsupportedJwtException::class)
- fun testIsTokenActiveWithMalformedJwtToken() {
- Assert.assertFalse(tokenManagerService.isTokenActive("gibberish-token"))
- }
-
- @Test
- fun testGetActiveAccount() {
- secureSharedPreference.saveCredentials(authCredentials)
- accountManager.addAccountExplicitly(
- Account(authCredentials.username, configService.provideAuthConfiguration().accountType),
- authCredentials.password,
- bundleOf()
- )
- val activeAccount = tokenManagerService.getActiveAccount()
- Assert.assertNotNull(activeAccount)
- Assert.assertEquals(authCredentials.username, activeAccount!!.name)
- Assert.assertEquals(configService.provideAuthConfiguration().accountType, activeAccount.type)
- }
-
- @Test
- fun getAccountTypeEqualValueFromConfigService() {
- Assert.assertEquals(
- configService.provideAuthConfiguration().accountType,
- tokenManagerService.getAccountType()
- )
- }
-
- @Test
- fun getBlockingActiveAuthTokenShouldCallGetAccountTypeWhenLocalSessionTokenIsNull() {
- secureSharedPreference.saveCredentials(authCredentials)
- accountManager.addAccountExplicitly(
- Account(authCredentials.username, configService.provideAuthConfiguration().accountType),
- authCredentials.password,
- bundleOf()
- )
- every { tokenManagerService.getLocalSessionToken() } returns null
- tokenManagerService.getBlockingActiveAuthToken()
- verify { tokenManagerService.getAccountType() }
- }
-}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt
index 17ed45af9b..0aa43ef948 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/configuration/ConfigurationRegistryTest.kt
@@ -18,14 +18,15 @@ package org.smartregister.fhircore.engine.configuration
import android.content.Context
import androidx.test.core.app.ApplicationProvider
-import dagger.hilt.android.testing.BindValue
+import com.google.android.fhir.FhirEngine
+import com.google.android.fhir.db.ResourceNotFoundException
+import com.google.android.fhir.logicalId
+import com.google.android.fhir.search.Search
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
-import io.mockk.spyk
-import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.StandardTestDispatcher
@@ -42,48 +43,47 @@ import org.smartregister.fhircore.engine.app.fakes.Faker
import org.smartregister.fhircore.engine.configuration.app.AppConfigClassification
import org.smartregister.fhircore.engine.configuration.view.LoginViewConfiguration
import org.smartregister.fhircore.engine.configuration.view.PinViewConfiguration
-import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
-import org.smartregister.fhircore.engine.util.DispatcherProvider
-import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
-@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class ConfigurationRegistryTest : RobolectricTest() {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
- @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper
- @Inject lateinit var dispatcherProvider: DispatcherProvider
+ private lateinit var sharedPreferencesHelper: SharedPreferencesHelper
val context = ApplicationProvider.getApplicationContext()
-
- @BindValue val secureSharedPreference: SecureSharedPreference = mockk()
-
+ @get:Rule(order = 1) val coroutineRule = CoroutineTestRule()
private val testAppId = "default"
private lateinit var fhirResourceDataSource: FhirResourceDataSource
lateinit var configurationRegistry: ConfigurationRegistry
- val defaultRepository: DefaultRepository = mockk()
+ var fhirEngine: FhirEngine = mockk()
@Before
+ @ExperimentalCoroutinesApi
fun setUp() {
hiltRule.inject()
- fhirResourceDataSource = spyk(FhirResourceDataSource(mockk()))
+ fhirResourceDataSource = mockk()
+ sharedPreferencesHelper = mockk()
+
configurationRegistry =
ConfigurationRegistry(
context,
+ fhirEngine,
fhirResourceDataSource,
sharedPreferencesHelper,
- dispatcherProvider,
- defaultRepository
+ coroutineRule.testDispatcherProvider,
)
+ coEvery { fhirResourceDataSource.loadData(any()) } returns
+ Bundle().apply { entry = mutableListOf() }
+ Assert.assertNotNull(configurationRegistry)
+ Faker.loadTestConfigurationRegistryData(fhirEngine, configurationRegistry)
}
@Test
fun testLoadConfiguration() {
- Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry)
-
Assert.assertEquals(testAppId, configurationRegistry.appId)
Assert.assertTrue(configurationRegistry.workflowPointsMap.isNotEmpty())
Assert.assertTrue(configurationRegistry.workflowPointsMap.containsKey("default|application"))
@@ -94,8 +94,6 @@ class ConfigurationRegistryTest : RobolectricTest() {
@Test
fun testRetrieveConfigurationShouldReturnLoginViewConfiguration() {
- Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry)
-
val retrievedConfiguration =
configurationRegistry.retrieveConfiguration(
AppConfigClassification.LOGIN
@@ -117,8 +115,6 @@ class ConfigurationRegistryTest : RobolectricTest() {
@Test
fun testRetrievePinConfigurationShouldReturnLoginViewConfiguration() {
- Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry)
-
val retrievedConfiguration =
configurationRegistry.retrieveConfiguration(AppConfigClassification.PIN)
@@ -140,8 +136,8 @@ class ConfigurationRegistryTest : RobolectricTest() {
fun testRetrieveConfigurationWithNoEntryShouldReturnNewConfiguration() {
configurationRegistry.appId = "testApp"
- Assert.assertTrue(configurationRegistry.workflowPointsMap.isEmpty())
- Assert.assertTrue(configurationRegistry.configurationsMap.isEmpty())
+ configurationRegistry.workflowPointsMap.clear()
+ configurationRegistry.configurationsMap.clear()
val retrievedConfiguration =
configurationRegistry.retrieveConfiguration(AppConfigClassification.PIN)
@@ -151,32 +147,18 @@ class ConfigurationRegistryTest : RobolectricTest() {
@Test
fun testLoadConfigurationRegistry() {
- Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry)
-
- coVerify { defaultRepository.searchCompositionByIdentifier(testAppId) }
- coVerify { defaultRepository.getBinary("62938") }
- coVerify { defaultRepository.getBinary("62940") }
- coVerify { defaultRepository.getBinary("62952") }
- coVerify { defaultRepository.getBinary("87021") }
- coVerify { defaultRepository.getBinary("63003") }
- coVerify { defaultRepository.getBinary("63011") }
- coVerify { defaultRepository.getBinary("63007") }
- coVerify { defaultRepository.getBinary("56181") }
+ runTest { configurationRegistry.fetchNonWorkflowConfigResources() }
+ coVerify { fhirEngine.search(any()) }
}
@Test
fun testIsAppIdInitialized() {
- Assert.assertFalse(configurationRegistry.isAppIdInitialized())
-
- Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry)
-
+ runBlocking { configurationRegistry.loadConfigurations(testAppId) {} }
Assert.assertTrue(configurationRegistry.isAppIdInitialized())
}
@Test
fun testIsWorkflowPointName() {
- Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry)
-
Assert.assertEquals("$testAppId|123", configurationRegistry.workflowPointName("123"))
Assert.assertEquals("$testAppId|abbb", configurationRegistry.workflowPointName("abbb"))
}
@@ -185,7 +167,7 @@ class ConfigurationRegistryTest : RobolectricTest() {
@Test
fun testLoadConfigurationsLocally_shouldReturn_8_workflows() {
runTest {
- Assert.assertEquals(0, configurationRegistry.workflowPointsMap.size)
+ configurationRegistry.workflowPointsMap.clear()
configurationRegistry.loadConfigurationsLocally("$testAppId/debug") { Assert.assertTrue(it) }
Assert.assertEquals(9, configurationRegistry.workflowPointsMap.size)
@@ -208,7 +190,7 @@ class ConfigurationRegistryTest : RobolectricTest() {
@Test
fun testLoadConfigurationsLocally_shouldReturn_empty_workflows() {
runTest {
- Assert.assertEquals(0, configurationRegistry.workflowPointsMap.size)
+ configurationRegistry.workflowPointsMap.clear()
configurationRegistry.loadConfigurationsLocally("") { Assert.assertFalse(it) }
Assert.assertEquals(0, configurationRegistry.workflowPointsMap.size)
}
@@ -217,19 +199,12 @@ class ConfigurationRegistryTest : RobolectricTest() {
@Test
fun testFetchNonWorkflowConfigResources() = runTest {
val dispatcher = StandardTestDispatcher(testScheduler)
- coEvery { configurationRegistry.repository.searchCompositionByIdentifier(testAppId) } returns
- Composition().apply {
- addSection().apply { this.focus = Reference().apply { reference = "Questionnaire/123" } }
- }
- coEvery { configurationRegistry.fhirResourceDataSource.loadData(any()) } returns Bundle()
-
configurationRegistry.appId = testAppId
configurationRegistry.fetchNonWorkflowConfigResources(dispatcher)
- // coVerify { configurationRegistry.repository.searchCompositionByIdentifier(any()) }
advanceUntilIdle()
coVerify {
- configurationRegistry.fhirResourceDataSource.loadData(
+ fhirResourceDataSource.loadData(
withArg { Assert.assertTrue(it.startsWith("Questionnaire", ignoreCase = true)) }
)
}
@@ -238,13 +213,11 @@ class ConfigurationRegistryTest : RobolectricTest() {
@Test
fun testFetchNonWorkflowConfigResourcesWithNoEntry() {
configurationRegistry.appId = "testApp"
- Assert.assertEquals(0, configurationRegistry.workflowPointsMap.size)
-
- coEvery { defaultRepository.searchCompositionByIdentifier(any()) } returns null
+ configurationRegistry.workflowPointsMap.clear()
+ coEvery { fhirEngine.search(any()) } returns listOf()
runBlocking { configurationRegistry.fetchNonWorkflowConfigResources() }
- // coVerify { defaultRepository.searchCompositionByIdentifier("testApp") }
coVerify(inverse = true) { fhirResourceDataSource.loadData(any()) }
}
@@ -265,4 +238,44 @@ class ConfigurationRegistryTest : RobolectricTest() {
}
Assert.assertFalse(configurationRegistry.isWorkflowPoint(sectionComponent))
}
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testAddOrUpdate() {
+ // when does not exist
+ val patient = Faker.buildPatient()
+ coEvery { fhirEngine.get(patient.resourceType, patient.logicalId) } returns patient
+ coEvery { fhirEngine.update(any()) } returns Unit
+
+ runTest {
+ val previousLastUpdate = patient.meta.lastUpdated
+ configurationRegistry.addOrUpdate(patient)
+ Assert.assertNotEquals(previousLastUpdate, patient.meta.lastUpdated)
+ }
+
+ // when exists
+ runTest {
+ val previousLastUpdate = patient.meta.lastUpdated
+ configurationRegistry.addOrUpdate(patient)
+ Assert.assertNotEquals(previousLastUpdate, patient.meta.lastUpdated)
+ }
+ }
+
+ @Test
+ @ExperimentalCoroutinesApi
+ fun testAddOrUpdateCatchesResourceNotFound() {
+ val patient = Faker.buildPatient()
+ coEvery { fhirEngine.get(patient.resourceType, patient.logicalId) } throws
+ ResourceNotFoundException("", "")
+ coEvery { fhirEngine.create(any()) } returns listOf()
+
+ runTest {
+ val previousLastUpdate = patient.meta.lastUpdated
+ configurationRegistry.addOrUpdate(patient)
+ Assert.assertNotEquals(previousLastUpdate, patient.meta.lastUpdated)
+ }
+
+ coVerify(inverse = true) { fhirEngine.update(any()) }
+ coVerify { fhirEngine.create(patient) }
+ }
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/cql/LibraryEvaluatorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/cql/LibraryEvaluatorTest.kt
index 40eaad9b55..e5b5f23ac0 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/cql/LibraryEvaluatorTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/cql/LibraryEvaluatorTest.kt
@@ -158,7 +158,14 @@ class LibraryEvaluatorTest {
Patient
val fhirEngine = mockk()
- val defaultRepository = DefaultRepository(fhirEngine, DefaultDispatcherProvider())
+ val defaultRepository: DefaultRepository =
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = DefaultDispatcherProvider(),
+ sharedPreferencesHelper = mockk(),
+ configurationRegistry = mockk(),
+ configService = mockk()
+ )
coEvery { fhirEngine.get(ResourceType.Library, cqlLibrary.logicalId) } returns cqlLibrary
coEvery { fhirEngine.get(ResourceType.Library, fhirHelpersLibrary.logicalId) } returns
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt
index db671cfc2e..689a93d0e6 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/DefaultRepositoryTest.kt
@@ -21,8 +21,12 @@ import com.google.android.fhir.db.ResourceNotFoundException
import com.google.android.fhir.get
import com.google.android.fhir.logicalId
import com.google.android.fhir.search.Search
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coEvery
import io.mockk.coVerify
+import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkStatic
@@ -31,6 +35,7 @@ import io.mockk.slot
import io.mockk.spyk
import io.mockk.unmockkStatic
import io.mockk.verify
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runBlockingTest
import org.hl7.fhir.r4.model.Address
@@ -47,18 +52,39 @@ import org.hl7.fhir.r4.model.ResourceType
import org.hl7.fhir.r4.model.StringType
import org.joda.time.LocalDate
import org.junit.Assert
+import org.junit.Before
+import org.junit.Rule
import org.junit.Test
+import org.smartregister.fhircore.engine.app.fakes.Faker
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
+import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.generateMissingId
import org.smartregister.fhircore.engine.util.extension.generateMissingVersionId
import org.smartregister.fhircore.engine.util.extension.loadPatientImmunizations
import org.smartregister.fhircore.engine.util.extension.loadRelatedPersons
+@HiltAndroidTest
class DefaultRepositoryTest : RobolectricTest() {
private val dispatcherProvider = spyk(DefaultDispatcherProvider())
-
+ @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule(order = 2)
+ var coroutineRule = CoroutineTestRule()
+ private val configurationRegistry = Faker.buildTestConfigurationRegistry()
+ @BindValue val sharedPreferencesHelper = mockk(relaxed = true)
+
+ private val configService: ConfigService = mockk()
+
+ @Before
+ fun setUp() {
+ hiltRule.inject()
+ every { configService.provideResourceTags(any()) } returns listOf()
+ }
+ @OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `addOrUpdate() should call fhirEngine#update when resource exists`() {
val patientId = "15672-9234"
@@ -92,10 +118,16 @@ class DefaultRepositoryTest : RobolectricTest() {
coEvery { fhirEngine.update(any()) } just runs
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
// Call the function under test
- runBlocking { defaultRepository.addOrUpdate(patient) }
+ runBlocking { defaultRepository.addOrUpdate(resource = patient) }
coVerify { fhirEngine.get(ResourceType.Patient, patientId) }
coVerify { fhirEngine.update(capture(savedPatientSlot)) }
@@ -115,10 +147,11 @@ class DefaultRepositoryTest : RobolectricTest() {
coEvery { fhirEngine.get(ResourceType.Patient, any()) } throws
mockk()
coEvery { fhirEngine.create(any()) } returns listOf()
- runBlocking { defaultRepository.addOrUpdate(Patient()) }
+ runBlocking { defaultRepository.addOrUpdate(resource = Patient()) }
coVerify(exactly = 1) { fhirEngine.create(any()) }
}
+ @OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `loadRelatedPersons() should call FhirEngine#loadRelatedPersons`() {
val patientId = "15672-9234"
@@ -126,7 +159,13 @@ class DefaultRepositoryTest : RobolectricTest() {
coEvery { fhirEngine.loadRelatedPersons(patientId) } returns listOf()
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
runBlocking { defaultRepository.loadRelatedPersons(patientId) }
@@ -140,7 +179,13 @@ class DefaultRepositoryTest : RobolectricTest() {
coEvery { fhirEngine.loadPatientImmunizations(patientId) } returns listOf()
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
runBlocking { defaultRepository.loadPatientImmunizations(patientId) }
@@ -153,7 +198,13 @@ class DefaultRepositoryTest : RobolectricTest() {
coEvery { fhirEngine.search(any()) } returns listOf()
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
runBlocking { defaultRepository.loadQuestionnaireResponses("1234", Questionnaire()) }
@@ -170,7 +221,13 @@ class DefaultRepositoryTest : RobolectricTest() {
ResourceNotFoundException("Exce", "Exce")
coEvery { fhirEngine.create(any()) } returns listOf()
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
runBlocking { defaultRepository.save(resource) }
@@ -188,10 +245,17 @@ class DefaultRepositoryTest : RobolectricTest() {
coEvery { fhirEngine.get(ResourceType.Patient, any()) } throws
ResourceNotFoundException("Exce", "Exce")
coEvery { fhirEngine.create(any()) } returns listOf()
+
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
- runBlocking { defaultRepository.addOrUpdate(resource) }
+ runBlocking { defaultRepository.addOrUpdate(resource = resource) }
verify { resource.generateMissingId() }
@@ -205,7 +269,13 @@ class DefaultRepositoryTest : RobolectricTest() {
listOf(Composition().apply { id = "123" })
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
val result = defaultRepository.searchCompositionByIdentifier("appId")
@@ -220,7 +290,13 @@ class DefaultRepositoryTest : RobolectricTest() {
coEvery { fhirEngine.get(ResourceType.Binary, any()) } returns Binary().apply { id = "111" }
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
val result = defaultRepository.getBinary("111")
@@ -239,9 +315,15 @@ class DefaultRepositoryTest : RobolectricTest() {
ResourceNotFoundException("Exce", "Exce")
coEvery { fhirEngine.create(any()) } returns listOf()
val defaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = dispatcherProvider)
-
- runBlocking { defaultRepository.addOrUpdate(resource) }
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
+
+ runBlocking { defaultRepository.addOrUpdate(resource = resource) }
verify { resource.generateMissingVersionId() }
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/AppRegisterRepositoryTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/AppRegisterRepositoryTest.kt
index ab16b329a7..190a177890 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/AppRegisterRepositoryTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/AppRegisterRepositoryTest.kt
@@ -17,6 +17,9 @@
package org.smartregister.fhircore.engine.data.local.register
import com.google.android.fhir.FhirEngine
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -25,6 +28,7 @@ import io.mockk.mockkConstructor
import io.mockk.mockkStatic
import io.mockk.unmockkAll
import java.util.Date
+import javax.inject.Inject
import kotlin.test.assertEquals
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -33,8 +37,11 @@ import org.hl7.fhir.r4.model.Patient
import org.junit.After
import org.junit.Assert
import org.junit.Before
+import org.junit.Rule
import org.junit.Test
+import org.smartregister.fhircore.engine.app.fakes.Faker
import org.smartregister.fhircore.engine.appfeature.model.HealthModule
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.data.local.AppointmentRegisterFilter
import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.data.local.TracingRegisterFilter
@@ -46,22 +53,37 @@ import org.smartregister.fhircore.engine.data.local.register.dao.RegisterDaoFact
import org.smartregister.fhircore.engine.domain.model.ProfileData
import org.smartregister.fhircore.engine.domain.model.RegisterData
import org.smartregister.fhircore.engine.domain.repository.RegisterDao
+import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.trace.FakePerformanceReporter
import org.smartregister.fhircore.engine.trace.PerformanceReporter
import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
@OptIn(ExperimentalCoroutinesApi::class)
-class AppRegisterRepositoryTest {
-
+@HiltAndroidTest
+class AppRegisterRepositoryTest : RobolectricTest() {
+ @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
private lateinit var repository: AppRegisterRepository
private val fhirEngine: FhirEngine = mockk()
private val dispatcherProvider: DefaultDispatcherProvider = mockk()
private val registerDaoFactory: RegisterDaoFactory = mockk()
private val tracer: PerformanceReporter = FakePerformanceReporter()
-
+ @BindValue val sharedPreferencesHelper = mockk(relaxed = true)
+ private val configurationRegistry = Faker.buildTestConfigurationRegistry()
+ @Inject lateinit var configService: ConfigService
@Before
fun setUp() {
- repository = AppRegisterRepository(fhirEngine, dispatcherProvider, registerDaoFactory, tracer)
+ hiltRule.inject()
+ repository =
+ AppRegisterRepository(
+ fhirEngine,
+ dispatcherProvider,
+ sharedPreferencesHelper,
+ configurationRegistry,
+ registerDaoFactory,
+ configService,
+ tracer
+ )
mockkConstructor(DefaultRepository::class)
mockkStatic("kotlinx.coroutines.DispatchersKt")
every { anyConstructed().fhirEngine } returns fhirEngine
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDaoTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDaoTest.kt
index e283b72d1a..57f4abac81 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDaoTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/AppointmentRegisterDaoTest.kt
@@ -26,6 +26,7 @@ import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import java.util.Date
+import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.Appointment
@@ -44,6 +45,7 @@ import org.junit.Test
import org.smartregister.fhircore.engine.app.fakes.Faker
import org.smartregister.fhircore.engine.app.fakes.Faker.buildPatient
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.data.local.AppointmentRegisterFilter
import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.domain.model.HealthStatus
@@ -53,13 +55,13 @@ import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider
import org.smartregister.fhircore.engine.util.LOGGED_IN_PRACTITIONER
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
-import org.smartregister.fhircore.engine.util.extension.encodeResourceToString
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class AppointmentRegisterDaoTest : RobolectricTest() {
@BindValue val sharedPreferencesHelper: SharedPreferencesHelper = mockk(relaxed = true)
+ @get:Rule(order = 2) var coroutineRule = CoroutineTestRule()
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@@ -71,22 +73,30 @@ class AppointmentRegisterDaoTest : RobolectricTest() {
private val fhirEngine: FhirEngine = mockk()
- var defaultRepository: DefaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = DefaultDispatcherProvider())
+ lateinit var defaultRepository: DefaultRepository
+ @Inject lateinit var configService: ConfigService
- var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry(mockk())
+ var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry()
@Before
fun setUp() {
hiltRule.inject()
-
+ defaultRepository =
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
coEvery { fhirEngine.get(ResourceType.Patient, "1234") } returns
buildPatient("1", "doe", "john", 10, patientType = "exposed-infant")
coEvery { configurationRegistry.retrieveDataFilterConfiguration(any()) } returns emptyList()
- every { sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, null) } returns
- Practitioner().apply { id = "123" }.encodeResourceToString()
+ every {
+ sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, decodeWithGson = true)
+ } returns Practitioner().apply { id = "123" }
appointmentRegisterDao =
AppointmentRegisterDao(
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDaoTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDaoTest.kt
index 9ffb350d3d..5ef725e088 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDaoTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/HivRegisterDaoTest.kt
@@ -20,6 +20,9 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.google.android.fhir.FhirEngine
import com.google.android.fhir.logicalId
import com.google.android.fhir.search.Search
+import dagger.hilt.android.testing.BindValue
+import dagger.hilt.android.testing.HiltAndroidRule
+import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
@@ -28,6 +31,7 @@ import io.mockk.mockk
import io.mockk.runs
import java.util.Calendar
import java.util.Date
+import javax.inject.Inject
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
@@ -57,6 +61,7 @@ import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
import org.smartregister.fhircore.engine.configuration.WorkflowPoint
import org.smartregister.fhircore.engine.configuration.app.AppConfigClassification
import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.configuration.app.applicationConfigurationOf
import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.domain.model.HealthStatus
@@ -64,26 +69,27 @@ import org.smartregister.fhircore.engine.domain.model.ProfileData
import org.smartregister.fhircore.engine.domain.model.RegisterData
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
-import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.asReference
import org.smartregister.fhircore.engine.util.extension.clinicVisitOrder
import org.smartregister.fhircore.engine.util.extension.referenceValue
+@HiltAndroidTest
@OptIn(ExperimentalCoroutinesApi::class)
-class HivRegisterDaoTest : RobolectricTest() {
-
+internal class HivRegisterDaoTest : RobolectricTest() {
+ @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val instantTaskExecutorRule = InstantTaskExecutorRule()
-
@get:Rule(order = 2) val coroutineTestRule = CoroutineTestRule()
+ @get:Rule(order = 2) var coroutineRule = CoroutineTestRule()
+ @BindValue val sharedPreferencesHelper: SharedPreferencesHelper = mockk(relaxed = true)
+ @Inject lateinit var configService: ConfigService
private lateinit var hivRegisterDao: HivRegisterDao
private val fhirEngine: FhirEngine = mockk()
- val defaultRepository: DefaultRepository =
- DefaultRepository(fhirEngine = fhirEngine, dispatcherProvider = DefaultDispatcherProvider())
-
- val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry(mockk())
+ lateinit var defaultRepository: DefaultRepository
+ val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry()
private val testPatient =
buildPatient(
@@ -178,7 +184,15 @@ class HivRegisterDaoTest : RobolectricTest() {
@Before
fun setUp() {
-
+ hiltRule.inject()
+ defaultRepository =
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
coEvery { fhirEngine.get(ResourceType.Patient, "1") } returns testPatient
coEvery { fhirEngine.get(ResourceType.Task, testTask1.logicalId) } returns testTask1
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDaoTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDaoTest.kt
index 53c9e98055..b060ecad75 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDaoTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDaoTest.kt
@@ -29,6 +29,7 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.spyk
import java.util.Date
+import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.Appointment
@@ -54,6 +55,7 @@ import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.smartregister.fhircore.engine.app.fakes.Faker
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.data.local.TracingAgeFilterEnum
import org.smartregister.fhircore.engine.data.local.TracingRegisterFilter
@@ -66,7 +68,6 @@ import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider
import org.smartregister.fhircore.engine.util.LOGGED_IN_PRACTITIONER
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.asReference
-import org.smartregister.fhircore.engine.util.extension.encodeResourceToString
import org.smartregister.fhircore.engine.util.extension.referenceValue
@OptIn(ExperimentalCoroutinesApi::class)
@@ -82,18 +83,30 @@ class TracingRegisterDaoTest : RobolectricTest() {
private val fhirEngine = mockk()
private val tracingRepository = spyk(TracingRepository(fhirEngine))
private val dispatcherProvider = DefaultDispatcherProvider()
- private val defaultRepository = DefaultRepository(fhirEngine, dispatcherProvider)
- private val configurationRegistry = Faker.buildTestConfigurationRegistry(spyk(defaultRepository))
+ private lateinit var defaultRepository: DefaultRepository
+ private val configurationRegistry = Faker.buildTestConfigurationRegistry()
private lateinit var tracingRegisterDao: TracingRegisterDao
+ @get:Rule(order = 2) var coroutineRule = CoroutineTestRule()
+ @Inject lateinit var configService: ConfigService
@Before
fun setUp() {
hiltRule.inject()
-
+ defaultRepository =
+ spyk(
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
+ )
coEvery { configurationRegistry.retrieveDataFilterConfiguration(any()) } returns emptyList()
- every { sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, null) } returns
- Practitioner().apply { id = "123" }.encodeResourceToString()
+ every {
+ sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, decodeWithGson = true)
+ } returns Practitioner().apply { id = "123" }
tracingRegisterDao =
HomeTracingRegisterDao(
@@ -451,10 +464,7 @@ class TracingRegisterDaoTest : RobolectricTest() {
@Test
fun loadProfileDataReturnsExpectedProfileData() = runTest {
val practitioner =
- sharedPreferencesHelper.read(
- LOGGED_IN_PRACTITIONER,
- decodeFhirResource = true
- )!!
+ sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, decodeWithGson = true)!!
val guardian0 = RelatedPerson().apply { id = "guardian0" }
val guardian1 = Faker.buildPatient("guardian1")
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/FhirResourceDataSourceTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/FhirResourceDataSourceTest.kt
index fb3be7b290..70d5aaf49e 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/FhirResourceDataSourceTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/FhirResourceDataSourceTest.kt
@@ -19,21 +19,18 @@ package org.smartregister.fhircore.engine.data.remote.fhir.resource
import io.mockk.coEvery
import io.mockk.mockk
import io.mockk.spyk
-import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.OperationOutcome
import org.hl7.fhir.r4.model.Patient
import org.hl7.fhir.r4.model.ResourceType
import org.junit.Assert
import org.junit.Before
-import org.junit.Rule
import org.junit.Test
-import org.smartregister.fhircore.engine.rule.CoroutineTestRule
+@OptIn(ExperimentalCoroutinesApi::class)
class FhirResourceDataSourceTest {
-
- @get:Rule(order = 1) val coroutineTestRule = CoroutineTestRule()
-
private val resourceService: FhirResourceService = mockk()
private lateinit var fhirResourceDataSource: FhirResourceDataSource
@@ -45,16 +42,16 @@ class FhirResourceDataSourceTest {
@Test
fun testLoadDataShouldRetrieveResource() {
- coroutineTestRule.runBlockingTest {
+ runTest {
val bundle = Bundle()
coEvery { resourceService.getResource(any()) } returns bundle
- Assert.assertEquals(bundle, fhirResourceDataSource.loadData("http://fake.url"))
+ Assert.assertEquals(bundle, fhirResourceDataSource.getResource("http://fake.url"))
}
}
@Test
fun testInsertShouldAddResource() {
- coroutineTestRule.runBlockingTest {
+ runTest {
val resource = Patient()
coEvery { resourceService.insertResource(any(), any(), any()) } returns resource
Assert.assertEquals(
@@ -66,7 +63,7 @@ class FhirResourceDataSourceTest {
@Test
fun testUpdateShouldUpdateResource() {
- coroutineTestRule.runBlockingTest {
+ runTest {
val operationOutcome = OperationOutcome()
coEvery { resourceService.updateResource(any(), any(), any()) } returns operationOutcome
Assert.assertEquals(
@@ -79,7 +76,7 @@ class FhirResourceDataSourceTest {
@Test
fun testDeleteShouldRemoveResource() {
- coroutineTestRule.runBlockingTest {
+ runTest {
val operationOutcome = OperationOutcome()
coEvery { resourceService.deleteResource(any(), any()) } returns operationOutcome
Assert.assertEquals(
@@ -91,7 +88,7 @@ class FhirResourceDataSourceTest {
@Test
fun testSearchResourceShouldReturnBundle() {
- coroutineTestRule.runBlockingTest {
+ runTest {
val bundle = Bundle()
coEvery { resourceService.searchResource(any(), any()) } returns bundle
Assert.assertEquals(
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolverTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolverTest.kt
index f6a2deecdd..e2546e0067 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolverTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/fhir/resource/ReferenceUrlResolverTest.kt
@@ -24,7 +24,8 @@ import io.mockk.mockk
import io.mockk.spyk
import java.io.ByteArrayInputStream
import java.nio.charset.Charset
-import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
import okhttp3.MediaType
import okhttp3.ResponseBody
import okio.BufferedSource
@@ -40,7 +41,7 @@ import retrofit2.Call
import retrofit2.Response
class ReferenceUrlResolverTest : RobolectricTest() {
- @get:Rule val coroutineTestRule = CoroutineTestRule()
+ @OptIn(ExperimentalCoroutinesApi::class) @get:Rule val coroutineTestRule = CoroutineTestRule()
private lateinit var referenceUrlResolver: ReferenceUrlResolver
private val fhirEngine = mockk()
@@ -54,57 +55,51 @@ class ReferenceUrlResolverTest : RobolectricTest() {
}
@Test
- fun testResolveBinaryResourceShouldReturnBinary() {
- coroutineTestRule.runBlockingTest {
- val binary = Binary().apply { id = "bId" }
- coEvery { fhirEngine.get(ResourceType.Binary, any()) } returns binary
- Assert.assertEquals(
- binary,
- referenceUrlResolver.resolveBinaryResource(
- "https://fhir-server.org/Binary/sample-binary-image"
- )
+ fun testResolveBinaryResourceShouldReturnBinary() = runTest {
+ val binary = Binary().apply { id = "bId" }
+ coEvery { fhirEngine.get(ResourceType.Binary, any()) } returns binary
+ Assert.assertEquals(
+ binary,
+ referenceUrlResolver.resolveBinaryResource(
+ "https://fhir-server.org/Binary/sample-binary-image"
)
- }
+ )
}
@Test
- fun testResolveImageUrlWithNullBodyShouldReturnNull() {
- coroutineTestRule.runBlockingTest {
- val mockResponse = mockk>()
- every { mockResponse.execute() } returns Response.success(null)
- every { fhirResourceService.fetchImage(any()) } returns mockResponse
- Assert.assertNull(referenceUrlResolver.resolveBitmapUrl("https://image-server.com/8929839"))
- }
+ fun testResolveImageUrlWithNullBodyShouldReturnNull() = runTest {
+ val mockResponse = mockk>()
+ every { mockResponse.execute() } returns Response.success(null)
+ every { fhirResourceService.fetchImage(any()) } returns mockResponse
+ Assert.assertNull(referenceUrlResolver.resolveBitmapUrl("https://image-server.com/8929839"))
}
@Test
- fun testResolveImageUrlShouldReturnBitmap() {
- coroutineTestRule.runBlockingTest {
- val mockResponseBody: ResponseBody =
- spyk(
- object : ResponseBody() {
- override fun contentLength(): Long = 1L
+ fun testResolveImageUrlShouldReturnBitmap() = runTest {
+ val mockResponseBody: ResponseBody =
+ spyk(
+ object : ResponseBody() {
+ override fun contentLength(): Long = 1L
- override fun contentType(): MediaType? = null
+ override fun contentType(): MediaType? = null
- override fun source(): BufferedSource = mockk()
- }
- )
+ override fun source(): BufferedSource = mockk()
+ }
+ )
- val mockResponse = Response.success(mockResponseBody)
+ val mockResponse = Response.success(mockResponseBody)
- every { mockResponseBody.byteStream() } returns
- (ByteArrayInputStream(
- "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".toByteArray(
- Charset.forName("UTF-8")
- )
- ))
- val callResponse = mockk>()
- every { callResponse.execute() } returns mockResponse
- every { fhirResourceService.fetchImage(any()) } returns callResponse
- val bitmap = referenceUrlResolver.resolveBitmapUrl("https://image-server.com/8929839")
- Assert.assertNotNull(bitmap)
- Assert.assertTrue(bitmap is Bitmap)
- }
+ every { mockResponseBody.byteStream() } returns
+ (ByteArrayInputStream(
+ "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7".toByteArray(
+ Charset.forName("UTF-8")
+ )
+ ))
+ val callResponse = mockk>()
+ every { callResponse.execute() } returns mockResponse
+ every { fhirResourceService.fetchImage(any()) } returns callResponse
+ val bitmap = referenceUrlResolver.resolveBitmapUrl("https://image-server.com/8929839")
+ Assert.assertNotNull(bitmap)
+ Assert.assertTrue(bitmap is Bitmap)
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptorTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptorTest.kt
deleted file mode 100644
index c5a3fd045f..0000000000
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptorTest.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-/*
- * Copyright 2021 Ona Systems, Inc
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package org.smartregister.fhircore.engine.data.remote.shared.interceptor
-
-import android.app.Application
-import androidx.test.core.app.ApplicationProvider
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.spyk
-import io.mockk.verify
-import okhttp3.Interceptor
-import okhttp3.Request
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.annotation.Config
-import org.smartregister.fhircore.engine.auth.TokenManagerService
-import org.smartregister.fhircore.engine.robolectric.FhircoreTestRunner
-import org.smartregister.fhircore.engine.robolectric.RobolectricTest
-
-@RunWith(FhircoreTestRunner::class)
-@Config(sdk = [29])
-class OAuthInterceptorTest : RobolectricTest() {
-
- @Test
- fun testInterceptShouldAddTokenHeader() {
- val context = ApplicationProvider.getApplicationContext()
-
- val tokenManagerService = mockk()
- val interceptor = OAuthInterceptor(context, tokenManagerService)
- every { tokenManagerService.getBlockingActiveAuthToken() } returns "my-access-token"
-
- val requestBuilder = spyk(Request.Builder())
- val request = spyk(Request.Builder().url("http://test-url.com").build())
- val chain = mockk()
- every { chain.request() } returns request
- every { request.newBuilder() } returns requestBuilder.url("http://test-url.com")
- every { chain.proceed(any()) } returns mockk()
-
- interceptor.intercept(chain)
-
- verify { requestBuilder.addHeader("Authorization", "Bearer my-access-token") }
- }
-}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/navigation/RegisterBottomSheetViewsKtTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/navigation/RegisterBottomSheetViewsKtTest.kt
index 0a1776fd0e..c54857df4d 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/navigation/RegisterBottomSheetViewsKtTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/navigation/RegisterBottomSheetViewsKtTest.kt
@@ -20,9 +20,7 @@ import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
-import io.mockk.spyk
-import io.mockk.verify
-import org.junit.Before
+import org.junit.Assert
import org.junit.Rule
import org.junit.Test
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
@@ -30,8 +28,6 @@ import org.smartregister.fhircore.engine.ui.register.model.RegisterItem
class RegisterBottomSheetViewsKtTest : RobolectricTest() {
- private val mockListener: (String) -> Unit = spyk({})
-
@get:Rule val composeRule = createComposeRule()
private val registerItems =
@@ -40,15 +36,10 @@ class RegisterBottomSheetViewsKtTest : RobolectricTest() {
RegisterItem(uniqueTag = "UniqueTag2", title = "Menu 2", isSelected = false)
)
- @Before
- fun setUp() {
- composeRule.setContent {
- RegisterBottomSheet(registers = registerItems, itemListener = mockListener)
- }
- }
-
@Test
fun testThatMenuItemsAreShowing() {
+ composeRule.mainClock.autoAdvance = false
+ composeRule.setContent { RegisterBottomSheet(registers = registerItems, itemListener = {}) }
composeRule.onNodeWithText("Menu 1").assertExists()
composeRule.onNodeWithText("Menu 2").assertExists()
@@ -58,9 +49,14 @@ class RegisterBottomSheetViewsKtTest : RobolectricTest() {
@Test
fun testThatMenuClickCallsTheListener() {
+ var itemListenerCalled = false
+ composeRule.mainClock.autoAdvance = false
+ composeRule.setContent {
+ RegisterBottomSheet(registers = registerItems, itemListener = { itemListenerCalled = true })
+ }
val menu2 = composeRule.onNodeWithText("Menu 2")
menu2.assertExists()
menu2.performClick()
- verify { mockListener(any()) }
+ Assert.assertTrue(itemListenerCalled)
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/FhircoreTestRunner.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/FhircoreTestRunner.kt
index 608ba2dedf..020eadf89c 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/FhircoreTestRunner.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/FhircoreTestRunner.kt
@@ -23,6 +23,7 @@ import org.robolectric.util.inject.Injector
internal class FhircoreTestRunner : RobolectricTestRunner {
constructor(testClass: Class<*>?) : super(testClass) {}
+
constructor(testClass: Class<*>?, injector: Injector?) : super(testClass, injector) {}
override fun createClassLoaderConfig(method: FrameworkMethod): InstrumentationConfiguration {
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/RobolectricTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/RobolectricTest.kt
index 014d373c68..f86f844f2d 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/RobolectricTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/robolectric/RobolectricTest.kt
@@ -72,6 +72,11 @@ abstract class RobolectricTest {
@JvmStatic
@BeforeClass
fun beforeClass() {
+ // Disable reporting of uncaught non-test related exceptions
+ Class.forName("kotlinx.coroutines.test.TestScopeKt")
+ .getDeclaredMethod("setCatchNonTestRelatedExceptions", Boolean::class.java)
+ .invoke(null, false)
+
FakeKeyStore.setup
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/rule/CoroutineTestRule.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/rule/CoroutineTestRule.kt
index 47f7c89d16..6b124855e9 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/rule/CoroutineTestRule.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/rule/CoroutineTestRule.kt
@@ -18,18 +18,17 @@ package org.smartregister.fhircore.engine.rule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.TestCoroutineDispatcher
-import kotlinx.coroutines.test.TestCoroutineScope
+import kotlinx.coroutines.test.TestDispatcher
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
-import org.junit.rules.TestRule
+import org.junit.rules.TestWatcher
import org.junit.runner.Description
-import org.junit.runners.model.Statement
import org.smartregister.fhircore.engine.util.DispatcherProvider
@ExperimentalCoroutinesApi
-class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) :
- TestRule, TestCoroutineScope by TestCoroutineScope(testDispatcher) {
+class CoroutineTestRule(val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()) :
+ TestWatcher() {
val testDispatcherProvider =
object : DispatcherProvider {
@@ -39,14 +38,11 @@ class CoroutineTestRule(val testDispatcher: TestCoroutineDispatcher = TestCorout
override fun unconfined() = testDispatcher
}
- override fun apply(base: Statement, description: Description): Statement =
- object : Statement() {
- @Throws(Throwable::class)
- override fun evaluate() {
- Dispatchers.setMain(testDispatcher)
- base.evaluate()
- Dispatchers.resetMain()
- testDispatcher.cleanupTestCoroutines()
- }
- }
+ override fun starting(description: Description) {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ override fun finished(description: Description) {
+ Dispatchers.resetMain()
+ }
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/AppSyncWorkerTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/AppSyncWorkerTest.kt
index 2cfb187c27..97a29bebf8 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/AppSyncWorkerTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/AppSyncWorkerTest.kt
@@ -49,7 +49,7 @@ class AppSyncWorkerTest : RobolectricTest() {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@Inject lateinit var fhirEngine: FhirEngine
@BindValue val dataStore: AppDataStore = mockk()
- @BindValue val syncParamsManager: SyncParametersManager = mockk()
+ @BindValue val syncParamsManager: SyncListenerManager = mockk()
private lateinit var appSyncWorker: AppSyncWorker
@Before
@@ -79,16 +79,16 @@ class AppSyncWorkerTest : RobolectricTest() {
@Test
fun getDownloadWorkManagerCallsSyncParameterManagerParams() {
- every { syncParamsManager.getSyncParams() } returns emptyMap()
+ every { syncParamsManager.loadSyncParams() } returns emptyMap()
val downloadManager = appSyncWorker.getDownloadWorkManager()
Assert.assertNotNull(downloadManager)
Assert.assertTrue(downloadManager is ResourceParamsBasedDownloadWorkManager)
- verify(exactly = 1) { syncParamsManager.getSyncParams() }
+ verify(exactly = 1) { syncParamsManager.loadSyncParams() }
}
@Test
fun getDownloadWorkManagerContextGetsAndSavesTimestampToDataStore() = runTest {
- every { syncParamsManager.getSyncParams() } returns emptyMap()
+ every { syncParamsManager.loadSyncParams() } returns emptyMap()
val oldTimestamp = "2023-04-20T07:24:47.111Z"
val newTimestamp = "2023-04-20T10:17:18.111Z"
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt
index 2b18f3079e..afeb697172 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt
@@ -57,7 +57,7 @@ import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.trace.FakePerformanceReporter
import org.smartregister.fhircore.engine.trace.PerformanceReporter
-import org.smartregister.fhircore.engine.util.LAST_SYNC_TIMESTAMP
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
@ExperimentalCoroutinesApi
@@ -86,7 +86,9 @@ class SyncBroadcasterTest : RobolectricTest() {
}
every { WorkManager.getInstance(any()) } returns workManager
- every { sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, null) } returns null
+ every {
+ sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null)
+ } returns null
every { tracer.startTrace(any()) } returns Unit
every { tracer.putAttribute(any(), any(), any()) } just runs
@@ -186,7 +188,7 @@ class SyncBroadcasterTest : RobolectricTest() {
@Test
fun runSyncWhenNetworkStateFalseEmitsSyncFailed() = runTest {
- val configurationRegistry = Faker.buildTestConfigurationRegistry(mockk())
+ val configurationRegistry = Faker.buildTestConfigurationRegistry()
val context = ApplicationProvider.getApplicationContext()
val configService = AppConfigService(context = context)
val sharedSyncStatus: MutableSharedFlow = MutableSharedFlow()
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/RegisterViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/RegisterViewModelTest.kt
index 8773084c67..ca1c480726 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/RegisterViewModelTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/RegisterViewModelTest.kt
@@ -27,6 +27,7 @@ import io.mockk.mockk
import io.mockk.spyk
import javax.inject.Inject
import kotlinx.coroutines.test.runBlockingTest
+import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.model.Patient
import org.hl7.fhir.r4.model.ResourceType
import org.junit.Assert
@@ -36,7 +37,6 @@ import org.junit.Test
import org.smartregister.fhircore.engine.app.fakes.Faker
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
import org.smartregister.fhircore.engine.configuration.app.ConfigService
-import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.ui.register.RegisterViewModel
@@ -54,11 +54,8 @@ class RegisterViewModelTest : RobolectricTest() {
@Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper
- @BindValue var defaultRepository: DefaultRepository = mockk()
-
@BindValue
- var configurationRegistry: ConfigurationRegistry =
- Faker.buildTestConfigurationRegistry(defaultRepository)
+ var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry()
@Inject lateinit var configService: ConfigService
@@ -123,12 +120,10 @@ class RegisterViewModelTest : RobolectricTest() {
}
@Test
- fun testPatientExistsShouldReturnTrue() {
- coroutineTestRule.runBlockingTest {
- val patientExists = viewModel.patientExists("barcodeId")
- Assert.assertNotNull(patientExists.value)
- Assert.assertTrue(patientExists.value!!.isSuccess)
- }
+ fun testPatientExistsShouldReturnTrue() = runTest {
+ val patientExists = viewModel.patientExists("barcodeId")
+ Assert.assertNotNull(patientExists.value)
+ Assert.assertTrue(patientExists.value!!.isSuccess)
}
@Test
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivityTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivityTest.kt
index 63b4416ad3..82d19b9e42 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivityTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivityTest.kt
@@ -18,121 +18,126 @@ package org.smartregister.fhircore.engine.ui.appsetting
import android.content.Context
import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.rules.activityScenarioRule
-import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.gson.Gson
import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
-import dagger.hilt.android.testing.HiltTestApplication
import io.mockk.every
import io.mockk.mockk
+import io.mockk.spyk
+import javax.inject.Inject
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.annotation.Config
+import org.robolectric.Robolectric
import org.smartregister.fhircore.engine.R
import org.smartregister.fhircore.engine.app.fakes.Faker
import org.smartregister.fhircore.engine.auth.AccountAuthenticator
-import org.smartregister.fhircore.engine.util.APP_ID_CONFIG
+import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.util.IS_LOGGED_IN
import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
@HiltAndroidTest
-@Config(application = HiltTestApplication::class)
-@RunWith(AndroidJUnit4::class)
-class AppSettingActivityTest {
+class AppSettingActivityTest : RobolectricTest() {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
- @get:Rule(order = 1) var activityScenarioRule = activityScenarioRule()
val context: Context =
ApplicationProvider.getApplicationContext().apply { setTheme(R.style.AppTheme) }
- @BindValue val sharedPreferencesHelper = SharedPreferencesHelper(context)
+ @Inject lateinit var gson: Gson
+ @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper
@BindValue val secureSharedPreference = mockk()
@BindValue val accountAuthenticator = mockk()
- @BindValue
- var configurationRegistry = Faker.buildTestConfigurationRegistry(defaultRepository = mockk())
+ @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry()
+
+ private lateinit var appSettingActivityActivity: AppSettingActivity
+
+ private lateinit var appSettingActivityActivitySpy: AppSettingActivity
@Before
fun setUp() {
hiltRule.inject()
+
+ appSettingActivityActivity =
+ Robolectric.buildActivity(AppSettingActivity::class.java).create().resume().get()
+
+ appSettingActivityActivitySpy = spyk(appSettingActivityActivity, recordPrivateCalls = true)
+ every { appSettingActivityActivitySpy.finish() } returns Unit
}
@Test
fun testAppSettingActivity_withAppId_hasNotBeenSubmitted() {
every { accountAuthenticator.hasActiveSession() } returns false
- activityScenarioRule.scenario.recreate()
- activityScenarioRule.scenario.onActivity { activity ->
- Assert.assertEquals(false, activity.sharedPreferencesHelper.read(IS_LOGGED_IN, false))
- Assert.assertEquals(null, activity.sharedPreferencesHelper.read(APP_ID_CONFIG, null))
- Assert.assertEquals(false, activity.accountAuthenticator.hasActiveSession())
- }
+ Assert.assertEquals(
+ false,
+ appSettingActivityActivity.sharedPreferencesHelper.read(IS_LOGGED_IN, false)
+ )
+ Assert.assertEquals(
+ null,
+ appSettingActivityActivity.sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)
+ )
+ Assert.assertEquals(false, appSettingActivityActivity.accountAuthenticator.hasActiveSession())
}
@Test
fun testAppSettingActivity_withAppId_hasBeenSubmitted_withUser_hasNotLoggedIn() {
- sharedPreferencesHelper.write(APP_ID_CONFIG, "default")
+ sharedPreferencesHelper.write(SharedPreferenceKey.APP_ID.name, "default")
every { accountAuthenticator.hasActiveSession() } returns false
- activityScenarioRule.scenario.recreate()
- activityScenarioRule.scenario.onActivity { activity ->
- Assert.assertEquals(false, activity.sharedPreferencesHelper.read(IS_LOGGED_IN, false))
- Assert.assertEquals("default", activity.sharedPreferencesHelper.read(APP_ID_CONFIG, null))
- Assert.assertEquals(false, activity.accountAuthenticator.hasActiveSession())
- }
+ Assert.assertEquals(
+ false,
+ appSettingActivityActivity.sharedPreferencesHelper.read(IS_LOGGED_IN, false)
+ )
+ Assert.assertEquals(
+ "default",
+ appSettingActivityActivity.sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)
+ )
+ Assert.assertEquals(false, appSettingActivityActivity.accountAuthenticator.hasActiveSession())
}
@Test
fun testAppSettingActivity_withAppId_hasBeenSubmitted_withUser_hasLoggedIn() {
sharedPreferencesHelper.write(IS_LOGGED_IN, true)
- sharedPreferencesHelper.write(APP_ID_CONFIG, "default")
+ sharedPreferencesHelper.write(SharedPreferenceKey.APP_ID.name, "default")
every { accountAuthenticator.hasActiveSession() } returns true
- activityScenarioRule.scenario.recreate()
- activityScenarioRule.scenario.onActivity { activity ->
- Assert.assertEquals(true, activity.sharedPreferencesHelper.read(IS_LOGGED_IN, false))
- Assert.assertEquals("default", activity.sharedPreferencesHelper.read(APP_ID_CONFIG, null))
- Assert.assertEquals(true, activity.accountAuthenticator.hasActiveSession())
- }
+ Assert.assertEquals(
+ true,
+ appSettingActivityActivity.sharedPreferencesHelper.read(IS_LOGGED_IN, false)
+ )
+ Assert.assertEquals(
+ "default",
+ appSettingActivityActivity.sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)
+ )
+ Assert.assertEquals(true, appSettingActivityActivity.accountAuthenticator.hasActiveSession())
}
@Test
fun testAppSettingActivity_withAppId_hasBeenSubmitted_withUser_hasLoggedIn_withSessionToken_hasExpired() {
sharedPreferencesHelper.write(IS_LOGGED_IN, true)
- sharedPreferencesHelper.write(APP_ID_CONFIG, "default")
+ sharedPreferencesHelper.write(SharedPreferenceKey.APP_ID.name, "default")
every { accountAuthenticator.hasActiveSession() } returns false
- activityScenarioRule.scenario.recreate()
- activityScenarioRule.scenario.onActivity { activity ->
- Assert.assertEquals(true, activity.sharedPreferencesHelper.read(IS_LOGGED_IN, false))
- Assert.assertEquals("default", activity.sharedPreferencesHelper.read(APP_ID_CONFIG, null))
- Assert.assertEquals(false, activity.accountAuthenticator.hasActiveSession())
- }
+ Assert.assertEquals(
+ true,
+ appSettingActivityActivity.sharedPreferencesHelper.read(IS_LOGGED_IN, false)
+ )
+ Assert.assertEquals(
+ "default",
+ appSettingActivityActivity.sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null)
+ )
+ Assert.assertEquals(false, appSettingActivityActivity.accountAuthenticator.hasActiveSession())
}
@Test
- fun testAppSettingActivity_withConfig_hasBeenLoaded() {
- sharedPreferencesHelper.write(APP_ID_CONFIG, "default/debug")
- every { accountAuthenticator.hasActiveSession() } returns true
-
- activityScenarioRule.scenario.recreate()
- activityScenarioRule.scenario.onActivity { activity ->
- activity.configurationRegistry.workflowPointsMap.let { workflows ->
- Assert.assertEquals(9, workflows.size)
- Assert.assertEquals(true, workflows.containsKey("default|application"))
- Assert.assertEquals(true, workflows.containsKey("default|login"))
- Assert.assertEquals(true, workflows.containsKey("default|app_feature"))
- Assert.assertEquals(true, workflows.containsKey("default|patient_register"))
- Assert.assertEquals(true, workflows.containsKey("default|patient_task_register"))
- Assert.assertEquals(true, workflows.containsKey("default|pin"))
- Assert.assertEquals(true, workflows.containsKey("default|patient_details_view"))
- Assert.assertEquals(true, workflows.containsKey("default|result_details_navigation"))
- Assert.assertEquals(true, workflows.containsKey("default|sync"))
- }
+ fun testThatConfigsAreLoadedWhenAppSettingsIsLaunched() {
+ appSettingActivityActivity.let { activity ->
+ Assert.assertTrue(activity != null)
+ Assert.assertTrue(configurationRegistry.workflowPointsMap.isNotEmpty())
}
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreenKtTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreenKtTest.kt
index eef453c6f3..7c28f2975e 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreenKtTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingScreenKtTest.kt
@@ -17,16 +17,11 @@
package org.smartregister.fhircore.engine.ui.appsetting
import android.content.Context
+import androidx.compose.ui.test.assertHasClickAction
import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.performClick
-import androidx.compose.ui.test.performTextInput
import androidx.test.core.app.ApplicationProvider
import io.mockk.spyk
-import io.mockk.verify
-import org.junit.Before
-import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.smartregister.fhircore.engine.R
@@ -35,7 +30,7 @@ import org.smartregister.fhircore.engine.robolectric.RobolectricTest
class AppSettingScreenKtTest : RobolectricTest() {
private class Listeners {
- val onLoadConfigurations: (Boolean) -> Unit = spyk()
+ val fetchConfiguration: (Context) -> Unit = spyk()
val onAppIdChanged: (String) -> Unit = spyk()
}
@@ -47,37 +42,51 @@ class AppSettingScreenKtTest : RobolectricTest() {
@get:Rule val composeRule = createComposeRule()
private var listenersSpy = spyk()
- @Before
- fun setUp() {
+
+ @Test
+ fun testAppSettingScreenLayout() {
composeRule.setContent {
AppSettingScreen(
appId = appId,
onAppIdChanged = listenersSpy.onAppIdChanged,
- onLoadConfigurations = listenersSpy.onLoadConfigurations
+ fetchConfiguration = listenersSpy.fetchConfiguration,
+ error = "",
)
}
- }
- @Test
- fun testAppSettingScreenLayout() {
- composeRule.onNodeWithText(context.getString(R.string.fhir_core_app))
- composeRule.onNodeWithText(context.getString(R.string.application_id))
- composeRule.onNodeWithText(context.getString(R.string.enter_app_id))
- composeRule.onNodeWithText(context.getString(R.string.app_id_sample))
- composeRule.onNodeWithText(context.getString(R.string.remember_app))
- composeRule.onNodeWithText(context.getString(R.string.load_configurations))
+ composeRule.onNodeWithText(context.getString(R.string.fhir_core_app)).assertExists()
+ composeRule.onNodeWithText(context.getString(R.string.application_id)).assertExists()
+ composeRule.onNodeWithText(context.getString(R.string.load_configurations)).assertExists()
}
@Test
fun testLoadConfigurationButtonListenerAction() {
- composeRule.onNodeWithText(context.getString(R.string.load_configurations)).performClick()
- verify { listenersSpy.onLoadConfigurations }
+ composeRule.setContent {
+ AppSettingScreen(
+ appId = appId,
+ onAppIdChanged = listenersSpy.onAppIdChanged,
+ fetchConfiguration = listenersSpy.fetchConfiguration,
+ error = "",
+ )
+ }
+
+ composeRule
+ .onNodeWithText(context.getString(R.string.load_configurations))
+ .assertHasClickAction()
}
@Test
- @Ignore("Fix this test; runs indefinitely")
- fun testUpdatingAppIdAction() {
- composeRule.onNodeWithTag(APP_ID_TEXT_INPUT_TAG).performTextInput("appId")
- verify { listenersSpy.onAppIdChanged }
+ fun testErrorString() {
+ val error = "theError"
+ composeRule.setContent {
+ AppSettingScreen(
+ appId = appId,
+ onAppIdChanged = listenersSpy.onAppIdChanged,
+ fetchConfiguration = listenersSpy.fetchConfiguration,
+ error = error,
+ )
+ }
+
+ composeRule.onNodeWithText(error).assertExists()
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModelTest.kt
index 08ef390395..68637701d1 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModelTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingViewModelTest.kt
@@ -16,31 +16,69 @@
package org.smartregister.fhircore.engine.ui.appsetting
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
+import android.content.Context
import androidx.test.core.app.ApplicationProvider
+import com.google.gson.GsonBuilder
+import dagger.hilt.android.testing.HiltTestApplication
import io.mockk.coEvery
import io.mockk.coVerify
+import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.runs
import io.mockk.spyk
+import io.mockk.verify
+import java.net.UnknownHostException
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
+import okhttp3.MediaType.Companion.toMediaTypeOrNull
+import okhttp3.ResponseBody
+import okhttp3.ResponseBody.Companion.toResponseBody
import org.hl7.fhir.r4.model.Bundle
import org.hl7.fhir.r4.model.Composition
import org.hl7.fhir.r4.model.Reference
import org.junit.Assert
+import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
+import org.mockito.ArgumentMatchers
+import org.smartregister.fhircore.engine.R
+import org.smartregister.fhircore.engine.app.fakes.Faker
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
+import org.smartregister.fhircore.engine.data.local.DefaultRepository
+import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
+import org.smartregister.fhircore.engine.rule.CoroutineTestRule
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
+import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
+import org.smartregister.fhircore.engine.util.extension.showToast
+import retrofit2.HttpException
+import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class AppSettingViewModelTest : RobolectricTest() {
+ @ExperimentalCoroutinesApi @get:Rule(order = 1) val coroutineTestRule = CoroutineTestRule()
+ private val defaultRepository = mockk()
+ private val fhirResourceDataSource = mockk()
+ private val sharedPreferencesHelper =
+ SharedPreferencesHelper(
+ ApplicationProvider.getApplicationContext(),
+ GsonBuilder().setLenient().create()
+ )
- @get:Rule var instantTaskExecutorRule = InstantTaskExecutorRule()
-
- private val appSettingViewModel = spyk(AppSettingViewModel(mockk(), mockk()))
+ private val configService = mockk()
+ @ExperimentalCoroutinesApi
+ private val appSettingViewModel =
+ spyk(
+ AppSettingViewModel(
+ fhirResourceDataSource = fhirResourceDataSource,
+ defaultRepository = defaultRepository,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = Faker.buildTestConfigurationRegistry(),
+ dispatcherProvider = this.coroutineTestRule.testDispatcherProvider
+ )
+ )
+ private val context = ApplicationProvider.getApplicationContext()
@Test
fun testOnApplicationIdChanged() {
@@ -50,33 +88,100 @@ class AppSettingViewModelTest : RobolectricTest() {
}
@Test
- fun testLoadConfigurations() = runBlockingTest {
- coEvery { appSettingViewModel.fhirResourceDataSource.loadData(any()) } returns
+ @ExperimentalCoroutinesApi
+ fun testLoadConfigurations() = runTest {
+ coEvery { appSettingViewModel.fhirResourceDataSource.getResource(any()) } returns
Bundle().apply { addEntry().resource = Composition() }
- coEvery { appSettingViewModel.defaultRepository.save(any()) } just runs
+ coEvery { appSettingViewModel.defaultRepository.create(any()) } returns emptyList()
- appSettingViewModel.loadConfigurations(true)
- Assert.assertNotNull(appSettingViewModel.loadConfigs.value)
- Assert.assertEquals(true, appSettingViewModel.loadConfigs.value)
+ val appId = "app/debug"
+ appSettingViewModel.appId.value = appId
+ appSettingViewModel.loadConfigurations(context)
+ Assert.assertNotNull(appSettingViewModel.showProgressBar.value)
+ Assert.assertFalse(appSettingViewModel.showProgressBar.value!!)
+ Assert.assertEquals(appId, sharedPreferencesHelper.read(SharedPreferenceKey.APP_ID.name, null))
}
@Test
+ @Ignore("Fix failing test")
+ @ExperimentalCoroutinesApi
fun testFetchConfigurations() = runTest {
- val repository = appSettingViewModel.defaultRepository
- val fhirResourceDataSource = appSettingViewModel.fhirResourceDataSource
- coEvery { fhirResourceDataSource.loadData(any()) } returns
+ coEvery { appSettingViewModel.fhirResourceDataSource.getResource(any()) } returns
Bundle().apply {
addEntry().resource =
Composition().apply {
addSection().apply { this.focus = Reference().apply { reference = "Binary/123" } }
}
}
- coEvery { repository.save(any()) } just runs
+ coEvery { appSettingViewModel.defaultRepository.create(any()) } returns emptyList()
+
+ appSettingViewModel.fetchConfigurations(context)
+
+ coVerify { appSettingViewModel.fhirResourceDataSource.getResource(any()) }
+ coVerify { appSettingViewModel.defaultRepository.create(any()) }
+ }
- appSettingViewModel.fetchConfigurations("appId", ApplicationProvider.getApplicationContext())
+ @Test(expected = HttpException::class)
+ @ExperimentalCoroutinesApi
+ fun testFetchConfigurationsThrowsHttpExceptionWithStatusCodeBetween400And503() = runTest {
+ val appId = "app_id"
+ appSettingViewModel.onApplicationIdChanged(appId)
+ val context = mockk(relaxed = true)
+ val fhirResourceDataSource = FhirResourceDataSource(mockk())
+ coEvery { fhirResourceDataSource.getResource(ArgumentMatchers.anyString()) } throws
+ HttpException(
+ Response.error(
+ 500,
+ "Internal Server Error".toResponseBody("application/json".toMediaTypeOrNull())
+ )
+ )
+ fhirResourceDataSource.getResource(ArgumentMatchers.anyString())
+ verify { context.showToast(context.getString(R.string.error_loading_config_http_error)) }
+ coVerify { fhirResourceDataSource.getResource(ArgumentMatchers.anyString()) }
+ coVerify { appSettingViewModel.fetchConfigurations(context) }
+ verify { context.showToast(context.getString(R.string.error_loading_config_http_error)) }
+ Assert.assertEquals(
+ context.getString(R.string.error_loading_config_http_error),
+ appSettingViewModel.error.value
+ )
+ Assert.assertEquals(false, appSettingViewModel.showProgressBar.value)
+ }
+
+ @Test(expected = UnknownHostException::class)
+ @ExperimentalCoroutinesApi
+ fun testFetchConfigurationsThrowsUnknownHostException() = runTest {
+ val appId = "app_id"
+ appSettingViewModel.onApplicationIdChanged(appId)
+ val fhirResourceDataSource = FhirResourceDataSource(mockk())
+ coEvery { fhirResourceDataSource.getResource(ArgumentMatchers.anyString()) } throws
+ UnknownHostException(context.getString(R.string.error_loading_config_no_internet))
+ fhirResourceDataSource.getResource(ArgumentMatchers.anyString())
+ coVerify { appSettingViewModel.fetchConfigurations(context) }
+ verify { context.showToast(context.getString(R.string.error_loading_config_no_internet)) }
+ Assert.assertEquals(
+ context.getString(R.string.error_loading_config_no_internet),
+ appSettingViewModel.error.value
+ )
+ Assert.assertEquals(false, appSettingViewModel.showProgressBar.value)
+ }
- coVerify { fhirResourceDataSource.loadData(any()) }
- coVerify { repository.save(any()) }
+ @Test(expected = Exception::class)
+ @ExperimentalCoroutinesApi
+ fun testFetchConfigurationsThrowsException() = runTest {
+ val context = mockk(relaxed = true)
+ val appId = "app_id"
+ appSettingViewModel.onApplicationIdChanged(appId)
+ val fhirResourceDataSource = FhirResourceDataSource(mockk())
+ coEvery { fhirResourceDataSource.getResource(ArgumentMatchers.anyString()) } throws
+ Exception(context.getString(R.string.error_loading_config_general))
+ coEvery { appSettingViewModel.fetchConfigurations(context) } just runs
+ appSettingViewModel.fetchConfigurations(ArgumentMatchers.any(Context::class.java))
+ every { context.getString(R.string.error_loading_config_general) }
+ Assert.assertEquals(
+ context.getString(R.string.error_loading_config_no_internet),
+ appSettingViewModel.error.value
+ )
+ Assert.assertEquals(false, appSettingViewModel.showProgressBar.value)
}
@Test
@@ -86,14 +191,16 @@ class AppSettingViewModelTest : RobolectricTest() {
}
@Test
+ @ExperimentalCoroutinesApi
fun testHasDebugSuffix_noSuffix_shouldReturn_false() {
- appSettingViewModel.appId.value = "default"
- Assert.assertFalse(appSettingViewModel.hasDebugSuffix()!!)
+ appSettingViewModel.appId.value = "app"
+ Assert.assertFalse(appSettingViewModel.hasDebugSuffix())
}
@Test
+ @ExperimentalCoroutinesApi
fun testHasDebugSuffix_emptyAppId_shouldReturn_null() {
- appSettingViewModel.appId.value = ""
- Assert.assertNull(appSettingViewModel.hasDebugSuffix())
+ appSettingViewModel.appId.value = null
+ Assert.assertFalse(appSettingViewModel.hasDebugSuffix())
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/CircularPercentageIndicatorKtTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/CircularPercentageIndicatorKtTest.kt
index aa5395bf86..4319a0bfad 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/CircularPercentageIndicatorKtTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/CircularPercentageIndicatorKtTest.kt
@@ -30,6 +30,7 @@ internal class CircularPercentageIndicatorKtTest : RobolectricTest() {
@Test
fun testCircularPercentageIndicatorWithText() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent { CircularPercentageIndicator(percentage = textPercentage) }
composeRule.onNodeWithTag(CIRCULAR_PERCENTAGE_INDICATOR).assertExists()
composeRule.onNodeWithTag(CIRCULAR_CANVAS_CIRCLE_TAG).assertExists()
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/CircularProgressBarKtTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/CircularProgressBarKtTest.kt
index c9eac3310c..e58ca863a2 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/CircularProgressBarKtTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/CircularProgressBarKtTest.kt
@@ -32,6 +32,7 @@ internal class CircularProgressBarKtTest : RobolectricTest() {
@Test
fun testCircularProgressBarWithText() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent { CircularProgressBar(text = textSyncing) }
composeRule.onNodeWithText(textSyncing).assertExists()
composeRule.onNodeWithTag(PROGRESS_MSG_TAG).assertIsDisplayed()
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/ErrorMessageKtTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/ErrorMessageKtTest.kt
index 24173cc888..233a0b09e8 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/ErrorMessageKtTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/ErrorMessageKtTest.kt
@@ -45,6 +45,7 @@ class ErrorMessageKtTest : RobolectricTest() {
@Before
fun setUp() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
ErrorMessage(message = errorMessage, onClickRetry = { listenerObjectSpy.onRetry() })
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginScreenTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginScreenTest.kt
index 23ec2d7304..e2ec73fca9 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginScreenTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginScreenTest.kt
@@ -76,7 +76,6 @@ class LoginScreenTest : RobolectricTest() {
{
this@LoginScreenTest.password.value = firstArg()
}
- every { attemptRemoteLogin() } returns Unit
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginScreenWithLogoTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginScreenWithLogoTest.kt
index c54aa2a94e..d4cabeae15 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginScreenWithLogoTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/LoginScreenWithLogoTest.kt
@@ -63,7 +63,6 @@ class LoginScreenWithLogoTest : RobolectricTest() {
{
this@LoginScreenWithLogoTest.password.value = firstArg()
}
- every { attemptRemoteLogin() } returns Unit
}
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/PaginatedRegisterViewsKtTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/PaginatedRegisterViewsKtTest.kt
index b472ebc094..1a6a4ca0d0 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/PaginatedRegisterViewsKtTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/PaginatedRegisterViewsKtTest.kt
@@ -61,6 +61,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testSearchHeaderComponent() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent { RegisterHeader(resultCount = 20) }
composeRule.onNodeWithTag(SEARCH_HEADER_TEXT_TAG).assertExists()
composeRule.onNodeWithTag(SEARCH_HEADER_TEXT_TAG).assertIsDisplayed()
@@ -83,6 +84,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testSearchFooterWithTenAsResultCount() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
RegisterFooter(
resultCount = 50,
@@ -113,6 +115,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testSearchFooterWithTenAsResultsSplitInThreePages() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
RegisterFooter(
resultCount = 50,
@@ -143,6 +146,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testSearchFooterWithResultsFittingOnePage() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
RegisterFooter(
resultCount = 20,
@@ -169,6 +173,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testNoResultsComponent() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent { NoResults() }
composeRule.onNodeWithText("No results", useUnmergedTree = true).assertExists()
composeRule.onNodeWithText("No results", useUnmergedTree = true).assertIsDisplayed()
@@ -176,6 +181,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testPaginatedRegisterShouldShowNoResultsView() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
PaginatedRegister(
loadState = LoadState.NotLoading(false),
@@ -239,6 +245,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testPaginatedRegisterShouldDisplayResultsBodyWithFooter() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
PaginatedRegister(
loadState = LoadState.NotLoading(true),
@@ -275,6 +282,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testPaginatedRegisterShouldDisplayResultsBodyWithNoFooter() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
PaginatedRegister(
loadState = LoadState.NotLoading(true),
@@ -308,6 +316,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testPaginatedRegisterShouldDisplayResultsBodyWithFooterAbsolute() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
PaginatedRegister(
loadState = LoadState.NotLoading(true),
@@ -341,6 +350,7 @@ class PaginatedRegisterViewsKtTest : RobolectricTest() {
@Test
fun testPaginatedRegisterShouldDisplayResultsBodyWithNoFooterAbsolute() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
PaginatedRegister(
loadState = LoadState.NotLoading(true),
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/PinViewTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/PinViewTest.kt
index 0c48460dac..c77f050c16 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/PinViewTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/components/PinViewTest.kt
@@ -41,6 +41,7 @@ class PinViewTest : RobolectricTest() {
@ExperimentalComposeUiApi
@Test
fun testPinCell() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
PinCell(
indexValue = "3",
@@ -57,6 +58,7 @@ class PinViewTest : RobolectricTest() {
@ExperimentalComposeUiApi
@Test
fun testPinCellDotted() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent { PinCell(isDotted = true, indexValue = "3", fullEditValue = "123") }
composeRule.onNodeWithTag(PIN_VIEW_CELL_DOTTED).assertExists()
composeRule.onNodeWithTag(PIN_VIEW_CELL_TEXT).assertExists()
@@ -65,6 +67,7 @@ class PinViewTest : RobolectricTest() {
@ExperimentalComposeUiApi
@Test
fun testPinCellViewError() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
PinCell(isDotted = false, indexValue = "4", fullEditValue = "1234", showError = true)
}
@@ -75,6 +78,7 @@ class PinViewTest : RobolectricTest() {
@ExperimentalComposeUiApi
@Test
fun testPinCellViewDottedError() {
+ composeRule.mainClock.autoAdvance = false
composeRule.setContent {
PinCell(isDotted = true, indexValue = "3", fullEditValue = "123", showError = true)
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginActivityTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginActivityTest.kt
index 5eec980116..2b4a847c70 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginActivityTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginActivityTest.kt
@@ -37,6 +37,7 @@ import io.mockk.spyk
import io.mockk.verify
import javax.inject.Inject
import kotlin.test.assertTrue
+import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import org.junit.Assert
import org.junit.Before
@@ -51,15 +52,19 @@ import org.smartregister.fhircore.engine.auth.AccountAuthenticator
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
import org.smartregister.fhircore.engine.configuration.view.loginViewConfigurationOf
import org.smartregister.fhircore.engine.data.local.DefaultRepository
+import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
import org.smartregister.fhircore.engine.di.AnalyticsModule
import org.smartregister.fhircore.engine.robolectric.ActivityRobolectricTest
+import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.trace.FakePerformanceReporter
import org.smartregister.fhircore.engine.trace.PerformanceReporter
import org.smartregister.fhircore.engine.ui.pin.PinSetupActivity
import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider
import org.smartregister.fhircore.engine.util.FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP
+import org.smartregister.fhircore.engine.util.SecureSharedPreference
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
@UninstallModules(AnalyticsModule::class)
@@ -69,7 +74,9 @@ class LoginActivityTest : ActivityRobolectricTest() {
private lateinit var loginActivity: LoginActivity
@get:Rule var hiltRule = HiltAndroidRule(this)
-
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @get:Rule(order = 2)
+ val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
@Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper
@BindValue val repository: DefaultRepository = mockk()
@@ -87,27 +94,37 @@ class LoginActivityTest : ActivityRobolectricTest() {
private lateinit var loginService: LoginService
private lateinit var fhirResourceDataSource: FhirResourceDataSource
-
+ @Inject lateinit var secureSharedPreference: SecureSharedPreference
@BindValue @JvmField val performanceReporter: PerformanceReporter = FakePerformanceReporter()
+ private val fhirResourceService = mockk()
+ private val keycloakService = mockk()
+ private val defaultRepository: DefaultRepository = mockk(relaxed = true)
+ private val tokenAuthenticator = mockk()
+ @OptIn(ExperimentalCoroutinesApi::class)
@Before
fun setUp() {
hiltRule.inject()
ApplicationProvider.getApplicationContext().apply { setTheme(R.style.AppTheme) }
- coEvery { accountAuthenticator.hasActivePin() } returns false
-
coEvery { accountAuthenticator.retrieveLastLoggedInUsername() } returns ""
fhirResourceDataSource = FhirResourceDataSource(resourceService)
loginViewModel =
- LoginViewModel(
- accountAuthenticator = accountAuthenticator,
- dispatcher = DefaultDispatcherProvider(),
- sharedPreferences = sharedPreferencesHelper,
- fhirResourceDataSource = fhirResourceDataSource
+ spyk(
+ LoginViewModel(
+ accountAuthenticator = accountAuthenticator,
+ sharedPreferences = sharedPreferencesHelper,
+ defaultRepository = defaultRepository,
+ keycloakService = keycloakService,
+ fhirResourceService = fhirResourceService,
+ tokenAuthenticator = tokenAuthenticator,
+ secureSharedPreference = secureSharedPreference,
+ dispatcherProvider = coroutineTestRule.testDispatcherProvider,
+ fhirResourceDataSource = fhirResourceDataSource
+ )
)
loginActivity =
@@ -115,11 +132,11 @@ class LoginActivityTest : ActivityRobolectricTest() {
configurationRegistry =
ConfigurationRegistry(
- ApplicationProvider.getApplicationContext(),
+ ApplicationProvider.getApplicationContext(),
+ mockk(),
fhirResourceDataSource,
sharedPreferencesHelper,
DefaultDispatcherProvider(),
- repository
)
loginActivity.configurationRegistry = configurationRegistry
@@ -138,13 +155,13 @@ class LoginActivityTest : ActivityRobolectricTest() {
val accountName = "testUser"
val updateAuthIntent =
Intent().apply {
- putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountAuthenticator.AUTH_TOKEN_TYPE)
+ putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountManager.KEY_ACCOUNT_TYPE)
putExtra(AccountManager.KEY_ACCOUNT_NAME, accountName)
putExtra(
AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
mockk()
)
- putExtra(AccountAuthenticator.AUTH_TOKEN_TYPE, AccountAuthenticator.AUTH_TOKEN_TYPE)
+ putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountManager.KEY_ACCOUNT_TYPE)
}
Robolectric.buildActivity(LoginActivity::class.java, updateAuthIntent).create().resume()
Assert.assertEquals(accountName, loginViewModel.username.value)
@@ -155,13 +172,13 @@ class LoginActivityTest : ActivityRobolectricTest() {
val accountName = "testUser"
val updateAuthIntent =
Intent().apply {
- putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountAuthenticator.AUTH_TOKEN_TYPE)
+ putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountManager.KEY_ACCOUNT_TYPE)
putExtra(AccountManager.KEY_ACCOUNT_NAME, accountName)
putExtra(
AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
mockk()
)
- putExtra(AccountAuthenticator.AUTH_TOKEN_TYPE, AccountAuthenticator.AUTH_TOKEN_TYPE)
+ putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountManager.KEY_ACCOUNT_TYPE)
}
loginActivity =
spyk(
@@ -189,13 +206,13 @@ class LoginActivityTest : ActivityRobolectricTest() {
val accountName = "testUser"
val updateAuthIntent =
Intent().apply {
- putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountAuthenticator.AUTH_TOKEN_TYPE)
+ putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountManager.KEY_ACCOUNT_TYPE)
putExtra(AccountManager.KEY_ACCOUNT_NAME, accountName)
putExtra(
AccountManager.KEY_ACCOUNT_AUTHENTICATOR_RESPONSE,
mockk()
)
- putExtra(AccountAuthenticator.AUTH_TOKEN_TYPE, AccountAuthenticator.AUTH_TOKEN_TYPE)
+ putExtra(AccountManager.KEY_ACCOUNT_TYPE, AccountManager.KEY_ACCOUNT_TYPE)
}
loginActivity =
spyk(
@@ -220,18 +237,8 @@ class LoginActivityTest : ActivityRobolectricTest() {
loginService = loginActivity.loginService
}
- @Test
- fun testNavigateToHomeShouldVerifyExpectedIntentWhenPinExists() {
- coEvery { accountAuthenticator.hasActivePin() } returns true
- val loginConfig = loginViewConfigurationOf(enablePin = true)
- loginViewModel.updateViewConfigurations(loginConfig)
- loginViewModel.navigateToHome()
- verify { loginService.navigateToHome() }
- }
-
@Test
fun testNavigateToHomeShouldVerifyExpectedIntentWhenForcedLogin() {
- coEvery { accountAuthenticator.hasActivePin() } returns false
sharedPreferencesHelper.write(FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP, true)
val loginConfig = loginViewConfigurationOf(enablePin = true)
loginViewModel.updateViewConfigurations(loginConfig)
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginViewModelTest.kt
index 0720cb8752..1c1e221219 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginViewModelTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginViewModelTest.kt
@@ -17,44 +17,44 @@
package org.smartregister.fhircore.engine.ui.login
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import com.google.android.fhir.logicalId
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coEvery
import io.mockk.every
-import io.mockk.just
import io.mockk.mockk
-import io.mockk.runs
+import io.mockk.slot
import io.mockk.spyk
import io.mockk.verify
-import java.io.IOException
import java.net.UnknownHostException
import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.test.runBlockingTest
+import okhttp3.internal.http.RealResponseBody
import org.hl7.fhir.r4.model.Bundle
-import org.hl7.fhir.r4.model.Practitioner
-import org.hl7.fhir.r4.model.ResourceType
+import org.hl7.fhir.r4.model.Organization
import org.junit.After
import org.junit.Assert
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.robolectric.annotation.Config
-import org.robolectric.util.ReflectionHelpers
-import org.smartregister.fhircore.engine.app.fakes.FakeModel.authCredentials
+import org.smartregister.fhircore.engine.HiltActivityForTest
import org.smartregister.fhircore.engine.auth.AccountAuthenticator
+import org.smartregister.fhircore.engine.data.local.DefaultRepository
+import org.smartregister.fhircore.engine.data.remote.auth.KeycloakService
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource
import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceService
import org.smartregister.fhircore.engine.data.remote.model.response.OAuthResponse
import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
import org.smartregister.fhircore.engine.robolectric.AccountManagerShadow
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
-import org.smartregister.fhircore.engine.util.LOGGED_IN_PRACTITIONER
import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
-import retrofit2.Call
+import org.smartregister.fhircore.engine.util.extension.isDeviceOnline
+import org.smartregister.model.practitioner.FhirPractitionerDetails
+import org.smartregister.model.practitioner.PractitionerDetails
import retrofit2.Response
@ExperimentalCoroutinesApi
@@ -68,256 +68,287 @@ internal class LoginViewModelTest : RobolectricTest() {
@get:Rule(order = 2) val coroutineTestRule: CoroutineTestRule = CoroutineTestRule()
- @Inject lateinit var accountAuthenticator: AccountAuthenticator
+ private val accountAuthenticator: AccountAuthenticator = mockk()
@Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper
@Inject lateinit var secureSharedPreference: SecureSharedPreference
private lateinit var loginViewModel: LoginViewModel
-
- private lateinit var accountAuthenticatorSpy: AccountAuthenticator
-
+ private val fhirResourceService = mockk()
+ private val keycloakService = mockk()
private val resourceService: FhirResourceService = mockk()
-
+ private val defaultRepository: DefaultRepository = mockk(relaxed = true)
private lateinit var fhirResourceDataSource: FhirResourceDataSource
+ private val tokenAuthenticator = mockk()
+
+ private val thisUsername = "demo"
+ private val thisPassword = "paswd"
@Before
fun setUp() {
hiltRule.inject()
- // Spy needed to control interaction with the real injected dependency
- accountAuthenticatorSpy = spyk(accountAuthenticator)
fhirResourceDataSource = spyk(FhirResourceDataSource(resourceService))
loginViewModel =
- LoginViewModel(
- accountAuthenticator = accountAuthenticatorSpy,
- dispatcher = coroutineTestRule.testDispatcherProvider,
- sharedPreferences = sharedPreferencesHelper,
- fhirResourceDataSource = fhirResourceDataSource
+ spyk(
+ LoginViewModel(
+ accountAuthenticator = accountAuthenticator,
+ sharedPreferences = sharedPreferencesHelper,
+ defaultRepository = defaultRepository,
+ keycloakService = keycloakService,
+ fhirResourceService = fhirResourceService,
+ tokenAuthenticator = tokenAuthenticator,
+ secureSharedPreference = secureSharedPreference,
+ dispatcherProvider = coroutineTestRule.testDispatcherProvider,
+ fhirResourceDataSource = fhirResourceDataSource
+ )
)
}
@After
fun tearDown() {
- accountAuthenticatorSpy.secureSharedPreference.deleteCredentials()
+ secureSharedPreference.deleteCredentials()
}
@Test
- fun testAttemptLocalLoginWithCorrectCredentials() {
- // Simulate saving of credentials prior to login
- accountAuthenticatorSpy.secureSharedPreference.saveCredentials(authCredentials)
+ fun testSuccessfulOfflineLogin() {
+ val activity = mockedActivity()
- // Provide username and password (The saved password is hashed, actual one is needed)
- loginViewModel.run {
- onUsernameUpdated(authCredentials.username)
- onPasswordUpdated("51r1K4l1")
- }
+ updateCredentials()
- val successfulLocalLogin = loginViewModel.attemptLocalLogin()
- Assert.assertTrue(successfulLocalLogin)
- }
+ every {
+ accountAuthenticator.validateLoginCredentials(thisUsername, thisPassword.toCharArray())
+ } returns true
- @Test
- fun testAttemptLocalLoginWithWrongCredentials() {
- // Simulate saving of credentials prior to login
- accountAuthenticatorSpy.secureSharedPreference.saveCredentials(authCredentials)
+ every {
+ tokenAuthenticator.validateSavedLoginCredentials(thisUsername, thisPassword.toCharArray())
+ } returns true
- // Provide username and password (The saved password is hashed, actual one is needed)
- loginViewModel.run {
- onUsernameUpdated("hello")
- onPasswordUpdated("51r1K4l1")
- }
+ loginViewModel.login(activity)
- val successfulLocalLogin = loginViewModel.attemptLocalLogin()
- Assert.assertFalse(successfulLocalLogin)
+ Assert.assertNull(loginViewModel.loginErrorState.value)
+ Assert.assertFalse(loginViewModel.showProgressBar.value!!)
+ Assert.assertTrue(loginViewModel.navigateToHome.value!!)
}
-
@Test
- fun testAttemptLocalLoginWithNewUser() {
+ fun testUnSuccessfulOfflineLogin() {
+ val activity = mockedActivity()
- // Provide username and password (The saved password is hashed, actual one is needed)
- loginViewModel.run {
- onUsernameUpdated("demo")
- onPasswordUpdated("51r1K4l1")
- }
-
- val callMock = spyk>()
+ updateCredentials()
- every { callMock.enqueue(any()) } just runs
+ every {
+ accountAuthenticator.validateLoginCredentials(thisUsername, thisPassword.toCharArray())
+ } returns false
- every { accountAuthenticatorSpy.fetchToken(any(), any()) } returns callMock
+ every {
+ tokenAuthenticator.validateSavedLoginCredentials(thisUsername, thisPassword.toCharArray())
+ } returns false
- loginViewModel.attemptRemoteLogin()
-
- // Login error is reset to null
- Assert.assertNull(loginViewModel.loginErrorState.value)
+ loginViewModel.login(activity)
- // Show progress bar active
- Assert.assertNotNull(loginViewModel.showProgressBar.value)
- Assert.assertTrue(loginViewModel.showProgressBar.value!!)
-
- verify { accountAuthenticatorSpy.fetchToken(any(), any()) }
+ Assert.assertNotNull(loginViewModel.loginErrorState.value)
+ Assert.assertFalse(loginViewModel.showProgressBar.value!!)
+ Assert.assertEquals(LoginErrorState.INVALID_CREDENTIALS, loginViewModel.loginErrorState.value!!)
}
@Test
- fun testOauthResponseHandlerHandleSuccessfulResponse() {
-
- // Provide username and password (The saved password is hashed, actual one is needed)
- loginViewModel.run {
- onUsernameUpdated("demo")
- onPasswordUpdated("51r1K4l1")
- }
-
- val callMock = spyk>()
-
- val mockResponse: Response =
- Response.success(
+ fun testSuccessfulOnlineLoginWithActiveSessionWithSavedPractitionerDetails() {
+ updateCredentials()
+ sharedPreferencesHelper.write(
+ SharedPreferenceKey.PRACTITIONER_DETAILS.name,
+ PractitionerDetails()
+ )
+ every { tokenAuthenticator.sessionActive() } returns true
+ coEvery {
+ tokenAuthenticator.fetchAccessToken(thisUsername, thisPassword.toCharArray())
+ } returns
+ Result.success(
OAuthResponse(
- accessToken = authCredentials.sessionToken,
- tokenType = "openid email profile",
- refreshToken = authCredentials.refreshToken,
- scope = "openid"
+ accessToken = "very_new_top_of_the_class_access_token",
+ tokenType = "you_guess_it",
+ refreshToken = "another_very_refreshing_token",
+ refreshExpiresIn = 540000,
+ scope = "open_my_guy"
)
)
+ coEvery { keycloakService.fetchUserInfo() } returns
+ Response.success(UserInfo(keycloakUuid = "awesome_uuid"))
+ val bundle = Bundle()
+ val bundleEntry = Bundle.BundleEntryComponent().apply { resource = practitionerDetails() }
+ coEvery { fhirResourceService.getResource(any()) } returns bundle.addEntry(bundleEntry)
- loginViewModel.oauthResponseHandler.handleResponse(call = callMock, response = mockResponse)
+ loginViewModel.login(mockedActivity(isDeviceOnline = true))
- // Show progress bar inactive
- Assert.assertNotNull(loginViewModel.showProgressBar.value)
Assert.assertFalse(loginViewModel.showProgressBar.value!!)
+ Assert.assertTrue(loginViewModel.navigateToHome.value!!)
+ }
- // New user credentials added
- val retrieveCredentials = secureSharedPreference.retrieveCredentials()
- Assert.assertNotNull(retrieveCredentials)
- Assert.assertEquals(authCredentials.username, retrieveCredentials!!.username)
- Assert.assertEquals(authCredentials.sessionToken, retrieveCredentials.sessionToken)
+ @Test
+ fun testSuccessfulOnlineLoginWithActiveSessionWithNoPractitionerDetailsSaved() {
+ updateCredentials()
+ every { tokenAuthenticator.sessionActive() } returns true
+ loginViewModel.login(mockedActivity(isDeviceOnline = true))
+ val toHome = loginViewModel.navigateToHome.value!!
+ Assert.assertFalse(toHome)
}
@Test
- fun testForgotPasswordLoadsContact() {
- loginViewModel.forgotPassword()
- Assert.assertEquals("tel:0123456789", loginViewModel.launchDialPad.value)
+ fun testUnSuccessfulOnlineLoginUsingDifferentUsername() {
+ updateCredentials()
+ secureSharedPreference.saveCredentials("nativeUser", "n4t1veP5wd".toCharArray())
+ every { tokenAuthenticator.sessionActive() } returns false
+ loginViewModel.login(mockedActivity(isDeviceOnline = true))
+ Assert.assertFalse(loginViewModel.showProgressBar.value!!)
+ Assert.assertEquals(
+ LoginErrorState.MULTI_USER_LOGIN_ATTEMPT,
+ loginViewModel.loginErrorState.value!!
+ )
}
@Test
- fun testAttemptRemoteLoginWithCredentialsCallsAccountAuthenticator() {
+ fun testSuccessfulNewOnlineLoginShouldFetchUserInfoAndPractitioner() {
+ updateCredentials()
+ secureSharedPreference.saveCredentials(thisUsername, thisPassword.toCharArray())
+ every { tokenAuthenticator.sessionActive() } returns false
+ coEvery {
+ tokenAuthenticator.fetchAccessToken(thisUsername, thisPassword.toCharArray())
+ } returns
+ Result.success(
+ OAuthResponse(
+ accessToken = "very_new_top_of_the_class_access_token",
+ tokenType = "you_guess_it",
+ refreshToken = "another_very_refreshing_token",
+ refreshExpiresIn = 540000,
+ scope = "open_my_guy"
+ )
+ )
- // Provide username and password
- loginViewModel.run {
- onUsernameUpdated("testUser")
- onPasswordUpdated("51r1K4l1")
- }
+ // Mock result for fetch user info via keycloak endpoint
+ coEvery { keycloakService.fetchUserInfo() } returns
+ Response.success(UserInfo(keycloakUuid = "awesome_uuid"))
+
+ // Mock result for retrieving a FHIR resource using user's keycloak uuid
+ val bundle = Bundle()
+ val bundleEntry = Bundle.BundleEntryComponent().apply { resource = practitionerDetails() }
+ coEvery { fhirResourceService.getResource(any()) } returns bundle.addEntry(bundleEntry)
- loginViewModel.attemptRemoteLogin()
+ loginViewModel.login(mockedActivity(isDeviceOnline = true))
+
+ Assert.assertFalse(loginViewModel.showProgressBar.value!!)
+ Assert.assertTrue(loginViewModel.navigateToHome.value!!)
- Assert.assertEquals(null, loginViewModel.loginErrorState.value)
- loginViewModel.showProgressBar.value?.let { Assert.assertTrue(it) }
- verify { accountAuthenticatorSpy.fetchToken("testUser", "51r1K4l1".toCharArray()) }
+ // Login was successful savePractitionerDetails was called
+ val bundleSlot = slot()
+ verify { loginViewModel.savePractitionerDetails(capture(bundleSlot), any()) }
+
+ Assert.assertNotNull(bundleSlot.captured)
+ Assert.assertTrue(bundleSlot.captured.entry.isNotEmpty())
+ Assert.assertTrue(bundleSlot.captured.entry[0].resource is PractitionerDetails)
}
@Test
- fun testHandleErrorMessageShouldVerifyExpectedMessage() {
+ fun testUnSuccessfulOnlineLoginUserInfoNotFetched() {
+ updateCredentials()
+ secureSharedPreference.saveCredentials(thisUsername, thisPassword.toCharArray())
+ every { tokenAuthenticator.sessionActive() } returns false
+ coEvery {
+ tokenAuthenticator.fetchAccessToken(thisUsername, thisPassword.toCharArray())
+ } returns
+ Result.success(
+ OAuthResponse(
+ accessToken = "very_new_top_of_the_class_access_token",
+ tokenType = "you_guess_it",
+ refreshToken = "another_very_refreshing_token",
+ refreshExpiresIn = 540000,
+ scope = "open_my_guy"
+ )
+ )
- ReflectionHelpers.callInstanceMethod(
- loginViewModel,
- "handleErrorMessage",
- ReflectionHelpers.ClassParameter(Throwable::class.java, UnknownHostException())
- )
- Assert.assertEquals(LoginErrorState.UNKNOWN_HOST, loginViewModel.loginErrorState.value)
+ // Mock result for fetch user info via keycloak endpoint
+ coEvery { keycloakService.fetchUserInfo() } returns
+ Response.error(400, mockk(relaxed = true))
- ReflectionHelpers.callInstanceMethod(
- loginViewModel,
- "handleErrorMessage",
- ReflectionHelpers.ClassParameter(Throwable::class.java, InvalidCredentialsException())
- )
- Assert.assertEquals(LoginErrorState.INVALID_CREDENTIALS, loginViewModel.loginErrorState.value)
+ // Mock result for retrieving a FHIR resource using user's keycloak uuid
+ coEvery { fhirResourceService.getResource(any()) } returns Bundle()
- ReflectionHelpers.callInstanceMethod(
- loginViewModel,
- "handleErrorMessage",
- ReflectionHelpers.ClassParameter(Throwable::class.java, LoginNetworkException())
- )
- Assert.assertEquals(LoginErrorState.NETWORK_ERROR, loginViewModel.loginErrorState.value)
+ loginViewModel.login(mockedActivity(isDeviceOnline = true))
- ReflectionHelpers.callInstanceMethod(
- loginViewModel,
- "handleErrorMessage",
- ReflectionHelpers.ClassParameter(Throwable::class.java, IOException())
- )
- Assert.assertEquals(LoginErrorState.NETWORK_ERROR, loginViewModel.loginErrorState.value)
+ Assert.assertFalse(loginViewModel.showProgressBar.value!!)
+ Assert.assertEquals(LoginErrorState.ERROR_FETCHING_USER, loginViewModel.loginErrorState.value!!)
}
@Test
- fun testFetchLoggedInPractitionerShouldRetrieveAndSavePractitioner() {
- coroutineTestRule.runBlockingTest {
- val userInfo =
- UserInfo(
- questionnairePublisher = "quesP1",
- keycloakUuid = "keyck1",
- organization = "org",
- location = "Nairobi"
- )
+ fun testUnSuccessfulOnlineLoginWhenAccessTokenNotReceived() {
+ updateCredentials()
+ secureSharedPreference.saveCredentials(thisUsername, thisPassword.toCharArray())
+ every { tokenAuthenticator.sessionActive() } returns false
+ coEvery {
+ tokenAuthenticator.fetchAccessToken(thisUsername, thisPassword.toCharArray())
+ } returns Result.failure(UnknownHostException())
- val practitionerId = "12123"
+ // Mock result for fetch user info via keycloak endpoint
+ coEvery { keycloakService.fetchUserInfo() } returns
+ Response.error(400, mockk(relaxed = true))
- coEvery { resourceService.searchResource(ResourceType.Practitioner.name, any()) } returns
- Bundle().apply {
- entry.add(
- Bundle.BundleEntryComponent().apply {
- resource = Practitioner().apply { id = practitionerId }
- }
- )
- }
+ // Mock result for retrieving a FHIR resource using user's keycloak uuid
+ coEvery { fhirResourceService.getResource(any()) } returns Bundle()
- loginViewModel.fetchLoggedInPractitioner(userInfo)
+ loginViewModel.login(mockedActivity(isDeviceOnline = true))
- // Shared preference contains practitioner details
- val practitioner =
- sharedPreferencesHelper.read(
- LOGGED_IN_PRACTITIONER,
- decodeFhirResource = true
- )
- Assert.assertNotNull(practitioner)
- Assert.assertEquals(practitionerId, practitioner!!.logicalId)
-
- // Eventually dismisses the progress dialog and navigates home
- Assert.assertNotNull(loginViewModel.showProgressBar.value)
- Assert.assertFalse(loginViewModel.showProgressBar.value!!)
- Assert.assertNotNull(loginViewModel.navigateToHome.value)
- Assert.assertTrue(loginViewModel.navigateToHome.value!!)
+ Assert.assertFalse(loginViewModel.showProgressBar.value!!)
+ Assert.assertEquals(LoginErrorState.UNKNOWN_HOST, loginViewModel.loginErrorState.value!!)
+ }
+
+ private fun practitionerDetails(): PractitionerDetails {
+ return PractitionerDetails().apply {
+ fhirPractitionerDetails =
+ FhirPractitionerDetails().apply {
+ organizations =
+ listOf(
+ Organization().apply {
+ name = "the.org"
+ id = "the.org.id"
+ }
+ )
+ }
}
}
- @Test
- fun testFetchLoggedInPractitionerWithNullKeycloakUuid() {
- coroutineTestRule.runBlockingTest {
- val userInfo =
- UserInfo(
- questionnairePublisher = "quesP1",
- keycloakUuid = null,
- organization = "org",
- location = "Nairobi"
- )
- val practitionerId = "12123"
+ @Test
+ fun testSavePractitionerDetails() {
+ coEvery { defaultRepository.create(true, any()) } returns listOf()
+ loginViewModel.savePractitionerDetails(
+ Bundle().addEntry(Bundle.BundleEntryComponent().apply { resource = practitionerDetails() })
+ ) {}
+ Assert.assertNotNull(
+ sharedPreferencesHelper.read(SharedPreferenceKey.PRACTITIONER_DETAILS.name)
+ )
+ }
- coEvery { resourceService.searchResource(ResourceType.Practitioner.name, any()) } returns
- Bundle().apply {
- entry.add(
- Bundle.BundleEntryComponent().apply {
- resource = Practitioner().apply { id = practitionerId }
- }
- )
- }
+ @Test
+ fun testUpdateNavigateShouldUpdateLiveData() {
+ loginViewModel.updateNavigateHome(true)
+ Assert.assertNotNull(loginViewModel.navigateToHome.value)
+ Assert.assertTrue(loginViewModel.navigateToHome.value!!)
+ }
- loginViewModel.fetchLoggedInPractitioner(userInfo)
+ @Test
+ fun testForgotPasswordLoadsContact() {
+ loginViewModel.forgotPassword()
+ Assert.assertEquals("tel:0123456789", loginViewModel.launchDialPad.value)
+ }
- // Eventually dismisses the progress dialog and navigates home
- Assert.assertNotNull(loginViewModel.showProgressBar.value)
- Assert.assertFalse(loginViewModel.showProgressBar.value!!)
- Assert.assertNotNull(loginViewModel.navigateToHome.value)
- Assert.assertTrue(loginViewModel.navigateToHome.value!!)
+ private fun updateCredentials() {
+ loginViewModel.run {
+ onUsernameUpdated(thisUsername)
+ onPasswordUpdated(thisPassword)
}
}
+ private fun mockedActivity(isDeviceOnline: Boolean = false): HiltActivityForTest {
+ val activity = mockk(relaxed = true)
+ every { activity.isDeviceOnline() } returns isDeviceOnline
+ return activity
+ }
}
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/pin/PinViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/pin/PinViewModelTest.kt
index ac3d2fed8b..3aaa653a4d 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/pin/PinViewModelTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/pin/PinViewModelTest.kt
@@ -35,10 +35,8 @@ import org.junit.Ignore
import org.junit.Rule
import org.junit.Test
import org.smartregister.fhircore.engine.app.fakes.Faker
-import org.smartregister.fhircore.engine.auth.AuthCredentials
import org.smartregister.fhircore.engine.configuration.view.PinViewConfiguration
import org.smartregister.fhircore.engine.configuration.view.pinViewConfigurationOf
-import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.util.DispatcherProvider
@@ -61,8 +59,7 @@ internal class PinViewModelTest : RobolectricTest() {
@BindValue val sharedPreferencesHelper: SharedPreferencesHelper = mockk()
@BindValue val secureSharedPreference: SecureSharedPreference = mockk()
- val defaultRepository: DefaultRepository = mockk()
- @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry(defaultRepository)
+ @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry()
private val application = ApplicationProvider.getApplicationContext()
private lateinit var pinViewModel: PinViewModel
@@ -88,11 +85,8 @@ internal class PinViewModelTest : RobolectricTest() {
coEvery { secureSharedPreference.retrieveSessionUsername() } returns "demo"
coEvery { secureSharedPreference.saveSessionPin("1234") } returns Unit
coEvery { secureSharedPreference.retrieveSessionPin() } returns "1234"
- coEvery {
- secureSharedPreference.saveCredentials(
- AuthCredentials("username", "password", "sessionToken", "refreshToken")
- )
- } returns Unit
+ coEvery { secureSharedPreference.saveCredentials("username", "password".toCharArray()) } returns
+ Unit
pinViewModel =
PinViewModel(
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivityTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivityTest.kt
index a435b20405..b8fc76e3e8 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivityTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireActivityTest.kt
@@ -16,6 +16,7 @@
package org.smartregister.fhircore.engine.ui.questionnaire
+import android.accounts.AccountManager
import android.app.Activity
import android.app.AlertDialog
import android.app.Application
@@ -68,8 +69,10 @@ import org.robolectric.shadows.ShadowAlertDialog
import org.robolectric.util.ReflectionHelpers
import org.smartregister.fhircore.engine.R
import org.smartregister.fhircore.engine.di.AnalyticsModule
+import org.smartregister.fhircore.engine.di.CoreModule
import org.smartregister.fhircore.engine.robolectric.ActivityRobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
+import org.smartregister.fhircore.engine.sync.SyncBroadcaster
import org.smartregister.fhircore.engine.trace.FakePerformanceReporter
import org.smartregister.fhircore.engine.trace.PerformanceReporter
import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity.Companion.QUESTIONNAIRE_FRAGMENT_TAG
@@ -80,7 +83,7 @@ import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.distinctifyLinkId
import org.smartregister.fhircore.engine.util.extension.encodeResourceToString
-@UninstallModules(AnalyticsModule::class)
+@UninstallModules(AnalyticsModule::class, CoreModule::class)
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class QuestionnaireActivityTest : ActivityRobolectricTest() {
@@ -102,12 +105,16 @@ class QuestionnaireActivityTest : ActivityRobolectricTest() {
@BindValue @JvmField val performanceReporter: PerformanceReporter = FakePerformanceReporter()
+ @BindValue val accountManager = mockk()
+
+ @BindValue val syncBroadcaster = mockk()
+
@BindValue
val questionnaireViewModel: QuestionnaireViewModel =
spyk(
QuestionnaireViewModel(
fhirEngine = mockk(),
- defaultRepository = mockk(),
+ defaultRepository = mockk { coEvery { addOrUpdate(true, any()) } just runs },
configurationRegistry = mockk(),
transformSupportServices = mockk(),
dispatcherProvider = dispatcherProvider,
@@ -130,6 +137,7 @@ class QuestionnaireActivityTest : ActivityRobolectricTest() {
putExtra(QuestionnaireActivity.QUESTIONNAIRE_ARG_PATIENT_KEY, "1234")
}
+ every { syncBroadcaster.runSync(any()) } just runs
coEvery { questionnaireViewModel.libraryEvaluator.initialize() } just runs
val questionnaireConfig = QuestionnaireConfig("form", "title", "form-id")
@@ -469,7 +477,7 @@ class QuestionnaireActivityTest : ActivityRobolectricTest() {
fun testHandleQuestionnaireSubmitShouldShowErrorAlertOnInvalidData() = runTest {
val questionnaire = buildQuestionnaireWithConstraints()
- coEvery { questionnaireViewModel.defaultRepository.addOrUpdate(any()) } just runs
+ coEvery { questionnaireViewModel.defaultRepository.addOrUpdate(resource = any()) } just runs
every { questionnaireFragment.getQuestionnaireResponse() } returns
QuestionnaireResponse().apply {
addItem().apply { linkId = "1" }
@@ -589,7 +597,7 @@ class QuestionnaireActivityTest : ActivityRobolectricTest() {
}
@Test
- fun testPostSaveSuccessfulWithExtractionMessageShouldShowAlert() {
+ fun testPostSaveSuccessfulWithExtractionMessageShouldShowAlert() = runTest {
questionnaireActivity.questionnaireViewModel.extractionProgressMessage.postValue("ABC")
questionnaireActivity.postSaveSuccessful(QuestionnaireResponse())
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModelTest.kt
index ee08cfb39d..8e4c52bff3 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModelTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModelTest.kt
@@ -29,7 +29,6 @@ import com.google.android.fhir.db.ResourceNotFoundException
import com.google.android.fhir.get
import com.google.android.fhir.logicalId
import com.google.android.fhir.search.Search
-import dagger.hilt.android.testing.BindValue
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import io.mockk.coEvery
@@ -46,9 +45,9 @@ import io.mockk.unmockkObject
import io.mockk.verify
import java.util.Calendar
import java.util.Date
+import javax.inject.Inject
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.test.runBlockingTest
import kotlinx.coroutines.test.runTest
import org.hl7.fhir.r4.context.SimpleWorkerContext
import org.hl7.fhir.r4.model.Appointment
@@ -81,7 +80,9 @@ import org.junit.Rule
import org.junit.Test
import org.robolectric.Shadows
import org.robolectric.util.ReflectionHelpers
+import org.smartregister.fhircore.engine.app.fakes.Faker
import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry
+import org.smartregister.fhircore.engine.configuration.app.ConfigService
import org.smartregister.fhircore.engine.cql.LibraryEvaluator
import org.smartregister.fhircore.engine.data.local.DefaultRepository
import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo
@@ -89,19 +90,21 @@ import org.smartregister.fhircore.engine.robolectric.RobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
import org.smartregister.fhircore.engine.trace.FakePerformanceReporter
import org.smartregister.fhircore.engine.util.LOGGED_IN_PRACTITIONER
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.USER_INFO_SHARED_PREFERENCE_KEY
import org.smartregister.fhircore.engine.util.extension.addTags
-import org.smartregister.fhircore.engine.util.extension.encodeJson
-import org.smartregister.fhircore.engine.util.extension.encodeResourceToString
import org.smartregister.fhircore.engine.util.extension.loadResource
import org.smartregister.fhircore.engine.util.extension.retainMetadata
+import org.smartregister.fhircore.engine.util.extension.valueToString
+import org.smartregister.model.practitioner.FhirPractitionerDetails
+import org.smartregister.model.practitioner.PractitionerDetails
@OptIn(ExperimentalCoroutinesApi::class)
@HiltAndroidTest
class QuestionnaireViewModelTest : RobolectricTest() {
- @BindValue val sharedPreferencesHelper: SharedPreferencesHelper = mockk(relaxed = true)
+ @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@@ -109,6 +112,8 @@ class QuestionnaireViewModelTest : RobolectricTest() {
@get:Rule(order = 2) var coroutineRule = CoroutineTestRule()
+ @Inject lateinit var configService: ConfigService
+
private val fhirEngine: FhirEngine = mockk()
private val context: Application = ApplicationProvider.getApplicationContext()
@@ -118,20 +123,39 @@ class QuestionnaireViewModelTest : RobolectricTest() {
private lateinit var defaultRepo: DefaultRepository
private val libraryEvaluator: LibraryEvaluator = mockk()
-
+ private val configurationRegistry = Faker.buildTestConfigurationRegistry()
private lateinit var samplePatientRegisterQuestionnaire: Questionnaire
@Before
fun setUp() {
hiltRule.inject()
- every { sharedPreferencesHelper.read(USER_INFO_SHARED_PREFERENCE_KEY, null) } returns
- getUserInfo().encodeJson()
+ sharedPreferencesHelper.write(
+ USER_INFO_SHARED_PREFERENCE_KEY,
+ getUserInfo(),
+ encodeWithGson = true
+ )
- every { sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, null) } returns
- Practitioner().apply { id = "123" }.encodeResourceToString()
+ sharedPreferencesHelper.write(
+ LOGGED_IN_PRACTITIONER,
+ Practitioner().apply { id = "123" },
+ encodeWithGson = true
+ )
+ sharedPreferencesHelper.write(
+ SharedPreferenceKey.PRACTITIONER_ID.name,
+ practitionerDetails().fhirPractitionerDetails.practitionerId.valueToString()
+ )
- defaultRepo = spyk(DefaultRepository(fhirEngine, coroutineRule.testDispatcherProvider))
+ defaultRepo =
+ spyk(
+ DefaultRepository(
+ fhirEngine = fhirEngine,
+ dispatcherProvider = coroutineRule.testDispatcherProvider,
+ sharedPreferencesHelper = sharedPreferencesHelper,
+ configurationRegistry = configurationRegistry,
+ configService = configService
+ )
+ )
val configurationRegistry = mockk()
every { configurationRegistry.appId } returns "appId"
@@ -149,7 +173,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
tracer = FakePerformanceReporter()
)
)
-
+ coEvery { fhirEngine.get(ResourceType.Patient, any()) } returns samplePatient()
runBlocking {
questionnaireViewModel.getQuestionnaireConfig(
"patient-registration",
@@ -161,7 +185,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
coEvery { fhirEngine.update(any()) } answers {}
coEvery { defaultRepo.save(any()) } returns Unit
- coEvery { defaultRepo.addOrUpdate(any()) } just runs
+ coEvery { defaultRepo.addOrUpdate(resource = any()) } just runs
// Setup sample resources
val iParser: IParser = FhirContext.forR4Cached().newJsonParser()
@@ -423,7 +447,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
Shadows.shadowOf(Looper.getMainLooper()).idle()
- coroutineRule.runBlockingTest {
+ runTest {
val questionnaireResponse = QuestionnaireResponse()
questionnaireViewModel.extractAndSaveResources(
@@ -433,8 +457,8 @@ class QuestionnaireViewModelTest : RobolectricTest() {
questionnaire = questionnaire
)
- coVerify { defaultRepo.addOrUpdate(patient) }
- coVerify { defaultRepo.addOrUpdate(questionnaireResponse) }
+ coVerify { defaultRepo.addOrUpdate(resource = patient) }
+ coVerify { defaultRepo.addOrUpdate(resource = questionnaireResponse) }
coVerify(timeout = 10000) { ResourceMapper.extract(any(), any(), any()) }
}
unmockkObject(ResourceMapper)
@@ -470,7 +494,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
questionnaire = questionnaire
)
- coVerify { defaultRepo.addOrUpdate(any()) }
+ coVerify { defaultRepo.addOrUpdate(resource = any()) }
unmockkObject(ResourceMapper)
}
@@ -495,7 +519,9 @@ class QuestionnaireViewModelTest : RobolectricTest() {
questionnaire = questionnaire
)
- coVerify(timeout = 2000) { defaultRepo.addOrUpdate(capture(questionnaireResponseSlot)) }
+ coVerify(timeout = 2000) {
+ defaultRepo.addOrUpdate(resource = capture(questionnaireResponseSlot))
+ }
Assert.assertEquals(
"12345",
@@ -511,7 +537,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
coEvery { ResourceMapper.extract(any(), any(), any()) } returns
Bundle().apply { addEntry().resource = samplePatient() }
coEvery { fhirEngine.get(ResourceType.Patient, "12345") } returns samplePatient()
- coEvery { defaultRepo.addOrUpdate(any()) } just runs
+ coEvery { defaultRepo.addOrUpdate(resource = any()) } just runs
val questionnaireResponseSlot = slot()
val patientSlot = slot()
@@ -533,8 +559,8 @@ class QuestionnaireViewModelTest : RobolectricTest() {
)
coVerifyOrder {
- defaultRepo.addOrUpdate(capture(patientSlot))
- defaultRepo.addOrUpdate(capture(questionnaireResponseSlot))
+ defaultRepo.addOrUpdate(resource = capture(patientSlot))
+ defaultRepo.addOrUpdate(resource = capture(questionnaireResponseSlot))
}
Assert.assertEquals(
@@ -638,13 +664,13 @@ class QuestionnaireViewModelTest : RobolectricTest() {
val questionnaire = Questionnaire().apply { id = "qId" }
val questionnaireResponse = QuestionnaireResponse().apply { subject = Reference("12345") }
- coEvery { defaultRepo.addOrUpdate(any()) } returns Unit
+ coEvery { defaultRepo.addOrUpdate(resource = any()) } returns Unit
runBlocking {
questionnaireViewModel.saveQuestionnaireResponse(questionnaire, questionnaireResponse)
}
- coVerify { defaultRepo.addOrUpdate(questionnaireResponse) }
+ coVerify { defaultRepo.addOrUpdate(resource = questionnaireResponse) }
}
@Test
@@ -657,7 +683,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
questionnaireViewModel.saveQuestionnaireResponse(questionnaire, questionnaireResponse)
}
- coVerify(inverse = true) { defaultRepo.addOrUpdate(questionnaireResponse) }
+ coVerify(inverse = true) { defaultRepo.addOrUpdate(resource = questionnaireResponse) }
}
@Test
@@ -684,7 +710,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
}
coVerify { ResourceMapper.extract(any(), any(), any()) }
- coVerify(inverse = true) { defaultRepo.addOrUpdate(questionnaireResponse) }
+ coVerify(inverse = true) { defaultRepo.addOrUpdate(resource = questionnaireResponse) }
unmockkObject(ResourceMapper)
}
@@ -694,7 +720,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
val questionnaire = Questionnaire().apply { id = "qId" }
val questionnaireResponse = QuestionnaireResponse().apply { subject = Reference("12345") }
- coEvery { defaultRepo.addOrUpdate(any()) } returns Unit
+ coEvery { defaultRepo.addOrUpdate(resource = any()) } returns Unit
Assert.assertNull(questionnaireResponse.id)
Assert.assertNull(questionnaireResponse.authored)
@@ -718,7 +744,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
authored = authoredDate
subject = Reference("12345")
}
- coEvery { defaultRepo.addOrUpdate(any()) } returns Unit
+ coEvery { defaultRepo.addOrUpdate(resource = any()) } returns Unit
runBlocking {
questionnaireViewModel.saveQuestionnaireResponse(questionnaire, questionnaireResponse)
@@ -843,7 +869,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
// call the method under test
runBlocking { questionnaireViewModel.saveBundleResources(bundle) }
- coVerify(exactly = size) { defaultRepo.addOrUpdate(any()) }
+ coVerify(exactly = size) { defaultRepo.addOrUpdate(resource = any()) }
}
@Test
@@ -869,7 +895,7 @@ class QuestionnaireViewModelTest : RobolectricTest() {
// call the method under test
runBlocking { questionnaireViewModel.saveBundleResources(bundle) }
- coVerify(exactly = 1) { defaultRepo.addOrUpdate(capture(resource)) }
+ coVerify(exactly = 1) { defaultRepo.addOrUpdate(resource = capture(resource)) }
}
@Test
@@ -1048,13 +1074,23 @@ class QuestionnaireViewModelTest : RobolectricTest() {
this.birthDate = questionnaireViewModel.calculateDobFromAge(25)
}
+ private fun practitionerDetails(): PractitionerDetails {
+ return PractitionerDetails().apply {
+ fhirPractitionerDetails =
+ FhirPractitionerDetails().apply {
+ id = "12345"
+ practitionerId = StringType("12345")
+ }
+ }
+ }
+
@Test
fun testAddPractitionerInfoShouldSetGeneralPractitionerReferenceToPatientResource() {
val patient = samplePatient()
questionnaireViewModel.appendPractitionerInfo(patient)
- Assert.assertEquals("Practitioner/123", patient.generalPractitioner[0].reference)
+ Assert.assertEquals("Practitioner/12345", patient.generalPractitioner[0].reference)
}
@Test
@@ -1074,31 +1110,29 @@ class QuestionnaireViewModelTest : RobolectricTest() {
fun testAddPractitionerInfoShouldSetIndividualPractitionerReferenceToEncounterResource() {
val encounter = Encounter().apply { this.id = "123456" }
questionnaireViewModel.appendPractitionerInfo(encounter)
- Assert.assertEquals("Practitioner/123", encounter.participant[0].individual.reference)
+ Assert.assertEquals("Practitioner/12345", encounter.participant[0].individual.reference)
}
@Test
- fun testAppendPatientsAndRelatedPersonsToGroupsShouldAddMembersToGroup() {
- coroutineRule.runBlockingTest {
- val patient = samplePatient()
- val familyGroup =
- Group().apply {
- id = "grp1"
- name = "Mandela Family"
- }
- coEvery { fhirEngine.get(familyGroup.id) } returns familyGroup
- questionnaireViewModel.appendPatientsAndRelatedPersonsToGroups(patient, familyGroup.id)
- Assert.assertEquals(1, familyGroup.member.size)
-
- val familyGroup2 = Group().apply { id = "grp2" }
- coEvery { fhirEngine.get(familyGroup2.id) } returns familyGroup2
- // Sets the managing entity
- questionnaireViewModel.appendPatientsAndRelatedPersonsToGroups(
- RelatedPerson().apply { id = "rel1" },
- familyGroup2.id
- )
- Assert.assertNotNull(familyGroup2.managingEntity)
- }
+ fun testAppendPatientsAndRelatedPersonsToGroupsShouldAddMembersToGroup() = runTest {
+ val patient = samplePatient()
+ val familyGroup =
+ Group().apply {
+ id = "grp1"
+ name = "Mandela Family"
+ }
+ coEvery { fhirEngine.get(familyGroup.id) } returns familyGroup
+ questionnaireViewModel.appendPatientsAndRelatedPersonsToGroups(patient, familyGroup.id)
+ Assert.assertEquals(1, familyGroup.member.size)
+
+ val familyGroup2 = Group().apply { id = "grp2" }
+ coEvery { fhirEngine.get(familyGroup2.id) } returns familyGroup2
+ // Sets the managing entity
+ questionnaireViewModel.appendPatientsAndRelatedPersonsToGroups(
+ RelatedPerson().apply { id = "rel1" },
+ familyGroup2.id
+ )
+ Assert.assertNotNull(familyGroup2.managingEntity)
}
@Test
diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/register/BaseRegisterActivityTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/register/BaseRegisterActivityTest.kt
index e954c4d132..dd870a4728 100644
--- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/register/BaseRegisterActivityTest.kt
+++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/register/BaseRegisterActivityTest.kt
@@ -41,9 +41,7 @@ import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import dagger.hilt.android.testing.UninstallModules
import io.mockk.every
-import io.mockk.just
import io.mockk.mockk
-import io.mockk.runs
import io.mockk.spyk
import io.mockk.verify
import java.io.InterruptedIOException
@@ -67,10 +65,9 @@ import org.smartregister.fhircore.engine.R
import org.smartregister.fhircore.engine.app.fakes.FakeModel
import org.smartregister.fhircore.engine.app.fakes.Faker
import org.smartregister.fhircore.engine.auth.AccountAuthenticator
-import org.smartregister.fhircore.engine.auth.TokenManagerService
import org.smartregister.fhircore.engine.configuration.ConfigClassification
import org.smartregister.fhircore.engine.configuration.view.registerViewConfigurationOf
-import org.smartregister.fhircore.engine.data.local.DefaultRepository
+import org.smartregister.fhircore.engine.data.remote.shared.TokenAuthenticator
import org.smartregister.fhircore.engine.di.AnalyticsModule
import org.smartregister.fhircore.engine.robolectric.ActivityRobolectricTest
import org.smartregister.fhircore.engine.rule.CoroutineTestRule
@@ -79,8 +76,8 @@ import org.smartregister.fhircore.engine.trace.PerformanceReporter
import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity
import org.smartregister.fhircore.engine.ui.register.model.RegisterItem
import org.smartregister.fhircore.engine.ui.register.model.SideMenuOption
-import org.smartregister.fhircore.engine.util.LAST_SYNC_TIMESTAMP
import org.smartregister.fhircore.engine.util.SecureSharedPreference
+import org.smartregister.fhircore.engine.util.SharedPreferenceKey
import org.smartregister.fhircore.engine.util.SharedPreferencesHelper
import org.smartregister.fhircore.engine.util.extension.asString
import retrofit2.HttpException
@@ -94,20 +91,22 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() {
@get:Rule(order = 1) val coroutineTestRule = CoroutineTestRule()
- @BindValue var tokenManagerService: TokenManagerService = mockk()
+ @BindValue var tokenAuthenticator: TokenAuthenticator = mockk()
@BindValue val sharedPreferencesHelper: SharedPreferencesHelper = mockk()
@BindValue val secureSharedPreference: SecureSharedPreference = mockk()
- @BindValue val accountAuthenticator = mockk()
+ @BindValue @JvmField val accountAuthenticator = mockk()
@BindValue @JvmField val performanceReporter: PerformanceReporter = FakePerformanceReporter()
- val defaultRepository: DefaultRepository = mockk()
- @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry(defaultRepository)
+ @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry()
private lateinit var testRegisterActivityController: ActivityController
private lateinit var testRegisterActivity: TestRegisterActivity
+ val context: Context =
+ ApplicationProvider.getApplicationContext().apply { setTheme(R.style.AppTheme) }
+
@Before
fun setUp() {
hiltRule.inject()
@@ -117,6 +116,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() {
every { secureSharedPreference.retrieveSessionUsername() } returns "demo"
every { secureSharedPreference.retrieveCredentials() } returns FakeModel.authCredentials
every { secureSharedPreference.deleteCredentials() } returns Unit
+ every { accountAuthenticator.logout(any()) } returns Unit
ApplicationProvider.getApplicationContext().apply { setTheme(R.style.AppTheme) }
@@ -423,7 +423,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() {
val currentDateTime = OffsetDateTime.now()
every { sharedPreferencesHelper.read(any(), any()) } answers
{
- if (firstArg() == LAST_SYNC_TIMESTAMP) {
+ if (firstArg