diff --git a/build.gradle.kts b/build.gradle.kts index c501999343..4055170bda 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() @@ -10,6 +11,7 @@ buildscript { classpath(Plugins.kotlinGradlePlugin) classpath(Plugins.navSafeArgsGradlePlugin) classpath(Plugins.spotlessGradlePlugin) + 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 60a973c45e..53cc5bc6ae 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/core/build.gradle.kts b/core/build.gradle.kts index 6bfd13bd72..9364edd7f5 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -86,7 +86,6 @@ dependencies { implementation(Dependencies.Room.runtime) implementation(Dependencies.Room.ktx) - implementation(Dependencies.Androidx.workRuntimeKtx) implementation(Dependencies.Kotlin.stdlib) implementation(Dependencies.caffeine) implementation(Dependencies.guava) diff --git a/core/src/main/java/com/google/android/fhir/FhirEngine.kt b/core/src/main/java/com/google/android/fhir/FhirEngine.kt index ba920509d2..b3a76d2ec5 100644 --- a/core/src/main/java/com/google/android/fhir/FhirEngine.kt +++ b/core/src/main/java/com/google/android/fhir/FhirEngine.kt @@ -17,10 +17,8 @@ package com.google.android.fhir 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,17 +53,9 @@ interface FhirEngine { */ suspend fun remove(clazz: Class, id: String) - /** - * One time sync. - * - * @param syncConfiguration - * - configuration of data that needs to be synchronised - */ - suspend fun sync(syncConfiguration: SyncConfiguration): Result - - suspend fun periodicSync(): Result + suspend fun search(search: Search): List - fun updatePeriodicSyncConfiguration(syncConfig: PeriodicSyncConfiguration) + suspend fun syncDownload(download: suspend (suspend (ResourceType) -> String?) -> List) - suspend fun search(search: Search): List + suspend fun syncUpload(upload: (suspend (List) -> Unit)?) } diff --git a/core/src/main/java/com/google/android/fhir/FhirEngineBuilder.kt b/core/src/main/java/com/google/android/fhir/FhirEngineBuilder.kt index c9a12cf7e6..feb00b68c1 100644 --- a/core/src/main/java/com/google/android/fhir/FhirEngineBuilder.kt +++ b/core/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/core/src/main/java/com/google/android/fhir/FhirServices.kt b/core/src/main/java/com/google/android/fhir/FhirServices.kt index c86eb46a95..e2de00cfc9 100644 --- a/core/src/main/java/com/google/android/fhir/FhirServices.kt +++ b/core/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/core/src/main/java/com/google/android/fhir/db/Database.kt b/core/src/main/java/com/google/android/fhir/db/Database.kt index c4c52ca550..a44cb28b67 100644 --- a/core/src/main/java/com/google/android/fhir/db/Database.kt +++ b/core/src/main/java/com/google/android/fhir/db/Database.kt @@ -24,7 +24,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. @@ -71,7 +71,7 @@ interface Database { * @param syncedResourceEntity The synced resource */ suspend fun insertSyncedResources( - syncedResourceEntity: SyncedResourceEntity, + syncedResourceEntity: List, resources: List ) diff --git a/core/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt b/core/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt index e950ab272d..cdfc41db2e 100644 --- a/core/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt +++ b/core/src/main/java/com/google/android/fhir/db/impl/DatabaseImpl.kt @@ -91,10 +91,10 @@ internal class DatabaseImpl(context: Context, private val iParser: IParser, data @Transaction override suspend fun insertSyncedResources( - syncedResourceEntity: SyncedResourceEntity, + syncedResourceEntity: List, resources: List ) { - syncedResourceDao.insert(syncedResourceEntity) + syncedResourceDao.insertAll(syncedResourceEntity) insertRemote(*resources.toTypedArray()) } diff --git a/core/src/main/java/com/google/android/fhir/db/impl/dao/SyncedResourceDao.kt b/core/src/main/java/com/google/android/fhir/db/impl/dao/SyncedResourceDao.kt index df00833477..f53d5ddb5e 100644 --- a/core/src/main/java/com/google/android/fhir/db/impl/dao/SyncedResourceDao.kt +++ b/core/src/main/java/com/google/android/fhir/db/impl/dao/SyncedResourceDao.kt @@ -20,7 +20,9 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import ca.uhn.fhir.rest.annotation.Transaction import com.google.android.fhir.db.impl.entities.SyncedResourceEntity +import org.hl7.fhir.r4.model.Resource import org.hl7.fhir.r4.model.ResourceType @Dao @@ -28,6 +30,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/core/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt b/core/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt index 1d773906fc..08b50029c6 100644 --- a/core/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt +++ b/core/src/main/java/com/google/android/fhir/impl/FhirEngineImpl.kt @@ -17,37 +17,21 @@ 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.db.Database import com.google.android.fhir.db.ResourceNotFoundInDbException +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,49 +53,21 @@ constructor( database.delete(clazz, id) } - override suspend fun sync(syncConfiguration: SyncConfiguration): Result { - return FhirSynchronizer(syncConfiguration, dataSource, database).sync() - } - - 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) + override suspend fun syncDownload(download: suspend (suspend (ResourceType) -> String?) -> List) { + val stuff = download(database::lastUpdate) + val timeStamps = + stuff.groupBy { it.resourceType }.entries.map { + SyncedResourceEntity(it.key, it.value.last().meta.lastUpdated.toTimeZoneString()) + } + database.insertSyncedResources(timeStamps, stuff) } - private fun triggerInitialDownload(syncConfig: PeriodicSyncConfiguration) { - setupDownload(syncConfig = syncConfig, withInitialDelay = false) + override suspend fun syncUpload(upload: (suspend (List) -> Unit)?) { + TODO("Not yet implemented") } - 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() - } - - WorkManager.getInstance(context) - .enqueueUniqueWork(SyncWorkType.DOWNLOAD.workerName, ExistingWorkPolicy.KEEP, downloadRequest) - } } diff --git a/core/src/main/java/com/google/android/fhir/search/MoreSearch.kt b/core/src/main/java/com/google/android/fhir/search/MoreSearch.kt index 825080d851..5c0129d791 100644 --- a/core/src/main/java/com/google/android/fhir/search/MoreSearch.kt +++ b/core/src/main/java/com/google/android/fhir/search/MoreSearch.kt @@ -20,7 +20,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/core/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt b/core/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt deleted file mode 100644 index 0bbf924807..0000000000 --- a/core/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt +++ /dev/null @@ -1,52 +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 org.hl7.fhir.r4.model.ResourceType - -sealed class Result { - object Success : Result() - data class Error(val exceptions: List) : Result() -} - -data class ResourceSyncException(val resourceType: ResourceType, val exception: 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 -) { - suspend fun sync(): Result { - val exceptions = mutableListOf() - syncConfiguration.syncData.forEach { syncData -> - val resourceSynchroniser = - ResourceSynchronizer(syncData, dataSource, database, syncConfiguration.retry) - try { - resourceSynchroniser.sync() - } catch (exception: Exception) { - exceptions.add(ResourceSyncException(syncData.resourceType, exception)) - } - } - if (exceptions.isEmpty()) { - return Result.Success - } else { - return Result.Error(exceptions) - } - } -} diff --git a/core/src/main/java/com/google/android/fhir/sync/ResourceSynchronizer.kt b/core/src/main/java/com/google/android/fhir/sync/ResourceSynchronizer.kt deleted file mode 100644 index 31bcd71365..0000000000 --- a/core/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/core/src/main/java/com/google/android/fhir/sync/SyncConfiguration.kt b/core/src/main/java/com/google/android/fhir/sync/SyncConfiguration.kt deleted file mode 100644 index 4e664eb412..0000000000 --- a/core/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/core/src/main/java/com/google/android/fhir/sync/SyncData.kt b/core/src/main/java/com/google/android/fhir/sync/SyncData.kt deleted file mode 100644 index d43d068e92..0000000000 --- a/core/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/core/src/main/java/com/google/android/fhir/sync/SyncWorkType.kt b/core/src/main/java/com/google/android/fhir/sync/SyncWorkType.kt deleted file mode 100644 index b3e1d6e73a..0000000000 --- a/core/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/cqlreference/build.gradle.kts b/cqlreference/build.gradle.kts index 06852407e8..57ff0e1712 100644 --- a/cqlreference/build.gradle.kts +++ b/cqlreference/build.gradle.kts @@ -63,7 +63,6 @@ dependencies { implementation(Dependencies.Androidx.appCompat) implementation(Dependencies.Androidx.constraintLayout) - implementation(Dependencies.Androidx.workRuntimeKtx) implementation(Dependencies.Cql.cqlEngine) implementation(Dependencies.Cql.cqlEngineFhir) implementation(Dependencies.Kotlin.androidxCoreKtx) diff --git a/reference/build.gradle.kts b/reference/build.gradle.kts index a1b98211ba..02edf5b157 100644 --- a/reference/build.gradle.kts +++ b/reference/build.gradle.kts @@ -48,6 +48,7 @@ configurations { } dependencies { + implementation(project(mapOf("path" to ":sync"))) androidTestImplementation(Dependencies.AndroidxTest.extJunit) androidTestImplementation(Dependencies.Espresso.espressoCore) @@ -74,6 +75,7 @@ dependencies { implementation(Dependencies.material) implementation(project(path = ":core")) + implementation(project(path = ":sync")) testImplementation(Dependencies.junit) } 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..da847ed871 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,22 @@ 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 kotlinx.coroutines.runBlocking +import org.hl7.fhir.r4.model.ResourceType /** An activity representing a list of Patients. */ class PatientListActivity() : AppCompatActivity() { @@ -36,6 +49,14 @@ class PatientListActivity() : AppCompatActivity() { private lateinit var patientListViewModel: PatientListViewModel override fun onCreate(savedInstanceState: Bundle?) { + Sync.periodicSync( + this, + PeriodicSyncConfiguration( + syncConstraints = Constraints.Builder().build(), + repeat = RepeatInterval(interval = 1, timeUnit = TimeUnit.MINUTES) + ) + ) + super.onCreate(savedInstanceState) Log.d("PatientListActivity", "onCreate() called") setContentView(R.layout.activity_patient_list) @@ -46,6 +67,14 @@ class PatientListActivity() : AppCompatActivity() { 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..94568f081c 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,18 @@ 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 getSyncData() = mapOf(ResourceType.Patient to mapOf("address-city" to "NAIROBI")) - override fun getFhirEngine(): FhirEngine { - return FhirApplication.fhirEngine(applicationContext) - } + 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 2a5a17376a..973a5902f0 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,12 +17,11 @@ 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 org.hl7.fhir.r4.model.Bundle -/** Implementation of the [FhirDataSource] that communicates with hapi fhir. */ -class HapiFhirResourceDataSource(private val service: HapiFhirService) : FhirDataSource { - +/** Implementation of the [DataSource] that communicates with hapi fhir. */ +class HapiFhirResourceDataSource(private val service: HapiFhirService) : DataSource { override suspend fun loadData(path: String): Bundle { return service.getResource(path) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4b60462781..13e1a592f2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,5 +1,11 @@ -include (":core") -include (":cqlreference") -include (":datacapture") -include (":datacapturegallery") -include (":reference") +include(":core") + +include(":cqlreference") + +include(":datacapture") + +include(":datacapturegallery") + +include(":reference") + +include(":sync") diff --git a/sync/.gitignore b/sync/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/sync/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sync/build.gradle.kts b/sync/build.gradle.kts new file mode 100644 index 0000000000..0f42e2ad08 --- /dev/null +++ b/sync/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id(Plugins.BuildPlugins.androidLib) + id(Plugins.BuildPlugins.kotlinAndroid) + id(Plugins.BuildPlugins.kotlinKapt) +} + +android { + compileSdkVersion(Sdk.compileSdk) + defaultConfig { + minSdkVersion(Sdk.minSdk) + targetSdkVersion(Sdk.targetSdk) + versionCode = 1 + versionName = "1.0" + + testInstrumentationRunner(Dependencies.androidJunitRunner) + } + + buildTypes { + getByName("release") { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro") + } + } + compileOptions { + // Flag to enable support for the new language APIs + // See https = //developer.android.com/studio/write/java8-support + isCoreLibraryDesugaringEnabled = true + // Sets Java compatibility to Java 8 + // See https = //developer.android.com/studio/write/java8-support + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { jvmTarget = JavaVersion.VERSION_1_8.toString() } +} + +dependencies { + androidTestImplementation(Dependencies.AndroidxTest.extJunit) + + api(Dependencies.hapiFhirStructuresR4) + + implementation(Dependencies.Kotlin.stdlib) + implementation(Dependencies.Androidx.workRuntimeKtx) + implementation(project(path = ":core")) + + testImplementation(Dependencies.junit) +} diff --git a/sync/proguard-rules.pro b/sync/proguard-rules.pro new file mode 100644 index 0000000000..ff59496d81 --- /dev/null +++ b/sync/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.kts. +# +# 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/sync/src/androidTest/java/com/google/android/fhir/sync/ExampleInstrumentedTest.kt b/sync/src/androidTest/java/com/google/android/fhir/sync/ExampleInstrumentedTest.kt new file mode 100644 index 0000000000..e91d8ccfd3 --- /dev/null +++ b/sync/src/androidTest/java/com/google/android/fhir/sync/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.google.android.fhir.sync + +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("com.google.android.fhir.sync.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/sync/src/main/AndroidManifest.xml b/sync/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..df95bc33c0 --- /dev/null +++ b/sync/src/main/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/core/src/main/java/com/google/android/fhir/sync/PeriodicSyncConfiguration.kt b/sync/src/main/java/com/google/android/fhir/sync/Config.kt similarity index 53% rename from core/src/main/java/com/google/android/fhir/sync/PeriodicSyncConfiguration.kt rename to sync/src/main/java/com/google/android/fhir/sync/Config.kt index cbfb95dbb5..4cf86ca6c5 100644 --- a/core/src/main/java/com/google/android/fhir/sync/PeriodicSyncConfiguration.kt +++ b/sync/src/main/java/com/google/android/fhir/sync/Config.kt @@ -17,20 +17,44 @@ 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 + * `SyncData(ResourceType.Patient, mapOf("address-country" to "United States")` + */ +typealias ParamMap = Map +typealias SyncData = 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 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 +) /** 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 ) @@ -42,3 +66,9 @@ data class RepeatInterval( /** 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")}" + } +} diff --git a/core/src/main/java/com/google/android/fhir/sync/FhirDataSource.kt b/sync/src/main/java/com/google/android/fhir/sync/DataSource.kt similarity index 97% rename from core/src/main/java/com/google/android/fhir/sync/FhirDataSource.kt rename to sync/src/main/java/com/google/android/fhir/sync/DataSource.kt index fbaddde706..a7f21cfeb1 100644 --- a/core/src/main/java/com/google/android/fhir/sync/FhirDataSource.kt +++ b/sync/src/main/java/com/google/android/fhir/sync/DataSource.kt @@ -22,7 +22,7 @@ import org.hl7.fhir.r4.model.Bundle * 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/sync/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt b/sync/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt new file mode 100644 index 0000000000..911875a80d --- /dev/null +++ b/sync/src/main/java/com/google/android/fhir/sync/FhirSynchronizer.kt @@ -0,0 +1,86 @@ +/* + * 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.FhirEngine +import java.io.IOException +import org.hl7.fhir.r4.model.Bundle +import org.hl7.fhir.r4.model.Resource +import org.hl7.fhir.r4.model.ResourceType + +sealed class Result { + object Success : Result() + data class Error(val exceptions: List) : Result() +} + +data class ResourceSyncException(val resourceType: ResourceType, val exception: Exception) + +/** Class that helps synchronize the data source and save it in the local database */ +class FhirSynchronizer( + private val fhirEngine: FhirEngine, + private val dataSource: DataSource, + private val syncData: SyncData +) { + suspend fun sync(): Result { + val exceptions = mutableListOf() + syncData.forEach { + try { + sync(it.key, it.value) + } catch (exception: Exception) { + exceptions.add(ResourceSyncException(it.key, exception)) + } + } + return if (exceptions.isEmpty()) { + Result.Success + } else { + Result.Error(exceptions) + } + } + + private suspend fun sync(resourceType: ResourceType, params: ParamMap) { + fhirEngine.syncDownload { + var nextUrl = getInitialUrl(resourceType, params, it(resourceType)) + val result = mutableListOf() + try { + 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()}" + } +} diff --git a/core/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt b/sync/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt similarity index 75% rename from core/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt rename to sync/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt index 88e9d1ac9c..5c75f69bc5 100644 --- a/core/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt +++ b/sync/src/main/java/com/google/android/fhir/sync/PeriodicSyncWorker.kt @@ -18,6 +18,12 @@ package com.google.android.fhir.sync import android.content.Context import androidx.work.CoroutineWorker +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.Worker import androidx.work.WorkerParameters import com.google.android.fhir.FhirEngine import com.google.android.fhir.sync.Result.Success @@ -27,10 +33,12 @@ abstract class PeriodicSyncWorker(appContext: Context, workerParams: WorkerParam CoroutineWorker(appContext, workerParams) { abstract fun getFhirEngine(): FhirEngine + abstract fun getDataSource(): DataSource + abstract fun getSyncData (): SyncData override suspend fun doWork(): Result { // TODO handle retry - val result = getFhirEngine().periodicSync() + val result = FhirSynchronizer(getFhirEngine(), getDataSource(), getSyncData()).sync() if (result is Success) { return Result.success() } diff --git a/sync/src/main/java/com/google/android/fhir/sync/Sync.kt b/sync/src/main/java/com/google/android/fhir/sync/Sync.kt new file mode 100644 index 0000000000..ff0fd27b1c --- /dev/null +++ b/sync/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, + syncData: SyncData + ): Result { + return FhirSynchronizer(fhirEngine, dataSource, syncData).sync() + } + + 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") +} diff --git a/sync/src/test/java/com/google/android/fhir/sync/ExampleUnitTest.kt b/sync/src/test/java/com/google/android/fhir/sync/ExampleUnitTest.kt new file mode 100644 index 0000000000..ff88ea2d69 --- /dev/null +++ b/sync/src/test/java/com/google/android/fhir/sync/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.google.android.fhir.sync + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file