From 3e905645b7b1f0b0319013818d4add8d0ab746b9 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Wed, 26 Apr 2023 15:55:55 +0200 Subject: [PATCH 01/68] rework auth items --- android/engine/build.gradle | 1 + android/engine/src/main/AndroidManifest.xml | 17 +- .../engine/auth/AccountAuthenticator.kt | 437 ++++-------------- .../engine/auth/TokenManagerService.kt | 87 ---- .../engine/data/local/DefaultRepository.kt | 14 + .../data/remote/auth/KeycloakService.kt | 25 + .../engine/data/remote/auth/OAuthService.kt | 11 +- .../data/remote/shared/TokenAuthenticator.kt | 275 +++++++++++ .../shared/interceptor/OAuthInterceptor.kt | 59 --- .../fhircore/engine/di/FhirEngineModule.kt | 14 +- .../fhircore/engine/di/NetworkModule.kt | 116 ++++- .../fhircore/engine/di/Qualifiers.kt | 19 +- .../ui/appsetting/AppSettingActivity.kt | 4 +- .../ui/appsetting/AppSettingViewModel.kt | 7 + .../ui/base/BaseMultiLanguageActivity.kt | 5 +- .../fhircore/engine/ui/login/LoginActivity.kt | 49 +- .../engine/ui/login/LoginErrorState.kt | 5 +- .../fhircore/engine/ui/login/LoginScreen.kt | 16 +- .../engine/ui/login/LoginViewModel.kt | 325 +++++++------ .../questionnaire/QuestionnaireViewModel.kt | 10 +- .../ui/register/BaseRegisterActivity.kt | 16 +- .../engine/ui/register/RegisterViewModel.kt | 8 +- .../ui/userprofile/UserProfileScreen.kt | 4 +- .../ui/userprofile/UserProfileViewModel.kt | 15 +- .../engine/util/FhirContextExtension.kt | 36 ++ .../engine/util/SharedPrefConstants.kt | 1 - .../engine/util/SharedPreferenceKey.kt | 31 ++ .../engine/util/SharedPreferencesHelper.kt | 47 +- .../util/extension/AndroidExtensions.kt | 51 ++ .../util/extension/DateTimeExtension.kt | 2 + .../util/extension/ResourceExtension.kt | 12 + .../engine/util/extension/StringExtensions.kt | 24 + .../engine/src/main/res/values/strings.xml | 2 + .../interceptor/OAuthInterceptorTest.kt | 1 - .../ui/register/BaseRegisterActivityTest.kt | 1 - android/quest/build.gradle | 1 + .../fhircore/quest/QuestApplication.kt | 16 +- .../fhircore/quest/ui/main/AppMainActivity.kt | 4 +- .../fhircore/quest/ui/main/AppMainEvent.kt | 2 +- .../quest/ui/main/AppMainViewModel.kt | 74 ++- .../quest/ui/main/components/AppDrawer.kt | 2 +- .../register/PatientRegisterViewModel.kt | 4 +- .../report/measure/MeasureReportViewModel.kt | 20 +- 43 files changed, 1028 insertions(+), 842 deletions(-) delete mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/auth/TokenManagerService.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/auth/KeycloakService.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt delete mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptor.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirContextExtension.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/SharedPreferenceKey.kt create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt diff --git a/android/engine/build.gradle b/android/engine/build.gradle index 9afe8d0228..be2956fee4 100644 --- a/android/engine/build.gradle +++ b/android/engine/build.gradle @@ -224,6 +224,7 @@ dependencies { api "com.squareup.retrofit2:retrofit:$retrofitVersion" api "com.squareup.retrofit2:converter-gson:$retrofitVersion" api "com.squareup.retrofit2:retrofit-mock:$retrofitVersion" + api "com.jakewharton.retrofit:retrofit2-kotlinx-serialization-converter:0.8.0" def okhttpVersion = '4.9.1' api "com.squareup.okhttp3:okhttp:$okhttpVersion" 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/java/org/smartregister/fhircore/engine/auth/AccountAuthenticator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/auth/AccountAuthenticator.kt index b8fa384b1f..a763547254 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 @@ -20,414 +20,147 @@ import android.accounts.AbstractAccountAuthenticator 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 org.smartregister.fhircore.engine.util.extension.getActivity +import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory +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 - ): 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) - } - - return Bundle().apply { putParcelable(AccountManager.KEY_INTENT, intent) } - } - - override fun getAuthToken( - response: AccountAuthenticatorResponse, - account: Account, - 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()) - } - .onSuccess { Timber.i("Got new accessToken") } - } - } - - if (accessToken?.isNotBlank() == true) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - accountManager.notifyAccountAuthenticated(account) - } - - return bundleOf( - Pair(AccountManager.KEY_ACCOUNT_NAME, account.name), - Pair(AccountManager.KEY_ACCOUNT_TYPE, account.type), - Pair(AccountManager.KEY_AUTHTOKEN, accessToken) - ) - } - - // failed to validate any token. now update credentials using auth activity - return updateCredentials(response, account, authTokenType, options) + val intent = loginIntent(accountType, authTokenType, response) + return bundleOf(AccountManager.KEY_INTENT to intent) } - override fun editProperties( - response: AccountAuthenticatorResponse?, - accountType: String? - ): Bundle = Bundle() - override fun confirmCredentials( - response: AccountAuthenticatorResponse, - account: Account, + response: AccountAuthenticatorResponse?, + account: Account?, options: Bundle? - ): Bundle = bundleOf() - - override fun getAuthTokenLabel(authTokenType: String): String { - return authTokenType.uppercase(Locale.ROOT) + ): Bundle { + return bundleOf() } - override fun updateCredentials( - response: AccountAuthenticatorResponse, + override fun getAuthToken( + 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) } - } - - 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) - } - } - - @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) - } - } - - 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 hasActivePin(): Boolean { - Timber.v("Checking for an active PIN") - return secureSharedPreference.retrieveSessionPin()?.isNotBlank() == true - } - - 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()) - } - ?: 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 - ) { - 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 + 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 } - - logInIntent!! - logInIntent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP - onValidTokenMissing(logInIntent) } - }, - errorHandler = Handler(Looper.getMainLooper(), DefaultErrorHandler) - ) + } } - } - fun invalidateAccount() { - tokenManagerService.getActiveAccount()?.run { - accountManager.invalidateAuthToken( - getAccountType(), - tokenManagerService.getLocalSessionToken() + // Auth token exists so return it + if (!authToken.isNullOrEmpty()) { + return bundleOf( + AccountManager.KEY_ACCOUNT_NAME to account.name, + AccountManager.KEY_ACCOUNT_TYPE to account.type, + AccountManager.KEY_AUTHTOKEN to authToken ) - secureSharedPreference.deleteSession() } - } - fun loadActiveAccount( - callback: AccountManagerCallback, - errorHandler: Handler = Handler(Looper.getMainLooper(), DefaultErrorHandler) - ) { - tokenManagerService.getActiveAccount()?.let { loadAccount(it, callback, errorHandler) } + // 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) } } - 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) + 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) + } } - 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()) - ) - } - } - } - } - .onFailure { - Timber.w(it) - context.showToast(context.getString(R.string.error_contacting_server, it.message ?: "")) - } - } + override fun getAuthTokenLabel(authTokenType: String): String = + authTokenType.uppercase(Locale.getDefault()) - sharedPreference.write(IS_LOGGED_IN, false) - launchScreen(AppSettingActivity::class.java) - } + override fun updateCredentials( + response: AccountAuthenticatorResponse?, + account: Account?, + authTokenType: String?, + options: Bundle? + ): Bundle = bundleOf() - 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") + override fun hasFeatures( + response: AccountAuthenticatorResponse?, + account: Account?, + features: Array? + ): Bundle = bundleOf() - 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()) - } - .onSuccess { - Timber.i("Got new accessToken") - tokenManagerService.getActiveAccount()?.let { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - accountManager.notifyAccountAuthenticated(it) - } - } - } - } - } - loadAccount( - this, - callback, - errorHandler = Handler(Looper.getMainLooper(), DefaultErrorHandler) - ) + fun logout(onLogout: () -> Unit) { + tokenAuthenticator.logout().onSuccess { loggedOut -> if (loggedOut) onLogout() }.onFailure { + onLogout() } } - val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> - throwable.printStackTrace() + fun logout(context: Context) { + logout { context.getActivity()?.launchActivityWithNoBackStackHistory() } } - fun launchLoginScreen() { - launchScreen(getLoginActivityClass()) - } + fun validateLoginCredentials(username: String, password: CharArray) = + tokenAuthenticator.validateSavedLoginCredentials(username, password) - 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 invalidateSession(onSessionInvalidated: () -> Unit) { + tokenAuthenticator.invalidateSession(onSessionInvalidated) } - fun getLoginActivityClass(): Class<*> = LoginActivity::class.java - - 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 hasActiveSession() = secureSharedPreference.retrieveSessionPin().isNullOrEmpty() 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/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/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt index 9c3d4b7d4d..58b92a386c 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 @@ -171,6 +171,20 @@ 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() + // TODO: Migrate to using this instead of save + // if (addResourceTags) { + // it.addTags(configService.provideResourceTags(sharedPreferencesHelper)) + // } + } + + fhirEngine.create(*resource) + } + } + suspend fun delete(resource: Resource) { return withContext(dispatcherProvider.io()) { fhirEngine.delete(resource.logicalId) } } 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/shared/TokenAuthenticator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt new file mode 100644 index 0000000000..6386e13896 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt @@ -0,0 +1,275 @@ +/* + * 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 javax.inject.Inject +import javax.inject.Singleton +import javax.net.ssl.SSLHandshakeException +import kotlinx.coroutines.runBlocking +import org.smartregister.fhircore.engine.auth.AuthCredentials +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.toSha1 +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 = accounts.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( + AuthCredentials(username, password.concatToString().toSha1()) + ) + } + } + + /** + * 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, password: CharArray): Boolean { + val credentials = secureSharedPreference.retrieveCredentials() + return username.equals(credentials?.username, ignoreCase = true) && + password.concatToString().toSha1().contentEquals(credentials?.password) + } + + 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..4c77acc7b2 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.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/ui/appsetting/AppSettingActivity.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/appsetting/AppSettingActivity.kt index 50242d6274..f148a590cd 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 @@ -74,7 +74,7 @@ class AppSettingActivity : AppCompatActivity() { if (loadSuccessful) { sharedPreferencesHelper.write(APP_ID_CONFIG, appId) if (!isLoggedIn) { - accountAuthenticator.launchLoginScreen() + appSettingViewModel.launchLoginScreen(this@AppSettingActivity) } else { loginService.loginActivity = this@AppSettingActivity loginService.navigateToHome() @@ -94,7 +94,7 @@ class AppSettingActivity : AppCompatActivity() { configurationRegistry.loadConfigurations(appId) { loadSuccessful: Boolean -> if (loadSuccessful) { sharedPreferencesHelper.write(APP_ID_CONFIG, appId) - accountAuthenticator.launchLoginScreen() + appSettingViewModel.launchLoginScreen(this@AppSettingActivity) finish() } else { launch(dispatcherProvider.main()) { 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..04b0a0ea4a 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 @@ -28,7 +28,10 @@ import org.smartregister.fhircore.engine.R 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.extension.extractId +import org.smartregister.fhircore.engine.util.extension.getActivity +import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory import timber.log.Timber @HiltViewModel @@ -115,4 +118,8 @@ constructor( appId.value!!.split("/").last().contentEquals(DEBUG_SUFFIX) else null } + + 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..1a249b4f67 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,6 +58,20 @@ 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() @@ -68,7 +82,7 @@ class LoginActivity : loginViewModel.username.value?.trim() if (loginViewModel.loginViewConfiguration.value?.enablePin == true) { - val lastPinExist = loginViewModel.accountAuthenticator.hasActivePin() + val lastPinExist = !secureSharedPreference.retrieveSessionPin().isNullOrEmpty() val forceLoginViaUsernamePinSetup = loginViewModel.sharedPreferences.read(FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP, false) when { @@ -94,29 +108,20 @@ class LoginActivity : } } 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..1d14b56ad1 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 @@ -109,6 +109,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 +120,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, ) @@ -276,7 +278,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 +289,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..544b1cd117 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,137 +16,58 @@ 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.withContext 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 +import javax.inject.Inject @HiltViewModel 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() val navigateToHome: LiveData get() = _navigateToHome @@ -175,52 +96,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,25 +136,88 @@ 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) { + if (!username.value.isNullOrBlank() && !password.value.isNullOrBlank()) { + _loginErrorState.postValue(null) + _showProgressBar.postValue(true) + + val trimmedUsername = username.value!!.trim() + val passwordAsCharArray = password.value!!.toCharArray() + + if (context.getActivity()!!.isDeviceOnline()) { + viewModelScope.launch { + 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(org.hl7.fhir.r4.model.Bundle()) + savePractitionerDetails(bundle) + } else { + Timber.e(bundleResult.exceptionOrNull()) + _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 + ) { + if (tokenAuthenticator.sessionActive()) { + _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. _launchDialPad.value = "tel:0123456789" @@ -269,17 +229,54 @@ 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: org.hl7.fhir.r4.model.Bundle) { + if (bundle.entry.isNullOrEmpty()) return + viewModelScope.launch { + 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 = + withContext(dispatcherProvider.io()) { + defaultRepository.create(true, *careTeams.toTypedArray()).map { + it.extractLogicalIdUuid() + } + } + val organizationIds = + withContext(dispatcherProvider.io()) { + defaultRepository.create(true, *organizations.toTypedArray()).map { + it.extractLogicalIdUuid() + } + } + val locationIds = + withContext(dispatcherProvider.io()) { + defaultRepository.create(true, *locations.toTypedArray()).map { + it.extractLogicalIdUuid() + } + } + + sharedPreferences.write( + key = SharedPreferenceKey.PRACTITIONER_ID.name, + value = practitionerDetails.fhirPractitionerDetails?.practitionerId.valueToString() + ) + + sharedPreferences.write(SharedPreferenceKey.PRACTITIONER_DETAILS.name, practitionerDetails) + sharedPreferences.write(ResourceType.CareTeam.name, careTeamIds) + sharedPreferences.write(ResourceType.Organization.name, organizationIds) + sharedPreferences.write(ResourceType.Location.name, locationIds) + sharedPreferences.write( + SharedPreferenceKey.PRACTITIONER_LOCATION_HIERARCHIES.name, + locationHierarchies + ) } } fun loadLastLoggedInUsername() { - _username.postValue(accountAuthenticator.retrieveLastLoggedInUsername() ?: "") + // _username.postValue(accountAuthenticator.retrieveLastLoggedInUsername() ?: "") } companion object { 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 1369786a78..9eb2f887d7 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 @@ -42,7 +42,6 @@ import org.hl7.fhir.r4.model.Encounter import org.hl7.fhir.r4.model.Group import org.hl7.fhir.r4.model.Identifier 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 @@ -59,7 +58,7 @@ import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo import org.smartregister.fhircore.engine.task.FhirCarePlanGenerator 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.USER_INFO_SHARED_PREFERENCE_KEY import org.smartregister.fhircore.engine.util.extension.addTags @@ -77,6 +76,7 @@ import org.smartregister.fhircore.engine.util.extension.referenceValue import org.smartregister.fhircore.engine.util.extension.retainMetadata import org.smartregister.fhircore.engine.util.extension.setPropertySafely import org.smartregister.fhircore.engine.util.helper.TransformSupportServices +import org.smartregister.model.practitioner.PractitionerDetails import timber.log.Timber @HiltViewModel @@ -111,9 +111,9 @@ constructor( } private val loggedInPractitioner by lazy { - sharedPreferencesHelper.read( - key = LOGGED_IN_PRACTITIONER, - decodeFhirResource = true + sharedPreferencesHelper.read( + key = SharedPreferenceKey.PRACTITIONER_DETAILS.name, + decodeWithGson = true ) } 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/FhirContextExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirContextExtension.kt new file mode 100644 index 0000000000..0da683d089 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirContextExtension.kt @@ -0,0 +1,36 @@ +/* + * 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 + +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, + ) + ) + } + .newJsonParser() +} 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..e990344677 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,7 +16,6 @@ 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" 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..8fcc8f897d 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,30 @@ package org.smartregister.fhircore.engine.util import android.content.Context import android.content.SharedPreferences +import com.google.gson.Gson import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton import org.smartregister.fhircore.engine.util.extension.decodeJson -import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString +import org.smartregister.fhircore.engine.util.extension.encodeJson @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 +49,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 +61,26 @@ 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) { + gson.fromJson(this.read(key, null), T::class.java) + } else { + this.read(key, null)?.decodeJson() + } + + /** 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 +88,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/extension/AndroidExtensions.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensions.kt index 477912c3ab..2c9b56a917 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,16 +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.hl7.fhir.r4.model.Resource import org.smartregister.fhircore.engine.R @@ -141,3 +146,49 @@ inline fun Context.launchQuestionnaireForRes 0 ) } + +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/DateTimeExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/DateTimeExtension.kt index b8f46b3cdc..79b5be5729 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 @@ -29,6 +29,8 @@ import org.hl7.fhir.r4.model.DateType val SDF_DD_MMM_YYYY = SimpleDateFormat("dd-MMM-yyyy") val SDF_YYYY_MM_DD = SimpleDateFormat("yyyy-MM-dd") +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/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index 3f19095ebd..9899b2e218 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 @@ -276,6 +276,18 @@ 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) } } 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..9a72901745 --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/StringExtensions.kt @@ -0,0 +1,24 @@ +/* + * 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" +fun String.practitionerEndpointUrl(): String = "Practitioner?identifier=$this" diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index d42c48bd1f..6f1335ac08 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. 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 index c5a3fd045f..d06585ef0c 100644 --- 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 @@ -27,7 +27,6 @@ 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 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 09c5100e37..bd02a6ecae 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 @@ -66,7 +66,6 @@ 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 diff --git a/android/quest/build.gradle b/android/quest/build.gradle index 1d560eca9a..ddc437b9dc 100644 --- a/android/quest/build.gradle +++ b/android/quest/build.gradle @@ -186,6 +186,7 @@ dependencies { implementation 'androidx.ui:ui-foundation:0.1.0-dev03' implementation deps.lifecycle.viewmodel implementation('org.smartregister:p2p-lib:0.3.0-SNAPSHOT') + implementation 'org.smartregister:fhir-common-utils:0.0.6-SNAPSHOT' implementation deps.accompanist.swiperefresh implementation 'com.github.anrwatchdog:anrwatchdog:1.4.0' diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt index 67a28493eb..690271ba59 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt @@ -128,14 +128,14 @@ class QuestApplication : override fun onStart(owner: LifecycleOwner) { appInActivityListener.stop() if (mForegroundActivityContext != null) { - accountAuthenticator.loadActiveAccount( - onActiveAuthTokenFound = {}, - onValidTokenMissing = { - if (it.component!!.className != mForegroundActivityContext!!::class.java.name) { - mForegroundActivityContext!!.startActivity(it) - } - } - ) +// accountAuthenticator.loadActiveAccount( +// onActiveAuthTokenFound = {}, +// onValidTokenMissing = { +// if (it.component!!.className != mForegroundActivityContext!!::class.java.name) { +// mForegroundActivityContext!!.startActivity(it) +// } +// } +// ) } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index ffee0172b4..ec076eae4b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -171,12 +171,12 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { } } - fun setupTimeOutListener() { + private fun setupTimeOutListener() { if (application is QuestApplication) { (application as QuestApplication).onInActivityListener = object : OnInActivityListener { override fun onTimeout() { - appMainViewModel.onTimeOut() + appMainViewModel.onTimeOut(application) } } } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainEvent.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainEvent.kt index 34a0944112..3a0ee0c5f9 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainEvent.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainEvent.kt @@ -24,7 +24,7 @@ import org.smartregister.fhircore.engine.domain.model.Language sealed class AppMainEvent { data class SwitchLanguage(val language: Language, val context: Context) : AppMainEvent() data class DeviceToDeviceSync(val context: Context) : AppMainEvent() - object Logout : AppMainEvent() + data class Logout(val context: Context) : AppMainEvent() data class SyncData(val launchManualAuth: (Intent) -> Unit) : AppMainEvent() object ResumeSync : AppMainEvent() data class UpdateSyncState(val state: SyncJobStatus, val lastSyncTime: String?) : AppMainEvent() diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt index 5a9f0fa040..08f83086f1 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt @@ -16,21 +16,14 @@ package org.smartregister.fhircore.quest.ui.main -import android.accounts.AccountManager import android.app.Activity -import android.content.Intent +import android.content.Context import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.fhir.sync.SyncJobStatus import dagger.hilt.android.lifecycle.HiltViewModel -import java.text.SimpleDateFormat -import java.time.OffsetDateTime -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.appfeature.AppFeature @@ -41,15 +34,24 @@ import org.smartregister.fhircore.engine.configuration.app.AppConfigClassificati import org.smartregister.fhircore.engine.configuration.app.ApplicationConfiguration import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.sync.SyncBroadcaster -import org.smartregister.fhircore.engine.util.LAST_SYNC_TIMESTAMP +import org.smartregister.fhircore.engine.ui.appsetting.AppSettingActivity 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 import org.smartregister.fhircore.engine.util.extension.refresh import org.smartregister.fhircore.engine.util.extension.setAppLocale import org.smartregister.fhircore.quest.navigation.SideMenuOptionFactory import org.smartregister.p2p.utils.startP2PScreen import timber.log.Timber +import java.text.SimpleDateFormat +import java.time.OffsetDateTime +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject @HiltViewModel class AppMainViewModel @@ -91,9 +93,9 @@ constructor( fun onEvent(event: AppMainEvent) { when (event) { - AppMainEvent.Logout -> accountAuthenticator.logout() + is AppMainEvent.Logout -> accountAuthenticator.logout(event.context) is AppMainEvent.SwitchLanguage -> { - sharedPreferencesHelper.write(SharedPreferencesHelper.LANG, event.language.tag) + sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, event.language.tag) event.context.run { setAppLocale(event.language.tag) (this as Activity).refresh() @@ -101,29 +103,24 @@ constructor( } is AppMainEvent.SyncData -> { appMainUiState.value = appMainUiState.value.copy(syncClickEnabled = false) - accountAuthenticator.loadActiveAccount( - onActiveAuthTokenFound = { - appMainUiState.value = appMainUiState.value.copy(syncClickEnabled = true) - run(resumeSync) - }, - onValidTokenMissing = { onEvent(AppMainEvent.RefreshAuthToken(event.launchManualAuth)) } - ) + appMainUiState.value = appMainUiState.value.copy(syncClickEnabled = true) + run(resumeSync) } is AppMainEvent.RefreshAuthToken -> { Timber.e("Refreshing token") - accountAuthenticator.refreshSessionAuthToken { accountBundleFuture -> - val bundle = accountBundleFuture.result - bundle.getParcelable(AccountManager.KEY_INTENT).let { intent -> - if (intent == null && bundle.containsKey(AccountManager.KEY_AUTHTOKEN)) { - syncBroadcaster.runSync() - return@let - } - intent!! - appMainUiState.value = appMainUiState.value.copy(syncClickEnabled = true) - intent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP - event.launchManualAuth(intent) - } - } +// accountAuthenticator.refreshSessionAuthToken { accountBundleFuture -> +// val bundle = accountBundleFuture.result +// bundle.getParcelable(AccountManager.KEY_INTENT).let { intent -> +// if (intent == null && bundle.containsKey(AccountManager.KEY_AUTHTOKEN)) { +// syncBroadcaster.runSync() +// return@let +// } +// intent!! +// appMainUiState.value = appMainUiState.value.copy(syncClickEnabled = true) +// intent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP +// event.launchManualAuth(intent) +// } +// } } AppMainEvent.ResumeSync -> { run(resumeSync) @@ -162,7 +159,7 @@ constructor( private fun loadCurrentLanguage() = Locale.forLanguageTag( - sharedPreferencesHelper.read(SharedPreferencesHelper.LANG, Locale.UK.toLanguageTag())!! + sharedPreferencesHelper.read(SharedPreferenceKey.LANG.name, Locale.UK.toLanguageTag())!! ) .displayName @@ -176,18 +173,19 @@ constructor( return if (parse == null) "" else simpleDateFormat.format(parse) } - fun retrieveLastSyncTimestamp(): String? = sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, null) + fun retrieveLastSyncTimestamp(): String? = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) fun updateLastSyncTimestamp(timestamp: OffsetDateTime) { sharedPreferencesHelper.write( - LAST_SYNC_TIMESTAMP, - formatLastSyncTimestamp(timestamp), - async = true + SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, + formatLastSyncTimestamp(timestamp) ) } - fun onTimeOut() { - accountAuthenticator.invalidateAccount() + fun onTimeOut(context: Context) { + accountAuthenticator.invalidateSession { + context.getActivity()?.launchActivityWithNoBackStackHistory() + } } fun onTaskComplete(id: String?) { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/AppDrawer.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/AppDrawer.kt index f35fe65163..bc7f96cfcb 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/AppDrawer.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/components/AppDrawer.kt @@ -196,7 +196,7 @@ fun AppDrawer( iconResource = R.drawable.ic_logout_white, title = stringResource(R.string.logout_user, username), showEndText = false, - onSideMenuClick = { onSideMenuClick(AppMainEvent.Logout) } + onSideMenuClick = { onSideMenuClick(AppMainEvent.Logout(context)) } ) } Box( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt index 439a57ec1d..5092eee467 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt @@ -58,7 +58,7 @@ import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireActivity import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireType -import org.smartregister.fhircore.engine.util.LAST_SYNC_TIMESTAMP +import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.data.patient.PatientRegisterPagingSource @@ -287,7 +287,7 @@ constructor( return appFeatureManager.appFeatureHasSetting(REGISTER_FORM_ID_KEY) } - fun isFirstTimeSync() = sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, null).isNullOrBlank() + fun isFirstTimeSync() = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank() fun progressMessage() = if (searchText.value.isEmpty()) { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt index 3a2a1c9db6..ff30e37fc8 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt @@ -31,11 +31,6 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.workflow.FhirOperator import com.google.android.material.datepicker.MaterialDatePicker import dagger.hilt.android.lifecycle.HiltViewModel -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import kotlin.math.ceil import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow @@ -43,10 +38,9 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.MeasureReport -import org.hl7.fhir.r4.model.Practitioner import org.smartregister.fhircore.engine.domain.util.PaginationConstant import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider -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.extension.loadCqlLibraryBundle import org.smartregister.fhircore.quest.data.report.measure.MeasureReportPatientsPagingSource @@ -58,6 +52,12 @@ import org.smartregister.fhircore.quest.ui.report.measure.models.MeasureReportIn import org.smartregister.fhircore.quest.ui.report.measure.models.MeasureReportPopulationResult import org.smartregister.fhircore.quest.ui.shared.models.MeasureReportPatientViewData import org.smartregister.fhircore.quest.util.mappers.MeasureReportPatientViewDataMapper +import org.smartregister.model.practitioner.PractitionerDetails +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import kotlin.math.ceil @HiltViewModel class MeasureReportViewModel @@ -99,9 +99,9 @@ constructor( MutableStateFlow(emptyFlow()) private val loggedInPractitioner by lazy { - sharedPreferencesHelper.read( - key = LOGGED_IN_PRACTITIONER, - decodeFhirResource = true + sharedPreferencesHelper.read( + key = SharedPreferenceKey.PRACTITIONER_DETAILS.name, + decodeWithGson = true ) } From 3ed6fb2a86049407c1629e0c225961022f3e98d5 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Mon, 5 Jun 2023 08:37:59 +0200 Subject: [PATCH 02/68] init tagging --- .../engine/configuration/app/ConfigService.kt | 40 +++++++++++++++++++ .../engine/data/local/DefaultRepository.kt | 24 ++++++----- .../register/dao/AppointmentRegisterDao.kt | 5 +-- .../local/register/dao/TracingRegisterDao.kt | 5 +-- .../fhircore/engine/sync/ResourceTag.kt | 21 ++++++++++ .../engine/ui/login/LoginViewModel.kt | 7 +++- .../engine/util/extension/StringExtensions.kt | 3 +- .../engine/src/main/res/values/strings.xml | 8 ++++ .../fhircore/quest/QuestApplication.kt | 16 +++++--- .../fhircore/quest/QuestConfigService.kt | 39 ++++++++++++++++++ .../register/TracingRegisterViewModel.kt | 4 +- .../res/xml/network_security_config.xml | 1 + 12 files changed, 144 insertions(+), 29 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/sync/ResourceTag.kt 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..0fd709a1a0 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,6 +21,7 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import java.util.concurrent.TimeUnit +import org.hl7.fhir.r4.model.Coding import org.hl7.fhir.r4.model.Parameters import org.hl7.fhir.r4.model.ResourceType import org.hl7.fhir.r4.model.SearchParameter @@ -29,8 +30,12 @@ import org.smartregister.fhircore.engine.appointment.ProposedWelcomeServiceAppoi 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 org.smartregister.fhircore.engine.util.SharedPreferenceKey +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid import timber.log.Timber /** An interface that provides the application configurations. */ @@ -48,6 +53,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.type == ResourceType.Practitioner.name) { + val id = sharedPreferencesHelper.read(SharedPreferenceKey.PRACTITIONER_ID.name, 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) } 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 58b92a386c..5298dde646 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,12 @@ 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.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 +61,12 @@ 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 configService: ConfigService +) { suspend inline fun loadResource(resourceId: String): T? { return withContext(dispatcherProvider.io()) { fhirEngine.loadResource(resourceId) } @@ -175,10 +183,10 @@ constructor(open val fhirEngine: FhirEngine, open val dispatcherProvider: Dispat return withContext(dispatcherProvider.io()) { resource.onEach { it.generateMissingId() - // TODO: Migrate to using this instead of save - // if (addResourceTags) { - // it.addTags(configService.provideResourceTags(sharedPreferencesHelper)) - // } + it.generateMissingVersionId() + if (addResourceTags) { + it.addTags(configService.provideResourceTags(sharedPreferencesHelper)) + } } fhirEngine.create(*resource) @@ -189,7 +197,7 @@ constructor(open val fhirEngine: FhirEngine, open val dispatcherProvider: Dispat 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 { @@ -197,9 +205,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/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/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/sync/ResourceTag.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/ResourceTag.kt new file mode 100644 index 0000000000..0839e36064 --- /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) 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 544b1cd117..2ecb1ad05d 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 @@ -22,6 +22,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.ResourceType @@ -47,7 +48,6 @@ import org.smartregister.fhircore.engine.util.extension.valueToString import org.smartregister.model.practitioner.PractitionerDetails import retrofit2.HttpException import timber.log.Timber -import javax.inject.Inject @HiltViewModel class LoginViewModel @@ -230,6 +230,7 @@ constructor( } fun savePractitionerDetails(bundle: org.hl7.fhir.r4.model.Bundle) { + Timber.e("Crashing here 2") if (bundle.entry.isNullOrEmpty()) return viewModelScope.launch { val practitionerDetails = bundle.entry.first().resource as PractitionerDetails @@ -239,9 +240,10 @@ constructor( val locations = practitionerDetails.fhirPractitionerDetails?.locations ?: listOf() val locationHierarchies = practitionerDetails.fhirPractitionerDetails?.locationHierarchyList ?: listOf() - + Timber.e("Crashing here 1") val careTeamIds = withContext(dispatcherProvider.io()) { + Timber.e("Crashing here") defaultRepository.create(true, *careTeams.toTypedArray()).map { it.extractLogicalIdUuid() } @@ -259,6 +261,7 @@ constructor( } } + Timber.e("Practitioner ID: ${practitionerDetails.fhirPractitionerDetails?.practitionerId}") sharedPreferences.write( key = SharedPreferenceKey.PRACTITIONER_ID.name, value = practitionerDetails.fhirPractitionerDetails?.practitionerId.valueToString() 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 index 9a72901745..7850e51824 100644 --- 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 @@ -20,5 +20,4 @@ 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" -fun String.practitionerEndpointUrl(): String = "Practitioner?identifier=$this" +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 6f1335ac08..da9c683366 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -140,4 +140,12 @@ Next Appointment Date New Visit Created A new visit for the patient has been successfully created. + https://smartregister.org/ + https://smartregister.org/ + https://smartregister.org/ + https://smartregister.org/ + Practitioner CareTeam + Practitioner Location + Practitioner Organization + Practitioner diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt index 2445b2eab7..7d2e71a44f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt @@ -29,6 +29,8 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration import com.github.anrwatchdog.ANRWatchDog import com.google.android.fhir.datacapture.DataCaptureConfig +import com.google.firebase.ktx.Firebase +import com.google.firebase.perf.ktx.performance import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject import org.smartregister.fhircore.engine.auth.AccountAuthenticator @@ -82,10 +84,12 @@ class QuestApplication : if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) + Firebase.performance.isPerformanceCollectionEnabled =false } if (BuildConfig.DEBUG.not()) { Thread.setDefaultUncaughtExceptionHandler(globalExceptionHandler) + Firebase.performance.isPerformanceCollectionEnabled =true } appInActivityListener = @@ -146,12 +150,12 @@ class QuestApplication : // } // ) } - mForegroundActivityContext - ?.takeIf { - val name = it::class.java.name - name !in activitiesAccessWithoutAuth - } - ?.let { accountAuthenticator.confirmActiveAccount { intent -> it.startActivity(intent) } } +// mForegroundActivityContext +// ?.takeIf { +// val name = it::class.java.name +// name !in activitiesAccessWithoutAuth +// } +// ?.let { accountAuthenticator.confirmActiveAccount { intent -> it.startActivity(intent) } } } private fun initANRWatcher() { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt index 8aaaa6630c..ec6f578f5f 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt @@ -18,10 +18,13 @@ package org.smartregister.fhircore.quest import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext +import org.hl7.fhir.r4.model.Coding +import org.hl7.fhir.r4.model.ResourceType import javax.inject.Inject import javax.inject.Singleton import org.smartregister.fhircore.engine.configuration.app.AuthConfiguration import org.smartregister.fhircore.engine.configuration.app.ConfigService +import org.smartregister.fhircore.engine.sync.ResourceTag @Singleton class QuestConfigService @Inject constructor(@ApplicationContext val context: Context) : @@ -35,4 +38,40 @@ class QuestConfigService @Inject constructor(@ApplicationContext val context: Co clientSecret = BuildConfig.OAUTH_CLIENT_SECRET, accountType = context.getString(R.string.authenticator_account_type) ) + + override fun defineResourceTags() = + listOf( + ResourceTag( + type = ResourceType.CareTeam.name, + tag = + Coding().apply { + system = context.getString(R.string.sync_strategy_careteam_system) + display = context.getString(R.string.sync_strategy_careteam_display) + } + ), + ResourceTag( + type = ResourceType.Location.name, + tag = + Coding().apply { + system = context.getString(R.string.sync_strategy_location_system) + display = context.getString(R.string.sync_strategy_location_display) + } + ), + ResourceTag( + type = ResourceType.Organization.name, + tag = + Coding().apply { + system = context.getString(R.string.sync_strategy_organization_system) + display = context.getString(R.string.sync_strategy_organization_display) + } + ), + ResourceTag( + type = ResourceType.Practitioner.name, + tag = + Coding().apply { + system = context.getString(R.string.sync_strategy_practitioner_system) + display = context.getString(R.string.sync_strategy_practitioner_display) + } + ) + ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterViewModel.kt index b365bdbc79..306ec8b728 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterViewModel.kt @@ -57,7 +57,7 @@ import org.smartregister.fhircore.engine.data.local.TracingRegisterFilter import org.smartregister.fhircore.engine.data.local.register.AppRegisterRepository import org.smartregister.fhircore.engine.sync.OnSyncListener import org.smartregister.fhircore.engine.sync.SyncBroadcaster -import org.smartregister.fhircore.engine.util.LAST_SYNC_TIMESTAMP +import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.quest.R import org.smartregister.fhircore.quest.data.patient.model.PatientPagingSourceState @@ -280,7 +280,7 @@ constructor( return appFeatureManager.appFeatureHasSetting(REGISTER_FORM_ID_KEY) } - fun isFirstTimeSync() = sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, null).isNullOrBlank() + fun isFirstTimeSync() = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank() override fun progressMessage() = if (searchText.value.isEmpty()) { diff --git a/android/quest/src/mwcoreDev/res/xml/network_security_config.xml b/android/quest/src/mwcoreDev/res/xml/network_security_config.xml index 8520516578..d9c36bbae5 100644 --- a/android/quest/src/mwcoreDev/res/xml/network_security_config.xml +++ b/android/quest/src/mwcoreDev/res/xml/network_security_config.xml @@ -2,5 +2,6 @@ fhir-dev.d-tree.org + 52.88.217.146 From ffff742989a778bd12e73f2ad2f53a84852cf460 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Tue, 6 Jun 2023 09:13:50 +0200 Subject: [PATCH 03/68] update register --- android/engine/build.gradle | 14 +-- .../configuration/ConfigurationRegistry.kt | 2 +- .../engine/data/local/DefaultRepository.kt | 10 +- .../local/register/AppRegisterRepository.kt | 22 +++- .../local/register/dao/FamilyRegisterDao.kt | 6 +- .../data/local/register/dao/HivRegisterDao.kt | 2 +- .../fhir/resource/FhirResourceDataSource.kt | 4 + .../questionnaire/QuestionnaireViewModel.kt | 6 +- .../util/extension/ApplicationExtension.kt | 8 -- .../util/extension/FhirEngineExtension.kt | 103 ++++++++++++++++++ .../util/extension/ResourceExtension.kt | 26 +++++ 11 files changed, 171 insertions(+), 32 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt diff --git a/android/engine/build.gradle b/android/engine/build.gradle index af6cbd6f08..103f56a17a 100644 --- a/android/engine/build.gradle +++ b/android/engine/build.gradle @@ -130,13 +130,13 @@ dependencies { implementation(group: "com.github.java-json-tools", name: "msg-simple", version: "1.2"); implementation 'org.codehaus.woodstox:woodstox-core-asl:4.4.1' implementation "ca.uhn.hapi.fhir:hapi-fhir-android:5.4.0" - implementation 'org.opencds.cqf.cql:engine:1.5.4-SNAPSHOT' - implementation 'org.opencds.cqf.cql:engine.fhir:1.5.4-SNAPSHOT' - implementation 'org.opencds.cqf.cql:evaluator:1.4.2-SNAPSHOT' - implementation 'org.opencds.cqf.cql:evaluator.builder:1.4.2-SNAPSHOT' - implementation 'org.opencds.cqf.cql:evaluator.activitydefinition:1.4.2-SNAPSHOT' - implementation 'org.opencds.cqf.cql:evaluator.plandefinition:1.4.2-SNAPSHOT' - implementation ('org.opencds.cqf.cql:evaluator.dagger:1.4.2-SNAPSHOT'){} + implementation 'org.opencds.cqf.cql:engine:1.5.4' + implementation 'org.opencds.cqf.cql:engine.fhir:1.5.4' + implementation 'org.opencds.cqf.cql:evaluator:1.4.2' + implementation 'org.opencds.cqf.cql:evaluator.builder:1.4.2' + implementation 'org.opencds.cqf.cql:evaluator.activitydefinition:1.4.2' + implementation 'org.opencds.cqf.cql:evaluator.plandefinition:1.4.2' + implementation ('org.opencds.cqf.cql:evaluator.dagger:1.4.2'){} api('org.smartregister:workflow:0.1.0-alpha01-preview6-SNAPSHOT') { transitive = true exclude group: 'xerces' 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..0e3d35584d 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 @@ -222,7 +222,7 @@ constructor( } val searchPath = resourceGroup.key + "?${Composition.SP_RES_ID}=$resourceIds" fhirResourceDataSource.loadData(searchPath).entry.forEach { - repository.addOrUpdate(it.resource) + repository.addOrUpdate(false, it.resource) } } } catch (exception: Exception) { 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 5298dde646..b0b900135f 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,6 +42,7 @@ 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 @@ -62,10 +63,11 @@ import org.smartregister.fhircore.engine.util.extension.updateLastUpdated open class DefaultRepository @Inject constructor( - open val fhirEngine: FhirEngine, - open val dispatcherProvider: DispatcherProvider, - open val sharedPreferencesHelper: SharedPreferencesHelper, - open val configService: ConfigService + 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? { 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 9e68207664..6353193e68 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.DefaultRepository import org.smartregister.fhircore.engine.data.local.RegisterFilter import org.smartregister.fhircore.engine.data.local.register.dao.HivRegisterDao @@ -31,17 +33,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, - val registerDaoFactory: RegisterDaoFactory, - val tracer: PerformanceReporter + 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/FamilyRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/FamilyRegisterDao.kt index ebfce9f4cf..e9b9286a5e 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 b89b73d435..1f7f9a8598 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 @@ -371,7 +371,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/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/ui/questionnaire/QuestionnaireViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/questionnaire/QuestionnaireViewModel.kt index 04f8571b31..ca3ebdad09 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 @@ -257,7 +257,7 @@ constructor( reference = "${ResourceType.RelatedPerson.name}/${resource.logicalId}" } } - defaultRepository.addOrUpdate(this) + defaultRepository.addOrUpdate(true,this) } } @@ -467,7 +467,7 @@ constructor( it.valueCodeableConcept.coding.forEach { questionnaireResponse.meta.addTag(it) } } - defaultRepository.addOrUpdate(questionnaireResponse) + defaultRepository.addOrUpdate(true,questionnaireResponse) } suspend fun performExtraction( @@ -489,7 +489,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) } } } 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/FhirEngineExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt new file mode 100644 index 0000000000..a18f8b6d9f --- /dev/null +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirEngineExtension.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2021-2023 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 9899b2e218..6ae283c41c 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 @@ -29,6 +29,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 @@ -47,6 +48,7 @@ import org.json.JSONObject import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import timber.log.Timber +import java.util.LinkedList private val fhirR4JsonParser = FhirContext.forR4Cached().newJsonParser() @@ -291,3 +293,27 @@ 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 +} From a4224b4de6dc6b9e6e1165675572454b8f6b5bb5 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Tue, 6 Jun 2023 09:23:48 +0200 Subject: [PATCH 04/68] update --- .../configuration/ConfigurationRegistry.kt | 68 ++++++++++++++++--- .../engine/data/local/DefaultRepository.kt | 10 +-- .../local/register/AppRegisterRepository.kt | 28 ++++---- .../local/register/dao/FamilyRegisterDao.kt | 6 +- .../data/local/register/dao/HivRegisterDao.kt | 2 +- .../questionnaire/QuestionnaireViewModel.kt | 6 +- .../util/extension/FhirEngineExtension.kt | 2 +- .../util/extension/ResourceExtension.kt | 2 +- 8 files changed, 85 insertions(+), 39 deletions(-) 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 0e3d35584d..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(false, 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/data/local/DefaultRepository.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/DefaultRepository.kt index b0b900135f..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 @@ -63,11 +63,11 @@ import org.smartregister.fhircore.engine.util.extension.updateLastUpdated open class DefaultRepository @Inject constructor( - open val fhirEngine: FhirEngine, - open val dispatcherProvider: DispatcherProvider, - open val sharedPreferencesHelper: SharedPreferencesHelper, - open val configurationRegistry: ConfigurationRegistry, - open val configService: ConfigService + 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? { 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 6353193e68..75359bc0c6 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 @@ -38,22 +38,22 @@ 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 + 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, - sharedPreferencesHelper = sharedPreferencesHelper, - configurationRegistry = configurationRegistry, - configService = configService - ) { + 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/FamilyRegisterDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/local/register/dao/FamilyRegisterDao.kt index e9b9286a5e..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(true,patient) + defaultRepository.addOrUpdate(true, patient) } } } @@ -221,7 +221,7 @@ constructor( family.member.clear() family.active = false - defaultRepository.addOrUpdate(true,family) + defaultRepository.addOrUpdate(true, family) } } @@ -253,7 +253,7 @@ constructor( } } } - defaultRepository.addOrUpdate(true,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 1f7f9a8598..93e41ebbb2 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 @@ -371,7 +371,7 @@ constructor( if (!this.active) throw IllegalStateException("Patient already deleted") this.active = false } - defaultRepository.addOrUpdate(true,patient) + defaultRepository.addOrUpdate(true, patient) } suspend fun transformChildrenPatientToRegisterData(patients: List): List { 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 ca3ebdad09..03a4a35133 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 @@ -257,7 +257,7 @@ constructor( reference = "${ResourceType.RelatedPerson.name}/${resource.logicalId}" } } - defaultRepository.addOrUpdate(true,this) + defaultRepository.addOrUpdate(true, this) } } @@ -467,7 +467,7 @@ constructor( it.valueCodeableConcept.coding.forEach { questionnaireResponse.meta.addTag(it) } } - defaultRepository.addOrUpdate(true,questionnaireResponse) + defaultRepository.addOrUpdate(true, questionnaireResponse) } suspend fun performExtraction( @@ -489,7 +489,7 @@ constructor( suspend fun saveBundleResources(bundle: Bundle) { if (!bundle.isEmpty) { - bundle.entry.forEach { defaultRepository.addOrUpdate(true,it.resource) } + bundle.entry.forEach { defaultRepository.addOrUpdate(true, it.resource) } } } 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 index a18f8b6d9f..cbf290e63e 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2021-2023 Ona Systems, Inc + * 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. 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 6ae283c41c..8776e1847c 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 @@ -48,7 +49,6 @@ import org.json.JSONObject import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.util.fhirpath.FhirPathDataExtractor import timber.log.Timber -import java.util.LinkedList private val fhirR4JsonParser = FhirContext.forR4Cached().newJsonParser() From 6aaa2fda0b55e7d9c5dd93e2708cd906ea0e1e97 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Fri, 9 Jun 2023 08:01:21 +0200 Subject: [PATCH 05/68] update sync --- .../engine/configuration/app/ConfigService.kt | 72 --------- .../fhircore/engine/sync/AppSyncWorker.kt | 4 +- .../fhircore/engine/sync/SyncBroadcaster.kt | 1 + .../engine/sync/SyncListenerManager.kt | 151 ++++++++++++++++++ .../engine/sync/SyncParametersManager.kt | 45 ------ .../fhircore/engine/sync/AppSyncWorkerTest.kt | 8 +- .../fhircore/quest/QuestApplication.kt | 8 +- .../fhircore/quest/QuestConfigService.kt | 72 ++++----- .../quest/ui/main/AppMainViewModel.kt | 19 +-- .../register/PatientRegisterViewModel.kt | 3 +- .../report/measure/MeasureReportViewModel.kt | 10 +- .../register/TracingRegisterViewModel.kt | 3 +- 12 files changed, 215 insertions(+), 181 deletions(-) create mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncListenerManager.kt delete mode 100644 android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncParametersManager.kt 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 0fd709a1a0..b6f1ab2d9b 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 @@ -22,21 +22,15 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import java.util.concurrent.TimeUnit import org.hl7.fhir.r4.model.Coding -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.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 org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper import org.smartregister.fhircore.engine.util.extension.extractLogicalIdUuid -import timber.log.Timber /** An interface that provides the application configurations. */ interface ConfigService { @@ -127,70 +121,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/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/SyncBroadcaster.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt index f18ad9e1ba..1be5a6df59 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 @@ -56,6 +56,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, 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/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/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt index 7d2e71a44f..8742118a18 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt @@ -29,8 +29,6 @@ import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration import com.github.anrwatchdog.ANRWatchDog import com.google.android.fhir.datacapture.DataCaptureConfig -import com.google.firebase.ktx.Firebase -import com.google.firebase.perf.ktx.performance import dagger.hilt.android.HiltAndroidApp import javax.inject.Inject import org.smartregister.fhircore.engine.auth.AccountAuthenticator @@ -84,12 +82,10 @@ class QuestApplication : if (BuildConfig.DEBUG) { Timber.plant(Timber.DebugTree()) - Firebase.performance.isPerformanceCollectionEnabled =false } if (BuildConfig.DEBUG.not()) { Thread.setDefaultUncaughtExceptionHandler(globalExceptionHandler) - Firebase.performance.isPerformanceCollectionEnabled =true } appInActivityListener = @@ -140,7 +136,7 @@ class QuestApplication : override fun onStart(owner: LifecycleOwner) { appInActivityListener.stop() - if (mForegroundActivityContext != null) { +// if (mForegroundActivityContext != null) { // accountAuthenticator.loadActiveAccount( // onActiveAuthTokenFound = {}, // onValidTokenMissing = { @@ -149,7 +145,7 @@ class QuestApplication : // } // } // ) - } +// } // mForegroundActivityContext // ?.takeIf { // val name = it::class.java.name diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt index ec6f578f5f..6ab6fb26d9 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestConfigService.kt @@ -18,10 +18,10 @@ package org.smartregister.fhircore.quest import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext -import org.hl7.fhir.r4.model.Coding -import org.hl7.fhir.r4.model.ResourceType import javax.inject.Inject import javax.inject.Singleton +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 @@ -40,38 +40,38 @@ class QuestConfigService @Inject constructor(@ApplicationContext val context: Co ) override fun defineResourceTags() = - listOf( - ResourceTag( - type = ResourceType.CareTeam.name, - tag = - Coding().apply { - system = context.getString(R.string.sync_strategy_careteam_system) - display = context.getString(R.string.sync_strategy_careteam_display) - } - ), - ResourceTag( - type = ResourceType.Location.name, - tag = - Coding().apply { - system = context.getString(R.string.sync_strategy_location_system) - display = context.getString(R.string.sync_strategy_location_display) - } - ), - ResourceTag( - type = ResourceType.Organization.name, - tag = - Coding().apply { - system = context.getString(R.string.sync_strategy_organization_system) - display = context.getString(R.string.sync_strategy_organization_display) - } - ), - ResourceTag( - type = ResourceType.Practitioner.name, - tag = - Coding().apply { - system = context.getString(R.string.sync_strategy_practitioner_system) - display = context.getString(R.string.sync_strategy_practitioner_display) - } - ) - ) + listOf( + ResourceTag( + type = ResourceType.CareTeam.name, + tag = + Coding().apply { + system = context.getString(R.string.sync_strategy_careteam_system) + display = context.getString(R.string.sync_strategy_careteam_display) + } + ), + ResourceTag( + type = ResourceType.Location.name, + tag = + Coding().apply { + system = context.getString(R.string.sync_strategy_location_system) + display = context.getString(R.string.sync_strategy_location_display) + } + ), + ResourceTag( + type = ResourceType.Organization.name, + tag = + Coding().apply { + system = context.getString(R.string.sync_strategy_organization_system) + display = context.getString(R.string.sync_strategy_organization_display) + } + ), + ResourceTag( + type = ResourceType.Practitioner.name, + tag = + Coding().apply { + system = context.getString(R.string.sync_strategy_practitioner_system) + display = context.getString(R.string.sync_strategy_practitioner_display) + } + ) + ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt index 08f83086f1..d5221cc27a 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt @@ -24,6 +24,12 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.google.android.fhir.sync.SyncJobStatus import dagger.hilt.android.lifecycle.HiltViewModel +import java.text.SimpleDateFormat +import java.time.OffsetDateTime +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import org.smartregister.fhircore.engine.appfeature.AppFeature @@ -46,12 +52,6 @@ import org.smartregister.fhircore.engine.util.extension.setAppLocale import org.smartregister.fhircore.quest.navigation.SideMenuOptionFactory import org.smartregister.p2p.utils.startP2PScreen import timber.log.Timber -import java.text.SimpleDateFormat -import java.time.OffsetDateTime -import java.util.Date -import java.util.Locale -import java.util.TimeZone -import javax.inject.Inject @HiltViewModel class AppMainViewModel @@ -173,12 +173,13 @@ constructor( return if (parse == null) "" else simpleDateFormat.format(parse) } - fun retrieveLastSyncTimestamp(): String? = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) + fun retrieveLastSyncTimestamp(): String? = + sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null) fun updateLastSyncTimestamp(timestamp: OffsetDateTime) { sharedPreferencesHelper.write( - SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, - formatLastSyncTimestamp(timestamp) + SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, + formatLastSyncTimestamp(timestamp) ) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt index ae2bda1a4b..0a2828dbad 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterViewModel.kt @@ -287,7 +287,8 @@ constructor( return appFeatureManager.appFeatureHasSetting(REGISTER_FORM_ID_KEY) } - fun isFirstTimeSync() = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank() + fun isFirstTimeSync() = + sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank() fun progressMessage() = if (searchText.value.isEmpty()) { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt index ff30e37fc8..7d0c1de60b 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/report/measure/MeasureReportViewModel.kt @@ -31,6 +31,11 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.workflow.FhirOperator import com.google.android.material.datepicker.MaterialDatePicker import dagger.hilt.android.lifecycle.HiltViewModel +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import javax.inject.Inject +import kotlin.math.ceil import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.emptyFlow @@ -53,11 +58,6 @@ import org.smartregister.fhircore.quest.ui.report.measure.models.MeasureReportPo import org.smartregister.fhircore.quest.ui.shared.models.MeasureReportPatientViewData import org.smartregister.fhircore.quest.util.mappers.MeasureReportPatientViewDataMapper import org.smartregister.model.practitioner.PractitionerDetails -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import kotlin.math.ceil @HiltViewModel class MeasureReportViewModel diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterViewModel.kt index 306ec8b728..558f836a5c 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/tracing/register/TracingRegisterViewModel.kt @@ -280,7 +280,8 @@ constructor( return appFeatureManager.appFeatureHasSetting(REGISTER_FORM_ID_KEY) } - fun isFirstTimeSync() = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank() + fun isFirstTimeSync() = + sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, null).isNullOrBlank() override fun progressMessage() = if (searchText.value.isEmpty()) { From 39564acacb49eafc2e26fee524bf3741b529f2fa Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Fri, 9 Jun 2023 08:08:36 +0200 Subject: [PATCH 06/68] update sync key --- .../org/smartregister/fhircore/engine/sync/SyncBroadcaster.kt | 4 ++-- .../smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt | 4 ++-- .../quest/ui/patient/register/PatientRegisterActivityTest.kt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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 82dce583fe..e01fd2a35f 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 @@ -126,7 +126,7 @@ 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/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt index 2b18f3079e..7635cb59d7 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,7 @@ 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 diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterActivityTest.kt index 6f25b8becc..7a125b67ab 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterActivityTest.kt @@ -43,8 +43,8 @@ import org.smartregister.fhircore.engine.configuration.view.NavigationOption import org.smartregister.fhircore.engine.databinding.BaseRegisterActivityBinding import org.smartregister.fhircore.engine.ui.register.model.RegisterItem import org.smartregister.fhircore.engine.ui.userprofile.UserProfileFragment -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.quest.R import org.smartregister.fhircore.quest.app.fakes.Faker @@ -73,7 +73,7 @@ class PatientRegisterActivityTest : ActivityRobolectricTest() { every { sharedPreferencesHelper.read(any(), any()) } answers { - if (firstArg() == LAST_SYNC_TIMESTAMP) { + if (firstArg() == SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name) { "" } else { "1234" From dc34c49f782f27eba5094fce81ce1f5b81484a93 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Sun, 18 Jun 2023 22:04:36 +0200 Subject: [PATCH 07/68] fix location syncs --- .../configs/default/config_application.json | 3 ++- .../fhircore/engine/auth/AccountAuthenticator.kt | 2 ++ .../fhircore/engine/di/NetworkModule.kt | 2 +- .../fhircore/engine/sync/SyncBroadcaster.kt | 3 ++- .../fhircore/engine/ui/login/LoginViewModel.kt | 2 +- .../ui/questionnaire/QuestionnaireViewModel.kt | 15 +++++++-------- .../util/{ => extension}/FhirContextExtension.kt | 6 ++++-- .../engine/util/extension/ResourceExtension.kt | 2 +- android/engine/src/main/res/values/strings.xml | 8 ++++---- .../fhircore/engine/sync/SyncBroadcasterTest.kt | 4 +++- .../assets/configs/quest/config_application.json | 3 ++- 11 files changed, 29 insertions(+), 21 deletions(-) rename android/engine/src/main/java/org/smartregister/fhircore/engine/util/{ => extension}/FhirContextExtension.kt (86%) 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 a763547254..a8b4333966 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 @@ -160,6 +160,8 @@ constructor( fun hasActiveSession() = secureSharedPreference.retrieveSessionPin().isNullOrEmpty() + fun retrieveLastLoggedInUsername(): String? = secureSharedPreference.retrieveSessionUsername() + companion object { const val ACCOUNT_TYPE = "ACCOUNT_TYPE" } 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 4c77acc7b2..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 @@ -39,7 +39,7 @@ 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.TokenAuthenticator -import org.smartregister.fhircore.engine.util.getCustomJsonParser +import org.smartregister.fhircore.engine.util.extension.getCustomJsonParser import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory 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 e01fd2a35f..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 @@ -126,7 +126,8 @@ constructor( } } - fun isInitialSync() = sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, 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/ui/login/LoginViewModel.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/ui/login/LoginViewModel.kt index 2ecb1ad05d..f7f6e8801b 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 @@ -279,7 +279,7 @@ constructor( } fun loadLastLoggedInUsername() { - // _username.postValue(accountAuthenticator.retrieveLastLoggedInUsername() ?: "") + _username.postValue(accountAuthenticator.retrieveLastLoggedInUsername() ?: "") } companion object { 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 03a4a35133..af14ecfa28 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 @@ -84,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 @@ -95,7 +96,6 @@ import org.smartregister.fhircore.engine.util.extension.retainMetadata import org.smartregister.fhircore.engine.util.extension.setPropertySafely import org.smartregister.fhircore.engine.util.extension.toCoding import org.smartregister.fhircore.engine.util.helper.TransformSupportServices -import org.smartregister.model.practitioner.PractitionerDetails import timber.log.Timber @HiltViewModel @@ -130,11 +130,10 @@ constructor( sharedPreferencesHelper.read(USER_INFO_SHARED_PREFERENCE_KEY) } - private val loggedInPractitioner by lazy { - sharedPreferencesHelper.read( - key = SharedPreferenceKey.PRACTITIONER_DETAILS.name, - decodeWithGson = 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 = diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirContextExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirContextExtension.kt similarity index 86% rename from android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirContextExtension.kt rename to android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirContextExtension.kt index 0da683d089..655248d10c 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/util/FhirContextExtension.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/FhirContextExtension.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.smartregister.fhircore.engine.util +package org.smartregister.fhircore.engine.util.extension import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.parser.IParser @@ -29,7 +29,9 @@ fun FhirContext.getCustomJsonParser(): IParser { 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/ResourceExtension.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/util/extension/ResourceExtension.kt index 8776e1847c..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 @@ -50,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 { diff --git a/android/engine/src/main/res/values/strings.xml b/android/engine/src/main/res/values/strings.xml index da9c683366..be3d022f5d 100644 --- a/android/engine/src/main/res/values/strings.xml +++ b/android/engine/src/main/res/values/strings.xml @@ -140,10 +140,10 @@ Next Appointment Date New Visit Created A new visit for the patient has been successfully created. - https://smartregister.org/ - https://smartregister.org/ - https://smartregister.org/ - https://smartregister.org/ + 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 Practitioner CareTeam Practitioner Location Practitioner Organization 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 7635cb59d7..6e3176546b 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 @@ -86,7 +86,9 @@ class SyncBroadcasterTest : RobolectricTest() { } every { WorkManager.getInstance(any()) } returns workManager - every { sharedPreferencesHelper.read(SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, 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 diff --git a/android/quest/src/main/assets/configs/quest/config_application.json b/android/quest/src/main/assets/configs/quest/config_application.json index 7e08cdfe81..1dca7fdb8d 100644 --- a/android/quest/src/main/assets/configs/quest/config_application.json +++ b/android/quest/src/main/assets/configs/quest/config_application.json @@ -8,5 +8,6 @@ ], "applicationName": "Quest", "appLogoIconResourceFile": "ic_liberia", - "count": "100" + "count": "100", + "syncStrategies": ["Organization", "Location", "CareTeam", "Practitioner"] } \ No newline at end of file From 639c7afe16d9168dcb972f1b775c44565a5770c7 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Wed, 21 Jun 2023 09:44:01 +0200 Subject: [PATCH 08/68] fix activity listener --- .../engine/auth/AccountAuthenticator.kt | 67 +++++++++++++++++++ .../data/remote/shared/TokenAuthenticator.kt | 2 + .../fhircore/quest/QuestApplication.kt | 31 +++++---- .../fhircore/quest/ui/main/AppMainActivity.kt | 13 ++-- .../quest/ui/main/AppMainViewModel.kt | 29 ++++---- 5 files changed, 104 insertions(+), 38 deletions(-) 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 a8b4333966..07632f2f6d 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 @@ -20,9 +20,12 @@ import android.accounts.AbstractAccountAuthenticator import android.accounts.Account import android.accounts.AccountAuthenticatorResponse import android.accounts.AccountManager +import android.accounts.AccountManagerCallback import android.content.Context import android.content.Intent 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 @@ -158,6 +161,70 @@ constructor( tokenAuthenticator.invalidateSession(onSessionInvalidated) } + fun refreshSessionAuthToken(): Bundle? { + val account = tokenAuthenticator.findAccount() + return if (account != null) { + getAuthToken(null, account, AUTH_TOKEN_TYPE, null) + } else { + null + } + } + + private fun confirmAccount( + account: Account, + callback: AccountManagerCallback, + errorHandler: Handler = Handler(Looper.getMainLooper(), DefaultErrorHandler) + ) { + accountManager.confirmCredentials(account, Bundle(), null, callback, errorHandler) + } + + fun confirmActiveAccount(onResult: (Intent) -> Unit) { + tokenAuthenticator.findAccount()?.run { + confirmAccount( + this, + callback = { + val bundle = it.result + bundle.getParcelable(AccountManager.KEY_INTENT)?.let { loginIntent -> + loginIntent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP + onResult(loginIntent) + } + } + ) + } + } + + 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) + } + + 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 + } + + logInIntent!! + logInIntent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP + onValidTokenMissing(logInIntent) + } + }, + Handler(Looper.getMainLooper(), DefaultErrorHandler) + ) + } + } + } + fun hasActiveSession() = secureSharedPreference.retrieveSessionPin().isNullOrEmpty() fun retrieveLastLoggedInUsername(): String? = secureSharedPreference.retrieveSessionUsername() 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 index 6386e13896..ddf6f24fa1 100644 --- 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 @@ -237,6 +237,8 @@ constructor( password.concatToString().toSha1().contentEquals(credentials?.password) } + fun getAccountType(): String = authConfiguration.accountType + fun findAccount(): Account? { val credentials = secureSharedPreference.retrieveCredentials() return accountManager.getAccountsByType(authConfiguration.accountType).find { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt index 3631b79fa8..51a811377a 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/QuestApplication.kt @@ -139,22 +139,21 @@ class QuestApplication : override fun onStart(owner: LifecycleOwner) { appInActivityListener.stop() -// if (mForegroundActivityContext != null) { -// accountAuthenticator.loadActiveAccount( -// onActiveAuthTokenFound = {}, -// onValidTokenMissing = { -// if (it.component!!.className != mForegroundActivityContext!!::class.java.name) { -// mForegroundActivityContext!!.startActivity(it) -// } -// } -// ) -// } -// mForegroundActivityContext -// ?.takeIf { -// val name = it::class.java.name -// name !in activitiesAccessWithoutAuth -// } -// ?.let { accountAuthenticator.confirmActiveAccount { intent -> it.startActivity(intent) } } + if (mForegroundActivityContext != null) { + accountAuthenticator.loadActiveAccount( + onValidTokenMissing = { + if (it.component!!.className != mForegroundActivityContext!!::class.java.name) { + mForegroundActivityContext!!.startActivity(it) + } + } + ) + } + mForegroundActivityContext + ?.takeIf { + val name = it::class.java.name + name !in activitiesAccessWithoutAuth + } + ?.let { accountAuthenticator.confirmActiveAccount { intent -> it.startActivity(intent) } } } private fun initANRWatcher() { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt index 6fee541649..b43b5faa99 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainActivity.kt @@ -119,10 +119,9 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { } showToast(getString(R.string.sync_failed_text)) val hasAuthError = - state.exceptions != null && - state.exceptions.any { - it.exception is HttpException && (it.exception as HttpException).code() == 401 - } + state.exceptions.any { + it.exception is HttpException && (it.exception as HttpException).code() == 401 + } val message = if (hasAuthError) R.string.session_expired else R.string.sync_check_internet showToast(getString(message)) appMainViewModel.onEvent( @@ -138,11 +137,7 @@ open class AppMainActivity : BaseMultiLanguageActivity(), OnSyncListener { AppMainEvent.RefreshAuthToken { intent -> authActivityLauncherForResult.launch(intent) } ) } - Timber.e( - (if (state.exceptions != null) state.exceptions else emptyList()).joinToString { - it.exception.message.toString() - } - ) + Timber.e((state.exceptions).joinToString { it.exception.message.toString() }) scheduleFhirBackgroundWorkers() } is SyncJobStatus.Finished -> { diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt index d5221cc27a..14e39d8c72 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt @@ -16,8 +16,10 @@ package org.smartregister.fhircore.quest.ui.main +import android.accounts.AccountManager import android.app.Activity import android.content.Context +import android.content.Intent import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel @@ -108,19 +110,20 @@ constructor( } is AppMainEvent.RefreshAuthToken -> { Timber.e("Refreshing token") -// accountAuthenticator.refreshSessionAuthToken { accountBundleFuture -> -// val bundle = accountBundleFuture.result -// bundle.getParcelable(AccountManager.KEY_INTENT).let { intent -> -// if (intent == null && bundle.containsKey(AccountManager.KEY_AUTHTOKEN)) { -// syncBroadcaster.runSync() -// return@let -// } -// intent!! -// appMainUiState.value = appMainUiState.value.copy(syncClickEnabled = true) -// intent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP -// event.launchManualAuth(intent) -// } -// } + try { + accountAuthenticator.refreshSessionAuthToken()?.let { bundle -> + bundle.getParcelable(AccountManager.KEY_INTENT).let { intent -> + if (intent == null && bundle.containsKey(AccountManager.KEY_AUTHTOKEN)) { + syncBroadcaster.runSync() + return@let + } + intent!! + appMainUiState.value = appMainUiState.value.copy(syncClickEnabled = true) + intent.flags += Intent.FLAG_ACTIVITY_SINGLE_TOP + event.launchManualAuth(intent) + } + } + } catch (e: Exception) {} } AppMainEvent.ResumeSync -> { run(resumeSync) From 92536ca1cfd5ddf819ccfbd071aa3d1684df62ca Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Mon, 26 Jun 2023 10:26:11 +0200 Subject: [PATCH 09/68] test fixes --- android/deps.gradle | 2 +- android/engine/build.gradle | 1 + .../fhircore/engine/auth/AuthCredentials.kt | 3 +- .../data/remote/shared/TokenAuthenticator.kt | 17 +- .../engine/ui/login/LoginViewModel.kt | 6 +- .../engine/util/SecureSharedPreference.kt | 32 +- .../fhircore/engine/util/SecurityUtil.kt | 30 +- .../fhircore/engine/app/AppConfigService.kt | 50 ++ .../fhircore/engine/app/ConfigServiceTest.kt | 139 +++-- .../fhircore/engine/app/fakes/FakeModel.kt | 8 +- .../fhircore/engine/app/fakes/Faker.kt | 63 ++- .../appfeature/AppFeatureManagerTest.kt | 28 +- .../engine/auth/AccountAuthenticatorTest.kt | 527 ++++++------------ .../engine/auth/TokenAuthenticatorTest.kt | 490 ++++++++++++++++ .../engine/auth/TokenManagerServiceTest.kt | 132 ----- .../ConfigurationRegistryTest.kt | 48 +- .../engine/cql/LibraryEvaluatorTest.kt | 9 +- .../data/local/DefaultRepositoryTest.kt | 113 +++- .../register/AppRegisterRepositoryTest.kt | 30 +- .../dao/AppointmentRegisterDaoTest.kt | 22 +- .../local/register/dao/HivRegisterDaoTest.kt | 32 +- .../register/dao/TracingRegisterDaoTest.kt | 28 +- .../interceptor/OAuthInterceptorTest.kt | 56 -- .../engine/sync/SyncBroadcasterTest.kt | 2 +- .../engine/ui/RegisterViewModelTest.kt | 6 +- .../ui/appsetting/AppSettingActivityTest.kt | 8 +- .../engine/ui/components/LoginScreenTest.kt | 1 - .../ui/components/LoginScreenWithLogoTest.kt | 1 - .../engine/ui/login/LoginActivityTest.kt | 53 +- .../engine/ui/login/LoginViewModelTest.kt | 389 ++++++------- .../engine/ui/pin/PinViewModelTest.kt | 12 +- .../QuestionnaireActivityTest.kt | 2 +- .../QuestionnaireViewModelTest.kt | 91 ++- .../ui/register/BaseRegisterActivityTest.kt | 44 +- .../register/ComposeRegisterFragmentTest.kt | 6 +- .../ui/userprofile/UserProfileFragmentTest.kt | 8 +- .../userprofile/UserProfileViewModelTest.kt | 44 +- .../engine/util/SecureSharedPreferenceTest.kt | 34 +- .../util/SharedPreferencesHelperTest.kt | 23 +- .../extension/AndroidExtensionApi24Test.kt | 2 +- 40 files changed, 1485 insertions(+), 1107 deletions(-) create mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt delete mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenManagerServiceTest.kt delete mode 100644 android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptorTest.kt diff --git a/android/deps.gradle b/android/deps.gradle index e3ad8d3a47..519dc9e4f0 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 diff --git a/android/engine/build.gradle b/android/engine/build.gradle index 103f56a17a..e99cac44fe 100644 --- a/android/engine/build.gradle +++ b/android/engine/build.gradle @@ -238,6 +238,7 @@ dependencies { // Hilt test dependencies testImplementation("com.google.dagger:hilt-android-testing:$hiltVersion") kaptTest("com.google.dagger:hilt-android-compiler:$hiltVersion") + kaptTest("com.google.dagger:hilt-compiler:$hiltVersion") testImplementation deps.junit5_api testRuntimeOnly deps.junit5_engine 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/data/remote/shared/TokenAuthenticator.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/data/remote/shared/TokenAuthenticator.kt index ddf6f24fa1..a2f96976e0 100644 --- 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 @@ -34,18 +34,18 @@ 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.auth.AuthCredentials 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.toSha1 +import org.smartregister.fhircore.engine.util.toPasswordHash import retrofit2.HttpException import timber.log.Timber @@ -208,9 +208,7 @@ constructor( setAuthToken(newAccount, AUTH_TOKEN_TYPE, oAuthResponse.accessToken) } // Save credentials - secureSharedPreference.saveCredentials( - AuthCredentials(username, password.concatToString().toSha1()) - ) + secureSharedPreference.saveCredentials(username, password) } } @@ -231,10 +229,13 @@ constructor( } } - fun validateSavedLoginCredentials(username: String, password: CharArray): Boolean { + fun validateSavedLoginCredentials(username: String, enteredPassword: CharArray): Boolean { val credentials = secureSharedPreference.retrieveCredentials() - return username.equals(credentials?.username, ignoreCase = true) && - password.concatToString().toSha1().contentEquals(credentials?.password) + 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 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 f7f6e8801b..3404f85025 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 @@ -137,12 +137,12 @@ constructor( } fun login(context: Context) { - if (!username.value.isNullOrBlank() && !password.value.isNullOrBlank()) { + if (!_username.value.isNullOrBlank() && !_password.value.isNullOrBlank()) { _loginErrorState.postValue(null) _showProgressBar.postValue(true) - val trimmedUsername = username.value!!.trim() - val passwordAsCharArray = password.value!!.toCharArray() + val trimmedUsername = _username.value!!.trim() + val passwordAsCharArray = _password.value!!.toCharArray() if (context.getActivity()!!.isDeviceOnline()) { viewModelScope.launch { 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/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/app/AppConfigService.kt index c06df357cb..b0e76ebb7d 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,11 @@ 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 class AppConfigService @Inject constructor(@ApplicationContext val context: Context) : ConfigService { @@ -32,4 +35,51 @@ 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 = ResourceType.Practitioner.name, + tag = + Coding().apply { + system = PRACTITIONER_SYSTEM + display = PRACTITIONER_DISPLAY + } + ) + ) + + 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..e8b80f1572 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 = "app/debug" private val systemPath = (System.getProperty("user.dir") + File.separator + @@ -48,35 +54,60 @@ 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 { fhirEngine.search(any()) } returns listOf(composition) + + runBlocking { + configurationRegistry.loadConfigurations( + appId = APP_DEBUG, + ) {} + } + } + + private fun getBasePath(configName: String): String { + return "/configs/default/config_$configName.json" + } + + 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 { defaultRepository.getBinary(any()) } answers + 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") {} } - } - - private fun getBasePath(configName: String): String { - return "/configs/default/config_$configName.json" - } - - fun buildTestConfigurationRegistry(defaultRepository: DefaultRepository): ConfigurationRegistry { 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, + ) {} + } 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..d27418707c 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 @@ -21,7 +21,6 @@ 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 +40,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 +68,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 +92,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 +113,10 @@ class AccountAuthenticatorTest : RobolectricTest() { val parcelable = bundle.getParcelable(KEY_INTENT) Assert.assertNotNull(parcelable) Assert.assertNotNull(parcelable!!.extras) - Assert.assertEquals( - configService.provideAuthConfiguration().accountType, - parcelable.getStringExtra(KEY_ACCOUNT_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)) + Assert.assertTrue(parcelable.extras!!.containsKey(AccountAuthenticator.ACCOUNT_TYPE)) + Assert.assertEquals(accountType, parcelable.getStringExtra(AccountAuthenticator.ACCOUNT_TYPE)) + Assert.assertEquals(authTokenType, parcelable.getStringExtra(TokenAuthenticator.AUTH_TOKEN_TYPE)) } @Test @@ -153,313 +129,32 @@ 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(), any(), any(), any() @@ -487,7 +182,7 @@ class AccountAuthenticatorTest : RobolectricTest() { } } - accountAuthenticator.loadActiveAccount(onActiveAuthTokenFound = {}, onValidTokenMissing = {}) + accountAuthenticator.loadActiveAccount(onValidTokenMissing = {}) verify { accountManager.peekAuthToken(account, accountType) } verify { accountManager.invalidateAuthToken(accountType, token) } @@ -495,8 +190,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 +228,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 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 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 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..f14c69f578 --- /dev/null +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/auth/TokenAuthenticatorTest.kt @@ -0,0 +1,490 @@ +/* + * 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 + + 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 + + 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..bbbf75c624 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,6 +18,9 @@ package org.smartregister.fhircore.engine.configuration import android.content.Context import androidx.test.core.app.ApplicationProvider +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.search.Search +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 @@ -42,12 +45,14 @@ 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.robolectric.RobolectricTest.Companion.readFile +import org.smartregister.fhircore.engine.rule.CoroutineTestRule 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.extension.decodeResourceFromString @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @@ -58,13 +63,13 @@ class ConfigurationRegistryTest : RobolectricTest() { @Inject lateinit var sharedPreferencesHelper: SharedPreferencesHelper @Inject lateinit var dispatcherProvider: DispatcherProvider val context = ApplicationProvider.getApplicationContext() - + @get:Rule(order = 1) val coroutineRule = CoroutineTestRule() @BindValue val secureSharedPreference: SecureSharedPreference = mockk() - private val testAppId = "default" + private val application: Context = ApplicationProvider.getApplicationContext() private lateinit var fhirResourceDataSource: FhirResourceDataSource lateinit var configurationRegistry: ConfigurationRegistry - val defaultRepository: DefaultRepository = mockk() + var fhirEngine: FhirEngine = mockk() @Before fun setUp() { @@ -73,17 +78,17 @@ class ConfigurationRegistryTest : RobolectricTest() { configurationRegistry = ConfigurationRegistry( context, + fhirEngine, fhirResourceDataSource, sharedPreferencesHelper, - dispatcherProvider, - defaultRepository + coroutineRule.testDispatcherProvider, ) + 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 +99,6 @@ class ConfigurationRegistryTest : RobolectricTest() { @Test fun testRetrieveConfigurationShouldReturnLoginViewConfiguration() { - Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry) - val retrievedConfiguration = configurationRegistry.retrieveConfiguration( AppConfigClassification.LOGIN @@ -117,8 +120,6 @@ class ConfigurationRegistryTest : RobolectricTest() { @Test fun testRetrievePinConfigurationShouldReturnLoginViewConfiguration() { - Faker.loadTestConfigurationRegistryData(defaultRepository, configurationRegistry) - val retrievedConfiguration = configurationRegistry.retrieveConfiguration(AppConfigClassification.PIN) @@ -151,32 +152,19 @@ 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) - 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")) } @@ -217,7 +205,7 @@ class ConfigurationRegistryTest : RobolectricTest() { @Test fun testFetchNonWorkflowConfigResources() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) - coEvery { configurationRegistry.repository.searchCompositionByIdentifier(testAppId) } returns + coEvery { configurationRegistry.searchCompositionByIdentifier(testAppId) } returns Composition().apply { addSection().apply { this.focus = Reference().apply { reference = "Questionnaire/123" } } } @@ -240,7 +228,7 @@ class ConfigurationRegistryTest : RobolectricTest() { configurationRegistry.appId = "testApp" Assert.assertEquals(0, configurationRegistry.workflowPointsMap.size) - coEvery { defaultRepository.searchCompositionByIdentifier(any()) } returns null + coEvery { configurationRegistry.searchCompositionByIdentifier(any()) } returns null runBlocking { configurationRegistry.fetchNonWorkflowConfigResources() } 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..b29cbc89a8 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,8 @@ import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkStatic import io.mockk.verify +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runBlockingTest import org.hl7.fhir.r4.model.Address @@ -47,18 +53,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 +119,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 +148,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 +160,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 +180,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 +199,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 +222,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 +246,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 +270,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 +291,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 +316,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..4dfe7598de 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 @@ -60,6 +62,7 @@ import org.smartregister.fhircore.engine.util.extension.encodeResourceToString 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 +74,29 @@ 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..244b9bf5a4 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 @@ -82,18 +84,29 @@ 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/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 d06585ef0c..0000000000 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/data/remote/shared/interceptor/OAuthInterceptorTest.kt +++ /dev/null @@ -1,56 +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.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/sync/SyncBroadcasterTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/sync/SyncBroadcasterTest.kt index 6e3176546b..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 @@ -188,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..0b612e2b0d 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 @@ -36,7 +36,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 +53,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 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..4c9ddb225b 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 @@ -20,12 +20,14 @@ 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 javax.inject.Inject import org.junit.Assert import org.junit.Before import org.junit.Rule @@ -50,11 +52,11 @@ class AppSettingActivityTest { 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() @Before fun setUp() { 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/login/LoginActivityTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/login/LoginActivityTest.kt index 5eec980116..749c68e7e1 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( @@ -222,7 +239,6 @@ class LoginActivityTest : ActivityRobolectricTest() { @Test fun testNavigateToHomeShouldVerifyExpectedIntentWhenPinExists() { - coEvery { accountAuthenticator.hasActivePin() } returns true val loginConfig = loginViewConfigurationOf(enablePin = true) loginViewModel.updateViewConfigurations(loginConfig) loginViewModel.navigateToHome() @@ -231,7 +247,6 @@ class LoginActivityTest : ActivityRobolectricTest() { @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..116407cebd 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,267 @@ 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() { - - // Provide username and password (The saved password is hashed, actual one is needed) - loginViewModel.run { - onUsernameUpdated("demo") - onPasswordUpdated("51r1K4l1") - } - - val callMock = spyk>() - - every { callMock.enqueue(any()) } just runs + fun testUnSuccessfulOfflineLogin() { + val activity = mockedActivity() - every { accountAuthenticatorSpy.fetchToken(any(), any()) } returns callMock + updateCredentials() - loginViewModel.attemptRemoteLogin() + every { + accountAuthenticator.validateLoginCredentials(thisUsername, thisPassword.toCharArray()) + } returns false - // Login error is reset to null - Assert.assertNull(loginViewModel.loginErrorState.value) + every { + tokenAuthenticator.validateSavedLoginCredentials(thisUsername, thisPassword.toCharArray()) + } returns false - // Show progress bar active - Assert.assertNotNull(loginViewModel.showProgressBar.value) - Assert.assertTrue(loginViewModel.showProgressBar.value!!) + loginViewModel.login(activity) - 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() { + fun testSuccessfulOnlineLoginWithActiveSessionWithSavedPractitionerDetails() { + updateCredentials() + sharedPreferencesHelper.write( + SharedPreferenceKey.PRACTITIONER_DETAILS.name, + PractitionerDetails() + ) + every { tokenAuthenticator.sessionActive() } returns true + loginViewModel.login(mockedActivity(isDeviceOnline = true)) + Assert.assertFalse(loginViewModel.showProgressBar.value!!) + Assert.assertTrue(loginViewModel.navigateToHome.value!!) + } - // Provide username and password (The saved password is hashed, actual one is needed) - loginViewModel.run { - onUsernameUpdated("demo") - onPasswordUpdated("51r1K4l1") - } + @Test + fun testSuccessfulOnlineLoginWithActiveSessionWithNoPractitionerDetailsSaved() { + updateCredentials() + every { tokenAuthenticator.sessionActive() } returns true + loginViewModel.login(mockedActivity(isDeviceOnline = true)) + Assert.assertFalse(loginViewModel.navigateToHome.value!!) + } - val callMock = spyk>() + @Test + 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!! + ) + } - val mockResponse: Response = - Response.success( + @Test + fun testSuccessfulNewOnlineLoginShouldFetchUserInfoAndPractitioner() { + updateCredentials() + secureSharedPreference.saveCredentials(thisUsername, thisPassword.toCharArray()) + every { tokenAuthenticator.sessionActive() } returns false + 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" ) ) - loginViewModel.oauthResponseHandler.handleResponse(call = callMock, response = mockResponse) + // 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.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) - } + // Login was successful savePractitionerDetails was called + val bundleSlot = slot() + verify { loginViewModel.savePractitionerDetails(capture(bundleSlot)) } - @Test - fun testForgotPasswordLoadsContact() { - loginViewModel.forgotPassword() - Assert.assertEquals("tel:0123456789", loginViewModel.launchDialPad.value) + Assert.assertNotNull(bundleSlot.captured) + Assert.assertTrue(bundleSlot.captured.entry.isNotEmpty()) + Assert.assertTrue(bundleSlot.captured.entry[0].resource is PractitionerDetails) } @Test - fun testAttemptRemoteLoginWithCredentialsCallsAccountAuthenticator() { + 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" + ) + ) - // Provide username and password - loginViewModel.run { - onUsernameUpdated("testUser") - onPasswordUpdated("51r1K4l1") - } + // Mock result for fetch user info via keycloak endpoint + coEvery { keycloakService.fetchUserInfo() } returns + Response.error(400, mockk(relaxed = true)) + + // Mock result for retrieving a FHIR resource using user's keycloak uuid + coEvery { fhirResourceService.getResource(any()) } returns Bundle() - loginViewModel.attemptRemoteLogin() + loginViewModel.login(mockedActivity(isDeviceOnline = true)) - Assert.assertEquals(null, loginViewModel.loginErrorState.value) - loginViewModel.showProgressBar.value?.let { Assert.assertTrue(it) } - verify { accountAuthenticatorSpy.fetchToken("testUser", "51r1K4l1".toCharArray()) } + Assert.assertFalse(loginViewModel.showProgressBar.value!!) + Assert.assertEquals(LoginErrorState.ERROR_FETCHING_USER, loginViewModel.loginErrorState.value!!) } @Test - fun testHandleErrorMessageShouldVerifyExpectedMessage() { + fun testUnSuccessfulOnlineLoginWhenAccessTokenNotReceived() { + updateCredentials() + secureSharedPreference.saveCredentials(thisUsername, thisPassword.toCharArray()) + every { tokenAuthenticator.sessionActive() } returns false + coEvery { + tokenAuthenticator.fetchAccessToken(thisUsername, thisPassword.toCharArray()) + } returns Result.failure(UnknownHostException()) - 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.UNKNOWN_HOST, loginViewModel.loginErrorState.value!!) } - @Test - fun testFetchLoggedInPractitionerShouldRetrieveAndSavePractitioner() { - coroutineTestRule.runBlockingTest { - val userInfo = - UserInfo( - questionnairePublisher = "quesP1", - keycloakUuid = "keyck1", - organization = "org", - location = "Nairobi" - ) - - val practitionerId = "12123" - - coEvery { resourceService.searchResource(ResourceType.Practitioner.name, any()) } returns - Bundle().apply { - entry.add( - Bundle.BundleEntryComponent().apply { - resource = Practitioner().apply { id = practitionerId } - } - ) + private fun practitionerDetails(): PractitionerDetails { + return PractitionerDetails().apply { + fhirPractitionerDetails = + FhirPractitionerDetails().apply { + organizations = + listOf( + Organization().apply { + name = "the.org" + id = "the.org.id" + } + ) } - - loginViewModel.fetchLoggedInPractitioner(userInfo) - - // 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!!) } } - @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 6834eec48e..997e55ffbd 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 @@ -466,7 +466,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" } 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..de37323e85 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,6 +45,7 @@ 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 @@ -81,7 +81,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 +91,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 +113,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 +124,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 +174,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { tracer = FakePerformanceReporter() ) ) - + coEvery { fhirEngine.get(ResourceType.Patient, any()) } returns samplePatient() runBlocking { questionnaireViewModel.getQuestionnaireConfig( "patient-registration", @@ -161,7 +186,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() @@ -433,8 +458,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 +495,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { questionnaire = questionnaire ) - coVerify { defaultRepo.addOrUpdate(any()) } + coVerify { defaultRepo.addOrUpdate(resource = any()) } unmockkObject(ResourceMapper) } @@ -495,7 +520,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 +538,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 +560,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 +665,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 +684,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { questionnaireViewModel.saveQuestionnaireResponse(questionnaire, questionnaireResponse) } - coVerify(inverse = true) { defaultRepo.addOrUpdate(questionnaireResponse) } + coVerify(inverse = true) { defaultRepo.addOrUpdate(resource = questionnaireResponse) } } @Test @@ -684,7 +711,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 +721,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 +745,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 +870,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 +896,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,6 +1075,16 @@ 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() 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 1805868ddd..15c680d836 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 @@ -69,7 +69,7 @@ import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.auth.AccountAuthenticator 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 @@ -78,8 +78,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 @@ -93,20 +93,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 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() @@ -422,7 +424,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { val currentDateTime = OffsetDateTime.now() every { sharedPreferencesHelper.read(any(), any()) } answers { - if (firstArg() == LAST_SYNC_TIMESTAMP) { + if (firstArg() == SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name) { currentDateTime.asString() } else { "" @@ -436,7 +438,10 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { // Shared preference saved with last sync timestamp Assert.assertEquals( currentDateTime.asString(), - testRegisterActivity.registerViewModel.sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, null) + testRegisterActivity.registerViewModel.sharedPreferencesHelper.read( + SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, + null + ) ) } @@ -449,7 +454,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { val lastDateTimestamp = OffsetDateTime.now() every { sharedPreferencesHelper.read(any(), any()) } answers { - if (firstArg() == LAST_SYNC_TIMESTAMP) { + if (firstArg() == SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name) { lastDateTimestamp.asString() } else { "" @@ -461,7 +466,10 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { Assert.assertTrue(registerActivityBinding.containerProgressSync.hasOnClickListeners()) Assert.assertEquals( lastDateTimestamp.asString(), - testRegisterActivity.registerViewModel.sharedPreferencesHelper.read(LAST_SYNC_TIMESTAMP, null) + testRegisterActivity.registerViewModel.sharedPreferencesHelper.read( + SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, + null + ) ) } @@ -476,7 +484,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { Assert.assertEquals(View.GONE, registerActivityBinding.progressSync.visibility) val syncStatus = testRegisterActivity.registerViewModel.sharedPreferencesHelper.read( - LAST_SYNC_TIMESTAMP, + SharedPreferenceKey.LAST_SYNC_TIMESTAMP.name, testRegisterActivity.getString(R.string.syncing_retry) ) Assert.assertEquals(syncStatus, registerActivityBinding.tvLastSyncTimestamp.text.toString()) @@ -491,7 +499,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { val dialog = Shadows.shadowOf(ShadowAlertDialog.getLatestAlertDialog()) dialog.clickOnItem(0) - verify(exactly = 1) { sharedPreferencesHelper.write(SharedPreferencesHelper.LANG, "en") } + verify(exactly = 1) { sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, "en") } Assert.assertEquals( testRegisterActivity.getString(R.string.select_language), @@ -502,16 +510,16 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { @Test fun testOnNavigation_logout_onItemClicked_should_finishActivity() { - every { tokenManagerService.getActiveAccount() } returns Account("abc", "type") - every { tokenManagerService.isTokenActive(any()) } returns false - every { accountAuthenticator.logout() } just runs + every { tokenAuthenticator.findAccount() } returns Account("abc", "type") + every { tokenAuthenticator.isTokenActive(any()) } returns false + every { accountAuthenticator.logout(context) } just runs val logoutMenuItem = RoboMenuItem(R.id.menu_item_logout) testRegisterActivity.onNavigationItemSelected(logoutMenuItem) Assert.assertFalse( testRegisterActivity.registerActivityBinding.drawerLayout.isDrawerOpen(GravityCompat.START) ) - verify(exactly = 1) { accountAuthenticator.logout() } + verify(exactly = 1) { accountAuthenticator.logout(context) } } @Test @@ -600,7 +608,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { @Test fun testHandleSyncFailed_should_verifyAllInternalState() { - every { accountAuthenticator.logout() } returns Unit + every { accountAuthenticator.logout(context) } returns Unit val glitchState = SyncJobStatus.Glitch( @@ -612,7 +620,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { ) handleSyncFailed(glitchState) - verify(exactly = 1) { accountAuthenticator.logout() } + verify(exactly = 1) { accountAuthenticator.logout(context) } val failedState = SyncJobStatus.Failed( @@ -624,7 +632,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { ) handleSyncFailed(failedState) - verify(exactly = 1, inverse = true) { accountAuthenticator.logout() } + verify(exactly = 1, inverse = true) { accountAuthenticator.logout(context) } val glitchStateInterruptedIOException = SyncJobStatus.Glitch( diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/register/ComposeRegisterFragmentTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/register/ComposeRegisterFragmentTest.kt index 23513ff183..3dd6efa311 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/register/ComposeRegisterFragmentTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/register/ComposeRegisterFragmentTest.kt @@ -33,7 +33,6 @@ 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.mockk import io.mockk.spyk import java.time.OffsetDateTime import org.junit.After @@ -44,7 +43,6 @@ import org.junit.Test import org.smartregister.fhircore.engine.HiltActivityForTest import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.data.domain.util.RegisterRepository -import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.domain.util.DataMapper import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.ui.components.CircularProgressBar @@ -61,9 +59,7 @@ class ComposeRegisterFragmentTest : RobolectricTest() { @get:Rule(order = 1) val activityScenarioRule = ActivityScenarioRule(HiltActivityForTest::class.java) - val defaultRepository: DefaultRepository = mockk() - - @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry(defaultRepository) + @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry() private lateinit var testComposeRegisterFragment: TestComposableRegisterFragment diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileFragmentTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileFragmentTest.kt index c8e2cc7eaf..597187d3b5 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileFragmentTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileFragmentTest.kt @@ -21,10 +21,12 @@ import android.content.Context import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.fragment.app.commitNow import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.rules.ActivityScenarioRule 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.just import io.mockk.mockk @@ -54,7 +56,7 @@ class UserProfileFragmentTest : RobolectricTest() { val activityScenarioRule = ActivityScenarioRule(HiltActivityForTest::class.java) @get:Rule(order = 2) val instantTaskExecutorRule = InstantTaskExecutorRule() - + private val context = ApplicationProvider.getApplicationContext() @BindValue var accountAuthenticator: AccountAuthenticator = mockk() @BindValue var userProfileViewModel: UserProfileViewModel = @@ -84,11 +86,11 @@ class UserProfileFragmentTest : RobolectricTest() { @Test fun testThatProfileIsDestroyedWhenUserLogsOut() { - every { accountAuthenticator.logout() } just runs + every { accountAuthenticator.logout(any<() -> Unit>()) } just runs launchUserProfileFragment() activityScenarioRule.scenario.moveToState(Lifecycle.State.RESUMED) - userProfileFragment.userProfileViewModel.logoutUser() + userProfileFragment.userProfileViewModel.logoutUser(context) Assert.assertNotNull(userProfileFragment.userProfileViewModel.onLogout.value) Assert.assertTrue(userProfileFragment.userProfileViewModel.onLogout.value!!) } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileViewModelTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileViewModelTest.kt index 3424ea3e00..f196ed8ee9 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileViewModelTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/ui/userprofile/UserProfileViewModelTest.kt @@ -41,7 +41,6 @@ import org.smartregister.fhircore.engine.app.AppConfigService import org.smartregister.fhircore.engine.app.fakes.Faker import org.smartregister.fhircore.engine.auth.AccountAuthenticator 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.data.remote.fhir.resource.FhirResourceService import org.smartregister.fhircore.engine.domain.model.Language @@ -49,6 +48,7 @@ import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.rule.CoroutineTestRule import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.util.SecureSharedPreference +import org.smartregister.fhircore.engine.util.SharedPreferenceKey import org.smartregister.fhircore.engine.util.SharedPreferencesHelper @OptIn(ExperimentalCoroutinesApi::class) @@ -60,25 +60,27 @@ class UserProfileViewModelTest : RobolectricTest() { lateinit var userProfileViewModel: UserProfileViewModel lateinit var accountAuthenticator: AccountAuthenticator lateinit var secureSharedPreference: SecureSharedPreference - var sharedPreferencesHelper: SharedPreferencesHelper + lateinit var sharedPreferencesHelper: SharedPreferencesHelper - val defaultRepository: DefaultRepository = mockk() - @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry(defaultRepository) + @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry() - private var configService: ConfigService + private lateinit var configService: ConfigService private val sharedSyncStatus: MutableSharedFlow = MutableSharedFlow() - private var syncBroadcaster: SyncBroadcaster + private lateinit var syncBroadcaster: SyncBroadcaster private val context = ApplicationProvider.getApplicationContext() - private val resourceService: FhirResourceService = mockk() + private lateinit var fhirResourceDataSource: FhirResourceDataSource - private var fhirResourceDataSource: FhirResourceDataSource - - init { - sharedPreferencesHelper = SharedPreferencesHelper(context) + @Before + fun setUp() { + hiltRule.inject() + accountAuthenticator = mockk() + secureSharedPreference = mockk() + sharedPreferencesHelper = mockk() configService = AppConfigService(context = context) fhirResourceDataSource = spyk(FhirResourceDataSource(resourceService)) + syncBroadcaster = SyncBroadcaster( configurationRegistry, @@ -90,14 +92,6 @@ class UserProfileViewModelTest : RobolectricTest() { tracer = mockk(), sharedPreferencesHelper = sharedPreferencesHelper ) - } - - @Before - fun setUp() { - hiltRule.inject() - accountAuthenticator = mockk() - secureSharedPreference = mockk() - sharedPreferencesHelper = mockk() userProfileViewModel = UserProfileViewModel( syncBroadcaster, @@ -123,11 +117,11 @@ class UserProfileViewModelTest : RobolectricTest() { @Test fun testLogoutUserShouldCallAuthLogoutService() { - every { accountAuthenticator.logout() } returns Unit + every { accountAuthenticator.logout(any<() -> Unit>()) } returns Unit - userProfileViewModel.logoutUser() + userProfileViewModel.logoutUser(context) - verify(exactly = 1) { accountAuthenticator.logout() } + verify(exactly = 1) { accountAuthenticator.logout(any<() -> Unit>()) } Shadows.shadowOf(Looper.getMainLooper()).idle() Assert.assertTrue(userProfileViewModel.onLogout.value!!) } @@ -154,10 +148,10 @@ class UserProfileViewModelTest : RobolectricTest() { @Test fun loadSelectedLanguage() { - every { sharedPreferencesHelper.read(SharedPreferencesHelper.LANG, "en-GB") } returns "fr" + every { sharedPreferencesHelper.read(SharedPreferenceKey.LANG.name, "en-GB") } returns "fr" Assert.assertEquals("French", userProfileViewModel.loadSelectedLanguage()) - verify { sharedPreferencesHelper.read(SharedPreferencesHelper.LANG, "en-GB") } + verify { sharedPreferencesHelper.read(SharedPreferenceKey.LANG.name, "en-GB") } } @Test @@ -173,7 +167,7 @@ class UserProfileViewModelTest : RobolectricTest() { Shadows.shadowOf(Looper.getMainLooper()).idle() - verify { sharedPreferencesHelper.write(SharedPreferencesHelper.LANG, "es") } + verify { sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, "es") } Assert.assertEquals(language, postedValue!!) } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SecureSharedPreferenceTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SecureSharedPreferenceTest.kt index b518458788..6a2709b872 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SecureSharedPreferenceTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SecureSharedPreferenceTest.kt @@ -21,11 +21,12 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.core.app.ApplicationProvider import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import io.mockk.every +import io.mockk.spyk import org.junit.Assert import org.junit.Before import org.junit.Rule import org.junit.Test -import org.smartregister.fhircore.engine.auth.AuthCredentials import org.smartregister.fhircore.engine.robolectric.RobolectricTest @HiltAndroidTest @@ -41,37 +42,26 @@ internal class SecureSharedPreferenceTest : RobolectricTest() { @Before fun setUp() { - secureSharedPreference = SecureSharedPreference(application) + secureSharedPreference = spyk(SecureSharedPreference(application)) } @Test fun testSaveCredentialsAndRetrieveSessionToken() { - secureSharedPreference.saveCredentials( - AuthCredentials( - username = "userName", - password = "!@#$", - sessionToken = "sessionToken", - refreshToken = "refreshToken" - ) - ) - Assert.assertEquals("sessionToken", secureSharedPreference.retrieveSessionToken()!!) + secureSharedPreference.saveCredentials(username = "userName", password = "!@#$".toCharArray()) Assert.assertEquals("userName", secureSharedPreference.retrieveSessionUsername()!!) } @Test fun testRetrieveCredentials() { - secureSharedPreference.saveCredentials( - AuthCredentials( - username = "userName", - password = "!@#$", - sessionToken = "sessionToken", - refreshToken = "refreshToken" - ) - ) + every { secureSharedPreference.get256RandomBytes() } returns byteArrayOf(-100, 0, 100, 101) + + secureSharedPreference.saveCredentials(username = "userName", password = "!@#$".toCharArray()) + Assert.assertEquals("userName", secureSharedPreference.retrieveCredentials()!!.username) - Assert.assertEquals("!@#$", secureSharedPreference.retrieveCredentials()!!.password) - Assert.assertEquals("sessionToken", secureSharedPreference.retrieveCredentials()!!.sessionToken) - Assert.assertEquals("refreshToken", secureSharedPreference.retrieveCredentials()!!.refreshToken) + Assert.assertEquals( + "!@#$".toCharArray().toPasswordHash(byteArrayOf(-100, 0, 100, 101)), + secureSharedPreference.retrieveCredentials()!!.passwordHash + ) } @Test diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelperTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelperTest.kt index 815f12c8ee..1ad69e9406 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelperTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/SharedPreferencesHelperTest.kt @@ -20,8 +20,10 @@ import android.app.Application import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.logicalId +import com.google.gson.Gson import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject import org.hl7.fhir.r4.model.Practitioner import org.junit.Assert import org.junit.Before @@ -30,22 +32,19 @@ import org.junit.Test import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo import org.smartregister.fhircore.engine.robolectric.RobolectricTest import org.smartregister.fhircore.engine.util.extension.encodeJson -import org.smartregister.fhircore.engine.util.extension.encodeResourceToString @HiltAndroidTest -class SharedPreferencesHelperTest : RobolectricTest() { - +internal class SharedPreferencesHelperTest : RobolectricTest() { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) - @get:Rule(order = 1) val instantTaskExecutorRule = InstantTaskExecutorRule() - private val application = ApplicationProvider.getApplicationContext() - private lateinit var sharedPreferencesHelper: SharedPreferencesHelper + @Inject lateinit var gson: Gson @Before fun setUp() { - sharedPreferencesHelper = SharedPreferencesHelper(application) + hiltRule.inject() + sharedPreferencesHelper = SharedPreferencesHelper(application, gson) } @Test @@ -71,7 +70,7 @@ class SharedPreferencesHelperTest : RobolectricTest() { @Test fun testWriteStringAsync() { - sharedPreferencesHelper.write("anyStringKey", "test write String", async = true) + sharedPreferencesHelper.write("anyStringKey", "test write String") Assert.assertEquals("test write String", sharedPreferencesHelper.read("anyStringKey", "")) } @@ -83,7 +82,7 @@ class SharedPreferencesHelperTest : RobolectricTest() { @Test fun testWriteBooleanAsync() { - sharedPreferencesHelper.write("anyBooleanKey", true, async = true) + sharedPreferencesHelper.write("anyBooleanKey", true) Assert.assertEquals(true, sharedPreferencesHelper.read("anyBooleanKey", false)) } @@ -95,17 +94,17 @@ class SharedPreferencesHelperTest : RobolectricTest() { @Test fun testWriteLongAsync() { - sharedPreferencesHelper.write("anyLongKey", 123456789, async = true) + sharedPreferencesHelper.write("anyLongKey", 123456789) Assert.assertEquals(123456789, sharedPreferencesHelper.read("anyLongKey", 0)) } @Test fun testReadObject() { val practitioner = Practitioner().apply { id = "1234" } - sharedPreferencesHelper.write(LOGGED_IN_PRACTITIONER, practitioner.encodeResourceToString()) + sharedPreferencesHelper.write(LOGGED_IN_PRACTITIONER, practitioner, encodeWithGson = true) val readPractitioner = - sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, decodeFhirResource = true) + sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, decodeWithGson = true) Assert.assertNotNull(readPractitioner!!.logicalId) Assert.assertEquals(practitioner.logicalId, readPractitioner.logicalId) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensionApi24Test.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensionApi24Test.kt index 400739e674..0c76f338ff 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensionApi24Test.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/util/extension/AndroidExtensionApi24Test.kt @@ -25,7 +25,7 @@ import org.junit.Test import org.robolectric.annotation.Config import org.smartregister.fhircore.engine.robolectric.RobolectricTest -@Config(sdk = [Build.VERSION_CODES.N]) +@Config(sdk = [Build.VERSION_CODES.O]) class AndroidExtensionApi2Test : RobolectricTest() { @Test From c638406eb33d0b194c7ac3bea6f29f3767f98069 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Mon, 26 Jun 2023 11:36:48 +0200 Subject: [PATCH 10/68] fixes --- .../fhircore/engine/app/fakes/Faker.kt | 14 +++++++--- .../engine/auth/AccountAuthenticatorTest.kt | 19 ++++---------- .../ConfigurationRegistryTest.kt | 26 +++++++++---------- .../data/local/DefaultRepositoryTest.kt | 5 ++-- .../dao/AppointmentRegisterDaoTest.kt | 6 ++--- .../register/dao/TracingRegisterDaoTest.kt | 6 ++--- 6 files changed, 36 insertions(+), 40 deletions(-) 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 e8b80f1572..c58b36688c 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 @@ -60,10 +60,18 @@ object Faker { 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() } + } runBlocking { configurationRegistry.loadConfigurations( - appId = APP_DEBUG, + appId = APP_DEBUG.substringBefore("/"), ) {} } } @@ -105,7 +113,7 @@ object Faker { runBlocking { configurationRegistry.loadConfigurations( - appId = APP_DEBUG, + appId = APP_DEBUG.substringBefore("/"), ) {} } 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 d27418707c..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,10 +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_AUTHTOKEN import android.accounts.AccountManager.KEY_INTENT import android.accounts.AccountManagerCallback @@ -116,7 +113,10 @@ class AccountAuthenticatorTest : RobolectricTest() { Assert.assertTrue(parcelable.extras!!.containsKey(AccountAuthenticator.ACCOUNT_TYPE)) Assert.assertEquals(accountType, parcelable.getStringExtra(AccountAuthenticator.ACCOUNT_TYPE)) - Assert.assertEquals(authTokenType, parcelable.getStringExtra(TokenAuthenticator.AUTH_TOKEN_TYPE)) + Assert.assertEquals( + authTokenType, + parcelable.getStringExtra(TokenAuthenticator.AUTH_TOKEN_TYPE) + ) } @Test @@ -150,16 +150,7 @@ class AccountAuthenticatorTest : RobolectricTest() { val token = "mystesttoken" 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") 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 bbbf75c624..25d6701cc0 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 @@ -27,7 +27,6 @@ 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 @@ -47,12 +46,10 @@ import org.smartregister.fhircore.engine.configuration.view.LoginViewConfigurati import org.smartregister.fhircore.engine.configuration.view.PinViewConfiguration import org.smartregister.fhircore.engine.data.remote.fhir.resource.FhirResourceDataSource import org.smartregister.fhircore.engine.robolectric.RobolectricTest -import org.smartregister.fhircore.engine.robolectric.RobolectricTest.Companion.readFile import org.smartregister.fhircore.engine.rule.CoroutineTestRule 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.extension.decodeResourceFromString @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @@ -65,8 +62,7 @@ class ConfigurationRegistryTest : RobolectricTest() { val context = ApplicationProvider.getApplicationContext() @get:Rule(order = 1) val coroutineRule = CoroutineTestRule() @BindValue val secureSharedPreference: SecureSharedPreference = mockk() - private val testAppId = "default" - private val application: Context = ApplicationProvider.getApplicationContext() + private val testAppId = "app" private lateinit var fhirResourceDataSource: FhirResourceDataSource lateinit var configurationRegistry: ConfigurationRegistry var fhirEngine: FhirEngine = mockk() @@ -74,7 +70,7 @@ class ConfigurationRegistryTest : RobolectricTest() { @Before fun setUp() { hiltRule.inject() - fhirResourceDataSource = spyk(FhirResourceDataSource(mockk())) + fhirResourceDataSource = mockk() configurationRegistry = ConfigurationRegistry( context, @@ -83,6 +79,8 @@ class ConfigurationRegistryTest : RobolectricTest() { sharedPreferencesHelper, coroutineRule.testDispatcherProvider, ) + coEvery { fhirResourceDataSource.loadData(any()) } returns + Bundle().apply { entry = mutableListOf() } Assert.assertNotNull(configurationRegistry) Faker.loadTestConfigurationRegistryData(fhirEngine, configurationRegistry) } @@ -107,8 +105,8 @@ class ConfigurationRegistryTest : RobolectricTest() { Assert.assertTrue(configurationRegistry.workflowPointsMap.isNotEmpty()) val configurationsMap = configurationRegistry.configurationsMap Assert.assertTrue(configurationsMap.isNotEmpty()) - Assert.assertTrue(configurationsMap.containsKey("default|login")) - Assert.assertTrue(configurationsMap["default|login"]!! is LoginViewConfiguration) + Assert.assertTrue(configurationsMap.containsKey("app|login")) + Assert.assertTrue(configurationsMap["app|login"]!! is LoginViewConfiguration) Assert.assertFalse(retrievedConfiguration.darkMode) Assert.assertFalse(retrievedConfiguration.showLogo) @@ -141,8 +139,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) @@ -173,7 +171,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) @@ -196,7 +194,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,7 +215,7 @@ class ConfigurationRegistryTest : RobolectricTest() { // coVerify { configurationRegistry.repository.searchCompositionByIdentifier(any()) } advanceUntilIdle() coVerify { - configurationRegistry.fhirResourceDataSource.loadData( + fhirResourceDataSource.loadData( withArg { Assert.assertTrue(it.startsWith("Questionnaire", ignoreCase = true)) } ) } @@ -226,7 +224,7 @@ class ConfigurationRegistryTest : RobolectricTest() { @Test fun testFetchNonWorkflowConfigResourcesWithNoEntry() { configurationRegistry.appId = "testApp" - Assert.assertEquals(0, configurationRegistry.workflowPointsMap.size) + configurationRegistry.workflowPointsMap.clear() coEvery { configurationRegistry.searchCompositionByIdentifier(any()) } returns null 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 b29cbc89a8..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 @@ -35,7 +35,6 @@ import io.mockk.slot import io.mockk.spyk import io.mockk.unmockkStatic import io.mockk.verify -import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runBlockingTest @@ -82,8 +81,8 @@ class DefaultRepositoryTest : RobolectricTest() { @Before fun setUp() { -hiltRule.inject() - every { configService.provideResourceTags(any()) } returns listOf() + hiltRule.inject() + every { configService.provideResourceTags(any()) } returns listOf() } @OptIn(ExperimentalCoroutinesApi::class) @Test 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 4dfe7598de..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 @@ -55,7 +55,6 @@ 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 @@ -95,8 +94,9 @@ class AppointmentRegisterDaoTest : RobolectricTest() { coEvery { configurationRegistry.retrieveDataFilterConfiguration(any()) } returns emptyList() - every { sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, decodeWithGson = true) } returns - Practitioner().apply { id = "123" } + 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/TracingRegisterDaoTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/data/local/register/dao/TracingRegisterDaoTest.kt index 244b9bf5a4..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 @@ -68,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) @@ -105,8 +104,9 @@ class TracingRegisterDaoTest : RobolectricTest() { ) coEvery { configurationRegistry.retrieveDataFilterConfiguration(any()) } returns emptyList() - every { sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, decodeWithGson = true) } returns - Practitioner().apply { id = "123" } + every { + sharedPreferencesHelper.read(LOGGED_IN_PRACTITIONER, decodeWithGson = true) + } returns Practitioner().apply { id = "123" } tracingRegisterDao = HomeTracingRegisterDao( From cea8e95b8da3cb06e29eda3456a6e9d145091483 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Tue, 27 Jun 2023 09:22:03 +0200 Subject: [PATCH 11/68] replace app with default Update ConfigurationRegistryTest.kt --- .../fhircore/engine/app/fakes/Faker.kt | 2 +- .../ConfigurationRegistryTest.kt | 25 ++++++------------- 2 files changed, 9 insertions(+), 18 deletions(-) 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 c58b36688c..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 @@ -42,7 +42,7 @@ import org.smartregister.fhircore.engine.robolectric.RobolectricTest.Companion.r import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString object Faker { - private const val APP_DEBUG = "app/debug" + private const val APP_DEBUG = "default/debug" private val systemPath = (System.getProperty("user.dir") + File.separator + 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 25d6701cc0..eb126defee 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 @@ -20,14 +20,12 @@ import android.content.Context import androidx.test.core.app.ApplicationProvider import com.google.android.fhir.FhirEngine import com.google.android.fhir.search.Search -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.mockk -import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher @@ -50,6 +48,7 @@ import org.smartregister.fhircore.engine.rule.CoroutineTestRule import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @@ -62,7 +61,7 @@ class ConfigurationRegistryTest : RobolectricTest() { val context = ApplicationProvider.getApplicationContext() @get:Rule(order = 1) val coroutineRule = CoroutineTestRule() @BindValue val secureSharedPreference: SecureSharedPreference = mockk() - private val testAppId = "app" + private val testAppId = "default" private lateinit var fhirResourceDataSource: FhirResourceDataSource lateinit var configurationRegistry: ConfigurationRegistry var fhirEngine: FhirEngine = mockk() @@ -105,8 +104,8 @@ class ConfigurationRegistryTest : RobolectricTest() { Assert.assertTrue(configurationRegistry.workflowPointsMap.isNotEmpty()) val configurationsMap = configurationRegistry.configurationsMap Assert.assertTrue(configurationsMap.isNotEmpty()) - Assert.assertTrue(configurationsMap.containsKey("app|login")) - Assert.assertTrue(configurationsMap["app|login"]!! is LoginViewConfiguration) + Assert.assertTrue(configurationsMap.containsKey("default|login")) + Assert.assertTrue(configurationsMap["default|login"]!! is LoginViewConfiguration) Assert.assertFalse(retrievedConfiguration.darkMode) Assert.assertFalse(retrievedConfiguration.showLogo) @@ -151,13 +150,14 @@ class ConfigurationRegistryTest : RobolectricTest() { @Test fun testLoadConfigurationRegistry() { runTest { configurationRegistry.fetchNonWorkflowConfigResources() } - coVerify { fhirEngine.search(any()) } } @Test fun testIsAppIdInitialized() { - Assert.assertFalse(configurationRegistry.isAppIdInitialized()) + runBlocking { + configurationRegistry.loadConfigurations(testAppId) {} + } Assert.assertTrue(configurationRegistry.isAppIdInitialized()) } @@ -203,16 +203,9 @@ class ConfigurationRegistryTest : RobolectricTest() { @Test fun testFetchNonWorkflowConfigResources() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) - coEvery { configurationRegistry.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 { fhirResourceDataSource.loadData( @@ -225,12 +218,10 @@ class ConfigurationRegistryTest : RobolectricTest() { fun testFetchNonWorkflowConfigResourcesWithNoEntry() { configurationRegistry.appId = "testApp" configurationRegistry.workflowPointsMap.clear() - - coEvery { configurationRegistry.searchCompositionByIdentifier(any()) } returns null + coEvery { fhirEngine.search(any()) } returns listOf() runBlocking { configurationRegistry.fetchNonWorkflowConfigResources() } - // coVerify { defaultRepository.searchCompositionByIdentifier("testApp") } coVerify(inverse = true) { fhirResourceDataSource.loadData(any()) } } From 7e5da06cf8d1c8fe5df4d8c7b8a5a79d886a37cd Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Tue, 27 Jun 2023 15:39:02 +0200 Subject: [PATCH 12/68] tests fixed --- .../engine/auth/AccountAuthenticator.kt | 4 ---- .../engine/ui/login/LoginViewModel.kt | 19 +++++++++++++------ .../ConfigurationRegistryTest.kt | 6 ++---- .../engine/ui/login/LoginActivityTest.kt | 8 -------- .../engine/ui/login/LoginViewModelTest.kt | 3 ++- .../QuestionnaireViewModelTest.kt | 4 ++-- .../ui/register/BaseRegisterActivityTest.kt | 13 +++++-------- .../quest/ui/main/AppMainViewModel.kt | 5 ++++- 8 files changed, 28 insertions(+), 34 deletions(-) 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 07632f2f6d..0a489aabf0 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 @@ -150,10 +150,6 @@ constructor( } } - fun logout(context: Context) { - logout { context.getActivity()?.launchActivityWithNoBackStackHistory() } - } - fun validateLoginCredentials(username: String, password: CharArray) = tokenAuthenticator.validateSavedLoginCredentials(username, password) 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 3404f85025..6f3614c324 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 @@ -25,6 +25,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +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 @@ -68,7 +69,7 @@ constructor( val launchDialPad get() = _launchDialPad - private val _navigateToHome = MutableLiveData() + private val _navigateToHome = MutableLiveData(false) val navigateToHome: LiveData get() = _navigateToHome @@ -137,7 +138,9 @@ constructor( } fun login(context: Context) { - if (!_username.value.isNullOrBlank() && !_password.value.isNullOrBlank()) { + val usernameValue = _username.value + val passwordValue = _password.value + if (!usernameValue.isNullOrBlank() && !passwordValue.isNullOrBlank()) { _loginErrorState.postValue(null) _showProgressBar.postValue(true) @@ -145,7 +148,7 @@ constructor( val passwordAsCharArray = _password.value!!.toCharArray() if (context.getActivity()!!.isDeviceOnline()) { - viewModelScope.launch { + viewModelScope.launch(dispatcherProvider.io()) { fetchToken( username = trimmedUsername, password = passwordAsCharArray, @@ -190,9 +193,14 @@ constructor( username: String, password: CharArray, onFetchUserInfo: (Result) -> Unit, - onFetchPractitioner: (Result) -> Unit + onFetchPractitioner: (Result) -> Unit ) { - if (tokenAuthenticator.sessionActive()) { + val practitionerDetails = + sharedPreferences.read( + key = SharedPreferenceKey.PRACTITIONER_DETAILS.name, + decodeWithGson = true + ) + if (tokenAuthenticator.sessionActive() && practitionerDetails != null) { _showProgressBar.postValue(false) updateNavigateHome(true) } else { @@ -213,7 +221,6 @@ constructor( } } } - fun updateNavigateHome(navigateHome: Boolean = true) { _navigateToHome.postValue(navigateHome) } 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 eb126defee..1a9c547c97 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 @@ -26,6 +26,7 @@ import dagger.hilt.android.testing.HiltAndroidTest import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.StandardTestDispatcher @@ -48,7 +49,6 @@ import org.smartregister.fhircore.engine.rule.CoroutineTestRule import org.smartregister.fhircore.engine.util.DispatcherProvider import org.smartregister.fhircore.engine.util.SecureSharedPreference import org.smartregister.fhircore.engine.util.SharedPreferencesHelper -import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) @HiltAndroidTest @@ -155,9 +155,7 @@ class ConfigurationRegistryTest : RobolectricTest() { @Test fun testIsAppIdInitialized() { - runBlocking { - configurationRegistry.loadConfigurations(testAppId) {} - } + runBlocking { configurationRegistry.loadConfigurations(testAppId) {} } Assert.assertTrue(configurationRegistry.isAppIdInitialized()) } 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 749c68e7e1..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 @@ -237,14 +237,6 @@ class LoginActivityTest : ActivityRobolectricTest() { loginService = loginActivity.loginService } - @Test - fun testNavigateToHomeShouldVerifyExpectedIntentWhenPinExists() { - val loginConfig = loginViewConfigurationOf(enablePin = true) - loginViewModel.updateViewConfigurations(loginConfig) - loginViewModel.navigateToHome() - verify { loginService.navigateToHome() } - } - @Test fun testNavigateToHomeShouldVerifyExpectedIntentWhenForcedLogin() { sharedPreferencesHelper.write(FORCE_LOGIN_VIA_USERNAME_FROM_PIN_SETUP, true) 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 116407cebd..547140422d 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 @@ -171,7 +171,8 @@ internal class LoginViewModelTest : RobolectricTest() { updateCredentials() every { tokenAuthenticator.sessionActive() } returns true loginViewModel.login(mockedActivity(isDeviceOnline = true)) - Assert.assertFalse(loginViewModel.navigateToHome.value!!) + val toHome = loginViewModel.navigateToHome.value!! + Assert.assertFalse(toHome) } @Test 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 de37323e85..52a4fc32cd 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 @@ -1091,7 +1091,7 @@ class QuestionnaireViewModelTest : RobolectricTest() { questionnaireViewModel.appendPractitionerInfo(patient) - Assert.assertEquals("Practitioner/123", patient.generalPractitioner[0].reference) + Assert.assertEquals("Practitioner/12345", patient.generalPractitioner[0].reference) } @Test @@ -1111,7 +1111,7 @@ 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 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 15c680d836..05bd1a09df 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 @@ -97,7 +97,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { @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() @BindValue var configurationRegistry = Faker.buildTestConfigurationRegistry() @@ -118,6 +118,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) } @@ -512,14 +513,13 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { fun testOnNavigation_logout_onItemClicked_should_finishActivity() { every { tokenAuthenticator.findAccount() } returns Account("abc", "type") every { tokenAuthenticator.isTokenActive(any()) } returns false - every { accountAuthenticator.logout(context) } just runs val logoutMenuItem = RoboMenuItem(R.id.menu_item_logout) testRegisterActivity.onNavigationItemSelected(logoutMenuItem) Assert.assertFalse( testRegisterActivity.registerActivityBinding.drawerLayout.isDrawerOpen(GravityCompat.START) ) - verify(exactly = 1) { accountAuthenticator.logout(context) } + verify(exactly = 1) { accountAuthenticator.logout(any()) } } @Test @@ -607,9 +607,6 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { @Test fun testHandleSyncFailed_should_verifyAllInternalState() { - - every { accountAuthenticator.logout(context) } returns Unit - val glitchState = SyncJobStatus.Glitch( listOf( @@ -620,7 +617,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { ) handleSyncFailed(glitchState) - verify(exactly = 1) { accountAuthenticator.logout(context) } + verify(exactly = 1) { accountAuthenticator.logout(any()) } val failedState = SyncJobStatus.Failed( @@ -632,7 +629,7 @@ class BaseRegisterActivityTest : ActivityRobolectricTest() { ) handleSyncFailed(failedState) - verify(exactly = 1, inverse = true) { accountAuthenticator.logout(context) } + verify(exactly = 1, inverse = true) { accountAuthenticator.logout(any()) } val glitchStateInterruptedIOException = SyncJobStatus.Glitch( diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt index 14e39d8c72..0500c13b96 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt @@ -43,6 +43,7 @@ import org.smartregister.fhircore.engine.configuration.app.ApplicationConfigurat import org.smartregister.fhircore.engine.configuration.app.ConfigService import org.smartregister.fhircore.engine.sync.SyncBroadcaster import org.smartregister.fhircore.engine.ui.appsetting.AppSettingActivity +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 @@ -95,7 +96,9 @@ constructor( fun onEvent(event: AppMainEvent) { when (event) { - is AppMainEvent.Logout -> accountAuthenticator.logout(event.context) + is AppMainEvent.Logout -> accountAuthenticator.logout { + event.context.getActivity()?.launchActivityWithNoBackStackHistory() + } is AppMainEvent.SwitchLanguage -> { sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, event.language.tag) event.context.run { From 2a28a52c9e862132c9974281f44a8abd67375aa7 Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Tue, 27 Jun 2023 20:25:26 +0200 Subject: [PATCH 13/68] stuff --- .../engine/auth/AccountAuthenticator.kt | 2 - .../ui/register/BaseRegisterActivityTest.kt | 4 +- .../quest/ui/main/AppMainViewModel.kt | 7 +- .../fhircore/quest/CqlContentTest.kt | 189 ++++++++++-------- .../fhircore/quest/QuestConfigServiceTest.kt | 155 ++------------ .../fhircore/quest/app/AppConfigService.kt | 19 ++ .../fhircore/quest/app/fakes/Faker.kt | 26 +-- .../quest/ui/login/QuestLoginServiceTest.kt | 3 +- .../details/ListDataDetailScreenTest.kt | 3 +- .../details/QuestPatientDetailActivityTest.kt | 7 +- ...estionnaireDataDetailDetailActivityTest.kt | 3 +- .../details/SimpleDetailsScreenTest.kt | 2 +- .../details/SimpleDetailsViewModelTest.kt | 2 +- .../register/PatientRegisterActivityTest.kt | 3 +- .../register/PatientRegisterFragmentTest.kt | 4 +- .../quest/ui/task/PatientTaskFragmentTest.kt | 4 +- .../fhircore/quest/util/PatientUtilTest.kt | 4 +- 17 files changed, 167 insertions(+), 270 deletions(-) create mode 100644 android/quest/src/test/java/org/smartregister/fhircore/quest/app/AppConfigService.kt 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 0a489aabf0..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 @@ -35,8 +35,6 @@ 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.SecureSharedPreference -import org.smartregister.fhircore.engine.util.extension.getActivity -import org.smartregister.fhircore.engine.util.extension.launchActivityWithNoBackStackHistory import retrofit2.HttpException import timber.log.Timber 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 05bd1a09df..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 @@ -118,7 +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 + every { accountAuthenticator.logout(any()) } returns Unit ApplicationProvider.getApplicationContext().apply { setTheme(R.style.AppTheme) } diff --git a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt index 0500c13b96..a0b57c2c99 100644 --- a/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt +++ b/android/quest/src/main/java/org/smartregister/fhircore/quest/ui/main/AppMainViewModel.kt @@ -96,9 +96,10 @@ constructor( fun onEvent(event: AppMainEvent) { when (event) { - is AppMainEvent.Logout -> accountAuthenticator.logout { - event.context.getActivity()?.launchActivityWithNoBackStackHistory() - } + is AppMainEvent.Logout -> + accountAuthenticator.logout { + event.context.getActivity()?.launchActivityWithNoBackStackHistory() + } is AppMainEvent.SwitchLanguage -> { sharedPreferencesHelper.write(SharedPreferenceKey.LANG.name, event.language.tag) event.context.run { diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt index a7de78b98e..0b05815185 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt @@ -20,35 +20,47 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import com.google.android.fhir.FhirEngine 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.coVerify +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 kotlinx.coroutines.runBlocking import org.cqframework.cql.cql2elm.CqlTranslator import org.cqframework.cql.cql2elm.FhirLibrarySourceProvider import org.cqframework.cql.cql2elm.LibraryManager import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.DataRequirement import org.hl7.fhir.r4.model.Library import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Resource 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.cql.LibraryEvaluator import org.smartregister.fhircore.engine.data.local.DefaultRepository -import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.fhircore.quest.robolectric.RobolectricTest +@HiltAndroidTest class CqlContentTest : RobolectricTest() { + @get:Rule + var hiltRule = HiltAndroidRule(this) val fhirContext: FhirContext = FhirContext.forCached(FhirVersionEnum.R4) val parser = fhirContext.newJsonParser()!! val evaluator = LibraryEvaluator().apply { initialize() } + @Before + fun setUp() { + hiltRule.inject() + } + @Test fun runCqlLibraryTestForPqMedication() { val resourceDir = "cql/pq-medication" @@ -56,57 +68,58 @@ class CqlContentTest : RobolectricTest() { val cqlElm = toJsonElm(cql).readStringToBase64Encoded() val cqlLibrary = - parser.parseResource( - "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) - ) as - Library + parser.parseResource( + "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) + ) as + Library println(cqlLibrary.convertToString(false) as String) val fhirHelpersLibrary = "cql-common/helper.json".parseSampleResourceFromFile() as Library val patient = - "patient-registration-questionnaire/sample/patient.json".parseSampleResourceFromFile() as - Patient + "patient-registration-questionnaire/sample/patient.json".parseSampleResourceFromFile() as + Patient val dataBundle = - Bundle().apply { - // output of test results extraction is input of this cql - "test-results-questionnaire/sample" - .readDir() - .map { it.parseSampleResource() as Resource } - .forEach { addEntry().apply { resource = it } } - - // output of test results cql is also added to input of this cql - "cql/test-results/sample".readDir().map { it.parseSampleResource() as Resource }.forEach { - addEntry().apply { resource = it } - } - } + Bundle().apply { + // output of test results extraction is input of this cql + "test-results-questionnaire/sample" + .readDir() + .map { it.parseSampleResource() as Resource } + .forEach { addEntry().apply { resource = it } } + + // output of test results cql is also added to input of this cql + "cql/test-results/sample".readDir().map { it.parseSampleResource() as Resource }.forEach { + addEntry().apply { resource = it } + } + } val fhirEngine = mockk() - val defaultRepository = spyk(DefaultRepository(fhirEngine, DefaultDispatcherProvider())) + val defaultRepository = mockk() coEvery { fhirEngine.get(ResourceType.Library, cqlLibrary.logicalId) } returns cqlLibrary coEvery { fhirEngine.get(ResourceType.Library, fhirHelpersLibrary.logicalId) } returns - fhirHelpersLibrary + fhirHelpersLibrary + every { defaultRepository.fhirEngine } returns fhirEngine coEvery { defaultRepository.save(any()) } just runs coEvery { defaultRepository.search(any()) } returns listOf() val result = runBlocking { evaluator.runCqlLibrary( - cqlLibrary.logicalId, - patient, - dataBundle.apply { - this.entry.removeIf { it.resource.resourceType == ResourceType.Patient } - }, - defaultRepository, - true + cqlLibrary.logicalId, + patient, + dataBundle.apply { + this.entry.removeIf { it.resource.resourceType == ResourceType.Patient } + }, + defaultRepository, + true ) } assertOutput( - "$resourceDir/output_medication_request.json", - result, - ResourceType.MedicationRequest + "$resourceDir/output_medication_request.json", + result, + ResourceType.MedicationRequest ) coVerify { defaultRepository.save(any()) } @@ -119,58 +132,58 @@ class CqlContentTest : RobolectricTest() { val cqlElm = toJsonElm(cql).readStringToBase64Encoded() val cqlLibrary = - parser.parseResource( - "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) - ) as - Library + parser.parseResource( + "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) + ) as + Library println(cqlLibrary.convertToString(false) as String) val fhirHelpersLibrary = "cql-common/helper.json".parseSampleResourceFromFile() as Library val patient = - "patient-registration-questionnaire/sample/patient.json".parseSampleResourceFromFile() as - Patient + "patient-registration-questionnaire/sample/patient.json".parseSampleResourceFromFile() as + Patient val dataBundle = - Bundle().apply { - // output of test results extraction is input of this cql - "test-results-questionnaire/sample" - .readDir() - .map { it.parseSampleResource() as Resource } - .forEach { addEntry().apply { resource = it } } - } + Bundle().apply { + // output of test results extraction is input of this cql + "test-results-questionnaire/sample" + .readDir() + .map { it.parseSampleResource() as Resource } + .forEach { addEntry().apply { resource = it } } + } val fhirEngine = mockk() - val defaultRepository = spyk(DefaultRepository(fhirEngine, DefaultDispatcherProvider())) + val defaultRepository = mockk() coEvery { fhirEngine.get(ResourceType.Library, cqlLibrary.logicalId) } returns cqlLibrary coEvery { fhirEngine.get(ResourceType.Library, fhirHelpersLibrary.logicalId) } returns - fhirHelpersLibrary + fhirHelpersLibrary coEvery { defaultRepository.save(any()) } just runs coEvery { defaultRepository.search(any()) } returns listOf() val result = runBlocking { evaluator.runCqlLibrary( - cqlLibrary.logicalId, - patient, - dataBundle.apply { - this.entry.removeIf { it.resource.resourceType == ResourceType.Patient } - }, - defaultRepository, - true + cqlLibrary.logicalId, + patient, + dataBundle.apply { + this.entry.removeIf { it.resource.resourceType == ResourceType.Patient } + }, + defaultRepository, + true ) } assertOutput("$resourceDir/sample/output_condition.json", result, ResourceType.Condition) assertOutput( - "$resourceDir/sample/output_service_request.json", - result, - ResourceType.ServiceRequest + "$resourceDir/sample/output_service_request.json", + result, + ResourceType.ServiceRequest ) assertOutput( - "$resourceDir/sample/output_diagnostic_report.json", - result, - ResourceType.DiagnosticReport + "$resourceDir/sample/output_diagnostic_report.json", + result, + ResourceType.DiagnosticReport ) coVerify(exactly = 3) { defaultRepository.save(any()) } @@ -183,32 +196,34 @@ class CqlContentTest : RobolectricTest() { val cqlElm = toJsonElm(cql).readStringToBase64Encoded() val cqlLibrary = - parser.parseResource( - "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) - ) as - Library + parser.parseResource( + "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) + ) as + Library println(cqlLibrary.convertToString(false) as String) val fhirHelpersLibrary = "cql-common/helper.json".parseSampleResourceFromFile() as Library val dataBundle = - Bundle().apply { - addEntry().apply { - // questionnaire-response of test results is input of this cql - resource = - "test-results-questionnaire/questionnaire-response.json".parseSampleResourceFromFile() as - Resource - } - } + Bundle().apply { + addEntry().apply { + // questionnaire-response of test results is input of this cql + resource = + "test-results-questionnaire/questionnaire-response.json".parseSampleResourceFromFile() as + Resource + } + } val fhirEngine = mockk() - val defaultRepository = spyk(DefaultRepository(fhirEngine, DefaultDispatcherProvider())) + val defaultRepository = mockk() coEvery { fhirEngine.get(ResourceType.Library, cqlLibrary.logicalId) } returns cqlLibrary coEvery { fhirEngine.get(ResourceType.Library, fhirHelpersLibrary.logicalId) } returns - fhirHelpersLibrary + fhirHelpersLibrary + every { defaultRepository.fhirEngine } returns fhirEngine coEvery { defaultRepository.save(any()) } just runs + coEvery { defaultRepository.search(any()) } returns listOf() val result = runBlocking { evaluator.runCqlLibrary(cqlLibrary.logicalId, null, dataBundle, defaultRepository) @@ -218,23 +233,23 @@ class CqlContentTest : RobolectricTest() { Assert.assertTrue(result.contains("OUTPUT -> Correct Result")) Assert.assertTrue( - result.contains( - "OUTPUT -> \nDetails:\n" + - "Value (3.0) is in Normal G6PD Range 0-3\n" + - "Value (11.0) is in Normal Haemoglobin Range 8-12" - ) + result.contains( + "OUTPUT -> \nDetails:\n" + + "Value (3.0) is in Normal G6PD Range 0-3\n" + + "Value (11.0) is in Normal Haemoglobin Range 8-12" + ) ) val observationSlot = slot() coVerify { defaultRepository.save(capture(observationSlot)) } Assert.assertEquals( - "QuestionnaireResponse/TEST_QUESTIONNAIRE_RESPONSE", - observationSlot.captured.focusFirstRep.reference + "QuestionnaireResponse/TEST_QUESTIONNAIRE_RESPONSE", + observationSlot.captured.focusFirstRep.reference ) Assert.assertEquals( - "Correct Result", - observationSlot.captured.valueCodeableConcept.codingFirstRep.display + "Correct Result", + observationSlot.captured.valueCodeableConcept.codingFirstRep.display ) Assert.assertEquals("Device Operation", observationSlot.captured.code.codingFirstRep.display) } @@ -244,7 +259,7 @@ class CqlContentTest : RobolectricTest() { libraryManager.librarySourceLoader.registerProvider(FhirLibrarySourceProvider()) val translator: CqlTranslator = - CqlTranslator.fromText(cql, evaluator.modelManager, libraryManager) + CqlTranslator.fromText(cql, evaluator.modelManager, libraryManager) return translator.toJxson().also { println(it.replace("\n", "").replace(" ", "")) } } @@ -254,12 +269,12 @@ class CqlContentTest : RobolectricTest() { val expectedResource = resource.parseSampleResourceFromFile().convertToString(true) val cqlResultStr = - cqlResult.find { it.startsWith("OUTPUT") && it.contains("\"resourceType\":\"$type\"") }!! - .replaceTimePart() + cqlResult.find { it.startsWith("OUTPUT") && it.contains("\"resourceType\":\"$type\"") }!! + .replaceTimePart() println(cqlResultStr) println(expectedResource as String) Assert.assertTrue(cqlResultStr.contains("OUTPUT -> $expectedResource")) } -} +} \ No newline at end of file diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/QuestConfigServiceTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/QuestConfigServiceTest.kt index 67a64cf743..0d54c6c451 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/QuestConfigServiceTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/QuestConfigServiceTest.kt @@ -17,34 +17,25 @@ package org.smartregister.fhircore.quest import androidx.test.core.app.ApplicationProvider +import androidx.test.platform.app.InstrumentationRegistry import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.mockk -import org.hl7.fhir.r4.model.Binary -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.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.configuration.app.ConfigService -import org.smartregister.fhircore.engine.data.local.DefaultRepository -import org.smartregister.fhircore.engine.data.remote.model.response.UserInfo -import org.smartregister.fhircore.engine.util.extension.isIn import org.smartregister.fhircore.quest.app.fakes.Faker import org.smartregister.fhircore.quest.robolectric.RobolectricTest @HiltAndroidTest class QuestConfigServiceTest : RobolectricTest() { - - @BindValue val repository: DefaultRepository = mockk() - @get:Rule val hiltRule = HiltAndroidRule(this) @BindValue - var configurationRegistry: ConfigurationRegistry = - Faker.buildTestConfigurationRegistry("g6pd", mockk()) + var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry("g6pd") private lateinit var configService: ConfigService @@ -56,134 +47,18 @@ class QuestConfigServiceTest : RobolectricTest() { } @Test - fun testResourceSyncParam_shouldHaveResourceTypes() { - val syncParam = - configService.loadRegistrySyncParams( - configurationRegistry = configurationRegistry, - authenticatedUserInfo = UserInfo("ONA-Systems", "105", "Nairobi") - ) - Assert.assertTrue(syncParam.isNotEmpty()) - - val resourceTypes = - arrayOf( - ResourceType.Library, - ResourceType.StructureMap, - 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) }.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")) - } - } - - @Test - fun testResourceSyncParam_allExpressionNull_shouldHaveResourceTypes() { - val syncParam = - configService.loadRegistrySyncParams( - configurationRegistry = configurationRegistry, - authenticatedUserInfo = UserInfo(null, null, null) - ) - val resourceTypes = - arrayOf( - ResourceType.Library, - ResourceType.StructureMap, - 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) }.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 testProvideAuthConfigurationShouldReturnConfigs() { + + val authConfiguration = configService.provideAuthConfiguration() + + Assert.assertNotNull(authConfiguration) + Assert.assertEquals(BuildConfig.FHIR_BASE_URL, authConfiguration.fhirServerBaseUrl) + Assert.assertEquals(BuildConfig.OAUTH_BASE_URL, authConfiguration.oauthServerBaseUrl) + Assert.assertEquals(BuildConfig.OAUTH_CIENT_ID, authConfiguration.clientId) + Assert.assertEquals(BuildConfig.OAUTH_CLIENT_SECRET, authConfiguration.clientSecret) + Assert.assertEquals( + InstrumentationRegistry.getInstrumentation().targetContext.packageName, + authConfiguration.accountType + ) } } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/AppConfigService.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/AppConfigService.kt new file mode 100644 index 0000000000..0f9afe2613 --- /dev/null +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/AppConfigService.kt @@ -0,0 +1,19 @@ +/* + * 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.quest.app.fakes + +class AppConfigService diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/Faker.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/Faker.kt index a062e92b15..3141749b9b 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/Faker.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/app/fakes/Faker.kt @@ -16,6 +16,8 @@ package org.smartregister.fhircore.quest.app.fakes +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 @@ -32,9 +34,9 @@ import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.HumanName import org.hl7.fhir.r4.model.Identifier 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.ui.questionnaire.QuestionnaireConfig import org.smartregister.fhircore.engine.util.extension.asDdMmmYyyy import org.smartregister.fhircore.engine.util.extension.decodeResourceFromString @@ -47,7 +49,7 @@ import org.smartregister.fhircore.quest.data.patient.model.QuestionnaireResponse import org.smartregister.fhircore.quest.robolectric.RobolectricTest.Companion.readFile object Faker { - + private const val APP_DEBUG = "quest" fun buildPatient( id: String = "sampleId", family: String = "Mandela", @@ -165,23 +167,23 @@ object Faker { fun loadTestConfigurationRegistryData( appId: String, - defaultRepository: DefaultRepository, + fhirEngine: FhirEngine, configurationRegistry: ConfigurationRegistry ) { val composition = getBasePath(appId, "composition").readFile(systemPath).decodeResourceFromString() as Composition - coEvery { defaultRepository.searchCompositionByIdentifier(any()) } returns composition + coEvery { fhirEngine.search(any()) } returns listOf(composition) - coEvery { defaultRepository.getBinary(any()) } answers + 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 classification = sectionComponent!!.focus.identifier.value + val configName = sectionComponent!!.focus.identifier.value Binary().apply { - content = getBasePath(appId, classification).readFile(systemPath).toByteArray() + content = getBasePath(appId, configName).readFile(systemPath).toByteArray() } } @@ -193,13 +195,13 @@ object Faker { } fun buildTestConfigurationRegistry( - appId: String, - defaultRepository: DefaultRepository + appId: String? = null, ): ConfigurationRegistry { + val fhirEngine: FhirEngine = mockk() val configurationRegistry = - spyk(ConfigurationRegistry(mockk(), mockk(), mockk(), mockk(), defaultRepository)) + spyk(ConfigurationRegistry(mockk(), fhirEngine, mockk(), mockk(), mockk())) - loadTestConfigurationRegistryData(appId, defaultRepository, configurationRegistry) + loadTestConfigurationRegistryData(appId ?: APP_DEBUG, fhirEngine, configurationRegistry) return configurationRegistry } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/QuestLoginServiceTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/QuestLoginServiceTest.kt index 6c7efdde45..03c7f8ef26 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/QuestLoginServiceTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/login/QuestLoginServiceTest.kt @@ -19,7 +19,6 @@ package org.smartregister.fhircore.quest.ui.login import android.content.Intent import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.mockk import io.mockk.spyk import javax.inject.Inject import kotlinx.coroutines.runBlocking @@ -55,7 +54,7 @@ class QuestLoginServiceTest : RobolectricTest() { @Before fun setUp() { hiltRule.inject() - runBlocking { configurationRegistry = Faker.buildTestConfigurationRegistry("quest", mockk()) } + runBlocking { configurationRegistry = Faker.buildTestConfigurationRegistry("quest") } loginService = spyk(questLoginService) loginActivity = Robolectric.buildActivity(LoginActivity::class.java).get() loginService.loginActivity = loginActivity diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/ListDataDetailScreenTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/ListDataDetailScreenTest.kt index 97d131cf04..9a4392cf36 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/ListDataDetailScreenTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/ListDataDetailScreenTest.kt @@ -75,8 +75,7 @@ class ListDataDetailScreenTest : RobolectricTest() { @Inject lateinit var patientItemMapper: PatientItemMapper @BindValue - var configurationRegistry: ConfigurationRegistry = - Faker.buildTestConfigurationRegistry("g6pd", mockk()) + var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry("g6pd") val application = ApplicationProvider.getApplicationContext() val patientRepository: PatientRepository = mockk() diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/QuestPatientDetailActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/QuestPatientDetailActivityTest.kt index ebfe0bf515..2077bd53e8 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/QuestPatientDetailActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/QuestPatientDetailActivityTest.kt @@ -79,8 +79,7 @@ class QuestPatientDetailActivityTest : RobolectricTest() { val defaultRepository: DefaultRepository = mockk() @BindValue - var configurationRegistry: ConfigurationRegistry = - Faker.buildTestConfigurationRegistry("g6pd", defaultRepository) + var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry("g6pd") @Inject lateinit var patientItemMapper: PatientItemMapper lateinit var questPatientDetailViewModel: ListDataDetailViewModel @@ -154,7 +153,7 @@ class QuestPatientDetailActivityTest : RobolectricTest() { @Test fun testOnTestResultItemClickListenerShouldStartQuestionnaireActivity() { runBlocking { - configurationRegistry = Faker.buildTestConfigurationRegistry("quest", defaultRepository) + configurationRegistry = Faker.buildTestConfigurationRegistry("quest") questPatientDetailActivity.configurationRegistry = configurationRegistry } @@ -193,7 +192,7 @@ class QuestPatientDetailActivityTest : RobolectricTest() { @Test fun testOnTestResultItemClickListenerEmptyQuestionnaireIdShouldShowAlertDialog() { runBlocking { - configurationRegistry = Faker.buildTestConfigurationRegistry("quest", defaultRepository) + configurationRegistry = Faker.buildTestConfigurationRegistry("quest") questPatientDetailActivity.configurationRegistry = configurationRegistry } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/QuestionnaireDataDetailDetailActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/QuestionnaireDataDetailDetailActivityTest.kt index 5fe327a13a..90b2c12005 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/QuestionnaireDataDetailDetailActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/QuestionnaireDataDetailDetailActivityTest.kt @@ -55,8 +55,7 @@ class QuestionnaireDataDetailDetailActivityTest : RobolectricTest() { @BindValue val sharedPreferencesHelper: SharedPreferencesHelper = mockk(relaxed = true) @BindValue - var configurationRegistry: ConfigurationRegistry = - Faker.buildTestConfigurationRegistry("g6pd", mockk()) + var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry("g6pd") private val hiltTestApplication = ApplicationProvider.getApplicationContext() diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/SimpleDetailsScreenTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/SimpleDetailsScreenTest.kt index c73ec33013..0cf9ab1faf 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/SimpleDetailsScreenTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/SimpleDetailsScreenTest.kt @@ -74,7 +74,7 @@ class SimpleDetailsScreenTest : RobolectricTest() { mockk(), mockk(), mockk(), - defaultRepository + mockk() ) viewModel = spyk(SimpleDetailsViewModel(patientRepository = patientRepository)) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/SimpleDetailsViewModelTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/SimpleDetailsViewModelTest.kt index 589289e55c..47624d3d3a 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/SimpleDetailsViewModelTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/details/SimpleDetailsViewModelTest.kt @@ -66,7 +66,7 @@ class SimpleDetailsViewModelTest : RobolectricTest() { @Test fun testLoadData() = runBlockingTest { coEvery { patientRepository.configurationRegistry } returns - Faker.buildTestConfigurationRegistry("g6pd", mockk()) + Faker.buildTestConfigurationRegistry("g6pd") coEvery { patientRepository.loadEncounter(any()) } returns Encounter().apply { id = encounterId } diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterActivityTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterActivityTest.kt index 7a125b67ab..3ce9a32788 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterActivityTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterActivityTest.kt @@ -59,8 +59,7 @@ class PatientRegisterActivityTest : ActivityRobolectricTest() { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @BindValue - var configurationRegistry: ConfigurationRegistry = - Faker.buildTestConfigurationRegistry("quest", mockk()) + var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry("quest") @BindValue val sharedPreferencesHelper: SharedPreferencesHelper = mockk() @BindValue val secureSharedPreference: SecureSharedPreference = mockk() diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterFragmentTest.kt index 21abc0a028..003c4daefe 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterFragmentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/patient/register/PatientRegisterFragmentTest.kt @@ -27,7 +27,6 @@ import androidx.test.core.app.ApplicationProvider import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.mockk import org.junit.Assert import org.junit.Before import org.junit.Ignore @@ -56,8 +55,7 @@ class PatientRegisterFragmentTest : RobolectricTest() { @get:Rule val composeRule = createComposeRule() @BindValue - var configurationRegistry: ConfigurationRegistry = - Faker.buildTestConfigurationRegistry("g6pd", mockk()) + var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry("g6pd") private lateinit var registerFragment: PatientRegisterFragment diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/task/PatientTaskFragmentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/task/PatientTaskFragmentTest.kt index ce62b20ff3..838109d2b9 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/task/PatientTaskFragmentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/ui/task/PatientTaskFragmentTest.kt @@ -26,7 +26,6 @@ import com.google.android.fhir.sync.Sync import dagger.hilt.android.testing.BindValue import dagger.hilt.android.testing.HiltAndroidRule import dagger.hilt.android.testing.HiltAndroidTest -import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject import org.junit.After @@ -56,8 +55,7 @@ class PatientTaskFragmentTest : RobolectricTest() { @get:Rule val composeRule = createComposeRule() @BindValue - var configurationRegistry: ConfigurationRegistry = - Faker.buildTestConfigurationRegistry("g6pd", mockk()) + var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry("g6pd") private lateinit var patientTaskFragment: PatientTaskFragment diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/PatientUtilTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/PatientUtilTest.kt index 3e21b48c8f..a2464abea9 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/util/PatientUtilTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/util/PatientUtilTest.kt @@ -24,7 +24,6 @@ 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.mockk import io.mockk.slot import io.mockk.spyk import java.util.Date @@ -51,8 +50,7 @@ import org.smartregister.fhircore.quest.robolectric.RobolectricTest class PatientUtilTest : RobolectricTest() { @BindValue - var configurationRegistry: ConfigurationRegistry = - Faker.buildTestConfigurationRegistry("g6pd", mockk()) + var configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry("g6pd") @Inject lateinit var fhirEngine: FhirEngine @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) From 9991c0b2e000b3416ba19927427b6067eac1abac Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Tue, 27 Jun 2023 21:02:28 +0200 Subject: [PATCH 14/68] test fixes --- .../test/java/org/smartregister/fhircore/quest/CqlContentTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt index 0b05815185..4a51b0d11a 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt @@ -160,6 +160,7 @@ class CqlContentTest : RobolectricTest() { coEvery { fhirEngine.get(ResourceType.Library, fhirHelpersLibrary.logicalId) } returns fhirHelpersLibrary coEvery { defaultRepository.save(any()) } just runs + every { defaultRepository.fhirEngine } returns fhirEngine coEvery { defaultRepository.search(any()) } returns listOf() val result = runBlocking { From 067b2ca43408dd6a021a54f1768885b494afa08b Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Tue, 27 Jun 2023 21:11:14 +0200 Subject: [PATCH 15/68] Update CqlContentTest.kt --- .../fhircore/quest/CqlContentTest.kt | 167 +++++++++--------- 1 file changed, 83 insertions(+), 84 deletions(-) diff --git a/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt b/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt index 4a51b0d11a..c3c956188b 100644 --- a/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt +++ b/android/quest/src/test/java/org/smartregister/fhircore/quest/CqlContentTest.kt @@ -50,8 +50,7 @@ import org.smartregister.fhircore.quest.robolectric.RobolectricTest @HiltAndroidTest class CqlContentTest : RobolectricTest() { - @get:Rule - var hiltRule = HiltAndroidRule(this) + @get:Rule var hiltRule = HiltAndroidRule(this) val fhirContext: FhirContext = FhirContext.forCached(FhirVersionEnum.R4) val parser = fhirContext.newJsonParser()!! val evaluator = LibraryEvaluator().apply { initialize() } @@ -68,58 +67,58 @@ class CqlContentTest : RobolectricTest() { val cqlElm = toJsonElm(cql).readStringToBase64Encoded() val cqlLibrary = - parser.parseResource( - "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) - ) as - Library + parser.parseResource( + "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) + ) as + Library println(cqlLibrary.convertToString(false) as String) val fhirHelpersLibrary = "cql-common/helper.json".parseSampleResourceFromFile() as Library val patient = - "patient-registration-questionnaire/sample/patient.json".parseSampleResourceFromFile() as - Patient + "patient-registration-questionnaire/sample/patient.json".parseSampleResourceFromFile() as + Patient val dataBundle = - Bundle().apply { - // output of test results extraction is input of this cql - "test-results-questionnaire/sample" - .readDir() - .map { it.parseSampleResource() as Resource } - .forEach { addEntry().apply { resource = it } } - - // output of test results cql is also added to input of this cql - "cql/test-results/sample".readDir().map { it.parseSampleResource() as Resource }.forEach { - addEntry().apply { resource = it } - } - } + Bundle().apply { + // output of test results extraction is input of this cql + "test-results-questionnaire/sample" + .readDir() + .map { it.parseSampleResource() as Resource } + .forEach { addEntry().apply { resource = it } } + + // output of test results cql is also added to input of this cql + "cql/test-results/sample".readDir().map { it.parseSampleResource() as Resource }.forEach { + addEntry().apply { resource = it } + } + } val fhirEngine = mockk() val defaultRepository = mockk() coEvery { fhirEngine.get(ResourceType.Library, cqlLibrary.logicalId) } returns cqlLibrary coEvery { fhirEngine.get(ResourceType.Library, fhirHelpersLibrary.logicalId) } returns - fhirHelpersLibrary + fhirHelpersLibrary every { defaultRepository.fhirEngine } returns fhirEngine coEvery { defaultRepository.save(any()) } just runs coEvery { defaultRepository.search(any()) } returns listOf() val result = runBlocking { evaluator.runCqlLibrary( - cqlLibrary.logicalId, - patient, - dataBundle.apply { - this.entry.removeIf { it.resource.resourceType == ResourceType.Patient } - }, - defaultRepository, - true + cqlLibrary.logicalId, + patient, + dataBundle.apply { + this.entry.removeIf { it.resource.resourceType == ResourceType.Patient } + }, + defaultRepository, + true ) } assertOutput( - "$resourceDir/output_medication_request.json", - result, - ResourceType.MedicationRequest + "$resourceDir/output_medication_request.json", + result, + ResourceType.MedicationRequest ) coVerify { defaultRepository.save(any()) } @@ -132,59 +131,59 @@ class CqlContentTest : RobolectricTest() { val cqlElm = toJsonElm(cql).readStringToBase64Encoded() val cqlLibrary = - parser.parseResource( - "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) - ) as - Library + parser.parseResource( + "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) + ) as + Library println(cqlLibrary.convertToString(false) as String) val fhirHelpersLibrary = "cql-common/helper.json".parseSampleResourceFromFile() as Library val patient = - "patient-registration-questionnaire/sample/patient.json".parseSampleResourceFromFile() as - Patient + "patient-registration-questionnaire/sample/patient.json".parseSampleResourceFromFile() as + Patient val dataBundle = - Bundle().apply { - // output of test results extraction is input of this cql - "test-results-questionnaire/sample" - .readDir() - .map { it.parseSampleResource() as Resource } - .forEach { addEntry().apply { resource = it } } - } + Bundle().apply { + // output of test results extraction is input of this cql + "test-results-questionnaire/sample" + .readDir() + .map { it.parseSampleResource() as Resource } + .forEach { addEntry().apply { resource = it } } + } val fhirEngine = mockk() val defaultRepository = mockk() coEvery { fhirEngine.get(ResourceType.Library, cqlLibrary.logicalId) } returns cqlLibrary coEvery { fhirEngine.get(ResourceType.Library, fhirHelpersLibrary.logicalId) } returns - fhirHelpersLibrary + fhirHelpersLibrary coEvery { defaultRepository.save(any()) } just runs every { defaultRepository.fhirEngine } returns fhirEngine coEvery { defaultRepository.search(any()) } returns listOf() val result = runBlocking { evaluator.runCqlLibrary( - cqlLibrary.logicalId, - patient, - dataBundle.apply { - this.entry.removeIf { it.resource.resourceType == ResourceType.Patient } - }, - defaultRepository, - true + cqlLibrary.logicalId, + patient, + dataBundle.apply { + this.entry.removeIf { it.resource.resourceType == ResourceType.Patient } + }, + defaultRepository, + true ) } assertOutput("$resourceDir/sample/output_condition.json", result, ResourceType.Condition) assertOutput( - "$resourceDir/sample/output_service_request.json", - result, - ResourceType.ServiceRequest + "$resourceDir/sample/output_service_request.json", + result, + ResourceType.ServiceRequest ) assertOutput( - "$resourceDir/sample/output_diagnostic_report.json", - result, - ResourceType.DiagnosticReport + "$resourceDir/sample/output_diagnostic_report.json", + result, + ResourceType.DiagnosticReport ) coVerify(exactly = 3) { defaultRepository.save(any()) } @@ -197,31 +196,31 @@ class CqlContentTest : RobolectricTest() { val cqlElm = toJsonElm(cql).readStringToBase64Encoded() val cqlLibrary = - parser.parseResource( - "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) - ) as - Library + parser.parseResource( + "$resourceDir/library.json".readFile().replace("#library-elm.json", cqlElm) + ) as + Library println(cqlLibrary.convertToString(false) as String) val fhirHelpersLibrary = "cql-common/helper.json".parseSampleResourceFromFile() as Library val dataBundle = - Bundle().apply { - addEntry().apply { - // questionnaire-response of test results is input of this cql - resource = - "test-results-questionnaire/questionnaire-response.json".parseSampleResourceFromFile() as - Resource - } - } + Bundle().apply { + addEntry().apply { + // questionnaire-response of test results is input of this cql + resource = + "test-results-questionnaire/questionnaire-response.json".parseSampleResourceFromFile() as + Resource + } + } val fhirEngine = mockk() val defaultRepository = mockk() coEvery { fhirEngine.get(ResourceType.Library, cqlLibrary.logicalId) } returns cqlLibrary coEvery { fhirEngine.get(ResourceType.Library, fhirHelpersLibrary.logicalId) } returns - fhirHelpersLibrary + fhirHelpersLibrary every { defaultRepository.fhirEngine } returns fhirEngine coEvery { defaultRepository.save(any()) } just runs coEvery { defaultRepository.search(any()) } returns listOf() @@ -234,23 +233,23 @@ class CqlContentTest : RobolectricTest() { Assert.assertTrue(result.contains("OUTPUT -> Correct Result")) Assert.assertTrue( - result.contains( - "OUTPUT -> \nDetails:\n" + - "Value (3.0) is in Normal G6PD Range 0-3\n" + - "Value (11.0) is in Normal Haemoglobin Range 8-12" - ) + result.contains( + "OUTPUT -> \nDetails:\n" + + "Value (3.0) is in Normal G6PD Range 0-3\n" + + "Value (11.0) is in Normal Haemoglobin Range 8-12" + ) ) val observationSlot = slot() coVerify { defaultRepository.save(capture(observationSlot)) } Assert.assertEquals( - "QuestionnaireResponse/TEST_QUESTIONNAIRE_RESPONSE", - observationSlot.captured.focusFirstRep.reference + "QuestionnaireResponse/TEST_QUESTIONNAIRE_RESPONSE", + observationSlot.captured.focusFirstRep.reference ) Assert.assertEquals( - "Correct Result", - observationSlot.captured.valueCodeableConcept.codingFirstRep.display + "Correct Result", + observationSlot.captured.valueCodeableConcept.codingFirstRep.display ) Assert.assertEquals("Device Operation", observationSlot.captured.code.codingFirstRep.display) } @@ -260,7 +259,7 @@ class CqlContentTest : RobolectricTest() { libraryManager.librarySourceLoader.registerProvider(FhirLibrarySourceProvider()) val translator: CqlTranslator = - CqlTranslator.fromText(cql, evaluator.modelManager, libraryManager) + CqlTranslator.fromText(cql, evaluator.modelManager, libraryManager) return translator.toJxson().also { println(it.replace("\n", "").replace(" ", "")) } } @@ -270,12 +269,12 @@ class CqlContentTest : RobolectricTest() { val expectedResource = resource.parseSampleResourceFromFile().convertToString(true) val cqlResultStr = - cqlResult.find { it.startsWith("OUTPUT") && it.contains("\"resourceType\":\"$type\"") }!! - .replaceTimePart() + cqlResult.find { it.startsWith("OUTPUT") && it.contains("\"resourceType\":\"$type\"") }!! + .replaceTimePart() println(cqlResultStr) println(expectedResource as String) Assert.assertTrue(cqlResultStr.contains("OUTPUT -> $expectedResource")) } -} \ No newline at end of file +} From 03cd001a68e582a2f38065552bd73cab5d5f2aca Mon Sep 17 00:00:00 2001 From: Christopher Seven Phiri Date: Wed, 28 Jun 2023 08:42:57 +0200 Subject: [PATCH 16/68] init --- android/dataclerk/.gitignore | 1 + android/dataclerk/build.gradle | 173 ++++++++++++++++++ android/dataclerk/proguard-rules.pro | 21 +++ .../dataclerk/ExampleInstrumentedTest.kt | 24 +++ .../dataclerk/src/main/AndroidManifest.xml | 25 +++ .../dataclerk/DataClerkApplication.kt | 44 +++++ .../dataclerk/DataClerkConfigService.kt | 22 +++ .../dataclerk/data/QuestXFhirQueryResolver.kt | 15 ++ .../dataclerk/di/ConfigServiceModule.kt | 15 ++ .../dataclerk/di/DataClerkServiceModule.kt | 14 ++ .../dataclerk/di/LoginServiceModule.kt | 15 ++ .../ui/appsettings/AppSettingActivity.kt | 32 ++++ .../ui/appsettings/AppSettingViewModel.kt | 8 + .../dataclerk/ui/main/AppMainActivity.kt | 11 ++ .../fhircore/dataclerk/ui/theme/Color.kt | 11 ++ .../fhircore/dataclerk/ui/theme/Theme.kt | 70 +++++++ .../dtree/fhircore/dataclerk/ui/theme/Type.kt | 34 ++++ .../drawable-v24/ic_launcher_foreground.xml | 30 +++ .../res/drawable/ic_launcher_background.xml | 170 +++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 6 + .../src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1404 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2898 bytes .../src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 982 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1772 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1900 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3918 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 2884 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5914 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 3844 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7778 bytes .../dataclerk/src/main/res/values/colors.xml | 10 + .../dataclerk/src/main/res/values/strings.xml | 4 + .../dataclerk/src/main/res/values/themes.xml | 5 + .../fhircore/dataclerk/ExampleUnitTest.kt | 17 ++ android/settings.gradle | 1 + 36 files changed, 784 insertions(+) create mode 100644 android/dataclerk/.gitignore create mode 100644 android/dataclerk/build.gradle create mode 100644 android/dataclerk/proguard-rules.pro create mode 100644 android/dataclerk/src/androidTest/java/org/dtree/fhircore/dataclerk/ExampleInstrumentedTest.kt create mode 100644 android/dataclerk/src/main/AndroidManifest.xml create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/data/QuestXFhirQueryResolver.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/DataClerkServiceModule.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/LoginServiceModule.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/appsettings/AppSettingActivity.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/appsettings/AppSettingViewModel.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainActivity.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Color.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Theme.kt create mode 100644 android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Type.kt create mode 100644 android/dataclerk/src/main/res/drawable-v24/ic_launcher_foreground.xml create mode 100644 android/dataclerk/src/main/res/drawable/ic_launcher_background.xml create mode 100644 android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher.webp create mode 100644 android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_round.webp create mode 100644 android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher.webp create mode 100644 android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher_round.webp create mode 100644 android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher.webp create mode 100644 android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_round.webp create mode 100644 android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher.webp create mode 100644 android/dataclerk/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp create mode 100644 android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher.webp create mode 100644 android/dataclerk/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp create mode 100644 android/dataclerk/src/main/res/values/colors.xml create mode 100644 android/dataclerk/src/main/res/values/strings.xml create mode 100644 android/dataclerk/src/main/res/values/themes.xml create mode 100644 android/dataclerk/src/test/java/org/dtree/fhircore/dataclerk/ExampleUnitTest.kt diff --git a/android/dataclerk/.gitignore b/android/dataclerk/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/android/dataclerk/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/android/dataclerk/build.gradle b/android/dataclerk/build.gradle new file mode 100644 index 0000000000..f483d248d0 --- /dev/null +++ b/android/dataclerk/build.gradle @@ -0,0 +1,173 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id 'kotlin-kapt' + id 'de.mannodermaus.android-junit5' version '1.9.3.0' + id 'org.jetbrains.dokka' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'jacoco' + id 'dagger.hilt.android.plugin' + id 'org.jetbrains.kotlin.android' +// id 'com.google.firebase.firebase-perf' +// id 'com.google.gms.google-services' +// id 'com.google.firebase.crashlytics' +} + +apply from: '../properties.gradle' + +android { + namespace 'org.dtree.fhircore.dataclerk' + compileSdkVersion sdk_versions.compile_sdk + + defaultConfig { + applicationId "org.dtree.fhircore.dataclerk" + minSdkVersion sdk_versions.min_sdk + targetSdkVersion sdk_versions.target_sdk + versionCode 1 + versionName "0.0.1" + multiDexEnabled true + + buildConfigField("boolean", 'SKIP_AUTH_CHECK', "false") + buildConfigField("String", 'FHIR_BASE_URL', "\"${FHIR_BASE_URL}\"") + buildConfigField("String", 'OAUTH_BASE_URL', "\"${OAUTH_BASE_URL}\"") + buildConfigField("String", 'OAUTH_CIENT_ID', "\"${OAUTH_CIENT_ID}\"") + buildConfigField("String", 'OAUTH_CLIENT_SECRET', "\"${OAUTH_CLIENT_SECRET}\"") + buildConfigField("String", 'OAUTH_SCOPE', "\"${OAUTH_SCOPE}\"") + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables { + useSupportLibrary true + } + } + +// signingConfigs { +// release { +// +// v1SigningEnabled false +// v2SigningEnabled true +// +// keyAlias System.getenv("KEYSTORE_ALIAS")?: project.KEYSTORE_ALIAS +// keyPassword System.getenv("KEY_PASSWORD") ?: project.KEY_PASSWORD +// storePassword System.getenv("KEYSTORE_PASSWORD") ?: project.KEYSTORE_PASSWORD +// storeFile file(System.getProperty("user.home") + "/fhircore.keystore.jks") +// } +// } + +// buildTypes { +// debug { +// testCoverageEnabled true +// } +// release { +// minifyEnabled false +// proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' +// signingConfig signingConfigs.release +// firebaseCrashlytics { +// nativeSymbolUploadEnabled false +// } +// } +// } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_11.toString() + freeCompilerArgs = ['-Xjvm-default=all-compatibility', '-opt-in=kotlin.RequiresOptIn'] + } + + buildFeatures { + compose true + } + + composeOptions { + kotlinCompilerExtensionVersion '1.4.4' + } + + testOptions { + execution 'ANDROIDX_TEST_ORCHESTRATOR' + animationsDisabled true + + unitTests { + includeAndroidResources = true + returnDefaultValues = true + all { + beforeTest { testDescriptor -> + println "${testDescriptor.className} > ${testDescriptor.name} STARTED" + } + } + } + } + + lintOptions { + abortOnError false + } + + configurations.all { + resolutionStrategy { + force "ca.uhn.hapi.fhir:org.hl7.fhir.utilities:5.5.7" + } + } + + packagingOptions { + exclude 'META-INF/ASL-2.0.txt' + exclude 'META-INF/LGPL-3.0.txt' + exclude 'license.html' + exclude 'readme.html' + exclude 'META-INF/DEPENDENCIES' + exclude 'META-INF/LICENSE' + exclude 'META-INF/LICENSE.txt' + exclude 'META-INF/license.txt' + exclude 'META-INF/license.html' + exclude 'META-INF/LICENSE.md' + exclude 'META-INF/NOTICE' + exclude 'META-INF/NOTICE.txt' + exclude 'META-INF/NOTICE.md' + exclude 'META-INF/notice.txt' + exclude 'META-INF/ASL2.0' + exclude 'META-INF/ASL-2.0.txt' + exclude 'META-INF/LGPL-3.0.txt' + exclude 'META-INF/sun-jaxb.episode' + exclude("META-INF/*.kotlin_module") + exclude("META-INF/AL2.0") + exclude("META-INF/LGPL2.1") + } +} + +dependencies { + coreLibraryDesugaring deps.desugar + implementation(project(":engine")) + + implementation 'androidx.core:core-ktx:1.8.0' + implementation platform('org.jetbrains.kotlin:kotlin-bom:1.8.0') + implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.3.1' + implementation 'androidx.activity:activity-compose:1.5.1' + + implementation platform('androidx.compose:compose-bom:2022.10.00') + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-graphics' + implementation 'androidx.compose.ui:ui-tooling-preview' + implementation 'androidx.compose.material3:material3' + + //Hilt - Dependency Injection + implementation "com.google.dagger:hilt-android:$hiltVersion" + kapt "com.google.dagger:hilt-compiler:$hiltVersion" + + testImplementation 'junit:junit:4.13.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.5' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation platform('androidx.compose:compose-bom:2022.10.00') + androidTestImplementation 'androidx.compose.ui:ui-test-junit4' + debugImplementation 'androidx.compose.ui:ui-tooling' + debugImplementation 'androidx.compose.ui:ui-test-manifest' +} + +kapt { + correctErrorTypes true +} + +hilt { + enableAggregatingTask = true +} \ No newline at end of file diff --git a/android/dataclerk/proguard-rules.pro b/android/dataclerk/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/android/dataclerk/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/android/dataclerk/src/androidTest/java/org/dtree/fhircore/dataclerk/ExampleInstrumentedTest.kt b/android/dataclerk/src/androidTest/java/org/dtree/fhircore/dataclerk/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..92fd495499 --- /dev/null +++ b/android/dataclerk/src/androidTest/java/org/dtree/fhircore/dataclerk/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package org.dtree.fhircore.dataclerk + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("org.dtree.fhircore.dataclerk", appContext.packageName) + } +} \ No newline at end of file diff --git a/android/dataclerk/src/main/AndroidManifest.xml b/android/dataclerk/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..7b1a2fac6e --- /dev/null +++ b/android/dataclerk/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt new file mode 100644 index 0000000000..4d4a6db105 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkApplication.kt @@ -0,0 +1,44 @@ +package org.dtree.fhircore.dataclerk + +import android.app.Application +import android.util.Log +import androidx.hilt.work.HiltWorkerFactory +import androidx.work.Configuration +import com.google.android.fhir.datacapture.DataCaptureConfig +import dagger.hilt.android.HiltAndroidApp +import org.dtree.fhircore.dataclerk.data.QuestXFhirQueryResolver +import org.smartregister.fhircore.engine.data.remote.fhir.resource.ReferenceUrlResolver +import org.smartregister.fhircore.engine.ui.questionnaire.QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl +import javax.inject.Inject + +@HiltAndroidApp +class DataClerkApplication : Application(), DataCaptureConfig.Provider, Configuration.Provider { + private var configuration: DataCaptureConfig? = null + + @Inject + lateinit var workerFactory: HiltWorkerFactory + + @Inject + lateinit var referenceUrlResolver: ReferenceUrlResolver + + @Inject + lateinit var xFhirQueryResolver: QuestXFhirQueryResolver + + override fun getDataCaptureConfig(): DataCaptureConfig { + configuration = + configuration + ?: DataCaptureConfig( + urlResolver = referenceUrlResolver, + xFhirQueryResolver = xFhirQueryResolver, + questionnaireItemViewHolderFactoryMatchersProviderFactory = + QuestionnaireItemViewHolderFactoryMatchersProviderFactoryImpl + ) + return configuration as DataCaptureConfig + } + + override fun getWorkManagerConfiguration(): Configuration = + Configuration.Builder() + .setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.VERBOSE else Log.INFO) + .setWorkerFactory(workerFactory) + .build() +} \ No newline at end of file diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt new file mode 100644 index 0000000000..cfd6801532 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/DataClerkConfigService.kt @@ -0,0 +1,22 @@ +package org.dtree.fhircore.dataclerk + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import org.smartregister.fhircore.engine.configuration.app.AuthConfiguration +import org.smartregister.fhircore.engine.configuration.app.ConfigService +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class DataClerkConfigService @Inject constructor(@ApplicationContext val context: Context) : + ConfigService { + + override fun provideAuthConfiguration() = + AuthConfiguration( + fhirServerBaseUrl = BuildConfig.FHIR_BASE_URL, + oauthServerBaseUrl = BuildConfig.OAUTH_BASE_URL, + clientId = BuildConfig.OAUTH_CIENT_ID, + clientSecret = BuildConfig.OAUTH_CLIENT_SECRET, + accountType = BuildConfig.APPLICATION_ID + ) +} diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/data/QuestXFhirQueryResolver.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/data/QuestXFhirQueryResolver.kt new file mode 100644 index 0000000000..fc5be2eb9e --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/data/QuestXFhirQueryResolver.kt @@ -0,0 +1,15 @@ +package org.dtree.fhircore.dataclerk.data + +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.datacapture.XFhirQueryResolver +import com.google.android.fhir.search.search +import org.hl7.fhir.r4.model.Resource +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class QuestXFhirQueryResolver @Inject constructor(val fhirEngine: FhirEngine) : XFhirQueryResolver { + override suspend fun resolve(xFhirQuery: String): List { + return fhirEngine.search(xFhirQuery) + } +} diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt new file mode 100644 index 0000000000..68a5401651 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/ConfigServiceModule.kt @@ -0,0 +1,15 @@ +package org.dtree.fhircore.dataclerk.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.dtree.fhircore.dataclerk.DataClerkConfigService +import org.smartregister.fhircore.engine.configuration.app.ConfigService + +@InstallIn(SingletonComponent::class) +@Module +abstract class ConfigServiceModule { + @Binds + abstract fun provideConfigService(questConfigService: DataClerkConfigService): ConfigService +} diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/DataClerkServiceModule.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/DataClerkServiceModule.kt new file mode 100644 index 0000000000..a7f4908260 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/DataClerkServiceModule.kt @@ -0,0 +1,14 @@ +package org.dtree.fhircore.dataclerk.di + +import androidx.appcompat.app.AppCompatActivity +import org.smartregister.fhircore.engine.ui.login.LoginService +import javax.inject.Inject + +class DataClerkServiceModule @Inject constructor() : LoginService { + + override lateinit var loginActivity: AppCompatActivity + + override fun navigateToHome() { + // Do nothing + } +} \ No newline at end of file diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/LoginServiceModule.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/LoginServiceModule.kt new file mode 100644 index 0000000000..ee317464b2 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/di/LoginServiceModule.kt @@ -0,0 +1,15 @@ +package org.dtree.fhircore.dataclerk.di + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.android.components.ActivityComponent +import org.smartregister.fhircore.engine.ui.login.LoginService + +@InstallIn(ActivityComponent::class) +@Module +abstract class LoginServiceModule { + + @Binds + abstract fun bindLoginService(loginService: DataClerkServiceModule): LoginService +} diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/appsettings/AppSettingActivity.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/appsettings/AppSettingActivity.kt new file mode 100644 index 0000000000..d8c80e5099 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/appsettings/AppSettingActivity.kt @@ -0,0 +1,32 @@ +package org.dtree.fhircore.dataclerk.ui.appsettings + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import androidx.compose.ui.res.stringResource +import dagger.hilt.android.AndroidEntryPoint +import org.dtree.fhircore.dataclerk.R +import org.dtree.fhircore.dataclerk.ui.theme.FhircoreandroidTheme +import org.smartregister.fhircore.engine.ui.components.register.LoaderDialog +import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.SharedPreferencesHelper +import javax.inject.Inject + +@AndroidEntryPoint +class AppSettingActivity : ComponentActivity() { + @Inject + lateinit var sharedPreferencesHelper: SharedPreferencesHelper + @Inject + lateinit var dispatcherProvider: DispatcherProvider + val appSettingViewModel: AppSettingViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + FhircoreandroidTheme { + LoaderDialog(dialogMessage = stringResource(R.string.initializing)) + } + } + } +} \ No newline at end of file diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/appsettings/AppSettingViewModel.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/appsettings/AppSettingViewModel.kt new file mode 100644 index 0000000000..f128f9663d --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/appsettings/AppSettingViewModel.kt @@ -0,0 +1,8 @@ +package org.dtree.fhircore.dataclerk.ui.appsettings + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel + +@HiltViewModel +class AppSettingViewModel : ViewModel() { +} \ No newline at end of file diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainActivity.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainActivity.kt new file mode 100644 index 0000000000..e8fb38abf0 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/main/AppMainActivity.kt @@ -0,0 +1,11 @@ +package org.dtree.fhircore.dataclerk.ui.main + +import androidx.compose.material.ExperimentalMaterialApi +import dagger.hilt.android.AndroidEntryPoint +import org.smartregister.fhircore.engine.ui.base.BaseMultiLanguageActivity + +@AndroidEntryPoint +@ExperimentalMaterialApi +class AppMainActivity: BaseMultiLanguageActivity() { + +} \ No newline at end of file diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Color.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Color.kt new file mode 100644 index 0000000000..b74b49d2b5 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package org.dtree.fhircore.dataclerk.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Theme.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Theme.kt new file mode 100644 index 0000000000..7adc2f11cc --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Theme.kt @@ -0,0 +1,70 @@ +package org.dtree.fhircore.dataclerk.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.core.view.WindowCompat + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun FhircoreandroidTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + val view = LocalView.current + if (!view.isInEditMode) { + SideEffect { + val window = (view.context as Activity).window + window.statusBarColor = colorScheme.primary.toArgb() + WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = darkTheme + } + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Type.kt b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Type.kt new file mode 100644 index 0000000000..9ec3882fb3 --- /dev/null +++ b/android/dataclerk/src/main/java/org/dtree/fhircore/dataclerk/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package org.dtree.fhircore.dataclerk.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/android/dataclerk/src/main/res/drawable-v24/ic_launcher_foreground.xml b/android/dataclerk/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000000..2b068d1146 --- /dev/null +++ b/android/dataclerk/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/dataclerk/src/main/res/drawable/ic_launcher_background.xml b/android/dataclerk/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000000..07d5da9cbf --- /dev/null +++ b/android/dataclerk/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000000..6f3b755bf5 --- /dev/null +++ b/android/dataclerk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..c209e78ecd372343283f4157dcfd918ec5165bb3 GIT binary patch literal 1404 zcmV-?1%vuhNk&F=1pok7MM6+kP&il$0000G0000-002h-06|PpNX!5L00Dqw+t%{r zzW2vH!KF=w&cMnnN@{whkTw+#mAh0SV?YL=)3MimFYCWp#fpdtz~8$hD5VPuQgtcN zXl<@<#Cme5f5yr2h%@8TWh?)bSK`O z^Z@d={gn7J{iyxL_y_%J|L>ep{dUxUP8a{byupH&!UNR*OutO~0{*T4q5R6@ApLF! z5{w?Z150gC7#>(VHFJZ-^6O@PYp{t!jH(_Z*nzTK4 zkc{fLE4Q3|mA2`CWQ3{8;gxGizgM!zccbdQoOLZc8hThi-IhN90RFT|zlxh3Ty&VG z?Fe{#9RrRnxzsu|Lg2ddugg7k%>0JeD+{XZ7>Z~{=|M+sh1MF7~ zz>To~`~LVQe1nNoR-gEzkpe{Ak^7{{ZBk2i_<+`Bq<^GB!RYG+z)h;Y3+<{zlMUYd zrd*W4w&jZ0%kBuDZ1EW&KLpyR7r2=}fF2%0VwHM4pUs}ZI2egi#DRMYZPek*^H9YK zay4Iy3WXFG(F14xYsoDA|KXgGc5%2DhmQ1gFCkrgHBm!lXG8I5h*uf{rn48Z!_@ z4Bk6TJAB2CKYqPjiX&mWoW>OPFGd$wqroa($ne7EUK;#3VYkXaew%Kh^3OrMhtjYN?XEoY`tRPQsAkH-DSL^QqyN0>^ zmC>{#F14jz4GeW{pJoRpLFa_*GI{?T93^rX7SPQgT@LbLqpNA}<@2wH;q493)G=1Y z#-sCiRNX~qf3KgiFzB3I>4Z%AfS(3$`-aMIBU+6?gbgDb!)L~A)je+;fR0jWLL-Fu z4)P{c7{B4Hp91&%??2$v9iRSFnuckHUm}or9seH6 z>%NbT+5*@L5(I9j@06@(!{ZI?U0=pKn8uwIg&L{JV14+8s2hnvbRrU|hZCd}IJu7*;;ECgO%8_*W Kmw_-CKmY()leWbG literal 0 HcmV?d00001 diff --git a/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/dataclerk/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9 GIT binary patch literal 2898 zcmV-Y3$650Nk&FW3jhFDMM6+kP&il$0000G0000-002h-06|PpNWB9900E$G+qN-D z+81ABX7q?;bwx%xBg?kcwr$(C-Tex-ZCkHUw(Y9#+`E5-zuONG5fgw~E2WDng@Bc@ z24xy+R1n%~6xI#u9vJ8zREI)sb<&Il(016}Z~V1n^PU3-_H17A*Bf^o)&{_uBv}Py zulRfeE8g(g6HFhk_?o_;0@tz?1I+l+Y#Q*;RVC?(ud`_cU-~n|AX-b`JHrOIqn(-t&rOg-o`#C zh0LPxmbOAEb;zHTu!R3LDh1QO zZTf-|lJNUxi-PpcbRjw3n~n-pG;$+dIF6eqM5+L();B2O2tQ~|p{PlpNcvDbd1l%c zLtXn%lu(3!aNK!V#+HNn_D3lp z2%l+hK-nsj|Bi9;V*WIcQRTt5j90A<=am+cc`J zTYIN|PsYAhJ|=&h*4wI4ebv-C=Be#u>}%m;a{IGmJDU`0snWS&$9zdrT(z8#{OZ_Y zxwJx!ZClUi%YJjD6Xz@OP8{ieyJB=tn?>zaI-4JN;rr`JQbb%y5h2O-?_V@7pG_+y z(lqAsqYr!NyVb0C^|uclHaeecG)Sz;WV?rtoqOdAAN{j%?Uo%owya(F&qps@Id|Of zo@~Y-(YmfB+chv^%*3g4k3R0WqvuYUIA+8^SGJ{2Bl$X&X&v02>+0$4?di(34{pt* zG=f#yMs@Y|b&=HyH3k4yP&goF2LJ#tBLJNNDo6lG06r}ghC-pC4Q*=x3;|+W04zte zAl>l4kzUBQFYF(E`KJy?ZXd1tnfbH+Z~SMmA21KokJNs#eqcXWKUIC>{TuoKe^vhF z);H)o`t9j~`$h1D`#bxe@E`oE`cM9w(@)5Bp8BNukIwM>wZHfd0S;5bcXA*5KT3bj zc&_~`&{z7u{Et!Z_k78H75gXf4g8<_ul!H$eVspPeU3j&&Au=2R*Zp#M9$9s;fqwgzfiX=E_?BwVcfx3tG9Q-+<5fw z%Hs64z)@Q*%s3_Xd5>S4dg$s>@rN^ixeVj*tqu3ZV)biDcFf&l?lGwsa zWj3rvK}?43c{IruV2L`hUU0t^MemAn3U~x3$4mFDxj=Byowu^Q+#wKRPrWywLjIAp z9*n}eQ9-gZmnd9Y0WHtwi2sn6n~?i#n9VN1B*074_VbZZ=WrpkMYr{RsI ztM_8X1)J*DZejxkjOTRJ&a*lrvMKBQURNP#K)a5wIitfu(CFYV4FT?LUB$jVwJSZz zNBFTWg->Yk0j&h3e*a5>B=-xM7dE`IuOQna!u$OoxLlE;WdrNlN)1 z7**de7-hZ!(%_ZllHBLg`Ir#|t>2$*xVOZ-ADZKTN?{(NUeLU9GbuG-+Axf*AZ-P1 z0ZZ*fx+ck4{XtFsbcc%GRStht@q!m*ImssGwuK+P@%gEK!f5dHymg<9nSCXsB6 zQ*{<`%^bxB($Z@5286^-A(tR;r+p7B%^%$N5h%lb*Vlz-?DL9x;!j<5>~kmXP$E}m zQV|7uv4SwFs0jUervsxVUm>&9Y3DBIzc1XW|CUZrUdb<&{@D5yuLe%Xniw^x&{A2s z0q1+owDSfc3Gs?ht;3jw49c#mmrViUfX-yvc_B*wY|Lo7; zGh!t2R#BHx{1wFXReX*~`NS-LpSX z#TV*miO^~B9PF%O0huw!1Zv>^d0G3$^8dsC6VI!$oKDKiXdJt{mGkyA`+Gwd4D-^1qtNTUK)`N*=NTG-6}=5k6suNfdLt*dt8D| z%H#$k)z#ZRcf|zDWB|pn<3+7Nz>?WW9WdkO5(a^m+D4WRJ9{wc>Y}IN)2Kbgn;_O? zGqdr&9~|$Y0tP=N(k7^Eu;iO*w+f%W`20BNo)=Xa@M_)+o$4LXJyiw{F?a633SC{B zl~9FH%?^Rm*LVz`lkULs)%idDX^O)SxQol(3jDRyBVR!7d`;ar+D7do)jQ}m`g$TevUD5@?*P8)voa?kEe@_hl{_h8j&5eB-5FrYW&*FHVt$ z$kRF9Nstj%KRzpjdd_9wO=4zO8ritN*NPk_9avYrsF(!4))tm{Ga#OY z(r{0buexOzu7+rw8E08Gxd`LTOID{*AC1m*6Nw@osfB%0oBF5sf<~wH1kL;sd zo)k6^VyRFU`)dt*iX^9&QtWbo6yE8XXH?`ztvpiOLgI3R+=MOBQ9=rMVgi<*CU%+d1PQQ0a1U=&b0vkF207%xU0ssI2 literal 0 HcmV?d00001 diff --git a/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/dataclerk/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..4f0f1d64e58ba64d180ce43ee13bf9a17835fbca GIT binary patch literal 982 zcmV;{11bDcNk&G_0{{S5MM6+kP&il$0000G0000l001ul06|PpNU8t;00Dqo+t#w^ z^1csucXz7-Qrhzl9HuHB%l>&>1tG2^vb*E&k^T3$FG1eQZ51g$uv4V+kI`0<^1Z@N zk?Jjh$olyC%l>)Xq;7!>{iBj&BjJ`P&$fsCfpve_epJOBkTF?nu-B7D!hO=2ZR}

