diff --git a/build.gradle.kts b/build.gradle.kts index 03cd33a7e3..4adbfd6375 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { + val kotlin_version by extra("1.4.31") repositories { google() mavenCentral() @@ -9,6 +10,7 @@ buildscript { classpath(Plugins.androidGradlePlugin) classpath(Plugins.kotlinGradlePlugin) classpath(Plugins.navSafeArgsGradlePlugin) + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") } } diff --git a/buildSrc/src/main/kotlin/Dependencies.kt b/buildSrc/src/main/kotlin/Dependencies.kt index d60ae67432..70ccacb69d 100644 --- a/buildSrc/src/main/kotlin/Dependencies.kt +++ b/buildSrc/src/main/kotlin/Dependencies.kt @@ -107,7 +107,7 @@ object Dependencies { const val navigation = "2.3.4" const val recyclerView = "1.1.0" const val room = "2.2.5" - const val workRuntimeKtx = "2.3.4" + const val workRuntimeKtx = "2.5.0" } object Cql { diff --git a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt index c872339349..3cf6ee9214 100644 --- a/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt +++ b/engine/src/androidTest/java/com/google/android/fhir/db/impl/DatabaseImplTest.kt @@ -27,7 +27,6 @@ import com.google.android.fhir.search.Order import com.google.android.fhir.search.Search import com.google.android.fhir.search.StringFilterModifier import com.google.android.fhir.search.getQuery -import com.google.android.fhir.sync.FhirDataSource import com.google.common.truth.Truth.assertThat import java.math.BigDecimal import kotlinx.coroutines.runBlocking diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt index 7949511390..2e3aa76e83 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -16,11 +16,12 @@ package com.google.android.fhir +import com.google.android.fhir.db.impl.dao.LocalChangeToken +import com.google.android.fhir.db.impl.dao.SquashedLocalChange +import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.search.Search -import com.google.android.fhir.sync.PeriodicSyncConfiguration -import com.google.android.fhir.sync.Result -import com.google.android.fhir.sync.SyncConfiguration import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType /** The FHIR Engine interface that handles the local storage of FHIR resources. */ interface FhirEngine { @@ -55,20 +56,13 @@ interface FhirEngine { */ suspend fun remove(clazz: Class, id: String) - /** - * One time download of resources. - * - * @param syncConfiguration - * - configuration of data that needs to be synchronised - */ - suspend fun sync(syncConfiguration: SyncConfiguration): Result - - /** Attempts to upload locally created and modified resources. */ - suspend fun syncUpload(): Result - - suspend fun periodicSync(): Result + suspend fun search(search: Search): List - fun updatePeriodicSyncConfiguration(syncConfig: PeriodicSyncConfiguration) + suspend fun syncDownload(download: suspend (SyncDownloadContext) -> List) - suspend fun search(search: Search): List + suspend fun syncUpload(upload: (suspend (List) -> List)) } + +interface SyncDownloadContext { + suspend fun getLatestTimestamptFor(type:ResourceType):String? +} \ No newline at end of file diff --git a/engine/src/main/java/com/google/android/fhir/FhirEngineBuilder.kt b/engine/src/main/java/com/google/android/fhir/FhirEngineBuilder.kt index c9a12cf7e6..feb00b68c1 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirEngineBuilder.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirEngineBuilder.kt @@ -17,23 +17,10 @@ package com.google.android.fhir import android.content.Context -import com.google.android.fhir.sync.FhirDataSource -import com.google.android.fhir.sync.PeriodicSyncConfiguration /** The builder for [FhirEngine] instance */ -class FhirEngineBuilder constructor(dataSource: FhirDataSource, context: Context) { - private val services = FhirServices.builder(dataSource, context) - - /** Sets the database file name for the FhirEngine to use. */ - fun databaseName(name: String) = apply { services.databaseName(name) } - - /** Instructs the FhirEngine to use an in memory database which can be useful for tests. */ - internal fun inMemory() = apply { services.inMemory() } - - /** Configures the FhirEngine periodic sync. */ - fun periodicSyncConfiguration(config: PeriodicSyncConfiguration) = apply { - services.periodicSyncConfiguration(config) - } +class FhirEngineBuilder constructor(context: Context) { + private val services = FhirServices.builder(context) /** Builds a new instance of the [FhirEngine]. */ fun build() = services.build().fhirEngine diff --git a/engine/src/main/java/com/google/android/fhir/FhirServices.kt b/engine/src/main/java/com/google/android/fhir/FhirServices.kt index c86eb46a95..5cf55775bd 100644 --- a/engine/src/main/java/com/google/android/fhir/FhirServices.kt +++ b/engine/src/main/java/com/google/android/fhir/FhirServices.kt @@ -22,34 +22,25 @@ import ca.uhn.fhir.parser.IParser import com.google.android.fhir.db.Database import com.google.android.fhir.db.impl.DatabaseImpl import com.google.android.fhir.impl.FhirEngineImpl -import com.google.android.fhir.sync.FhirDataSource -import com.google.android.fhir.sync.PeriodicSyncConfiguration internal data class FhirServices( val fhirEngine: FhirEngine, val parser: IParser, val database: Database ) { - class Builder(private val dataSource: FhirDataSource, private val context: Context) { + class Builder(private val context: Context) { private var databaseName: String? = "fhirEngine" - private var periodicSyncConfiguration: PeriodicSyncConfiguration? = null fun inMemory() = apply { databaseName = null } fun databaseName(name: String) = apply { databaseName = name } - fun periodicSyncConfiguration(config: PeriodicSyncConfiguration) = apply { - periodicSyncConfiguration = config - } - fun build(): FhirServices { val parser = FhirContext.forR4().newJsonParser() val db = DatabaseImpl(context = context, iParser = parser, databaseName = databaseName) val engine = FhirEngineImpl( database = db, - periodicSyncConfiguration = periodicSyncConfiguration, - dataSource = dataSource, context = context ) return FhirServices(fhirEngine = engine, parser = parser, database = db) @@ -57,6 +48,6 @@ internal data class FhirServices( } companion object { - fun builder(dataSource: FhirDataSource, context: Context) = Builder(dataSource, context) + fun builder(context: Context) = Builder(context) } } diff --git a/engine/src/main/java/com/google/android/fhir/db/Database.kt b/engine/src/main/java/com/google/android/fhir/db/Database.kt index cf89a64349..b6542e5992 100644 --- a/engine/src/main/java/com/google/android/fhir/db/Database.kt +++ b/engine/src/main/java/com/google/android/fhir/db/Database.kt @@ -25,7 +25,7 @@ import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType /** The interface for the FHIR resource database. */ -interface Database { +internal interface Database { /** * Inserts a list of local `resources` into the FHIR resource database. If any of the resources * already exists, it will be overwritten. @@ -69,10 +69,10 @@ interface Database { /** * Insert a resource that was syncronised. * - * @param syncedResourceEntity The synced resource + * @param syncedResources The synced resource */ suspend fun insertSyncedResources( - syncedResourceEntity: SyncedResourceEntity, + syncedResources: List, resources: List ) diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index 5e42588038..f0dc8955fa 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt @@ -92,10 +92,10 @@ internal class DatabaseImpl(context: Context, private val iParser: IParser, data @Transaction override suspend fun insertSyncedResources( - syncedResourceEntity: SyncedResourceEntity, + syncedResources: List, resources: List ) { - syncedResourceDao.insert(syncedResourceEntity) + syncedResourceDao.insertAll(syncedResources) insertRemote(*resources.toTypedArray()) } diff --git a/engine/src/main/java/com/google/android/fhir/db/impl/dao/SyncedResourceDao.kt b/engine/src/main/java/com/google/android/fhir/db/impl/dao/SyncedResourceDao.kt index df00833477..ca3bc298ed 100644 --- a/engine/src/main/java/com/google/android/fhir/db/impl/dao/SyncedResourceDao.kt +++ b/engine/src/main/java/com/google/android/fhir/db/impl/dao/SyncedResourceDao.kt @@ -20,6 +20,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Transaction import com.google.android.fhir.db.impl.entities.SyncedResourceEntity import org.hl7.fhir.r4.model.ResourceType @@ -28,6 +29,11 @@ interface SyncedResourceDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: SyncedResourceEntity) + @Transaction + suspend fun insertAll(resources: List) { + resources.forEach { resource -> insert(resource) } + } + /** * We will always have 1 entry for each [ResourceType] as it's the primary key, so we can limit * the result to 1. If there is no entry for that [ResourceType] then `null` will be returned. diff --git a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt index 83b35dfbf9..466c6da504 100644 --- a/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt +++ b/engine/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt @@ -17,37 +17,24 @@ package com.google.android.fhir.impl import android.content.Context -import androidx.work.ExistingWorkPolicy -import androidx.work.OneTimeWorkRequest -import androidx.work.WorkManager import com.google.android.fhir.FhirEngine import com.google.android.fhir.ResourceNotFoundException +import com.google.android.fhir.SyncDownloadContext import com.google.android.fhir.db.Database import com.google.android.fhir.db.ResourceNotFoundInDbException +import com.google.android.fhir.db.impl.dao.LocalChangeToken +import com.google.android.fhir.db.impl.dao.SquashedLocalChange +import com.google.android.fhir.db.impl.entities.SyncedResourceEntity import com.google.android.fhir.resource.getResourceType import com.google.android.fhir.search.Search import com.google.android.fhir.search.execute -import com.google.android.fhir.sync.FhirDataSource -import com.google.android.fhir.sync.FhirSynchronizer -import com.google.android.fhir.sync.PeriodicSyncConfiguration -import com.google.android.fhir.sync.Result -import com.google.android.fhir.sync.SyncConfiguration -import com.google.android.fhir.sync.SyncWorkType +import com.google.android.fhir.toTimeZoneString import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType /** Implementation of [FhirEngine]. */ -class FhirEngineImpl -constructor( - private val database: Database, - private var periodicSyncConfiguration: PeriodicSyncConfiguration?, - private val dataSource: FhirDataSource, - private val context: Context -) : FhirEngine { - - init { - periodicSyncConfiguration?.let { config -> triggerInitialDownload(config) } - } - +internal class FhirEngineImpl +constructor(private val database: Database, private val context: Context) : FhirEngine { override suspend fun save(vararg resource: R) { database.insert(*resource) } @@ -69,53 +56,29 @@ constructor( database.delete(clazz, id) } - override suspend fun sync(syncConfiguration: SyncConfiguration): Result { - return FhirSynchronizer(syncConfiguration, dataSource, database).sync() - } - - override suspend fun syncUpload(): Result { - return FhirSynchronizer(SyncConfiguration(), dataSource, database).upload() - } - - override suspend fun periodicSync(): Result { - val syncConfig = - periodicSyncConfiguration - ?: throw java.lang.UnsupportedOperationException("Periodic sync configuration was not set") - val syncResult = FhirSynchronizer(syncConfig.syncConfiguration, dataSource, database).sync() - setupNextDownload(syncConfig) - return syncResult - } - - override fun updatePeriodicSyncConfiguration(syncConfig: PeriodicSyncConfiguration) { - periodicSyncConfiguration = syncConfig - setupNextDownload(syncConfig) - } - override suspend fun search(search: Search): List { return search.execute(database) } - private fun setupNextDownload(syncConfig: PeriodicSyncConfiguration) { - setupDownload(syncConfig = syncConfig, withInitialDelay = true) - } - - private fun triggerInitialDownload(syncConfig: PeriodicSyncConfiguration) { - setupDownload(syncConfig = syncConfig, withInitialDelay = false) - } - - private fun setupDownload(syncConfig: PeriodicSyncConfiguration, withInitialDelay: Boolean) { - val workerClass = syncConfig.periodicSyncWorker - val downloadRequest = - if (withInitialDelay) { - OneTimeWorkRequest.Builder(workerClass) - .setConstraints(syncConfig.syncConstraints) - .setInitialDelay(syncConfig.repeat.interval, syncConfig.repeat.timeUnit) - .build() - } else { - OneTimeWorkRequest.Builder(workerClass).setConstraints(syncConfig.syncConstraints).build() + override suspend fun syncDownload(download: suspend (SyncDownloadContext) -> List) { + val stuff = + download( + object : SyncDownloadContext { + override suspend fun getLatestTimestamptFor(type: ResourceType) = + database.lastUpdate(type) + } + ) + + val timeStamps = + stuff.groupBy { it.resourceType }.entries.map { + SyncedResourceEntity(it.key, it.value.last().meta.lastUpdated.toTimeZoneString()) } + database.insertSyncedResources(timeStamps, stuff) + } - WorkManager.getInstance(context) - .enqueueUniqueWork(SyncWorkType.DOWNLOAD.workerName, ExistingWorkPolicy.KEEP, downloadRequest) + override suspend fun syncUpload( + upload: (suspend (List) -> List) + ) { + upload(database.getAllLocalChanges()).forEach { database.deleteUpdates(it) } } } diff --git a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt index 8243d62ff5..4810c3722f 100644 --- a/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt +++ b/engine/src/main/java/com/google/android/fhir/search/MoreSearch.kt @@ -22,7 +22,7 @@ import com.google.android.fhir.db.Database import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType -suspend fun Search.execute(database: Database): List { +internal suspend fun Search.execute(database: Database): List { return database.search(getQuery()) } diff --git a/engine/src/main/java/com/google/android/fhir/sync/Config.kt b/engine/src/main/java/com/google/android/fhir/sync/Config.kt new file mode 100644 index 0000000000..7b46cdce91 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/Config.kt @@ -0,0 +1,57 @@ +package com.google.android.fhir.sync + +import androidx.work.Constraints +import java.net.URLEncoder +import java.util.concurrent.TimeUnit +import org.hl7.fhir.r4.model.ResourceType + +/** + * Class that holds what type of resources we need to synchronise and what are the parameters of + * that type. e.g. we only want to synchronise patients that live in United States + * `ResourceSyncParams(ResourceType.Patient, mapOf("address-country" to "United States")` + */ +typealias ParamMap = Map +typealias ResourceSyncParams = Map + +object SyncDataParams { + const val SORT_KEY = "_sort" + const val LAST_UPDATED_KEY = "_lastUpdated" + const val ADDRESS_COUNTRY_KEY = "address-country" + const val LAST_UPDATED_ASC_VALUE = "_lastUpdated" +} + +/** Configuration for synchronization. */ +data class SyncConfiguration( + /** Data that needs to be synchronised */ + val resourceSyncParams: List = emptyList(), + /** + * true if the SDK needs to retry a failed sync attempt, false otherwise If this is set to true, + * then the result of the sync will be reported after the retry. + */ + val retry: Boolean = false +) + +/** Configuration for period synchronisation */ +class PeriodicSyncConfiguration( + /** + * Constraints that specify the requirements needed before the synchronisation is triggered. E.g. + * network type (Wifi, 3G etc), the device should be charging etc. + */ + val syncConstraints: Constraints = Constraints.Builder().build(), + + /** The interval at which the sync should be triggered in */ + val repeat: RepeatInterval +) + +data class RepeatInterval( + /** The interval at which the sync should be triggered in */ + val interval: Long, + /** The time unit for the repeat interval */ + val timeUnit: TimeUnit +) + +fun ParamMap.concatParams(): String { + return this.entries.joinToString("&") { (key, value) -> + "$key=${URLEncoder.encode(value, "UTF-8")}" + } +} \ No newline at end of file diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirDataSource.kt b/engine/src/main/java/com/google/android/fhir/sync/DataSource.kt similarity index 98% rename from engine/src/main/java/com/google/android/fhir/sync/FhirDataSource.kt rename to engine/src/main/java/com/google/android/fhir/sync/DataSource.kt index 7ec97db8aa..f0bc2f6fcd 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirDataSource.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/DataSource.kt @@ -24,7 +24,7 @@ import org.hl7.fhir.r4.model.Resource * Interface for an abstraction of retrieving static data from a network source. The data can be * retrieved in pages and each data retrieval is an expensive operation. */ -interface FhirDataSource { +interface DataSource { /** * Implement this method to load remote data based on a url [path]. A service base url is of the diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt index 46c01a9095..7cf0d3b157 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt @@ -16,11 +16,14 @@ package com.google.android.fhir.sync -import com.google.android.fhir.db.Database +import com.google.android.fhir.FhirEngine +import com.google.android.fhir.db.impl.dao.LocalChangeToken import com.google.android.fhir.db.impl.entities.LocalChangeEntity import com.google.android.fhir.isUploadSuccess import com.google.android.fhir.logicalId +import java.io.IOException import org.hl7.fhir.exceptions.FHIRException +import org.hl7.fhir.r4.model.Bundle import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @@ -33,70 +36,106 @@ data class ResourceSyncException(val resourceType: ResourceType, val exception: /** Class that helps synchronize the data source and save it in the local database */ class FhirSynchronizer( - private val syncConfiguration: SyncConfiguration, - private val dataSource: FhirDataSource, - private val database: Database + private val fhirEngine: FhirEngine, + private val dataSource: DataSource, + private val resourceSyncParams: ResourceSyncParams ) { - suspend fun sync(): Result { + suspend fun download(): Result { val exceptions = mutableListOf() - syncConfiguration.syncData.forEach { syncData -> - val resourceSynchroniser = - ResourceSynchronizer(syncData, dataSource, database, syncConfiguration.retry) + + upload() + + resourceSyncParams.forEach { try { - resourceSynchroniser.sync() + download(it.key, it.value) } catch (exception: Exception) { - exceptions.add(ResourceSyncException(syncData.resourceType, exception)) + exceptions.add(ResourceSyncException(it.key, exception)) } } - if (exceptions.isEmpty()) { - return Result.Success + return if (exceptions.isEmpty()) { + Result.Success } else { - return Result.Error(exceptions) + Result.Error(exceptions) } } - suspend fun upload(): Result { - val exceptions = mutableListOf() - database.getAllLocalChanges().forEach { + private suspend fun download(resourceType: ResourceType, params: ParamMap) { + fhirEngine.syncDownload { + var nextUrl = getInitialUrl(resourceType, params, it(resourceType)) + val result = mutableListOf() try { - val response: Resource = doUpload(it.localChange, dataSource) - if (response.logicalId.equals(it.localChange.resourceId) || response.isUploadSuccess()) { - database.deleteUpdates(it.token) - } else { - // TODO improve exception message - exceptions.add( - ResourceSyncException( - ResourceType.valueOf(it.localChange.resourceType), - FHIRException( - "Could not infer response \"${response.resourceType}/${response.logicalId}\" as success." + while (nextUrl != null) { + val bundle = dataSource.loadData(nextUrl) + nextUrl = bundle.link.firstOrNull { component -> component.relation == "next" }?.url + if (bundle.type == Bundle.BundleType.SEARCHSET) { + result.addAll(bundle.entry.map { it.resource }) + } + } + } catch (e: IOException) {} + + return@syncDownload result + } + } + + private fun getInitialUrl( + resourceType: ResourceType, + params: ParamMap, + lastUpdate: String? + ): String? { + val newParams = params.toMutableMap() + if (!params.containsKey(SyncDataParams.SORT_KEY)) { + newParams[SyncDataParams.SORT_KEY] = SyncDataParams.LAST_UPDATED_ASC_VALUE + } + if (lastUpdate != null) { + newParams[SyncDataParams.LAST_UPDATED_KEY] = "gt$lastUpdate" + } + return "${resourceType.name}?${newParams.concatParams()}" + } + + private suspend fun upload(): Result { + val exceptions = mutableListOf() + + fhirEngine.syncUpload { list -> + val tokens = mutableListOf() + list.forEach { + try { + val response: Resource = doUpload(it.localChange) + if (response.logicalId.equals(it.localChange.resourceId) || response.isUploadSuccess()) { + tokens.add(it.token) + } else { + // TODO improve exception message + exceptions.add( + ResourceSyncException( + ResourceType.valueOf(it.localChange.resourceType), + FHIRException( + "Could not infer response \"${response.resourceType}/${response.logicalId}\" as success." + ) ) ) + } + } catch (exception: Exception) { + exceptions.add( + ResourceSyncException(ResourceType.valueOf(it.localChange.resourceType), exception) ) } - } catch (exception: Exception) { - exceptions.add( - ResourceSyncException(ResourceType.valueOf(it.localChange.resourceType), exception) - ) } + return@syncUpload tokens } - if (exceptions.isEmpty()) { - return Result.Success + return if (exceptions.isEmpty()) { + Result.Success } else { - return Result.Error(exceptions) + Result.Error(exceptions) } } - private suspend fun doUpload( - localChange: LocalChangeEntity, - datasource: FhirDataSource - ): Resource = + private suspend fun doUpload(localChange: LocalChangeEntity): Resource = when (localChange.type) { LocalChangeEntity.Type.INSERT -> - datasource.insert(localChange.resourceType, localChange.resourceId, localChange.payload) + dataSource.insert(localChange.resourceType, localChange.resourceId, localChange.payload) LocalChangeEntity.Type.UPDATE -> - datasource.update(localChange.resourceType, localChange.resourceId, localChange.payload) + dataSource.update(localChange.resourceType, localChange.resourceId, localChange.payload) LocalChangeEntity.Type.DELETE -> - datasource.delete(localChange.resourceType, localChange.resourceId) + dataSource.delete(localChange.resourceType, localChange.resourceId) } } diff --git a/engine/src/main/java/com/google/android/fhir/sync/PeriodicSyncConfiguration.kt b/engine/src/main/java/com/google/android/fhir/sync/PeriodicSyncConfiguration.kt deleted file mode 100644 index cbfb95dbb5..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/PeriodicSyncConfiguration.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * 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 com.google.android.fhir.sync - -import androidx.work.Constraints -import java.util.concurrent.TimeUnit - -/** Configuration for period synchronisation */ -class PeriodicSyncConfiguration( - val syncConfiguration: SyncConfiguration, - /** - * Constraints that specify the requirements needed before the synchronisation is triggered. E.g. - * network type (Wifi, 3G etc), the device should be charging etc. - */ - val syncConstraints: Constraints = Constraints.Builder().build(), - - /** Worker that will execute the periodic sync */ - val periodicSyncWorker: Class, - - /** The interval at which the sync should be triggered in */ - val repeat: RepeatInterval -) - -data class RepeatInterval( - /** The interval at which the sync should be triggered in */ - val interval: Long, - - /** The time unit for the repeat interval */ - val timeUnit: TimeUnit -) diff --git a/engine/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt b/engine/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt index 88e9d1ac9c..92ea83da13 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt @@ -27,10 +27,12 @@ abstract class PeriodicSyncWorker(appContext: Context, workerParams: WorkerParam CoroutineWorker(appContext, workerParams) { abstract fun getFhirEngine(): FhirEngine + abstract fun getDataSource(): DataSource + abstract fun getSyncData(): ResourceSyncParams override suspend fun doWork(): Result { // TODO handle retry - val result = getFhirEngine().periodicSync() + val result = FhirSynchronizer(getFhirEngine(), getDataSource(), getSyncData()).download() if (result is Success) { return Result.success() } diff --git a/engine/src/main/java/com/google/android/fhir/sync/ResourceSynchronizer.kt b/engine/src/main/java/com/google/android/fhir/sync/ResourceSynchronizer.kt deleted file mode 100644 index 31bcd71365..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/ResourceSynchronizer.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * 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 com.google.android.fhir.sync - -import com.google.android.fhir.db.Database -import com.google.android.fhir.db.impl.entities.SyncedResourceEntity -import com.google.android.fhir.sync.SyncData.Companion.LAST_UPDATED_ASC_VALUE -import com.google.android.fhir.sync.SyncData.Companion.LAST_UPDATED_KEY -import com.google.android.fhir.sync.SyncData.Companion.SORT_KEY -import com.google.android.fhir.toTimeZoneString -import java.io.IOException -import org.hl7.fhir.r4.model.Bundle - -/** Class that synchronises only one resource. */ -class ResourceSynchronizer( - private val syncData: SyncData, - private val dataSource: FhirDataSource, - private val database: Database, - retry: Boolean -) { - private var retrySync = retry - - suspend fun sync() { - var nextUrl: String? = getInitialUrl() - try { - while (nextUrl != null) { - val bundle = dataSource.loadData(nextUrl) - nextUrl = bundle.link.firstOrNull { component -> component.relation == "next" }?.url - if (bundle.type == Bundle.BundleType.SEARCHSET) { - saveSyncedResource(bundle) - } - } - } catch (exception: IOException) { - if (retrySync) { - retrySync = false - sync() - } else { - // propagate the exception upstream - throw exception - } - } - } - - private suspend fun getInitialUrl(): String? { - val updatedSyncData = syncData.addSortParam().addLastUpdateDate() - return "${updatedSyncData.resourceType.name}?${updatedSyncData.concatParams()}" - } - - private fun SyncData.addSortParam(): SyncData { - if (params.containsKey(SORT_KEY)) { - return this - } - val newParams = params.toMutableMap() - newParams[SORT_KEY] = LAST_UPDATED_ASC_VALUE - return SyncData(resourceType, newParams) - } - - private suspend fun SyncData.addLastUpdateDate(): SyncData { - val lastUpdate = database.lastUpdate(resourceType) - if (lastUpdate == null) { - return this - } - val newParams = params.toMutableMap() - newParams[LAST_UPDATED_KEY] = "gt$lastUpdate" - return SyncData(resourceType, newParams) - } - - private suspend fun saveSyncedResource(bundle: Bundle) { - val resources = bundle.entry.map { it.resource } - if (resources.isNotEmpty()) { - val mostRecentResource = resources[resources.lastIndex] - database.insertSyncedResources( - SyncedResourceEntity( - syncData.resourceType, - mostRecentResource.meta.lastUpdated.toTimeZoneString() - ), - resources - ) - } - } -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt new file mode 100644 index 0000000000..8a12f74238 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -0,0 +1,42 @@ +package com.google.android.fhir.sync + +import android.content.Context +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import com.google.android.fhir.FhirEngine + +object Sync { + suspend fun oneTimeSync( + fhirEngine: FhirEngine, + dataSource: DataSource, + resourceSyncParams: ResourceSyncParams + ): Result { + return FhirSynchronizer(fhirEngine, dataSource, resourceSyncParams).download() + } + + inline fun periodicSync( + context: Context, + periodicSyncConfiguration: PeriodicSyncConfiguration + ) { + val periodicWorkRequest = + PeriodicWorkRequestBuilder( + periodicSyncConfiguration.repeat.interval, + periodicSyncConfiguration.repeat.timeUnit + ) + .setConstraints(periodicSyncConfiguration.syncConstraints) + .build() + WorkManager.getInstance(context) + .enqueueUniquePeriodicWork( + SyncWorkType.DOWNLOAD.workerName, + ExistingPeriodicWorkPolicy.KEEP, + periodicWorkRequest + ) + } +} + +/** Defines different types of synchronisation workers: download and upload */ +enum class SyncWorkType(val workerName: String) { + DOWNLOAD("download"), + UPLOAD("upload") +} \ No newline at end of file diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncConfiguration.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncConfiguration.kt deleted file mode 100644 index 4e664eb412..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncConfiguration.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * 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 com.google.android.fhir.sync - -/** Configuration for synchronization. */ -data class SyncConfiguration( - /** Data that needs to be synchronised */ - val syncData: List = emptyList(), - /** - * true if the SDK needs to retry a failed sync attempt, false otherwise If this is set to true, - * then the result of the sync will be reported after the retry. - */ - val retry: Boolean = false -) diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncData.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncData.kt deleted file mode 100644 index d43d068e92..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncData.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * 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 com.google.android.fhir.sync - -import java.net.URLEncoder -import org.hl7.fhir.r4.model.ResourceType - -fun SyncData.concatParams(): String { - return this.params.entries.joinToString("&") { (key, value) -> - "$key=${URLEncoder.encode(value, "UTF-8")}" - } -} - -/** - * Class that holds what type of resources we need to synchronise and what are the parameters of - * that type. e.g. we only want to synchronise patients that live in United States - * `SyncData(ResourceType.Patient, mapOf("address-country" to "United States")` - */ -data class SyncData(val resourceType: ResourceType, val params: Map = emptyMap()) { - companion object { - const val SORT_KEY = "_sort" - const val LAST_UPDATED_KEY = "_lastUpdated" - const val ADDRESS_COUNTRY_KEY = "address-country" - const val LAST_UPDATED_ASC_VALUE = "_lastUpdated" - } -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncWorkType.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncWorkType.kt deleted file mode 100644 index b3e1d6e73a..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncWorkType.kt +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * 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 com.google.android.fhir.sync - -/** Defines different types of synchronisation workers: download and upload */ -internal enum class SyncWorkType(val workerName: String) { - DOWNLOAD("download"), - UPLOAD("upload") -} diff --git a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt index 36b5ad28be..1ed28374ef 100644 --- a/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt +++ b/engine/src/test/java/com/google/android/fhir/impl/FhirEngineImplTest.kt @@ -21,7 +21,6 @@ import com.google.android.fhir.FhirServices.Companion.builder import com.google.android.fhir.ResourceNotFoundException import com.google.android.fhir.db.ResourceNotFoundInDbException import com.google.android.fhir.resource.TestingUtils -import com.google.android.fhir.sync.FhirDataSource import com.google.common.truth.Truth import kotlinx.coroutines.runBlocking import org.hl7.fhir.r4.model.Bundle diff --git a/reference/src/main/java/com/google/android/fhir/reference/CqlActivityViewModel.kt b/reference/src/main/java/com/google/android/fhir/reference/CqlActivityViewModel.kt deleted file mode 100644 index ed1ba0788b..0000000000 --- a/reference/src/main/java/com/google/android/fhir/reference/CqlActivityViewModel.kt +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * 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 com.google.android.fhir.reference - -import android.util.Log -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.sync.SyncConfiguration -import com.google.android.fhir.sync.SyncData -import kotlinx.coroutines.launch -import org.hl7.fhir.r4.model.ResourceType - -class CqlActivityViewModel(private val fhirEngine: FhirEngine) : ViewModel() { - - init { - requestPatients() - } - - private fun requestPatients() { - viewModelScope.launch { - val syncData = - listOf( - SyncData( - // For the purpose of demo, sync patients that live in Nairobi. - resourceType = ResourceType.Patient, - // add "_revinclude" to "Observation:subject" to return Observations for - // the patients. - params = mapOf("address-city" to "NAIROBI") - ) - ) - - val syncConfig = SyncConfiguration(syncData = syncData) - val result = fhirEngine.sync(syncConfig) - Log.d("CqlActivityViewModel", "sync result: $result") - } - } -} - -class CqlLoadActivityViewModelFactory(private val fhirEngine: FhirEngine) : - ViewModelProvider.Factory { - override fun create(modelClass: Class): T { - if (modelClass.isAssignableFrom(CqlActivityViewModel::class.java)) { - return CqlActivityViewModel(fhirEngine) as T - } - throw IllegalArgumentException("Unknown ViewModel class") - } -} diff --git a/reference/src/main/java/com/google/android/fhir/reference/FhirApplication.kt b/reference/src/main/java/com/google/android/fhir/reference/FhirApplication.kt index 725546081a..602a527655 100644 --- a/reference/src/main/java/com/google/android/fhir/reference/FhirApplication.kt +++ b/reference/src/main/java/com/google/android/fhir/reference/FhirApplication.kt @@ -18,44 +18,15 @@ package com.google.android.fhir.reference import android.app.Application import android.content.Context -import androidx.work.Constraints -import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineBuilder -import com.google.android.fhir.reference.api.HapiFhirService.Companion.create -import com.google.android.fhir.reference.data.FhirPeriodicSyncWorker -import com.google.android.fhir.reference.data.HapiFhirResourceDataSource -import com.google.android.fhir.sync.FhirDataSource -import com.google.android.fhir.sync.PeriodicSyncConfiguration -import com.google.android.fhir.sync.RepeatInterval -import com.google.android.fhir.sync.SyncConfiguration -import com.google.android.fhir.sync.SyncData -import java.util.concurrent.TimeUnit -import org.hl7.fhir.r4.model.ResourceType class FhirApplication : Application() { - // only initiate the FhirEngine when used for the first time, not when the app is created private val fhirEngine: FhirEngine by lazy { constructFhirEngine() } private fun constructFhirEngine(): FhirEngine { - val parser = FhirContext.forR4().newJsonParser() - val service = create(parser) - val params = mutableMapOf("address-city" to "NAIROBI") - val syncData: MutableList = ArrayList() - syncData.add(SyncData(ResourceType.Patient, params)) - val configuration = SyncConfiguration(syncData, false) - val periodicSyncConfiguration = - PeriodicSyncConfiguration( - syncConfiguration = configuration, - syncConstraints = Constraints.Builder().build(), - periodicSyncWorker = FhirPeriodicSyncWorker::class.java, - repeat = RepeatInterval(interval = 1, timeUnit = TimeUnit.HOURS) - ) - val dataSource: FhirDataSource = HapiFhirResourceDataSource(service) - return FhirEngineBuilder(dataSource, this) - .periodicSyncConfiguration(periodicSyncConfiguration) - .build() + return FhirEngineBuilder(this).build() } companion object { diff --git a/reference/src/main/java/com/google/android/fhir/reference/PatientListActivity.kt b/reference/src/main/java/com/google/android/fhir/reference/PatientListActivity.kt index c7936d067f..7fe6a7f4a2 100644 --- a/reference/src/main/java/com/google/android/fhir/reference/PatientListActivity.kt +++ b/reference/src/main/java/com/google/android/fhir/reference/PatientListActivity.kt @@ -26,9 +26,21 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.view.menu.MenuBuilder import androidx.appcompat.widget.Toolbar import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView +import androidx.work.Constraints +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.FhirEngine import com.google.android.fhir.reference.FhirApplication.Companion.fhirEngine +import com.google.android.fhir.reference.api.HapiFhirService +import com.google.android.fhir.reference.data.FhirPeriodicSyncWorker +import com.google.android.fhir.reference.data.HapiFhirResourceDataSource +import com.google.android.fhir.sync.PeriodicSyncConfiguration +import com.google.android.fhir.sync.RepeatInterval +import com.google.android.fhir.sync.Sync +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.launch +import org.hl7.fhir.r4.model.ResourceType /** An activity representing a list of Patients. */ class PatientListActivity() : AppCompatActivity() { @@ -40,12 +52,28 @@ class PatientListActivity() : AppCompatActivity() { Log.d("PatientListActivity", "onCreate() called") setContentView(R.layout.activity_patient_list) + Sync.periodicSync( + this, + PeriodicSyncConfiguration( + syncConstraints = Constraints.Builder().build(), + repeat = RepeatInterval(interval = 1, timeUnit = TimeUnit.MINUTES) + ) + ) + val toolbar = findViewById(R.id.toolbar) setSupportActionBar(toolbar) toolbar.title = title fhirEngine = fhirEngine(this) + lifecycleScope.launch { + Sync.oneTimeSync( + fhirEngine, + HapiFhirResourceDataSource(HapiFhirService.create(FhirContext.forR4().newJsonParser())), + mapOf(ResourceType.Patient to mapOf("address-city" to "NAIROBI")) + ) + } + patientListViewModel = ViewModelProvider(this, PatientListViewModelFactory(this.application, fhirEngine)) .get(PatientListViewModel::class.java) diff --git a/reference/src/main/java/com/google/android/fhir/reference/data/FhirPeriodicSyncWorker.kt b/reference/src/main/java/com/google/android/fhir/reference/data/FhirPeriodicSyncWorker.kt index 1bf6812a97..762d83a928 100644 --- a/reference/src/main/java/com/google/android/fhir/reference/data/FhirPeriodicSyncWorker.kt +++ b/reference/src/main/java/com/google/android/fhir/reference/data/FhirPeriodicSyncWorker.kt @@ -18,14 +18,19 @@ package com.google.android.fhir.reference.data import android.content.Context import androidx.work.WorkerParameters -import com.google.android.fhir.FhirEngine +import ca.uhn.fhir.context.FhirContext import com.google.android.fhir.reference.FhirApplication +import com.google.android.fhir.reference.api.HapiFhirService import com.google.android.fhir.sync.PeriodicSyncWorker +import org.hl7.fhir.r4.model.ResourceType class FhirPeriodicSyncWorker(appContext: Context, workerParams: WorkerParameters) : PeriodicSyncWorker(appContext, workerParams) { - override fun getFhirEngine(): FhirEngine { - return FhirApplication.fhirEngine(applicationContext) - } + override fun getSyncData() = mapOf(ResourceType.Patient to mapOf("address-city" to "NAIROBI")) + + override fun getDataSource() = + HapiFhirResourceDataSource(HapiFhirService.create(FhirContext.forR4().newJsonParser())) + + override fun getFhirEngine() = FhirApplication.fhirEngine(applicationContext) } diff --git a/reference/src/main/java/com/google/android/fhir/reference/data/HapiFhirResourceDataSource.kt b/reference/src/main/java/com/google/android/fhir/reference/data/HapiFhirResourceDataSource.kt index 7b380ba81a..d61b7d0f6e 100644 --- a/reference/src/main/java/com/google/android/fhir/reference/data/HapiFhirResourceDataSource.kt +++ b/reference/src/main/java/com/google/android/fhir/reference/data/HapiFhirResourceDataSource.kt @@ -17,7 +17,7 @@ package com.google.android.fhir.reference.data import com.google.android.fhir.reference.api.HapiFhirService -import com.google.android.fhir.sync.FhirDataSource +import com.google.android.fhir.sync.DataSource import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import org.hl7.fhir.r4.model.Bundle @@ -25,7 +25,7 @@ import org.hl7.fhir.r4.model.OperationOutcome import org.hl7.fhir.r4.model.Resource /** Implementation of the [FhirDataSource] that communicates with hapi fhir. */ -class HapiFhirResourceDataSource(private val service: HapiFhirService) : FhirDataSource { +class HapiFhirResourceDataSource(private val service: HapiFhirService) : DataSource { override suspend fun loadData(path: String): Bundle { return service.getResource(path)