diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts index bdca91c203c..dd162035569 100644 --- a/android/engine/build.gradle.kts +++ b/android/engine/build.gradle.kts @@ -154,7 +154,7 @@ dependencies { implementation("androidx.datastore:datastore-preferences:1.1.1") // P2P dependency - api("org.smartregister:p2p-lib:0.3.0-SNAPSHOT") + api("org.smartregister:p2p-lib:0.6.11-preview1-SNAPSHOT") // Configure Jetpack Compose api(platform("androidx.compose:compose-bom:2024.05.00")) 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 cfcafad0fe0..ff356106c9f 100644 --- a/android/engine/src/main/assets/configs/default/config_application.json +++ b/android/engine/src/main/assets/configs/default/config_application.json @@ -6,6 +6,19 @@ "en", "sw" ], + "deviceToDeviceSync": { + "resourcesToSync": [ + "Group", + "Patient", + "CarePlan", + "Task", + "Encounter", + "Observation", + "Condition", + "Questionnaire", + "QuestionnaireResponse" + ] + }, "applicationName": "Sample App", "appLogoIconResourceFile": "ic_launcher", "count": "100", diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/appfeature/AppFeature.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/appfeature/AppFeature.kt index 221936cc5d1..c61f0503b64 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/appfeature/AppFeature.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/appfeature/AppFeature.kt @@ -27,6 +27,4 @@ sealed class AppFeature(val name: String) { object PatientManagement : AppFeature(name = "PatientManagement") object HouseholdManagement : AppFeature(name = "HouseholdManagement") - - object DeviceToDeviceSync : AppFeature(name = "DeviceToDeviceSync") } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/Configuration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/Configuration.kt index 411559b9a47..b6ea7882715 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/Configuration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/Configuration.kt @@ -66,3 +66,5 @@ data class Resource( data class Parameter( @SerializedName("resource") var resource: Resource, ) + +@Serializable data class DeviceToDeviceSyncConfig(val resourcesToSync: List? = null) diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt index 98ce1dfb347..2c1e209db9e 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/configuration/app/ApplicationConfiguration.kt @@ -19,6 +19,7 @@ package org.smartregister.fhircore.engine.configuration.app import kotlinx.serialization.Serializable import org.smartregister.fhircore.engine.configuration.Configuration import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.configuration.DeviceToDeviceSyncConfig import org.smartregister.fhircore.engine.util.SystemConstants @Serializable @@ -38,6 +39,7 @@ data class ApplicationConfiguration( var registrationForm: String = "patient-demographic-registration", var supportEmail: String = "info@tingathe.org", var supportPhoneNumber: String = "", + val deviceToDeviceSync: DeviceToDeviceSyncConfig? = null, ) : Configuration /** diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt index 03ac9783de6..d22efd841ee 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDao.kt @@ -17,44 +17,61 @@ package org.smartregister.fhircore.engine.p2p.dao import ca.uhn.fhir.context.FhirContext -import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import ca.uhn.fhir.rest.gclient.DateClientParam +import ca.uhn.fhir.rest.gclient.StringClientParam import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.FhirEngine -import com.google.android.fhir.datacapture.extensions.logicalId -import com.google.android.fhir.db.ResourceNotFoundException -import com.google.android.fhir.get +import com.google.android.fhir.SearchResult +import com.google.android.fhir.search.Order import com.google.android.fhir.search.Search -import com.google.android.fhir.search.search +import com.google.android.fhir.sync.SyncDataParams import java.util.Date import java.util.TreeSet import kotlinx.coroutines.withContext import org.hl7.fhir.r4.model.DateTimeType -import org.hl7.fhir.r4.model.Encounter -import org.hl7.fhir.r4.model.Group -import org.hl7.fhir.r4.model.Observation -import org.hl7.fhir.r4.model.Patient -import org.hl7.fhir.r4.model.Questionnaire -import org.hl7.fhir.r4.model.QuestionnaireResponse 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.util.DispatcherProvider -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 org.smartregister.fhircore.engine.util.extension.isValidResourceType +import org.smartregister.fhircore.engine.util.extension.resourceClassType +import org.smartregister.p2p.model.RecordCount import org.smartregister.p2p.sync.DataType open class BaseP2PTransferDao -constructor(open val fhirEngine: FhirEngine, open val dispatcherProvider: DispatcherProvider) { +constructor( + open val fhirEngine: FhirEngine, + open val dispatcherProvider: DispatcherProvider, + open val configurationRegistry: ConfigurationRegistry, +) { - protected val jsonParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + protected val jsonParser: IParser = FhirContext.forR4Cached().newJsonParser() - open fun getDataTypes(): TreeSet = + open fun getDataTypes(): TreeSet { + val appRegistry = configurationRegistry.getAppConfigs() + val deviceToDeviceSyncConfigs = appRegistry.deviceToDeviceSync + + return if ( + deviceToDeviceSyncConfigs?.resourcesToSync != null && + deviceToDeviceSyncConfigs.resourcesToSync.isNotEmpty() + ) { + getDynamicDataTypes(deviceToDeviceSyncConfigs.resourcesToSync) + } else { + getDefaultDataTypes() + } + } + + open fun getDefaultDataTypes(): TreeSet = TreeSet( listOf( ResourceType.Group, ResourceType.Patient, + ResourceType.CarePlan, + ResourceType.Task, + ResourceType.Condition, + ResourceType.List, + ResourceType.RelatedPerson, ResourceType.Questionnaire, ResourceType.QuestionnaireResponse, ResourceType.Observation, @@ -65,73 +82,64 @@ constructor(open val fhirEngine: FhirEngine, open val dispatcherProvider: Dispat }, ) - suspend fun addOrUpdate(resource: R) { - return withContext(dispatcherProvider.io()) { - resource.updateLastUpdated() - try { - fhirEngine.get(resource.resourceType, resource.logicalId).run { - fhirEngine.update(updateFrom(resource)) - } - } catch (resourceNotFoundException: ResourceNotFoundException) { - resource.generateMissingId() - fhirEngine.create(resource) - } - } - } + open fun getDynamicDataTypes(resourceList: List): TreeSet = + TreeSet( + resourceList + .filter { isValidResourceType(it) } + .mapIndexed { index, resource -> DataType(name = resource, DataType.Filetype.JSON, index) }, + ) suspend fun loadResources( lastRecordUpdatedAt: Long, batchSize: Int, + offset: Int, classType: Class, - ): List { + ): List> { return withContext(dispatcherProvider.io()) { - // TODO FIX search order by _lastUpdated; SearchQuery no longer allowed in search API - - /* val searchQuery = - SearchQuery( - """ - SELECT a.serializedResource, b.index_to - FROM ResourceEntity a - LEFT JOIN DateTimeIndexEntity b - ON a.resourceType = b.resourceType AND a.resourceId = b.resourceId AND b.index_name = '_lastUpdated' - WHERE a.resourceType = '${classType.newInstance().resourceType}' - AND a.resourceId IN ( - SELECT resourceId FROM DateTimeIndexEntity - WHERE resourceType = '${classType.newInstance().resourceType}' AND index_name = '_lastUpdated' AND index_to > ? - ) - ORDER BY b.index_from ASC - LIMIT ? - """.trimIndent(), - listOf(lastRecordUpdatedAt, batchSize) - ) - - fhirEngine.search(searchQuery)*/ - val search = Search(type = classType.newInstance().resourceType).apply { filter( - DateClientParam("_lastUpdated"), + DateClientParam(SyncDataParams.LAST_UPDATED_KEY), { value = of(DateTimeType(Date(lastRecordUpdatedAt))) - prefix = ParamPrefixEnum.GREATERTHAN + prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS }, ) - // sort(StringClientParam("_lastUpdated"), Order.ASCENDING) + sort(StringClientParam(SyncDataParams.LAST_UPDATED_KEY), Order.ASCENDING) count = batchSize + from = offset } - fhirEngine.search(search).map { it.resource } + fhirEngine.search(search) + } + } + + suspend fun countTotalRecordsForSync(highestRecordIdMap: HashMap): RecordCount { + var totalRecordCount: Long = 0 + val resourceCountMap: HashMap = HashMap() + + getDataTypes().forEach { + it.name.resourceClassType().let { classType -> + val lastRecordId = highestRecordIdMap[it.name] ?: 0L + val searchCount = getSearchObjectForCount(lastRecordId, classType) + val resourceCount = fhirEngine.count(searchCount) + totalRecordCount += resourceCount + resourceCountMap[it.name] = resourceCount + } } + + return RecordCount(totalRecordCount, resourceCountMap) } - fun resourceClassType(type: DataType) = - when (ResourceType.valueOf(type.name)) { - ResourceType.Group -> Group::class.java - ResourceType.Encounter -> Encounter::class.java - ResourceType.Observation -> Observation::class.java - ResourceType.Patient -> Patient::class.java - ResourceType.Questionnaire -> Questionnaire::class.java - ResourceType.QuestionnaireResponse -> QuestionnaireResponse::class.java - else -> null // TODO support other resource types + fun getSearchObjectForCount(lastRecordUpdatedAt: Long, classType: Class): Search { + return Search(type = classType.newInstance().resourceType).apply { + filter( + DateClientParam(SyncDataParams.LAST_UPDATED_KEY), + { + value = of(DateTimeType(Date(lastRecordUpdatedAt))) + prefix = ParamPrefixEnum.GREATERTHAN_OR_EQUALS + }, + ) } + } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt index 697784fa85c..9595853bfc7 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDao.kt @@ -16,39 +16,42 @@ package org.smartregister.fhircore.engine.p2p.dao -import androidx.annotation.NonNull import com.google.android.fhir.FhirEngine import com.google.android.fhir.datacapture.extensions.logicalId import java.util.TreeSet import javax.inject.Inject import kotlinx.coroutines.runBlocking import org.json.JSONArray +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry +import org.smartregister.fhircore.engine.data.local.DefaultRepository import org.smartregister.fhircore.engine.util.DispatcherProvider +import org.smartregister.fhircore.engine.util.extension.resourceClassType import org.smartregister.p2p.dao.ReceiverTransferDao import org.smartregister.p2p.sync.DataType import timber.log.Timber open class P2PReceiverTransferDao @Inject -constructor(fhirEngine: FhirEngine, dispatcherProvider: DispatcherProvider) : - BaseP2PTransferDao(fhirEngine, dispatcherProvider), ReceiverTransferDao { +constructor( + fhirEngine: FhirEngine, + dispatcherProvider: DispatcherProvider, + configurationRegistry: ConfigurationRegistry, + val defaultRepository: DefaultRepository, +) : BaseP2PTransferDao(fhirEngine, dispatcherProvider, configurationRegistry), ReceiverTransferDao { override fun getP2PDataTypes(): TreeSet = getDataTypes() - override fun receiveJson(@NonNull type: DataType, @NonNull jsonArray: JSONArray): Long { + override fun receiveJson(type: DataType, jsonArray: JSONArray): Long { var maxLastUpdated = 0L - Timber.e("saving resources from base dai") + Timber.i("saving resources from base dai ${type.name} -> ${jsonArray.length()}") (0 until jsonArray.length()).forEach { runBlocking { val resource = - jsonParser.parseResource(resourceClassType(type), jsonArray.get(it).toString()) - addOrUpdate(resource = resource) + jsonParser.parseResource(type.name.resourceClassType(), jsonArray.get(it).toString()) + val recordLastUpdated = resource.meta.lastUpdated.time + defaultRepository.addOrUpdate(resource = resource) maxLastUpdated = - (if (resource.meta.lastUpdated.time > maxLastUpdated) { - resource.meta.lastUpdated.time - } else { - maxLastUpdated - }) + (if (recordLastUpdated > maxLastUpdated) recordLastUpdated else maxLastUpdated) Timber.e("Received ${resource.resourceType} with id = ${resource.logicalId}") } } diff --git a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt index c17d52fdcc5..0350a681cd3 100644 --- a/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt +++ b/android/engine/src/main/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDao.kt @@ -22,49 +22,70 @@ import java.util.TreeSet import javax.inject.Inject import kotlinx.coroutines.runBlocking import org.json.JSONArray +import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider +import org.smartregister.fhircore.engine.util.extension.resourceClassType import org.smartregister.p2p.dao.SenderTransferDao +import org.smartregister.p2p.model.RecordCount import org.smartregister.p2p.search.data.JsonData import org.smartregister.p2p.sync.DataType import timber.log.Timber class P2PSenderTransferDao @Inject -constructor(fhirEngine: FhirEngine, dispatcherProvider: DefaultDispatcherProvider) : - BaseP2PTransferDao(fhirEngine, dispatcherProvider), SenderTransferDao { +constructor( + fhirEngine: FhirEngine, + dispatcherProvider: DefaultDispatcherProvider, + configurationRegistry: ConfigurationRegistry, +) : BaseP2PTransferDao(fhirEngine, dispatcherProvider, configurationRegistry), SenderTransferDao { override fun getP2PDataTypes(): TreeSet = getDataTypes() - override fun getJsonData(dataType: DataType, lastUpdated: Long, batchSize: Int): JsonData? { + override fun getTotalRecordCount(highestRecordIdMap: HashMap): RecordCount { + return runBlocking { countTotalRecordsForSync(highestRecordIdMap) } + } + + override fun getJsonData( + dataType: DataType, + lastUpdated: Long, + batchSize: Int, + offset: Int, + ): JsonData? { // TODO: complete retrieval of data implementation Timber.e("Last updated at value is $lastUpdated") var highestRecordId = lastUpdated - val records = - runBlocking { - resourceClassType(dataType)?.let { classType -> - loadResources(lastRecordUpdatedAt = highestRecordId, batchSize = batchSize, classType) - } - } ?: listOf() + val records = runBlocking { + dataType.name.resourceClassType().let { classType -> + loadResources( + lastRecordUpdatedAt = highestRecordId, + batchSize = batchSize, + offset = offset, + classType, + ) + } + } - Timber.e("Fetching resources from base dao of type $dataType.name") + Timber.i("Fetching resources from base dao of type $dataType.name") highestRecordId = (if (records.isNotEmpty()) { - records.last().meta?.lastUpdated?.time ?: highestRecordId + records.last().resource.meta?.lastUpdated?.time ?: highestRecordId } else { lastUpdated }) val jsonArray = JSONArray() records.forEach { - jsonArray.put(jsonParser.encodeResourceToString(it)) + jsonArray.put(jsonParser.encodeResourceToString(it.resource)) highestRecordId = - if (it.meta?.lastUpdated?.time!! > highestRecordId) { - it.meta?.lastUpdated?.time!! + if (it.resource.meta?.lastUpdated?.time!! > highestRecordId) { + it.resource.meta?.lastUpdated?.time!! } else { highestRecordId } - Timber.e("Sending ${it.resourceType} with id ====== ${it.logicalId}") + Timber.i( + "Sending ${it.resource.resourceType} with id ====== ${it.resource.logicalId} and lastUpdated = ${it.resource.meta?.lastUpdated?.time!!}", + ) } Timber.e("New highest Last updated at value is $highestRecordId") 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 6c3e47d3a57..dcb44c5d123 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 @@ -24,6 +24,7 @@ import com.google.android.fhir.datacapture.extensions.logicalId import java.util.Date import java.util.LinkedList import java.util.UUID +import org.hl7.fhir.exceptions.FHIRException import org.hl7.fhir.r4.model.Base import org.hl7.fhir.r4.model.BaseDateTimeType import org.hl7.fhir.r4.model.Binary @@ -258,6 +259,18 @@ fun Resource.referenceParamForObservation(): ReferenceClientParam = fun Resource.setPropertySafely(name: String, value: Base) = kotlin.runCatching { this.setProperty(name, value) }.onFailure { Timber.w(it) }.getOrNull() +fun isValidResourceType(resourceCode: String): Boolean { + return try { + ResourceType.fromCode(resourceCode) + true + } catch (exception: FHIRException) { + false + } +} + +fun String.resourceClassType(): Class = + FhirContext.forR4Cached().getResourceDefinition(this).implementingClass as Class + fun generateUniqueId() = UUID.randomUUID().toString() fun Base.extractWithFhirPath(expression: String) = diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/appfeature/AppFeatureTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/appfeature/AppFeatureTest.kt index d9f1e9109da..6ec24a9869a 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/appfeature/AppFeatureTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/appfeature/AppFeatureTest.kt @@ -35,10 +35,5 @@ class AppFeatureTest { Assert.assertTrue( appFeatureHouseholdManagement.name.equals("HouseholdManagement", ignoreCase = true), ) - - val appFeatureDeviceToDeviceSync = AppFeature.DeviceToDeviceSync - Assert.assertTrue( - appFeatureDeviceToDeviceSync.name.equals("DeviceToDeviceSync", ignoreCase = true), - ) } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDaoTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDaoTest.kt index ee667d60dc5..c12786b73be 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDaoTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/BaseP2PTransferDaoTest.kt @@ -16,210 +16,239 @@ package org.smartregister.fhircore.engine.p2p.dao -import ca.uhn.fhir.rest.param.ParamPrefixEnum import com.google.android.fhir.FhirEngine -import com.google.android.fhir.datacapture.extensions.logicalId -import com.google.android.fhir.db.ResourceNotFoundException -import com.google.android.fhir.search.Search -import com.google.android.fhir.search.filter.DateParamFilterCriterion +import com.google.android.fhir.search.SearchQuery import io.mockk.coEvery -import io.mockk.coVerify +import io.mockk.every import io.mockk.mockk import io.mockk.slot import io.mockk.spyk -import java.util.Date +import java.util.TreeSet import kotlinx.coroutines.runBlocking -import org.hl7.fhir.r4.model.Address -import org.hl7.fhir.r4.model.ContactPoint +import kotlinx.coroutines.test.runTest import org.hl7.fhir.r4.model.Encounter -import org.hl7.fhir.r4.model.Enumerations import org.hl7.fhir.r4.model.Group -import org.hl7.fhir.r4.model.HumanName -import org.hl7.fhir.r4.model.Meta +import org.hl7.fhir.r4.model.ListResource import org.hl7.fhir.r4.model.Observation import org.hl7.fhir.r4.model.Patient import org.hl7.fhir.r4.model.Questionnaire import org.hl7.fhir.r4.model.QuestionnaireResponse -import org.hl7.fhir.r4.model.Resource 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.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Ignore import org.junit.Test -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.robolectric.RobolectricTest import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider +import org.smartregister.fhircore.engine.util.extension.resourceClassType +import org.smartregister.p2p.model.RecordCount import org.smartregister.p2p.sync.DataType class BaseP2PTransferDaoTest : RobolectricTest() { - private lateinit var baseP2PTransferDao: BaseP2PTransferDao - private lateinit var fhirEngine: FhirEngine - private val currentDate = Date() + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + private val fhirEngine: FhirEngine = mockk(relaxed = true) @Before fun setUp() { - fhirEngine = mockk(relaxed = true) - baseP2PTransferDao = spyk(P2PReceiverTransferDao(fhirEngine, DefaultDispatcherProvider())) + baseP2PTransferDao = + spyk( + P2PReceiverTransferDao( + fhirEngine, + DefaultDispatcherProvider(), + configurationRegistry, + mockk(), + ), + ) } @Test fun `getDataTypes() returns correct list of datatypes`() { val actualDataTypes = baseP2PTransferDao.getDataTypes() - Assert.assertEquals(6, actualDataTypes.size) - Assert.assertTrue( + assertEquals(9, actualDataTypes.size) + assertTrue( actualDataTypes.contains(DataType(ResourceType.Group.name, DataType.Filetype.JSON, 0)), ) - Assert.assertTrue( + assertTrue( actualDataTypes.contains(DataType(ResourceType.Patient.name, DataType.Filetype.JSON, 1)), ) - Assert.assertTrue( + assertTrue( actualDataTypes.contains( DataType(ResourceType.Questionnaire.name, DataType.Filetype.JSON, 2), ), ) - Assert.assertTrue( + assertTrue( actualDataTypes.contains( DataType(ResourceType.QuestionnaireResponse.name, DataType.Filetype.JSON, 3), ), ) - Assert.assertTrue( + assertTrue( actualDataTypes.contains(DataType(ResourceType.Observation.name, DataType.Filetype.JSON, 4)), ) - Assert.assertTrue( + assertTrue( actualDataTypes.contains(DataType(ResourceType.Encounter.name, DataType.Filetype.JSON, 5)), ) } @Test - fun `addOrUpdate() calls fhirEngine#update() when resource already exists`() { - val expectedPatient = populateTestPatient() - - coEvery { fhirEngine.get(ResourceType.Patient, expectedPatient.logicalId) } returns - expectedPatient - runBlocking { baseP2PTransferDao.addOrUpdate(expectedPatient) } - - val resourceSlot = slot() - coVerify { fhirEngine.update(capture(resourceSlot)) } - val actualPatient = resourceSlot.captured as Patient - Assert.assertEquals(expectedPatient.logicalId, actualPatient.logicalId) - Assert.assertEquals(expectedPatient.birthDate, actualPatient.birthDate) - Assert.assertEquals(expectedPatient.gender, actualPatient.gender) - Assert.assertEquals(expectedPatient.address[0].city, actualPatient.address[0].city) - Assert.assertEquals(expectedPatient.address[0].country, actualPatient.address[0].country) - Assert.assertEquals(expectedPatient.name[0].family, actualPatient.name[0].family) - Assert.assertEquals(expectedPatient.meta.lastUpdated, actualPatient.meta.lastUpdated) - } - - @Test - fun `addOrUpdate() calls fhirEngine#create() when resource does not exist`() { - val expectedPatient = populateTestPatient() - val resourceNotFoundException = ResourceNotFoundException("", "") - coEvery { fhirEngine.get(ResourceType.Patient, expectedPatient.logicalId) } throws - resourceNotFoundException - runBlocking { baseP2PTransferDao.addOrUpdate(expectedPatient) } - - val resourceSlot = slot() - coVerify { fhirEngine.create(capture(resourceSlot)) } - val actualPatient = resourceSlot.captured as Patient - Assert.assertEquals(expectedPatient.logicalId, actualPatient.logicalId) - Assert.assertEquals(expectedPatient.birthDate, actualPatient.birthDate) - Assert.assertEquals(expectedPatient.gender, actualPatient.gender) - Assert.assertEquals(expectedPatient.address[0].city, actualPatient.address[0].city) - Assert.assertEquals(expectedPatient.address[0].country, actualPatient.address[0].country) - Assert.assertEquals(expectedPatient.name[0].family, actualPatient.name[0].family) - Assert.assertEquals(expectedPatient.meta.lastUpdated, actualPatient.meta.lastUpdated) + fun `getDynamicDataTypes() returns correct list of datatypes`() { + val resourceList = + listOf( + ResourceType.Group.name, + ResourceType.Patient.name, + ResourceType.Questionnaire.name, + ResourceType.QuestionnaireResponse.name, + ResourceType.Observation.name, + ResourceType.Encounter.name, + ) + val actualDataTypes = baseP2PTransferDao.getDynamicDataTypes(resourceList) + assertEquals(6, actualDataTypes.size) + assertTrue( + actualDataTypes.contains(DataType(ResourceType.Group.name, DataType.Filetype.JSON, 0)), + ) + assertTrue( + actualDataTypes.contains(DataType(ResourceType.Patient.name, DataType.Filetype.JSON, 1)), + ) + assertTrue( + actualDataTypes.contains( + DataType(ResourceType.Questionnaire.name, DataType.Filetype.JSON, 2), + ), + ) + assertTrue( + actualDataTypes.contains( + DataType(ResourceType.QuestionnaireResponse.name, DataType.Filetype.JSON, 3), + ), + ) + assertTrue( + actualDataTypes.contains(DataType(ResourceType.Observation.name, DataType.Filetype.JSON, 4)), + ) + assertTrue( + actualDataTypes.contains(DataType(ResourceType.Encounter.name, DataType.Filetype.JSON, 5)), + ) } @Test + @Ignore("SDK does not allow custom queries, do we need this anymore?") fun `loadResources() calls fhirEngine#search()`() { + val expectedQuery = + "SELECT a.serializedResource\n" + + " FROM ResourceEntity a\n" + + " LEFT JOIN DateIndexEntity b\n" + + " ON a.resourceType = b.resourceType AND a.resourceUuid = b.resourceUuid AND b.index_name = \"_lastUpdated\"\n" + + " LEFT JOIN DateTimeIndexEntity c\n" + + " ON a.resourceType = c.resourceType AND a.resourceUuid = c.resourceUuid AND c.index_name = \"_lastUpdated\"\n" + + " WHERE a.resourceType = \"Patient\"\n" + + " AND a.resourceUuid IN (\n" + + " SELECT resourceUuid FROM DateTimeIndexEntity\n" + + " WHERE resourceType = \"Patient\" AND index_name = \"_lastUpdated\" AND index_to >= ?\n" + + " )\n" + + " ORDER BY b.index_from ASC, c.index_from ASC\n" + + " LIMIT ? OFFSET ?" + + val patientDataType = DataType("Patient", DataType.Filetype.JSON, 1) + val classType = patientDataType.name.resourceClassType() runBlocking { baseP2PTransferDao.loadResources( lastRecordUpdatedAt = 0, batchSize = 25, - classType = Patient::class.java, + offset = 0, + classType = classType, ) } + val searchQuerySlot = slot() - val searchSlot = slot() - coVerify { fhirEngine.search(capture(searchSlot)) } - Assert.assertEquals(25, searchSlot.captured.count) - Assert.assertEquals(ResourceType.Patient, searchSlot.captured.type) - - val dateTimeFilterCriterion: MutableList = - ReflectionHelpers.getField(searchSlot.captured, "dateTimeFilterCriteria") - val tokenFilters: MutableList = - ReflectionHelpers.getField(dateTimeFilterCriterion[0], "filters") - Assert.assertEquals("_lastUpdated", tokenFilters[0].parameter.paramName) - Assert.assertEquals(ParamPrefixEnum.GREATERTHAN, tokenFilters[0].prefix) + // coVerify { fhirEngine.search(capture(searchQuerySlot)) } + assertEquals(25, searchQuerySlot.captured.args[1]) + assertEquals(expectedQuery, searchQuerySlot.captured.query) } @Test fun `resourceClassType() returns correct resource class type for data type`() { - Assert.assertEquals( + assertEquals( Group::class.java, - baseP2PTransferDao.resourceClassType( - DataType(ResourceType.Group.name, DataType.Filetype.JSON, 0), - ), + DataType(ResourceType.Group.name, DataType.Filetype.JSON, 0).name.resourceClassType(), ) - Assert.assertEquals( + assertEquals( Encounter::class.java, - baseP2PTransferDao.resourceClassType( - DataType(ResourceType.Encounter.name, DataType.Filetype.JSON, 0), - ), + DataType(ResourceType.Encounter.name, DataType.Filetype.JSON, 0).name.resourceClassType(), ) - Assert.assertEquals( + assertEquals( Observation::class.java, - baseP2PTransferDao.resourceClassType( - DataType(ResourceType.Observation.name, DataType.Filetype.JSON, 0), - ), + DataType(ResourceType.Observation.name, DataType.Filetype.JSON, 0).name.resourceClassType(), ) - Assert.assertEquals( + assertEquals( Patient::class.java, - baseP2PTransferDao.resourceClassType( - DataType(ResourceType.Patient.name, DataType.Filetype.JSON, 0), - ), + DataType(ResourceType.Patient.name, DataType.Filetype.JSON, 0).name.resourceClassType(), ) - Assert.assertEquals( + assertEquals( Questionnaire::class.java, - baseP2PTransferDao.resourceClassType( - DataType(ResourceType.Questionnaire.name, DataType.Filetype.JSON, 0), - ), + DataType(ResourceType.Questionnaire.name, DataType.Filetype.JSON, 0).name.resourceClassType(), ) - Assert.assertEquals( + assertEquals( QuestionnaireResponse::class.java, - baseP2PTransferDao.resourceClassType( - DataType(ResourceType.QuestionnaireResponse.name, DataType.Filetype.JSON, 0), - ), + DataType(ResourceType.QuestionnaireResponse.name, DataType.Filetype.JSON, 0) + .name + .resourceClassType(), + ) + assertEquals( + ListResource::class.java, + DataType(ResourceType.List.name, DataType.Filetype.JSON, 0).name.resourceClassType(), ) } - private fun populateTestPatient(): Patient { - val patientId = "patient-123456" - val patient: Patient = - Patient().apply { - id = patientId - active = true - birthDate = LocalDate.parse("1999-10-03").toDate() - gender = Enumerations.AdministrativeGender.MALE - address = - listOf( - Address().apply { - city = "Nairobi" - country = "Kenya" - }, - ) - name = - listOf( - HumanName().apply { - given = mutableListOf(StringType("Kiptoo")) - family = "Maina" - }, - ) - telecom = listOf(ContactPoint().apply { value = "12345" }) - meta = Meta().apply { lastUpdated = currentDate } + @Test + @kotlinx.coroutines.ExperimentalCoroutinesApi + fun `countTotalRecordsForSync() calls fhirEngine#count`() = runTest { + val expectedDataTypeTotalCountMap: HashMap = hashMapOf() + expectedDataTypeTotalCountMap["Patient"] = 1L + val expectedRecordCount = + RecordCount(totalRecordCount = 1, dataTypeTotalCountMap = expectedDataTypeTotalCountMap) + every { baseP2PTransferDao.getDataTypes() } returns + TreeSet().apply { + add(DataType(ResourceType.Patient.name, DataType.Filetype.JSON, 1)) } - return patient + + coEvery { fhirEngine.count(any()) } returns 1 + + val actualRecordCount = baseP2PTransferDao.countTotalRecordsForSync(HashMap()) + assertEquals(expectedRecordCount, actualRecordCount) + assertEquals(1L, actualRecordCount.dataTypeTotalCountMap["Patient"]) + } + + @Test + fun `getSearchObjectForCount() create search filter in fhirEngine`() { + val search = baseP2PTransferDao.getSearchObjectForCount(1656663911, Patient::class.java) + assertEquals("Patient", search.type.name) + } + + @Test + fun getDefaultDataTypesReturnsCorrectListOfDataTypes() { + val actualDataTypes = baseP2PTransferDao.getDefaultDataTypes() + assertEquals(6, actualDataTypes.size) + assertTrue( + actualDataTypes.contains(DataType(ResourceType.Group.name, DataType.Filetype.JSON, 0)), + ) + assertTrue( + actualDataTypes.contains(DataType(ResourceType.Patient.name, DataType.Filetype.JSON, 1)), + ) + assertTrue( + actualDataTypes.contains( + DataType(ResourceType.Questionnaire.name, DataType.Filetype.JSON, 2), + ), + ) + assertTrue( + actualDataTypes.contains( + DataType(ResourceType.QuestionnaireResponse.name, DataType.Filetype.JSON, 3), + ), + ) + assertTrue( + actualDataTypes.contains(DataType(ResourceType.Observation.name, DataType.Filetype.JSON, 4)), + ) + assertTrue( + actualDataTypes.contains(DataType(ResourceType.Encounter.name, DataType.Filetype.JSON, 5)), + ) } } diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDaoTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDaoTest.kt index 005c4a38f86..81952be080e 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDaoTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/P2PReceiverTransferDaoTest.kt @@ -43,6 +43,9 @@ import org.json.JSONArray 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.robolectric.RobolectricTest import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.p2p.sync.DataType @@ -50,20 +53,34 @@ import org.smartregister.p2p.sync.DataType class P2PReceiverTransferDaoTest : RobolectricTest() { private val jsonParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + private lateinit var p2PReceiverTransferDao: P2PReceiverTransferDao - private lateinit var fhirEngine: FhirEngine + + private val defaultRepository: DefaultRepository = mockk() + + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + + private val fhirEngine: FhirEngine = mockk() + private val currentDate = Date() @Before fun setUp() { - fhirEngine = mockk() - p2PReceiverTransferDao = spyk(P2PReceiverTransferDao(fhirEngine, DefaultDispatcherProvider())) + p2PReceiverTransferDao = + spyk( + P2PReceiverTransferDao( + fhirEngine, + DefaultDispatcherProvider(), + configurationRegistry, + defaultRepository, + ), + ) } @Test fun `getP2PDataTypes() returns correct list of datatypes`() { val actualDataTypes = p2PReceiverTransferDao.getDataTypes() - Assert.assertEquals(6, actualDataTypes.size) + Assert.assertEquals(9, actualDataTypes.size) Assert.assertTrue( actualDataTypes.contains(DataType(ResourceType.Group.name, DataType.Filetype.JSON, 0)), ) @@ -93,11 +110,11 @@ class P2PReceiverTransferDaoTest : RobolectricTest() { val expectedPatient = populateTestPatient() val jsonArray = populateTestJsonArray() val patientDataType = DataType(ResourceType.Patient.name, DataType.Filetype.JSON, 1) - coEvery { p2PReceiverTransferDao.addOrUpdate(any()) } just runs + coEvery { defaultRepository.addOrUpdate(resource = any()) } just runs p2PReceiverTransferDao.receiveJson(patientDataType, jsonArray) val resourceSlot = slot() - coVerify { p2PReceiverTransferDao.addOrUpdate(capture(resourceSlot)) } + coVerify { defaultRepository.addOrUpdate(resource = capture(resourceSlot)) } val actualPatient = resourceSlot.captured as Patient Assert.assertEquals(expectedPatient.logicalId, actualPatient.logicalId) Assert.assertEquals(expectedPatient.birthDate, actualPatient.birthDate) diff --git a/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDaoTest.kt b/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDaoTest.kt index 68d6e9b979c..961e831f895 100644 --- a/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDaoTest.kt +++ b/android/engine/src/test/java/org/smartregister/fhircore/engine/p2p/dao/P2PSenderTransferDaoTest.kt @@ -20,11 +20,14 @@ import ca.uhn.fhir.context.FhirContext import ca.uhn.fhir.context.FhirVersionEnum import ca.uhn.fhir.parser.IParser import com.google.android.fhir.FhirEngine +import com.google.android.fhir.SearchResult import com.google.android.fhir.datacapture.extensions.logicalId import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.mockk import io.mockk.spyk import java.util.Date +import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Address import org.hl7.fhir.r4.model.ContactPoint import org.hl7.fhir.r4.model.Enumerations @@ -37,6 +40,8 @@ import org.joda.time.LocalDate 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.robolectric.RobolectricTest import org.smartregister.fhircore.engine.util.DefaultDispatcherProvider import org.smartregister.p2p.sync.DataType @@ -44,20 +49,25 @@ import org.smartregister.p2p.sync.DataType class P2PSenderTransferDaoTest : RobolectricTest() { private val jsonParser: IParser = FhirContext.forCached(FhirVersionEnum.R4).newJsonParser() + private lateinit var p2PSenderTransferDao: P2PSenderTransferDao - private lateinit var fhirEngine: FhirEngine + + private val configurationRegistry: ConfigurationRegistry = Faker.buildTestConfigurationRegistry() + + private val fhirEngine: FhirEngine = mockk() + private val currentDate = Date() @Before fun setUp() { - fhirEngine = mockk() - p2PSenderTransferDao = spyk(P2PSenderTransferDao(fhirEngine, DefaultDispatcherProvider())) + p2PSenderTransferDao = + spyk(P2PSenderTransferDao(fhirEngine, DefaultDispatcherProvider(), configurationRegistry)) } @Test fun `getP2PDataTypes() returns correct list of datatypes`() { val actualDataTypes = p2PSenderTransferDao.getDataTypes() - Assert.assertEquals(6, actualDataTypes.size) + Assert.assertEquals(9, actualDataTypes.size) Assert.assertTrue( actualDataTypes.contains(DataType(ResourceType.Group.name, DataType.Filetype.JSON, 0)), ) @@ -89,9 +99,13 @@ class P2PSenderTransferDaoTest : RobolectricTest() { p2PSenderTransferDao.loadResources( lastRecordUpdatedAt = 0, batchSize = 25, + offset = 0, Patient::class.java, ) - } returns listOf(expectedPatient) + } returns + listOf( + SearchResult(resource = expectedPatient, revIncluded = emptyMap(), included = emptyMap()), + ) val patientDataType = DataType(ResourceType.Patient.name, DataType.Filetype.JSON, 1) val actualJsonData = @@ -99,7 +113,7 @@ class P2PSenderTransferDaoTest : RobolectricTest() { val actualPatient: Patient = jsonParser.parseResource(actualJsonData!!.getJsonArray()!!.get(0).toString()) as Patient - Assert.assertEquals(currentDate.time, actualJsonData!!.getHighestRecordId()) + Assert.assertEquals(currentDate.time, actualJsonData.getHighestRecordId()) Assert.assertEquals(expectedPatient.logicalId, actualPatient.logicalId) Assert.assertEquals(expectedPatient.birthDate, actualPatient.birthDate) Assert.assertEquals(expectedPatient.gender, actualPatient.gender) @@ -136,4 +150,12 @@ class P2PSenderTransferDaoTest : RobolectricTest() { } return patient } + + fun `getTotalRecordCount() calls countTotalRecordsForSync()`() { + val highestRecordIdMap: HashMap = HashMap() + highestRecordIdMap.put("Patient", 25) + + runBlocking { p2PSenderTransferDao.countTotalRecordsForSync(highestRecordIdMap) } + coVerify { p2PSenderTransferDao.countTotalRecordsForSync(highestRecordIdMap) } + } } 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 1dca7fdb8dc..89d8406e7ab 100644 --- a/android/quest/src/main/assets/configs/quest/config_application.json +++ b/android/quest/src/main/assets/configs/quest/config_application.json @@ -6,6 +6,19 @@ "en", "sw" ], + "deviceToDeviceSync": { + "resourcesToSync": [ + "Group", + "Patient", + "CarePlan", + "Task", + "Encounter", + "Observation", + "Condition", + "Questionnaire", + "QuestionnaireResponse" + ] + }, "applicationName": "Quest", "appLogoIconResourceFile": "ic_liberia", "count": "100", 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 52280278dce..37563728d96 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 @@ -34,7 +34,6 @@ import java.util.TimeZone import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch -import org.smartregister.fhircore.engine.appfeature.AppFeature import org.smartregister.fhircore.engine.appfeature.AppFeatureManager import org.smartregister.fhircore.engine.auth.AccountAuthenticator import org.smartregister.fhircore.engine.configuration.ConfigurationRegistry @@ -89,7 +88,7 @@ constructor( sideMenuOptions = sideMenuOptionFactory.retrieveSideMenuOptions(), lastSyncTime = retrieveLastSyncTimestamp() ?: "", languages = configurationRegistry.fetchLanguages(), - enableDeviceToDeviceSync = appFeatureManager.isFeatureActive(AppFeature.DeviceToDeviceSync), + enableDeviceToDeviceSync = true, // Disable in-app reporting -- Measure reports not well supported // enableReports = appFeatureManager.isFeatureActive(AppFeature.InAppReporting), enableReports = false,