C%4 zc_9eOXvPbC4kzU8YowIA8cW~Uv|eB&yYwAObSwL2vY~UYI7NXPvf3b+c^?wcs~_t{ ze_m66-0)^{JdOMKPwjpQ@Sna!*?$wTZ~su*tNv7o!gXT!GRgivP}ec?5>l1!7<(rT zds|8x(qGc673zrvYIz;J23FG{9nHMnAuP}NpAED^laz3mAN1sy+NXK)!6v1FxQ;lh zOBLA>$~P3r4b*NcqR;y6pwyhZ3_PiDb|%n1gGjl3ZU}ujInlP{eks-#oA6>rh&g+!f`hv#_%JrgYPu z(U^&XLW^QX7F9Z*SRPpQl{B%x)_AMp^}_v~?j7 zapvHMKxSf*Mtyx8I}-<*UGn3)oHd(nn=)BZ`d$lDBwq_GL($_TPaS{UeevT(AJ`p0 z9%+hQb6z)U9qjbuXjg|dExCLjpS8$VKQ55VsIC%@{N5t{NsW)=hNGI`J=x97_kbz@ E0Of=7!TQj4N+cqN`nQhxvX7dAV-`K|Ub$-q+H-5I?Tx0g9jWxd@A|?POE8`3b8fO$T))xP* z(X?&brZw({`)WU&rdAs1iTa0x6F@PIxJ&&L|dpySV!ID|iUhjCcKz(@mE z!x@~W#3H<)4Ae(4eQJRk`Iz3<1)6^m)0b_4_TRZ+cz#eD3f8V;2r-1fE!F}W zEi0MEkTTx}8i1{`l_6vo0(Vuh0HD$I4SjZ=?^?k82R51bC)2D_{y8mi_?X^=U?2|F{Vr7s!k(AZC$O#ZMyavHhlQ7 zUR~QXuH~#o#>(b$u4?s~HLF*3IcF7023AlwAYudn0FV~|odGH^05AYPEfR)8p`i{n zwg3zPVp{+wOsxKc>)(pMupKF!Y2HoUqQ3|Yu|8lwR=?5zZuhG6J?H`bSNk_wPoM{u zSL{c@pY7+c2kck>`^q1^^gR0QB7Y?KUD{vz-uVX~;V-rW)PDcI)$_UjgVV?S?=oLR zf4}zz{#*R_{LkiJ#0RdQLNC^2Vp%JPEUvG9ra2BVZ92(p9h7Ka@!yf9(lj#}>+|u* z;^_?KWdzkM`6gqPo9;;r6&JEa)}R3X{(CWv?NvgLeOTq$cZXqf7|sPImi-7cS8DCN zGf;DVt3Am`>hH3{4-WzH43Ftx)SofNe^-#|0HdCo<+8Qs!}TZP{HH8~z5n`ExcHuT zDL1m&|DVpIy=xsLO>8k92HcmfSKhflQ0H~9=^-{#!I1g(;+44xw~=* zxvNz35vfsQE)@)Zsp*6_GjYD};Squ83<_?^SbALb{a`j<0Gn%6JY!zhp=Fg}Ga2|8 z52e1WU%^L1}15Ex0fF$e@eCT(()_P zvV?CA%#Sy08_U6VPt4EtmVQraWJX` zh=N|WQ>LgrvF~R&qOfB$!%D3cGv?;Xh_z$z7k&s4N)$WYf*k=|*jCEkO19{h_(%W4 zPuOqbCw`SeAX*R}UUsbVsgtuG?xs(#Ikx9`JZoQFz0n*7ZG@Fv@kZk`gzO$HoA9kN z8U5{-yY zvV{`&WKU2$mZeoBmiJrEdzUZAv1sRxpePdg1)F*X^Y)zp^Y*R;;z~vOv-z&)&G)JQ{m!C9cmziu1^nHA z`#`0c>@PnQ9CJKgC5NjJD8HM3|KC(g5nnCq$n0Gsu_DXk36@ql%npEye|?%RmG)

FJ$wK}0tWNB{uH;AM~i literal 0 HcmV?d00001 diff --git a/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..948a3070fe34c611c42c0d3ad3013a0dce358be0 GIT binary patch literal 1900 zcmV-y2b1_xNk&Fw2LJ$9MM6+kP&il$0000G0001A003VA06|PpNH75a00DqwTbm-~ zullQTcXxO9ki!OCRx^i?oR|n!<8G0=kI^!JSjFi-LL*`V;ET0H2IXfU0*i>o6o6Gy zRq6Ap5(_{XLdXcL-MzlN`ugSdZY_`jXhcENAu)N_0?GhF))9R;E`!bo9p?g?SRgw_ zEXHhFG$0{qYOqhdX<(wE4N@es3VIo$%il%6xP9gjiBri+2pI6aY4 zJbgh-Ud|V%3O!IcHKQx1FQH(_*TK;1>FQWbt^$K1zNn^cczkBs=QHCYZ8b&l!UV{K z{L0$KCf_&KR^}&2Fe|L&?1I7~pBENnCtCuH3sjcx6$c zwqkNkru);ie``q+_QI;IYLD9OV0ZxkuyBz|5<$1BH|vtey$> z5oto4=l-R-Aaq`Dk0}o9N0VrkqW_#;!u{!bJLDq%0092{Ghe=F;(kn} z+sQ@1=UlX30+2nWjkL$B^b!H2^QYO@iFc0{(-~yXj2TWz?VG{v`Jg zg}WyYnwGgn>{HFaG7E~pt=)sOO}*yd(UU-D(E&x{xKEl6OcU?pl)K%#U$dn1mDF19 zSw@l8G!GNFB3c3VVK0?uyqN&utT-D5%NM4g-3@Sii9tSXKtwce~uF zS&Jn746EW^wV~8zdQ1XC28~kXu8+Yo9p!<8h&(Q({J*4DBglPdpe4M_mD8AguZFn~ ztiuO~{6Bx?SfO~_ZV(GIboeR9~hAym{{fV|VM=77MxDrbW6`ujX z<3HF(>Zr;#*uCvC*bpoSr~C$h?_%nXps@A)=l_;({Fo#6Y1+Zv`!T5HB+)#^-Ud_; zBwftPN=d8Vx)*O1Mj+0oO=mZ+NVH*ptNDC-&zZ7Hwho6UQ#l-yNvc0Cm+2$$6YUk2D2t#vdZX-u3>-Be1u9gtTBiMB^xwWQ_rgvGpZ6(C@e23c!^K=>ai-Rqu zhqT`ZQof;9Bu!AD(i^PCbYV%yha9zuoKMp`U^z;3!+&d@Hud&_iy!O-$b9ZLcSRh? z)R|826w}TU!J#X6P%@Zh=La$I6zXa#h!B;{qfug}O%z@K{EZECu6zl)7CiNi%xti0 zB{OKfAj83~iJvmpTU|&q1^?^cIMn2RQ?jeSB95l}{DrEPTW{_gmU_pqTc)h@4T>~& zluq3)GM=xa(#^VU5}@FNqpc$?#SbVsX!~RH*5p0p@w z;~v{QMX0^bFT1!cXGM8K9FP+=9~-d~#TK#ZE{4umGT=;dfvWi?rYj;^l_Zxywze`W z^Cr{55U@*BalS}K%Czii_80e0#0#Zkhlij4-~I@}`-JFJ7$5{>LnoJSs??J8kWVl6|8A}RCGAu9^rAsfCE=2}tHwl93t0C?#+jMpvr7O3`2=tr{Hg$=HlnjVG^ewm|Js0J*kfPa6*GhtB>`fN!m#9J(sU!?(OSfzY*zS(FJ<-Vb zfAIg+`U)YaXv#sY(c--|X zEB+TVyZ%Ie4L$gi#Fc++`h6%vzsS$pjz9aLt+ZL(g;n$Dzy5=m=_TV(3H8^C{r0xd zp#a%}ht55dOq?yhwYPrtp-m1xXp;4X;)NhxxUpgP%XTLmO zcjaFva^}dP3$&sfFTIR_jC=2pHh9kpI@2(6V*GQo7Ws)`j)hd+tr@P~gR*2gO@+1? zG<`_tB+LJuF|SZ9tIec;h%}}6WClT`L>HSW?E{Hp1h^+mlbf_$9zA>!ug>NALJsO{ mU%z=YwVD?}XMya)Bp;vlyE5&E_6!fzx9pwrdz474!~g(M6R?N? literal 0 HcmV?d00001 diff --git a/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/dataclerk/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f GIT binary patch literal 3918 zcmV-U53%r4Nk&FS4*&pHMM6+kP&il$0000G0001A003VA06|PpNSy@$00HoY|G(*G z+qV7x14$dSO^Re!iqt-AAIE9iwr$(CZQJL$blA4B`>;C3fBY6Q8_YSjb2%a=fc}4E zrSzssacq<^nmW|Rs93PJni30R<8w<(bK_$LO4L?!_OxLl$}K$MUEllnMK|rg=f3;y z*?;3j|Nh>)p0JQ3A~rf(MibH2r+)3cyV1qF&;8m{w-S*y+0mM){KTK^M5}ksc`qX3 zy>rf^b>~l>SSHds8(I@hz3&PD@LmEs4&prkT=BjsBCXTMhN$_)+kvnl0bLKW5rEsj z*d#KXGDB4P&>etx0X+`R19yC=LS)j!mgs5M0L~+o-T~Jl!p!AJxnGAhV%~rhYUL4hlWhgES3Kb5oA&X z{}?3OBSS-{!v$nCIGj->(-TAG)8LR{htr41^gxsT8yqt2@DEG6Yl`Uma3Nd4;YUoW zTbkYl3CMU5ypMF3EIkYmWL|*BknM`0+Kq6CpvO(y$#j94e+q{vI{Zp8cV_6RK!`&C zob$*5Q|$IZ09dW=L!V zw@#2wviu|<#3lgGE8GEhcx+zBt`} zOwP8j9X%^f7i_bth4PiJ$LYtFJSCN$3xwDN;8mr*B;CJwBP2G0TMq0uNt7S^DO_wE zepk!Wrn#Z#03j{`c*Rf~y3o7?J}w?tEELRUR2cgxB*Y{LzA#pxHgf}q?u5idu>077 zd^=p)`nA}6e`|@`p?u}YU66PP_MA}Zqqe!c{nK&z%Jwq1N4e_q<#4g^xaz=ao;u|6 zwpRcW2Lax=ZGbx=Q*HhlJ`Ns#Y*r0*%!T?P*TTiX;rb)$CGLz=rSUum$)3Qyv{BL2 zO*=OI2|%(Yz~`pNEOnLp>+?T@glq-DujlIp?hdJeZ7ctP4_OKx|5@EOps3rr(pWzg zK4d3&oN-X2qN(d_MkfwB4I)_)!I_6nj2iA9u^pQ{;GckGLxBGrJUM2Wdda!k)Y>lq zmjws>dVQ*vW9lvEMkiN3wE-__6OWD0txS&Qn0n22cyj4Q*8(nG4!G{6OOwNvsrPIL zCl-$W9UwkEUVuLwyD%|inbOF*xMODZ4VMEVAq_zUxZ+K#Gdqf!DW$5f)?7UNOFMz! zrB~tuu=6X2FE(p^iqgxr+?ZK;=yz`e;C$#_@D9Lj-+TDVOrva>(#*PVbaHO>A)mhl z07OJWCqYC60518$!&c`eNBcBW%GnfaQ*$eazV^2_AW?j)h;J1nUjN(I9=0+!RVx~% z3@Tf!P0TE+98jA?WceK-}A1% zW!K)lyKcGqy#M~})315-A#2NXQ`?6NR#Apo=S!oF=JfpX>iR*49ec{7AN$xxpK{D$ z2d%Fz&rdfSqourN$~Y^NFIMV1CZ?J*bMx~H3k&meGtH@q9ra2vZxmA$S(#jaaj-g4 ztJmxG+DLV<*q<|sDXPp$X>E)#S}Vm&sRaO5P&goh2><}FEdZSXDqsL$06sAkh(e+v zAsBhKSRexgwg6tIy~GFJzaTxXD(}|+0eOwFDA%rn`X;MVwDHT9=4=g%OaJ9s%3b9>9EUTnnp0t;2Zpa{*>mk~hZqItE_!dQ zOtC>8`$l|mV43Jbudf0N6&&X;{=z}Zi}d1`2qmJ}i|0*GsulD3>GgQXHN)pkR6sf1 z?5ZU%&xtL}oH;YiAA)d*^Ndw2T$+Mjuzyzz@-SM`9df7LqTxLuIwC~S0092~+=qYv z@*ja;?Wt!T!{U?c*Z0YtGe)XbI&y-?B&G2$`JDM)(dIV9G`Sc#6?sI60de6kv+)Qb zUW~2|WjvJq3TA8`0+sWA3zRhY9a~ow)O~&StBkG2{*{TGiY~S8ep{V&Vo2l<6LWsu z^#p0-v*t2?3&aA1)ozu|%efSR=XnpX$lvTeRdKlvM!@|pM5p2w3u-6 zU>}t2xiYLS+{|%C65AzX+23Mtlq?BS&YdYcYsVjoiE&rT>;Necn6l^K)T^lmE`5u{ zm1i+-a-gc;Z&v-{;8r)z6NYfBUv+=_L}ef}qa9FX01)+Aaf+;xj(mL6|JUzGJR1|fnanb%?BPPIp>SCjP|8qE5qJ{=n5ZGw?81z3(k;pzH%1CtlX50{E7h)$h{qGKfzC`e2o`*IqA#tjA z`Fz&^%$b9F*N`)U-#6>a)Z`55`$Dd0cfcs0$d13^ONrdCu9xcv_=n#WQo8stcz3jP9|2EvdI-RhJM3%Q%oM&!OlShM|0 z?gz?wHZSnm45njLtsz8PVT1S&jAlbKg5kVam$p16=EK@Sj4EP0OtH zmJDmdc^v)x>56Qg_wmYHz6h)>kl_h$>0@J!ypv%APmjZTAQVLy6Fu50RGY&JAVNhx zrF_qG6`x9MkT;1SFWo$)l{M$;3qUDn9JwE}z zRl#E_bDRJFii61kPgBybIgp8dNW!Cc1b*^YYk-#oWLJvtM_v^hQx~9?8LD4VFFxBF z3MlrsSC%f9Oupn*ctPL0U1fwfX?`tRhPD{PSLFPQOmIt$mDy0SgpNVvHS+f#Do>h1Gn?LZU9(KaN>Q_=Y*_T zvtD7%_u^^+{g`0VGzg(VZrpVQ6Ub5M=tI_p7T93R8@3Zulu3|#{iNcu!oiHxZ4Rf*( zfmiN$$ru(*_Zqn=`Gq#OuHRTSwp7uH_SokR&|)RuW5yo=Z|_4?qU-JU+tpt>!B&Is z@N(=SG;bpVc;AO@zbmMM zScqq1)b-ZQIrs={oD}|?6y{$HNB1U0^LsBh8JI&3!GBZxOXI<}&5-$lgkAaYqhOTb z?2vEnZ$-kk;*M_17(upJF3%+iH*s0-r{vttXVB2OUwI1s^+G(Ft(U8gYFXC}#P&E^ z>T@C^tS`Z7{6HT4_nF~n>JlZtk5&qDBl6r|^kzQYe`wq!C)n@$c>WOPA61NDFj<<6 zGW71NMMhwAl!U-yqrq2xrSFqRCI8acw7?}3j;ynxo*-b7Co;g5r%^j=H@9({PXXBf z@r>U>>N;E)81wx`B4f%{PB~MHka_);%kBCb(d|Jy5!MqJ%2p`t&@L)4$T2j&-WHvG zv3(uyA_gwqNu(k?jQTtv3dgPKRZoH8prxe7>pQBW5L&dpumS&5Ld2?(sCpJjvc4L5 zEnh&?91WVm)ZdTj=fjJ$pPDdgAttLXuke+?KdKxu*;kTC(r!tQk6;gxj4h%FdHAt(^M3YvYj(!tOeN)+Hvj6+< zzyJRG?^lZfWuR#t!tUKP&(?%3v&Zd$R2YN>lB(Lq`OInY48%4%yTv2 zYe1{G`3)(PDEio5Y@-I5tUf`c%%OCJMtSW56g3iEg%3`$7XSJJHyA z<|7&N)5Xrlgv~%BO24eFd;Hd;uiK%D`EdK|quUeRZDqbh9l)%j%J#0lfrZumvA<_w zu&=AVvdChf6}eqh(bUz`(`Ue*p01{fBAcTgKyDYLs_I+YyJEk+rM@avU~>fB$n)HS zM7pfJydu`i%gfS<{PF94kZDv$t>06sAkheDzu40NJ$5CMW%n^Lls?8^p^QGWURbKu3ZduZQZ((s2? zzE`}<{;Zt7<$C|9R8A~DJ~@%x>TfP zF>TX8)@v|t)q4GjRt<}5s6hLHwRel7>V@&r-O|Av(yh;Q1A{E>Ir>p+%dHD|=l+lT zpr(Dg&>#Nu=!)6bCLr-ZS%|;h)Ij$+e@r8_{qO19QvDe=&1tmpY*0lcA^Cc-#{9fQ z<~$*<&P$Q<_jy#<$40PMofM7aQ}C=jphI`4kLg}Z7CIN#26D{-4v-_CA-LiE@(%{y!BzsU%gG`Q?sjLUf%qFSl0y)2#ae*+EI>s|i`d^V$Dn)qmzqRq6VJRY|{4ujsIU%#bnqU6MR&-1I_43=|5(6Jr;Jvert) zE?S|Tmn}Tv<-??sxV5@9t}3D=>YZ0JrQe$CO~|EY=Lj9RM&4svQHPQL6%pV5fPFiH zfXDx;l@~et{*{U*#c#Dvzu)|znDO7$#CRx)Z&yp-}SrD{&|(MQtfUz~n35@RLfUy=aqrhCX0M}J_r5QsK~NmRCR|Nm&L z41UdsLjWxSUlL41r^0K&nCCK>fdR-!MYjFg(z9_mF^C|#ZQw?`)f6uVzF^`bRnVY& zo}@M06J&_+>w9@jpaO4snmU;0t-(zYW1qVBHtuD!d?%?AtN7Plp><-1Y8Rqb20ZaP zTCgn*-Sri4Q8Xn>=gNaWQ57%!D35UkA@ksOlPB*Dvw}t02ENAqw|kFhn%ZyyW%+t{ zNdM!uqEM^;2}f+tECHbwLmH*!nZVrb$-az%t50Y2pg(HqhvY-^-lb}>^6l{$jOI6} zo_kBzj%8aX|6H5M0Y<)7pzz_wLkIpRm!;PzY)9+24wk2&TT{w--phDGDCOz{cN_ca zpnm7`$oDy=HX%0i-`769*0M6(e5j-?(?24%)<)&46y0e&6@HCDZAm9W6Ib#Y#BF6- z=30crHGg+RRTe%VBC>T00OV6F+gQDAK38Ne3N9bm|62tPccBJi)5{B z4zc^Db72XiBd}v$CF|yU{Z=M|DZ%-(XarYNclODlb1Kz1_EKLy(NSLCN`eUl(rBCL zT*jx@wNvze0|TSqgE(QArOZU)_?qH(sj#TwzElLs9q)(0u!_P|R%Cy_0JFQxgGV>1 zz4?_uq<8_gM0`c*Hh|;UMz~vrg1gQXp{ufg`hM_qU;U>+zmvc5blCLSq@PrEBSGR# z&8=2Z4uXN`F3p73ueD1l{s{k$WipAvSh5W7ABe?4)t;r@V?y`bNB5FvBuE|0VRTb< zM1Hn^?DSsJY+sX@T5xW=#>T9VEV|?<(=6|ge$X6Sb05!LFdjDcoq*gM(Zq=t;_)Le&jyt(&9jzR73noru`a# zN*<`KwGa^gZU3-)MSLF0aFag#f0<>E(bYTeHmtdbns#|I)-$)mJ`q9ctQ8g0=ET?| zdO}eZ*b_p>ygRTtR^5Ggdam=Zb5wmd{}np+Jn1d_=M`~P=M67jj})fH4ztb5yQqQW z^C|C&^LHAK-u+ooIK)yM)QM?t;|<{P;;{`p=BclzAN#JzL4jCwXkQB1Dy{=^KR`=~ zTrr)y7eiYBzSNs_DvO=4A6#EgGS-zY%Vi)N*Yb`U;6o}KR}dq{r9pT5wqZ@3NOE8- z9-(}D|Nc5732CSYQbL)!gPQ#RbD8BhK3dl{sUuPvei0tkvnJBxDEAYTesU8H$)g(Plra{VH(v3u^CO1~(+ zU0O7#)jaS4{NcwA+LuSm&VBcX2#Im3xg)W}ySNw%->orn1taZ&+d)}8gJTqA!u|5P z{yv?zol_3|(1(%M(EVU=cp?L`{Pi|ixk{U)*guFML3P!OSlz;zGA#T+E@8@cgQ_mv1o7RSU=Zo_82F?&&2r;WE z@wk}JHYEZ9nYUc(Vv~iTCa3u8e4q(yq<29VoNbKk|`mq%I6u)My=gPIDuUb&lzf4`MEA9^g8u z)vp8|$$HE9m_BTV?lOosIGa4jud=jIbw)O2eCMfyw2*S8?hjWw^nqws$O*M$3I1)x zR0PWFb3$ySOcGTe1dz%N0l;RPc`x%05FtT^f^j{YCP}*Q=lvp4$ZXrTZQHhO+w%wJn3c8j%+5C3UAFD&%8dBl_qi9D5g8fry}6Ev z2_Q~)5^N$!IU`BPh1O|=BxQ#*C5*}`lluC515$lxc-vNC)IgW=K|=z7o%cWFpndn= zX}f{`!VK02_kU+Q5a3m37J;c} zTzbxteE{GNf?yLt5X=Bzc-mio^Up0nunMCgp*ZJ;%MJvPM3QK)BryP(_v@ei4UvHr z6+sbCifQaOkL6-;5fL8$W($zZ_;CZp305C;~$hhRquZr-r)jjd1z z31%ZK{-(`P#|Um_Sivn@p$-vz46uqT>QG0B1w9znfS9A8PB2LaHdzA|_)yjXVR*l{ zkcu3@vEf7bxH0nkh`q?8FmoO_Ucui*>_a~P?qQrlZ9@+D7%MTpSnztpylXrt5!-k8_QPB?YL8Kx_On8WD zgT+111d(Op$^$&KLAN5+@?>f7F4~wFi(8TL8+szgVmcMDTp5l&k6~=rA{Dt}!gb^r zSWY<)M7D|Z2P0cEodj6E42PV>&>DFmQpgt)E-|#sSUU@uKed+F680H@<;-x{p|nuH4!_mn85rx>wz;0mPi2ZkL#k6;sznu?cXh!T0S>{w6 zL^gvR05NY64l*<+_L>On$rjx9!US;l;LX6@z}yi#2XHh)F@Oo+l)h%fq$v}DNmF2> zfs^_t0)3N-W<9-N?uedVv{)-J0W5mh#29QM5R5h&KuiRM=0Zvnf#lF=K#WlCgc#9c zS;qvh(P$!_a8JwyhI^ZJV2k+B6Z^64?w|1?5gyo6y{}923CRZfYVe1#?F% z7h2SUiNO3;T#JUOyovSs@@C1GtwipycA=*x5{BpIZ_#GCMuV8XK=x;qCNy{d7?wA~ zC+=vjls;ci&zW=6$H~4^K%v{p}Ab?U%C6Z4p%eC<3ExqU$XR<}LLF67A$Sr20DR_pJ3yeBa~ z^sw{V0FI5;UpwXsScYuhbqGQ`YQ25;6p6W^+tgL&;Ml;>S3CGpSZ>VrTn0m1$y$HU z&65)I!c?oREz};c=nLCliriqQX->4uivHTgd${GqeAlf*!P^B|jkU|*IdNP(&6C>4 zqOW$)Nw9nvjy^&`?E|gotDV{JmJ9Q~vuhy<`^C4XIUDt|j4o6rK^e8_(=YqC zuaR6TRVf@tUFHB079o4MBIh{M~4>WwnGgesQH*3?w(RA%hCZ*7)b!aNV=yOQ%o_Y=Lt0Sl*(9^jfRnC210Om$=y>*o|3z} zAR&vAdrB#mWoaB0fJSw9xw|Am$fzK>rx-~R#7IFSAwdu_EI|SRfB*yl0w8oX09H^q zAjl2?0I)v*odGJ40FVGaF&2qJq9Gv`>V>2r0|c`GX8h>CX8eHcOy>S0@<;M3<_6UM z7yCEpug5NZL!H_0>Hg_HasQGxR`rY&Z{geOy?N92Z z{lER^um|$*?*G63*njwc(R?NT)Bei*3jVzR>FWUDb^gKhtL4A=kE_1p-%Fo2`!8M} z(0AjuCiS;G{?*^1tB-uY%=)SRx&D)pK4u@>f6@KPe3}2j_har$>HqzH;UCR^ssFD0 z7h+VLO4o@_Yt>>AeaZKUxqyvxWCAjKB>qjQ30UA)#w z&=RmdwlT`7a8J8Yae=7*c8XL|{@%wA8uvCqfsNX^?UZsS>wX}QD{K}ad4y~iO*p%4 z_cS{u7Ek%?WV6em2(U9#d8(&JDirb^u~7wK4+xP$iiI6IlD|a&S)6o=kG;59N|>K1 zn(0mUqbG3YIY7dQd+*4~)`!S9m7H6HP6YcKHhBc#b%1L}VIisp%;TckEkcu0>lo@u995$<*Em;XNodjTiCdC%R+TX|_ZR#|1`RR|`^@Teh zl#w@8fI1FTx2Dy+{blUT{`^kY*V-AZUd?ZZqCS4gW(kY5?retkLbF=>p=59Nl|=sf zo1Pc|{{N4>5nt#627ylGF`3n>X%`w%bw-Y~zWM_{Si$dc82|=YhISal{N7OY?O`C4 zD|qb}6nLWJ`hUyL+E>-;ricg9J@ZNYP(x(Sct&OI$Y!QWr*=^VN;G3#i>^1n4e#Je zOVhbFbLpXVu*16enDM+ic;97@R~u&kh__kgP#!R`*rQEnA+_dLkNP~L`0alC|J;c; zeiK=s8;BsLE)KbG3BD&Br@(Ha@SBT&$?xX`=$;eeel=|R_dIr6-Ro?=HEjnsJ_b`1 zK6Yg^-6;^2aW!xeTK)A~3Rm|L^FCHB_I>jIju7ZGo&N_1*QHkxH2!!%@o4iZ?vntS;&zJdPe1dH#04YD93A44o-MpfD zP{rn_aq>U%RDvC2+bp;xPlsOzauIi3*Lf42`jVKKZCRuKdYhi>FDuL2l=v{$BCN#Q6796s%r-AG$Q^t(3c@ zD?w0UhYr11@feiyl9kY_@H8~|xlmO<8PfQmj1!$@WieW@VxR@Psxfe-v9WCi1+f>F4VL?0O~K7T?m4-u|pSkBpUJZZe*16_wAp zSYZ@;k`3;W3UHKUWc8QeI}0jH5Ly=cGWQPw(Kr2fm=-5L(d`lcXofy8tJY3@Tuadz zYWXR{mW7XT!RF#RVCe%}=tM*O6!AD3^(!8un~opNI%Uko7$5t@<8+?; zTxDys(MyyGsUjtSu9$+|_-t!U3fVb1dkK?l`17<+jfl=hrBHnDSV>^R1=TnQeyqbW z>ov#l%!1|S!1>8UUxIdhQq`_klcHVx0{?#>K3#$4GlXncwldt!g17TcvKq-jo_996 z>oA=tH9CqRl6Yw?Uc`am!V?lHJbizOJaVaScf1UP5e7Dbgabq=b!B~T&_F6?ooU>w%x0A zH~&MHJ=q`fCH{U<7MDXE4SD32cDZA)WJeWkllJ`UspWaS#eDe^kg^oU_A14UE9zG-a^g{xaXf$})Wik>gT zl#dkzGr(;h0JZDuFn(+k8wNq?PZ5grQ<+sM?wBGt@JnH6v0#or-5wBQWKU~(S_> zkE!tc*ZJ1Y&*p(xX84POb3cClRMd!^qJ#CAZfIepEj-<`VURS_yCz0(?*Ixcj4 z-!zV1_QZhpm=0<;*(nm+F>T=)o?ep@CK5I%g^VAA+RB25ab?7)A~z~egru=I1S|@v zH7tXV!0wmGS^qj#e+MY;C5eUjEAp$Y?LDkS^QPZ}8WN85?r$u<-Epi;yZ1|J2J`se z$D6DpH~2F=eI0B&=UFAUnJvZAmClJlK)sutJ?M>xpZiWV&0=G4MZP+x+p>EX=HbCz zxls%Mw?*u^;LbHWIWCyq+yi)`GmFn9J112CZda_u@YIP%i;srFg_paU02Ifij*7}l z&CF-(3|>*a|+vbNR`^RP=9G?ymEJ0Z~)d&c*UE$UMepZ zcITr{0WqhxkjUnM15js_gW=e3Uh|y6ZReaXHIz-=p`x5VvB&rH9y>Amv@^WmXFEw) zQXYrk3feir=a{jMQ+wDIkkFnZ$k{sJakHn*?u za%4b!00ev8NVLM1TY=cl?KB&55BY_MU-sg?c>=Dbz_W{(Z~c?HJi*XpYL)C6Bd8WH zt+v-#0&o~@t4qESi*)+eW%@VD0|o^yF)n0hME$UtXF$*Lvh}7sso{`|pn*JDIy5^Fm3s$5*zEE=?u5<=l8FJc3r%+H} zdfoNl2J0^~!-*mOL5o-x32|e0Im*E!yY7F7E5N)W3>+v_LBydlEx?4$RL5f2oYRD# zaR0wv(-p~wO0eLDl3K=%`{5+0Gd$ktO=W)gWlGZJ0`K z$_RNA=ckrfa;H0KA~dR^p�(p-{x$&=IACIfoAR!za)F-^da-t3#0Dycnp zwO~NVXwXCl;jE<}>%@xz|=8fIJAB?>+E{7)|4l${4ngA3G|=r z2Dyv;VVWSgZx9Wj>qUjleGl3Ei9K4>h!(lPS%8VOG>Xu0%6VDz^O=bjJmuP7>DeUv zrbI}MlHB^^d?{zv6d=@_ZD2lg1&G7UjnVN{1}9WkaM3H~btX0GtSzB+tZ^qRgWo4m z!GmimlG$=wgXCnr6j@m<1gAL46#T~5Bnm=2{^@>|t&`9mkEPddj zAvG~@Tv~TAm2i%VW}R-g(Z0)z-Y|szHr@rk>4MAyG*Ma*7Yh#H7(!-5>DZ@8r;_dx z{prSe<>~099F8vsYd2xff7uAS%7{S)f(|@me3t2$iy&NEc7OUEchp@9A|X;;IA>8!oX+y(BKJ$EzV* znR$z;!L$s7uy@{OT~nG#B!NRraT8(X##Ho!0r_o@gg0CA-9H^;-uE&?$2$nHv_00o z%cbuUc-tCx$Uh&EZ4Nf4Zgqv)Y6>usG3>GeQnxx_Z6+PcbX-+ysbt1hQ`K1LDpOE? zrAhIZhSN9yVIAOa22gn577tbc&i3|3V8NWy&!tw##`}9*x}gtI^h1DzZRA>UuaJG) zaZ7j)dq!O}{?#8Y7~7i6fHh4{`pL?>-18|p!S75Y#^DM>-S3)vuZG+Q7l@ek zQP~#cBpWgg#mApc_sPYjpw8odQuRokmTkzcNl`^CcKB7e&;zViV;{Y{o^Y$%7i0m# z62%#1Lq!RC?}lK>%mp}T!3Xv;L*0v*>USLm``N%>w>@fwC+#T&Tx2bN4w(20JB}oU zuSa6v^kXi0xPs?pbaOHnyiqq6By1EZY9OZ^^QA>{q-Hsd&m`pbQ%8121aWG-F5xf zlZ%;B{;C>X19|`^_?dVyCq>n+41w7|!tUS!{9rHlbhX=SZO5CQ^;!Du_E7*`GiR^Q w)2!4MKjfSAeNo!9>IaV6aUZ*?W>} zs4%E?srLW`CJh0GCIK@hTkrW7A15Iu%N&?Q^$0+!{Tv&|t^Y@u%!L zglTg&?Q5q#ijZ;&HBQ?FNPp;k3J5!&{^+SGq?AX~SiOM9jJMRpyP?RCr@z38AQyy&WRMaC;n4una$~nJKSp?q|s8F00c9?Q! zY_ovvjTFm+DeQM^LXJ#v0}6HRt3R1%5PT*}W!k8BEM;Jrj8dIceFo2fhzTqaB3KKk zGlCLI)gU25(#u6ch6GeB1k@eHq7l{EHXv0n6xE#ws#ri}08kkCf8hUt{|Ejb`2YW* zvg}0nSSX1m=76s?sZhRY$K=3dpJ+y*eDULGnL2}4>4nvW^7_<~wIM_5fjvwt4h1|g z)g0Z6ZFq9j<~9~b8((~TN{Z?ZQfw|is&Xp~AC61sj;xItKyCHdI|tCMC_LbXF>~vR z=w6V3^H=W4CbAgR4#xw}ETTwu2guW~=Crl@SMXv85jQ=%y!s^?m4PI0My7MWICO;- z175jm%&PcPWh8QdOU(#8bp4!N7ET-+)N}N2zk2)8ch|4Q&lPFNQgT-thu053`r*h3 z_8dI@G;`zn;lH$zX3RzIk`E8~`J=BBdR}qD%n@vVG1834)!pS1Y?zVkJGtsa(sB~y zNfMYKsOJb%5J(0ivK8d+l2D2y&5X!cg3BG!AJ}910|_${nF}sC1QF^nLIhzXk-Y#x z0)&1iK!O;Og0Ky!;`b~v%b$`S4E&fB)1NB4v@8wr( z&+NX4e^&o)ecb=)dd~C!{(1e6t?&9j{l8%U*k4)?`(L3;Qjw z#w7FS+U(94MaJKS!J9O8^$)36_J8;thW#2$y9i{bB{?M{QS_inZIJ!jwqAbfXYVd$ zQ5fC$6Nc9hFi8m^;oI-%C#BS|c8vy+@{jx6hFcf^_;2VRgkoN(0h!_VSGmgNPRsxI z8$rTo0LaYq-H5i&gtj81=&xU?H-Y2==G@uQV7E`@+2E9XQW@{&j`?EOktk|Ho{HU>ZqDzvgjwBmdex z&uZNd2C1h{{}2k6Ys9$*nFP3;K%u!MhW`uZy7Sn`1M1zs@Es&;z*Z>Gsh@-3Fe6pE zQD2@cqF((NrRevgvLsvM_8;;iNyJ5nyPyy?e!kvKjGj`6diRFBEe49Oa7wwkJFV7Z z$YT&DWloYu-H?3<0BKn9L&JYDT-SK~*6c5pi18P26$JESKRYj{T7Zk6KiRJcbvOO*{P56Q6s8msbeI3>|j>K9}Q9UBeq*inXKemCm`-<5|-$ZyN4u$(3 z&HcvqehFD%5Yrmykg-^d`=BSa8(i=>ZoC77^mWY{evp(km@aHqhUECBz76YiR+VYK zY_avFC~V3$=`6C4JhfHAQ@DZtUOwH`L;oYX6zK0-uI^?hS$ALfq}A7evR;ohJHij} zHSZdW?EKv9U1s4oD*<(0oQ*;MaQ6@cvGL zuHCPgm_NhVsgp^sfr*ia^Db}swo1?O(_Q2)y+S$CBm+g=9wCOUPbz(x)_GbaKa@A7 zuI&!ynLiZRT#V%_y_-D`0Z5lT*auoe{(U5NylTzFSJW()W-#F6*&A`LNO1bV#Y;QJ zSbLBnp|B^dtK|KIWC|No>JjWBWE@n7O)x{&^E(WMeMvp57#qA8m* zeTow*U@_86B#Fm*rxyYu5PRWaWHx8y> z*qmHEp(AMDl0v)ij(AY8fnH=~ZwwjVAbu*m5;xPfidh@ov6d8g zfJsi&!QyK53Es%sC39ts;54V68koALD4b|%tNHW0bIkZAJKa=W&FomJSEDT>W1xIX z1x%Z>AvNIsSPLcn3RTcHXb@KB?cuM)=x6fcIx>&(GxqZ8w3p#jJ(GVgc*`c0HG}dv zIop&Qim!K1NFwic%07KcjWgHBPUkq7f~lj;TPqVGTiT#cUeim>;nY`>h@a*S{qQex zQ`z62WK|Mj)Y{tfF{;T4P;c8$Q|KU?Joh zIkA^z%X7z|r>4aTh@|StTi!-r1D!g=zb#3d#{{&K3CqE$Iz-UH<%37c zRfkO`&uM%#AD3PHv`g5t0e^O%nVL0d{Xlx^EjEC3#skF@`zl-7PF^0oxW)1!C!JxR zWvuAHH?)61FKA1QeT*_sY7;_Id#!GmV4n`MO{~sv}VLSK` zXRw=Y=Clz*00B(5y^K;gCZMAzjT5+c3IC=)l(9VIDdatpxj3y89WwI|bH&$!ZEvp` zPR!T@#!(|KfI-w?!&+7$N3F6>tD{YO4Qg$d_`nNEdfVCha9vaPn0jI0`)`@*72hq! zpU5ND^P*RoEkbD5o#az(-g=Y)L>HH>Oc%}$ zT3Rs_ih0;4+Lv4Y;@Iv(;fUbQ=i-G(#>vghec~*j(I#r|5mqFiJBpzi&hzEcD{u$< zRsm0BVYn=pT;0>R(itW|*D&;O%bOc7et9ACaH#J>z3A1A~6fdP>pmbM%xzm4>|;c_?B+%sl;Qs2{t!60$^u zH1t@9^6>;?!FuusnISi$f5CL&;z?EqJN$FBuWDA#D5`cy_UvCFIVvf{c?4N0teh;d zET$7aVbj08KTQS!x?Nd1Is8q8qFzs}a=!@nJ;7FSfCY^T@D-gpw`w<6e#X3+;O}1h z$%I!M)0bg|EKUA04Qjn@+x{Rj8vt6Wn!R|3A92z}^$KfF5(#CWr4y#~re1CN4i4w0 z#GsypBR{xA3Er7sgAi(|}1-W?s~n$7?K|9WL8kpVfw-;#b9 z+mn;=ep!162U5R>_t}fOt~tE?s#m( zO-S$7>Ay6*hHdZ)7_oU915WYYCIX;hFI-U2EWYX!pllONr@Q--2o~`!isi6vTPLJ4@(|o=%NHYjo0_S&q*UQIROw@*N-By@PaQ&;YxFZ0aR zX&}LeOEz);#m~Hwm^VAY8DK}b$F4bo{jMN?d!lxKPhNklzr^Cd`0f4oJr^z=I|l`* zm8AHm*fPV`0=lF3Pnnp}&J0N1X@}-D94YvmUabFrLGSnTz7Mu^21F#O5tN#CuY9Vh zUZBH=ez%h*wkf0hBtXJh1SN3d+IF{gzT7lp)j}n?03lt;XSQRAh7qd&v;RwTYDuQ# zbI2*r<>?x-G0@hM{;%{VBD7nLKt~D`T~-HAt5;h%i0_=Ifs=yHma5dhJ+QMG?Ux(a z|E?1CMy1!~oA`FP!k~iG=t&5#>bVdz=peT8HMB6Y)#7PpETtNryT^+Rv3vpJaF^zP z{H}0-LyV9Fu21ID%wO9f1IKlFr1p4c{o-?03vyB-tr5duk^&L$;m_|f$vs`^Sl{j2 z95}oY{LlY+=ZS%J+tZoXCd0*sSU7w^gjovXn+g7uyra5{cU49@yHf#Z^Jl-$9cIfo z+AJuxH$VLb=#+uBbVmUjnx zxb1pZ@-O9=AIk4@S)m6fJ2?{HrNYwwnL3a45muuNjr;6$O`bGEM0T4A2_S$t=86*- zcO+0mywg*j#A4mU}enR_!cGmIYQ;qwfchWtFEXL)AK%*;=j znYne+hS4EMy3S)C*mZ1KI>!+)0V@9!N6H$Y}~MJ{rYuf zz^KljIWvFi-?#?V@LPR&c6Nn{!=XM z>}-h$S76;$H{E{Y%@^zlmOl^efBwa%UU+jJD9UVukQ3ti_kH-?H*RC0?M1W%FCvMB zM_+v6fk$6X2sx)-p~B3&Kl{nscK}pNLM*qjtpaf9>AU{-iPKQZR8yCg!TY}Qg*(;) z)gdvCcB%kppZc$VdvsK@)3l1{&DG!d_6OHOS`y=ITLEVu`unSKA2E%JD*DVX{LJ}K z9l>hMRDqxQh0lnpGHpVYneX}eA3Pt|2v%=q;rt)``R|#bDyB)OXY&vI_@|*}h}G?^ z@aZ4_!7cQPX`!fW_?{oT1NTwHs#l5L-0`E|y@48<3Q^HFf8=Idi zpJYD%1MkII!~|7I^WGo)IF=?{>ACnjJ_WUi39C}!Q{QnheVJqeKKqq5^o5CBde(g9 zvw$X6^jz_^E2$wSw4!q5*RG(C2_^XO$HBn_55vbl44OnTTRwRaePP0vo{K)U1#99& z<>rq7V&V(<&@I%MFoN5zrY}sz=(*-L&}1QQ*a%`u25h{cFj===17eB_uGuzG&byQ< zrm8BJZl4r_E$3k|Wo6FW0-6M7>qac5uFQsQcmkLWGfeH74S3Z_rJ!jgN++!@i=HW8 zkyjI(oPH-+-N#Qc^-mpNO`bc6r=2-<%&Wy5K1vfFJB(L_IkpS6fY^NmuL8qsgj>MD zn~BHH9WM~32_3vd=W&B)k7F9q%stJx+b_L_X-4zr^LVUMCmyCTA3sWtkvsmME?Xiy z?xOSfB=_$oY06~J-HcCq&)qcW{j;uP;?Dm}=hkq?zh&n!;m((-G-u_t|6x399Q;>A zgNpxoJNj{u|MFDH7Rhq@FCAl0dE|ddnl!oh9{Lq?@JDoR6L;C941IK`ISfdE$4S zE0AUQ8+2|Ncl_q5QkSp#AODp~(^mfP&%Au@@|TBQwoP`UU+V{6u8|)6ZA{~uKmQ*M zmrMTDU8S~8Eqi{^v0Ug&5Upcm#y7Z1(RbgZAG8jB$eRwCspQ)>5;U)oGZ&E5aeR*K z8Yt`Y0$G))Yd(Y3KH}tA4`-_QmNke5hU_|nq=xtyjwW(_o?itz>B>WM&^63bNdQ)k@-IgDHW*RW$Xo9#RzrTrCn7L2H{9Amq|qNg@#eZY=|P zCoI?2s+L)zsM%WX(NbVEY^`C>lFjIBYmJ6@DKJ0ZT4&F&WHW!dwa%QzOG!?jY_2(S zDcEzZbz*2Q!43|z))9yOP9X1Xt%DXzwY(3tl-TR=Qb_MbZYRrooh;dYYmS!U_as1(=YVB?Q_A|tNu5Ut&_q3jbfDM zoFxT^uEuH`nX3*sB%K?GuHUkweYReBwnHqh3P)~`+s3+Tj!rDA1e)8vuBv5J*IsxC zkd^~b(aGzArj08{>cnzOuy04C+C`}gb|Yz-1avxeWzev3NzcHbz_&4W@QCr$z3~w=8Ua- z`;vfG1~BP8CyLb=F7t1am~ph_#|O%$khSJ9%Vtcn)YmpgQxF?xM^_Vb+5fnpB^W0I`f%X8gb9#X{Q-yJG0{Z56aWeI&zPxnf5pdJA38bM`cYnS#x)% z`n1tFf$i)W-hGm(f9mde^=X@NcV_lFb=P`4&CI&H=IArijGwdCk&X@uQ$5xmj!~^? z#$ROCI)V-~t%L%GS#wo@U27ddR`4`3)WoB{R-4snfNrfee|kI8^bu#yDgYqOwas9# zmcb`3!kRJ`Cr=_tq)8aMt{aGtUZsqwVlj6DgCGre>AEt&x8H_in!x@uwgExIh|-mA zjdaC(29~CTVSaaF7HPbql&*9Uo8P@f)>LqCXclr}peS7_1BQ28u9PO8Eq1@`l3q9o zkfKCaO2?T?ZyA6loW<#9_c^O=m<&h}CA!ineAD@=(gbq`vyT|tiJ6#^B1$P;;qax` z55k&Q?wEh#87niLo*+n4L@65J(Nz~=Ya%7^(miLb(E>A3B@|Jjl;FU&D>o|9#7PJH z?|ago!o;WC^h=|T7PVBg(DAB}72cyUS zb(f>Bwbr!F1eTCO5fpj<{PqhY5>143p?~5ZA5H40);=@M#MYvrB6gqHbU_!GSY??i z%s=>-ciA4*zOOZHds0a(kWewZ4h(k8h(ua7HX)Au&mY~H8KY6(_cb$_&fA@QjIW-*heP3%$d!m5^AdnT}`12qA^c@!g3DOwZ5WwE2?)-yU z!)Vx#Mtxt?FzFTwK!77sy7)sMzUd->w4^bxtpM2j!b1pjgyk zGKwWGeb4)^zjy{9Es&PU1}gwg?|J#L$KJB7ett9@4M%-nGtIQr0>Fl@8-yh`-+1ed zS6r}(MeSvgSoFmH*_WPu@i?}!AB~2?;i&IxrkNg~cQ9Som98tcq)k^|eeER|Zl77t za-TVUc;DNvzVXJ%w52+#weN?+;i#{f#!Oc&z?81*N>^e~ltRS%ZI@lR{rs()HmqG! zx*}ZrI-EZ}ckJMiy>A^oofwDfC~IH)z8{VHKGT@#E5I(Ll&+MnMCl>~AV7+>Gi%mF zkU1QlKASdR0B80!YhP<$Ywi0?W2Ux45oPfxv9QolWzJPD^weBfvo4SONxP35106sAmh(e+vAs0GboFD@PvNs)jNPvarhW}0YliZEg{Gazv z+JDIpoojRVPr<*C|BTq<`6ga{5q^8^!|0cxe=rZ!zxH3%f5ZO0cQ*Z<^$Yt2{|Ek0 zyT|*F+CO@K;(owBKtGg!S^xj-Z~rga2m6nxKl9J=fBSuNKW_dLKWhJKeg^-Xe`^1? z`TyJj)8E!#>_3Y?uKrwqq3LJ#SGU>AzUO|6`nR^u&3FNN_jGOc zw)Nw`wr3yIKhgcee6IaN=ws>M{6677%)hPwx&HzC(f&u~&)6@b2kNRzBDQAP0*H73 zq%McOmRk{B3i47qRe=DA*$&odrbEJZ*pV9XXa&p@wlW~@Yfs>V{yiTtplMhgM*-Bz zsSnlq&pG;z0OUN%$~$3=g1UF+G*>+17eRbBf3=y79J}KR8owon@$1Z7MIrvvWWH)34nK2SD)GsrJ{l z1Cl#oVo3A8qY3e=aF)qzms~FG#2$LzT=gs&aVMOj>(%{y<&O0cG!nCiESl~x=^dF{ zKvj8F1K8Ng171wwM5Fh4KoQw`_c6#y$(5cAm7e}~nJ#A*fx+c9;y#&W!#VukR)ugk zKp3=+;Ut+IYn%m+r4d*<`L2h%aDnX5}^!5R|H;(34AoVWjRx(msBZvk;rCI*|~ zdOijqI@9Z{Vu!~jvHW{lBa$rnl4+!s_5sfK3bCGk-B%iDe&@-}+%fOKU|(9?V1 zHE8&@4z)Kx!RAvAs z!Wic9=o#(bg?kc-G68-m(jZ`^=XGUXb)}t(%&~sjFnV^sEX%hSy6UKC4iOhgV=BHV z2w`4g7Y=s#Vu2B_?#VQ|hP39@eArgfX>-0S+dd&^mx0*wp}>)x;c4RUgxz%;oNe?& z-7-lJ@Y^2^C;=qJsxx5|xF)*pTGhch2B&kxtn;f!7=gznk}I3}Dh}(CoMXgA5-p&kS202!l?!fT3t|HG*rIP~mS* z$Wjo}jq3}z$Qq!9yrtd3fM0N629ZM?LU$nv@Tv9b7I;D|;0H2dsA~g7Z7zp1| zB)XmrkMgF6OQr|R)HHD^TE{Y#j!~SR?b`Xt3Qs`B+x<hxexYeAjMUWdZ-*n9%(1)Wb(n2U<><7&9dwGJmrob)4%H? zlQ%z+L-^$dFhhH|@u$%97Qz?*Ynh2VG@q|?8vY&L74&fs&_b&3$x&Oyjl~LQDRRap zJU4U*R+(2Dd!G+lh8!V{pT_UJn+^1Qg6$` zqkNm(a#hWyc6SP+p5=C4HL8-m`pO`5o~`-LI?_h5CsH?F_%?nDodmz&pWR20WTpJE z?N|wSzLjMUK8E)a2tI}Lf;+;*M|h3Y(U#>)g1>zk9|Hd}oZAa2 zLYBWBoSW!Ts!RwXr^8h+U*@{9{zqS^iH)Op<;r`Uw~nc}<^$V~_i%$GFjaG?X1@E|M`h)nekvFKt`Dh-f>@|0-`Xoq)o` zx;JmzDfOV9qCx|EVpogEe0LK~tGS?5$$L_i6P$P6wIsCQaP_;d{{N=iV@+8LI}o#( zvo*Ejy=IIn{rdIQh1&q-{EuohpVOjJ^Q3lD*YTp37$^RRgn8ihpdu5{Ct%5-KO!VL zcNB6dUajXI9jkm-P|i3~GB-A(X`P1Oqqb$tcku)UJw0w3GeUijb__#QT4j%64z%EeB7S?jlWwx_7&+EEvB|6N=kV}DwnyAlX=?j`) zmU#!$*^@NIu#n_d7;WoJV@*Fbv9|yJO4;n|BNF2xy(54RyB>t~8lUOUW$&2%Nwi1y zx6JxW88>U2$#qhl^6KUbtmg9}D0o5vYDT7kWJthLGkpGnN4T>{St^_EU>4;DmLF9o zr|LqsA8_MoNLQ=}w?8u!ziSZ@PC#Y<#9uJFo-ozVo6D;<8j^1$c|qAE3ZTE5i~zmE z$BU5lw6l=EWsg^y^;8>r9qH{xfL|~PZYK#md$zZ0?o11gV<*WSW~cgy2GYGQir%wf zt4iW8D+;s*;RGrmd(-T<@2&j(Cb9xhV*l-x`TpK`xq|7p?5R%5*s!69?2c!cC*VY* z2DE^9pvOPLU!1e}wA8S8opcTJ3`NB>hY=JQnL~QFXR4K8A$BqJnoEB$wn-%u@E6Mh zCfMF4kusv3N!(aHC}4)Xs^xoOwXd%e^6pi5|DZo=Q25j+6HlJ^7FodH6y1bMROR^q zGu6)fopS`h%Sw<;ZH%TEPf+#81-#_v+@8nlR0jLcIDKQtLleOC)6yLZgC!D9X3GgS zohwU{v$jl=quD#Go^hB{`@Qw*a%`(^jyT~=q^bWgGzRj;|12J55HWdCWV}EB|K=%N z3Nq-qxJJ`>^|1MNN+q}zTB&ooE3j==AgK@^UW<^oSbeALa2peF)Th6{@sj0KyMNHZ zksk1+MXN2tv+22A%cQOGpS9)77(uP9mh+!5T5ERLvF@b}$+WvXM45Z?-kCa)fb~f1 znVbTD$Gx-0Zxc`0D@YgHakge6SL0H`-vN_x?AP0>iGH0_EE&=v83hMJgaKAI0jJXm zVxVz;X<$v6WW7}fxROO7vr#YLP;;lij5VrX{;>7kK6TtOH&6|Ar^xo>00%+u$C4@# z>!jOt6*3><171+WxoZnKDTzJtDRw+T030;yI}~uV@9fCnei^I*j>Bp&mzP2d=FPb_ zCM*l_+$LDR3B*a!A$g#>xsrZvw0lckxmMg>0aQd7tPyN=t{dgXb;Ie+T8{fZH=gdu zM7Rg9c(kg(Jg0?ARRRl=AONFKrvFj)lTY$KfT%6^6s`mk*ABGhsce*LsoD>K{z_M2 ziPpnu+lw22PfF!CoId^6n*G4H(Ix+#+N{C(da7t1BYMGEaE#PdpOLxsVD5riQXHp@OX;`S`8VnpM~)I920w~<3|mo0 zf8~Az`*?2?H&gZ&*K&bRkV@qzvMlRHXys8*Ze2+1c?5o!^+$&MHxB@4Ee5cke52R! zmn7AZtY6ST%ixgU5)%$%QcwHj7Es-Qu^kLAPwy%7pGBw_4Q9#da^W2$}axNHr03)_nw z5?yuNmXrI5HgS46)c5&}B)Tts49oU92>3xBLLy}FMUW=84DQbVq^;7_e7|(Sdz|&J z73N+M`rc2rt*oSWu#7S{*s~nH6HRHJS1SmzeXk|;CA)FI4bat3<%}nkB%;;?=F>B7ms9QSxv#@+69;@>QaR?REYX4&)=itG>rM{<{A79Rmk)`5ON#GL`*KX%}Ihk3w(RtM-WLt z?f&FLF}4N^yE!(pZ&Yj&Bc`~K0@4_}*0Om?wN|}4WJ>WL;G^H2*QpgEkGA~OET-Km zkwz|5{6dnz1U<2Pe9DNL>3g5FEIvp1jzP&2K#z~j%g6!7B;^zF+o95?fV{3mnB8*RMhCDNp>Am-3e@jNfMj?jHV$MWjk!DDKP zkAz$Y?Sr)!GUOX}qTQ5aMh|wq1uq}~joWyKl=b_LboM#wi{CMuz5x6BKlA-qy++cM01D3b7`uD z#l6M4pI;JCypO8JZ6?U&wNxR!{4oB_ zlV!x9+-&Qy6{%MQ{~yoZGkKiTSC`YS_j22~G;xUV855g2&C(zm^V!(wpcm@zn{%!g z4}JGo(sGZ1O~to-}le

UmY2RIYtNPVDpE$%vda+HD#3m z&VuXJ{BK&Qe+rBa7eq}Q(bq|tn(RrJAk|ztj2(i{d>nmQnM?;HF2k&9sA6up5tmjl z7lySlzMbifH17-m-Lwa_F&e7nOH?ESi3#ckR3tsM+jsck3`oG!uMS}|eAwVXv>}qxwq?QY%QJ0}r@^;fhuUA9W z*BVl>TGo&N004@xSiwDUXUvp51sVmqO3m)=B55aPwf@0=e}cN+$-BdKxY`YrT_4)0 z_d10#i44Q*rFr8MC>*)v$EJvz``(pb{e&*6k+b zsMz%($|1+8hn8c2?P(l@;Rb&CsZeYoCI3?2!LqjbwPXW3z4G$Qfj=cT5Yb%vY0(AX oeb?AaKtwrnc|$|zzw9vfvn^aJJ!zd)XFXqqy0000001=f@-~a#s literal 0 HcmV?d00001 diff --git a/android/dataclerk/src/main/res/values/colors.xml b/android/dataclerk/src/main/res/values/colors.xml new file mode 100644 index 0000000000..f8c6127d32 --- /dev/null +++ b/android/dataclerk/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/android/dataclerk/src/main/res/values/strings.xml b/android/dataclerk/src/main/res/values/strings.xml new file mode 100644 index 0000000000..6a9da4284a --- /dev/null +++ b/android/dataclerk/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + DataClerk + Initializing settings … + \ No newline at end of file diff --git a/android/dataclerk/src/main/res/values/themes.xml b/android/dataclerk/src/main/res/values/themes.xml new file mode 100644 index 0000000000..991883a483 --- /dev/null +++ b/android/dataclerk/src/main/res/values/themes.xml @@ -0,0 +1,5 @@ + + + +