From 7433a039b318723e8cb81bdddfade8c561af1c40 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Sun, 4 Sep 2022 14:29:37 +0530 Subject: [PATCH 01/13] Remove the `SyncJob` interface --- .../android/fhir/demo/FhirApplication.kt | 6 +- .../google/android/fhir/demo/MainActivity.kt | 8 +- .../fhir/demo/MainActivityViewModel.kt | 34 +- .../android/fhir/demo/PatientListFragment.kt | 16 +- ...eriodicSyncWorker.kt => FhirSyncWorker.kt} | 2 +- .../android/fhir/sync/FhirSynchronizer.kt | 2 + .../java/com/google/android/fhir/sync/Sync.kt | 86 ++-- .../com/google/android/fhir/sync/SyncJob.kt | 44 -- .../google/android/fhir/sync/SyncJobImpl.kt | 137 ------- .../google/android/fhir/sync/SyncJobTest.kt | 376 ------------------ .../android/fhir/sync/TestSyncWorker.kt | 33 -- 11 files changed, 57 insertions(+), 687 deletions(-) rename demo/src/main/java/com/google/android/fhir/demo/data/{FhirPeriodicSyncWorker.kt => FhirSyncWorker.kt} (93%) delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/SyncJob.kt delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/SyncJobImpl.kt delete mode 100644 engine/src/test/java/com/google/android/fhir/sync/SyncJobTest.kt delete mode 100644 engine/src/test/java/com/google/android/fhir/sync/TestSyncWorker.kt diff --git a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt index 453319d811..8a6b88b919 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineConfiguration import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.ServerConfiguration -import com.google.android.fhir.demo.data.FhirPeriodicSyncWorker +import com.google.android.fhir.demo.data.FhirSyncWorker import com.google.android.fhir.sync.Sync import timber.log.Timber @@ -43,7 +43,7 @@ class FhirApplication : Application() { ServerConfiguration("https://hapi.fhir.org/baseR4/") ) ) - Sync.oneTimeSync(this) + Sync.oneTimeSync(this) } private fun constructFhirEngine(): FhirEngine { diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt index 9ce53a4e58..1b5185b470 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt @@ -24,7 +24,9 @@ import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout +import com.google.android.fhir.demo.data.FhirSyncWorker import com.google.android.fhir.demo.databinding.ActivityMainBinding +import com.google.android.fhir.sync.Sync const val MAX_RESOURCE_COUNT = 20 @@ -40,7 +42,7 @@ class MainActivity : AppCompatActivity() { initActionBar() initNavigationDrawer() observeLastSyncTime() - viewModel.updateLastSyncTimestamp() + // viewModel.updateLastSyncTimestamp() } override fun onBackPressed() { @@ -60,7 +62,7 @@ class MainActivity : AppCompatActivity() { fun openNavigationDrawer() { binding.drawer.openDrawer(GravityCompat.START) - viewModel.updateLastSyncTimestamp() + // viewModel.updateLastSyncTimestamp() } private fun initActionBar() { @@ -78,7 +80,7 @@ class MainActivity : AppCompatActivity() { private fun onNavigationItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_sync -> { - viewModel.poll() + Sync.oneTimeSync(this) true } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index dc14b17ab2..5de57c6685 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,23 +17,20 @@ package com.google.android.fhir.demo import android.app.Application -import android.text.format.DateFormat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.work.Constraints -import com.google.android.fhir.demo.data.FhirPeriodicSyncWorker +import com.google.android.fhir.demo.data.FhirSyncWorker import com.google.android.fhir.sync.PeriodicSyncConfiguration import com.google.android.fhir.sync.RepeatInterval import com.google.android.fhir.sync.State import com.google.android.fhir.sync.Sync -import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch /** View model for [MainActivity]. */ @@ -43,41 +40,20 @@ class MainActivityViewModel(application: Application, private val state: SavedSt val lastSyncTimestampLiveData: LiveData get() = _lastSyncTimestampLiveData - private val job = Sync.basicSyncJob(application.applicationContext) private val _pollState = MutableSharedFlow() val pollState: Flow get() = _pollState init { - poll() - } - - /** Requests periodic sync. */ - fun poll() { viewModelScope.launch { - job.poll( + Sync.periodicSync( + application.applicationContext, PeriodicSyncConfiguration( syncConstraints = Constraints.Builder().build(), repeat = RepeatInterval(interval = 15, timeUnit = TimeUnit.MINUTES) - ), - FhirPeriodicSyncWorker::class.java + ) ) .collect { _pollState.emit(it) } } } - - /** Emits last sync time. */ - fun updateLastSyncTimestamp() { - val formatter = - DateTimeFormatter.ofPattern( - if (DateFormat.is24HourFormat(getApplication())) formatString24 else formatString12 - ) - _lastSyncTimestampLiveData.value = - job.lastSyncTimestamp()?.toLocalDateTime()?.format(formatter) ?: "" - } - - companion object { - private const val formatString24 = "yyyy-MM-dd HH:mm:ss" - private const val formatString12 = "yyyy-MM-dd hh:mm:ss a" - } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt index 3b195e92ed..6218bfc95d 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt @@ -44,7 +44,6 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.demo.PatientListViewModel.PatientListViewModelFactory import com.google.android.fhir.demo.databinding.FragmentPatientListBinding import com.google.android.fhir.sync.State -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import timber.log.Timber @@ -91,13 +90,10 @@ class PatientListFragment : Fragment() { } ) - patientListViewModel.liveSearchedPatients.observe( - viewLifecycleOwner, - { - Timber.d("Submitting ${it.count()} patient records") - adapter.submitList(it) - } - ) + patientListViewModel.liveSearchedPatients.observe(viewLifecycleOwner) { + Timber.d("Submitting ${it.count()} patient records") + adapter.submitList(it) + } patientListViewModel.patientCount.observe( viewLifecycleOwner, @@ -164,13 +160,13 @@ class PatientListFragment : Fragment() { is State.Finished -> { Timber.i("Sync: ${it::class.java.simpleName} at ${it.result.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp() + // mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) } is State.Failed -> { Timber.i("Sync: ${it::class.java.simpleName} at ${it.result.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp() + // mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) } else -> Timber.i("Sync: Unknown state.") diff --git a/demo/src/main/java/com/google/android/fhir/demo/data/FhirPeriodicSyncWorker.kt b/demo/src/main/java/com/google/android/fhir/demo/data/FhirSyncWorker.kt similarity index 93% rename from demo/src/main/java/com/google/android/fhir/demo/data/FhirPeriodicSyncWorker.kt rename to demo/src/main/java/com/google/android/fhir/demo/data/FhirSyncWorker.kt index d7dd1f7ab8..413036c16b 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/data/FhirPeriodicSyncWorker.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/data/FhirSyncWorker.kt @@ -23,7 +23,7 @@ import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.FhirSyncWorker -class FhirPeriodicSyncWorker(appContext: Context, workerParams: WorkerParameters) : +class FhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : FhirSyncWorker(appContext, workerParams) { override fun getDownloadWorkManager(): DownloadWorkManager { 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 7f0bffba05..f1e79cfda6 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 @@ -72,6 +72,8 @@ internal class FhirSynchronizer( } private suspend fun setSyncState(result: Result): Result { + + // todo: emit this properly instead of using datastore? datastoreUtil.writeLastSyncTimestamp(result.timestamp) when (result) { 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 index 4b31e83a7c..ae85b357e9 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -17,63 +17,29 @@ package com.google.android.fhir.sync import android.content.Context +import androidx.lifecycle.asFlow import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.sync.download.DownloaderImpl -import com.google.android.fhir.sync.upload.BundleUploader -import com.google.android.fhir.sync.upload.LocalChangesPaginator -import com.google.android.fhir.sync.upload.TransactionBundleGenerator -import org.hl7.fhir.r4.model.ResourceType +import androidx.work.hasKeyWithValueOfType +import com.google.android.fhir.OffsetDateTimeTypeAdapter +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import java.time.OffsetDateTime +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.mapNotNull object Sync { - fun basicSyncJob(context: Context): SyncJob { - return SyncJobImpl(context) - } - - /** - * Does a one time sync based on [ResourceSearchParams]. Returns a [Result] that tells caller - * whether process was Success or Failure. In case of failure, caller needs to take care of the - * retry - */ - // TODO: Check if this api is required anymore since we have SyncJob.run to do the same work. - suspend fun oneTimeSync( - context: Context, - fhirEngine: FhirEngine, - downloadManager: DownloadWorkManager, - uploadConfiguration: UploadConfiguration = UploadConfiguration(), - resolver: ConflictResolver - ): Result { - return FhirEngineProvider.getDataSource(context)?.let { - FhirSynchronizer( - context, - fhirEngine, - BundleUploader( - it, - TransactionBundleGenerator.getDefault(), - LocalChangesPaginator.create(uploadConfiguration) - ), - DownloaderImpl(it, downloadManager), - resolver - ) - .synchronize() - } - ?: Result.Error( - listOf( - ResourceSyncException( - ResourceType.Bundle, - IllegalStateException( - "FhirEngineConfiguration.ServerConfiguration is not set. Call FhirEngineProvider.init to initialize with appropriate configuration." - ) - ) - ) - ) - } + val gson: Gson = + GsonBuilder() + .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeTypeAdapter().nullSafe()) + .create() /** * Starts a one time sync based on [FhirSyncWorker]. In case of a failure, [RetryConfiguration] @@ -90,22 +56,40 @@ object Sync { createOneTimeWorkRequest(retryConfiguration, W::class.java) ) } + /** * Starts a periodic sync based on [FhirSyncWorker]. It takes [PeriodicSyncConfiguration] to * determine the sync frequency and [RetryConfiguration] to guide the retry mechanism. Caller can * set [retryConfiguration] to [null] to stop retry. */ + @ExperimentalCoroutinesApi inline fun periodicSync( context: Context, periodicSyncConfiguration: PeriodicSyncConfiguration - ) { + ): Flow { + val flow = + WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkLiveData(SyncWorkType.DOWNLOAD.workerName) + .asFlow() + .flatMapConcat { it.asFlow() } + .mapNotNull { workInfo -> + workInfo.progress + .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType("StateType") } + ?.let { + val state = it.getString("StateType")!! + val stateData = it.getString("State") + gson.fromJson(stateData, Class.forName(state)) as State + } + } WorkManager.getInstance(context) .enqueueUniquePeriodicWork( SyncWorkType.DOWNLOAD.workerName, - ExistingPeriodicWorkPolicy.KEEP, + ExistingPeriodicWorkPolicy.REPLACE, createPeriodicWorkRequest(periodicSyncConfiguration, W::class.java) ) + + return flow } @PublishedApi diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncJob.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncJob.kt deleted file mode 100644 index 9e02a9af45..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncJob.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2022 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.WorkInfo -import com.google.android.fhir.FhirEngine -import java.time.OffsetDateTime -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow - -interface SyncJob { - @ExperimentalCoroutinesApi - fun poll( - periodicSyncConfiguration: PeriodicSyncConfiguration, - clazz: Class - ): Flow - - suspend fun run( - fhirEngine: FhirEngine, - downloadManager: DownloadWorkManager, - resolver: ConflictResolver, - subscribeTo: MutableSharedFlow?, - uploadConfiguration: UploadConfiguration = UploadConfiguration() - ): Result - - fun workInfoFlow(): Flow - fun stateFlow(): Flow - fun lastSyncTimestamp(): OffsetDateTime? -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncJobImpl.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncJobImpl.kt deleted file mode 100644 index 574f3c2cec..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncJobImpl.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2022 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 android.content.Context -import androidx.lifecycle.asFlow -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.hasKeyWithValueOfType -import com.google.android.fhir.DatastoreUtil -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.OffsetDateTimeTypeAdapter -import com.google.android.fhir.sync.download.DownloaderImpl -import com.google.android.fhir.sync.upload.BundleUploader -import com.google.android.fhir.sync.upload.LocalChangesPaginator -import com.google.android.fhir.sync.upload.TransactionBundleGenerator -import com.google.gson.GsonBuilder -import java.time.OffsetDateTime -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.flatMapConcat -import kotlinx.coroutines.flow.mapNotNull -import org.hl7.fhir.r4.model.ResourceType -import timber.log.Timber - -class SyncJobImpl(private val context: Context) : SyncJob { - private val syncWorkType = SyncWorkType.DOWNLOAD_UPLOAD - private val gson = - GsonBuilder() - .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeTypeAdapter().nullSafe()) - .create() - - /** Periodically sync the data with given configuration for given worker class */ - @ExperimentalCoroutinesApi - override fun poll( - periodicSyncConfiguration: PeriodicSyncConfiguration, - clazz: Class - ): Flow { - val workerUniqueName = syncWorkType.workerName - - Timber.d("Configuring polling for $workerUniqueName") - - val periodicWorkRequest = Sync.createPeriodicWorkRequest(periodicSyncConfiguration, clazz) - val workManager = WorkManager.getInstance(context) - - val flow = stateFlow() - - workManager.enqueueUniquePeriodicWork( - workerUniqueName, - ExistingPeriodicWorkPolicy.REPLACE, - periodicWorkRequest - ) - - return flow - } - - override fun stateFlow(): Flow { - return workInfoFlow().mapNotNull { convertToState(it) } - } - - override fun lastSyncTimestamp(): OffsetDateTime? { - return DatastoreUtil(context).readLastSyncTimestamp() - } - - override fun workInfoFlow(): Flow { - return WorkManager.getInstance(context) - .getWorkInfosForUniqueWorkLiveData(syncWorkType.workerName) - .asFlow() - .flatMapConcat { it.asFlow() } - } - - private fun convertToState(workInfo: WorkInfo): State? { - return workInfo.progress - .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType("StateType") } - ?.let { - val state = it.getString("StateType")!! - val stateData = it.getString("State") - gson.fromJson(stateData, Class.forName(state)) as State - } - } - - /** - * Run fhir synchronizer immediately with default sync params configured on initialization and - * subscribe to given flow - */ - override suspend fun run( - fhirEngine: FhirEngine, - downloadManager: DownloadWorkManager, - resolver: ConflictResolver, - subscribeTo: MutableSharedFlow?, - uploadConfiguration: UploadConfiguration - ): Result { - return FhirEngineProvider.getDataSource(context)?.let { - FhirSynchronizer( - context, - fhirEngine, - BundleUploader( - it, - TransactionBundleGenerator.getDefault(), - LocalChangesPaginator.create(uploadConfiguration) - ), - DownloaderImpl(it, downloadManager), - resolver - ) - .apply { if (subscribeTo != null) subscribe(subscribeTo) } - .synchronize() - } - ?: Result.Error( - listOf( - ResourceSyncException( - ResourceType.Bundle, - IllegalStateException( - "FhirEngineConfiguration.ServerConfiguration is not set. Call FhirEngineProvider.init to initialize with appropriate configuration." - ) - ) - ) - ) - } -} diff --git a/engine/src/test/java/com/google/android/fhir/sync/SyncJobTest.kt b/engine/src/test/java/com/google/android/fhir/sync/SyncJobTest.kt deleted file mode 100644 index 6f6309c002..0000000000 --- a/engine/src/test/java/com/google/android/fhir/sync/SyncJobTest.kt +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Copyright 2022 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 android.content.Context -import android.os.Build -import android.util.Log -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.core.app.ApplicationProvider -import androidx.work.Configuration -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.impl.utils.SynchronousExecutor -import androidx.work.testing.WorkManagerTestInitHelper -import com.google.android.fhir.DatastoreUtil -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.db.Database -import com.google.android.fhir.impl.FhirEngineImpl -import com.google.android.fhir.resource.TestingUtils -import com.google.common.truth.Truth.assertThat -import java.time.OffsetDateTime -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runBlockingTest -import org.hl7.fhir.r4.model.Bundle -import org.junit.After -import org.junit.Before -import org.junit.Ignore -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.MockedStatic -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.LooperMode - -@ExperimentalCoroutinesApi -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.P]) -@LooperMode(LooperMode.Mode.PAUSED) -class SyncJobTest { - private val context: Context = ApplicationProvider.getApplicationContext() - private val datastoreUtil = DatastoreUtil(context) - - private lateinit var workManager: WorkManager - private lateinit var fhirEngine: FhirEngine - - private val database = mock() - private val dataSource = mock() - - @get:Rule var instantExecutorRule = InstantTaskExecutorRule() - - private lateinit var syncJob: SyncJob - private lateinit var mock: MockedStatic - - @Before - fun setup() { - fhirEngine = FhirEngineImpl(database, context) - syncJob = Sync.basicSyncJob(context) - - val config = - Configuration.Builder() - .setMinimumLoggingLevel(Log.DEBUG) - .setExecutor(SynchronousExecutor()) - .build() - - // Initialize WorkManager for instrumentation tests. - WorkManagerTestInitHelper.initializeTestWorkManager(context, config) - workManager = WorkManager.getInstance(context) - mock = Mockito.mockStatic(FhirEngineProvider::class.java) - whenever(FhirEngineProvider.getDataSource(anyOrNull())).thenReturn(dataSource) - } - - @After - fun tearDown() { - mock.close() - } - - @Test - fun `should poll accurately with given delay`() = runBlockingTest { - val worker = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() - - // Get flows return by work manager wrapper - val workInfoFlow = syncJob.workInfoFlow() - val stateFlow = syncJob.stateFlow() - - val workInfoList = mutableListOf() - val stateList = mutableListOf() - - // Convert flows to list to assert later - val job1 = launch { workInfoFlow.toList(workInfoList) } - val job2 = launch { stateFlow.toList(stateList) } - - workManager - .enqueueUniquePeriodicWork( - SyncWorkType.DOWNLOAD_UPLOAD.workerName, - ExistingPeriodicWorkPolicy.REPLACE, - worker - ) - .result - .get() - - Thread.sleep(5000) - - assertThat(workInfoList.map { it.state }) - .containsAtLeast( - WorkInfo.State.ENQUEUED, // Waiting for turn - WorkInfo.State.RUNNING, // Worker launched - WorkInfo.State.RUNNING, // Progresses emitted Started, InProgress..State.Success - WorkInfo.State.ENQUEUED // Waiting again for next turn - ) - .inOrder() - - // States are Started, InProgress .... , Finished (Success) - assertThat(stateList.map { it::class.java }).contains(State.Finished::class.java) - - val success = (stateList[stateList.size - 1] as State.Finished).result - assertThat(success.timestamp).isEqualTo(datastoreUtil.readLastSyncTimestamp()) - - job1.cancel() - job2.cancel() - } - - @Test - fun `should run synchronizer and emit states accurately in sequence`() = runBlockingTest { - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - whenever(dataSource.download(any())) - .thenReturn(Bundle().apply { type = Bundle.BundleType.SEARCHSET }) - - val res = mutableListOf() - - val flow = MutableSharedFlow() - val job = launch { flow.collect { res.add(it) } } - - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImpl(), - AcceptRemoteConflictResolver, - flow - ) - - // State transition for successful job as below - // Started, InProgress, Finished (Success) - assertThat(res.map { it::class.java }) - .containsExactly( - State.Started::class.java, - State.InProgress::class.java, - State.Finished::class.java - ) - .inOrder() - - val success = (res[2] as State.Finished).result - - assertThat(success.timestamp).isEqualTo(datastoreUtil.readLastSyncTimestamp()) - - job.cancel() - } - - @Test - fun `should run synchronizer and emit with error accurately in sequence`() = runBlockingTest { - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - whenever(dataSource.download(any())).thenThrow(IllegalStateException::class.java) - - val res = mutableListOf() - - val flow = MutableSharedFlow() - - val job = launch { flow.collect { res.add(it) } } - - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImpl(), - AcceptRemoteConflictResolver, - flow - ) - // State transition for failed job as below - // Started, InProgress, Glitch, Failed (Error) - assertThat(res.map { it::class.java }) - .containsExactly( - State.Started::class.java, - State.InProgress::class.java, - State.Glitch::class.java, - State.Failed::class.java - ) - .inOrder() - - val error = (res[3] as State.Failed).result - - assertThat(error.timestamp).isEqualTo(datastoreUtil.readLastSyncTimestamp()) - - assertThat(error.exceptions[0].exception) - .isInstanceOf(java.lang.IllegalStateException::class.java) - - job.cancel() - } - - @Test - @Ignore("https://github.com/google/android-fhir/issues/1464") - fun `sync time should update on every sync call`() = runBlockingTest { - val worker1 = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() - - // Get flows return by work manager wrapper - val stateFlow1 = syncJob.stateFlow() - - val stateList1 = mutableListOf() - - // Convert flows to list to assert later - val job1 = launch { stateFlow1.toList(stateList1) } - - val currentTimeStamp: OffsetDateTime = OffsetDateTime.now() - workManager - .enqueueUniquePeriodicWork( - SyncWorkType.DOWNLOAD_UPLOAD.workerName, - ExistingPeriodicWorkPolicy.REPLACE, - worker1 - ) - .result - .get() - Thread.sleep(5000) - val firstSyncResult = (stateList1[stateList1.size - 1] as State.Finished).result - assertThat(firstSyncResult.timestamp).isGreaterThan(currentTimeStamp) - assertThat(datastoreUtil.readLastSyncTimestamp()!!).isGreaterThan(currentTimeStamp) - job1.cancel() - - // Run sync for second time - val worker2 = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() - val stateFlow2 = syncJob.stateFlow() - val stateList2 = mutableListOf() - val job2 = launch { stateFlow2.toList(stateList2) } - workManager - .enqueueUniquePeriodicWork( - SyncWorkType.DOWNLOAD_UPLOAD.workerName, - ExistingPeriodicWorkPolicy.REPLACE, - worker2 - ) - .result - .get() - Thread.sleep(5000) - val secondSyncResult = (stateList2[stateList2.size - 1] as State.Finished).result - assertThat(secondSyncResult.timestamp).isGreaterThan(firstSyncResult.timestamp) - assertThat(datastoreUtil.readLastSyncTimestamp()!!).isGreaterThan(firstSyncResult.timestamp) - job2.cancel() - } - - @Test - fun `while loop in download keeps running after first exception`() = runBlockingTest { - whenever(dataSource.download(any())) - .thenReturn(Bundle()) - .thenThrow(RuntimeException("test")) - .thenThrow(RuntimeException("anotherOne")) - - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - - val res = mutableListOf() - - val flow = MutableSharedFlow() - - val job = launch { flow.collect { res.add(it) } } - - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImplWithQueue( - listOf("Patient/bob", "Encounter/doc", "Observation/obs") - ), - AcceptRemoteConflictResolver, - flow - ) - - assertThat(res.map { it::class.java }) - .containsExactly( - State.Started::class.java, - State.InProgress::class.java, - State.Glitch::class.java, - State.Failed::class.java - ) - .inOrder() - - val error = (res[3] as State.Failed).result - - assertThat(error.exceptions.size).isEqualTo(2) - - assertThat(error.exceptions[0].exception).isInstanceOf(java.lang.RuntimeException::class.java) - assertThat(error.exceptions[0].exception.message).isEqualTo("test") - assertThat(error.exceptions[1].exception.message).isEqualTo("anotherOne") - - job.cancel() - } - - @Test - fun `number of resources loaded equals number of resources in TestDownloaderImpl`() = - runBlockingTest { - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - whenever(dataSource.download(any())).thenReturn(Bundle()) - - val res = mutableListOf() - - val flow = MutableSharedFlow() - - val job = launch { flow.collect { res.add(it) } } - - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImplWithQueue( - listOf("Patient/bob", "Encounter/doc", "Observation/obs") - ), - AcceptRemoteConflictResolver, - flow - ) - - assertThat(res.map { it::class.java }) - .containsExactly( - State.Started::class.java, - State.InProgress::class.java, - State.Finished::class.java - ) - .inOrder() - job.cancel() - - verify(dataSource, times(3)).download(any()) - } - - @Test - fun `should fail when there data source is null`() = runBlockingTest { - whenever(FhirEngineProvider.getDataSource(anyOrNull())).thenReturn(null) - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - whenever(dataSource.download(any())) - .thenReturn(Bundle().apply { type = Bundle.BundleType.SEARCHSET }) - - val res = mutableListOf() - val flow = MutableSharedFlow() - val job = launch { flow.collect { res.add(it) } } - - val result = - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImplWithQueue(), - AcceptRemoteConflictResolver, - flow - ) - - assertThat(res).isEmpty() - assertThat(result).isInstanceOf(Result.Error::class.java) - assertThat((result as Result.Error).exceptions.first().exception) - .isInstanceOf(IllegalStateException::class.java) - - job.cancel() - } -} diff --git a/engine/src/test/java/com/google/android/fhir/sync/TestSyncWorker.kt b/engine/src/test/java/com/google/android/fhir/sync/TestSyncWorker.kt deleted file mode 100644 index 26e04fd2b2..0000000000 --- a/engine/src/test/java/com/google/android/fhir/sync/TestSyncWorker.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2022 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 android.content.Context -import androidx.work.WorkerParameters -import com.google.android.fhir.resource.TestingUtils - -class TestSyncWorker(appContext: Context, workerParams: WorkerParameters) : - FhirSyncWorker(appContext, workerParams) { - - override fun getDataSource() = TestingUtils.TestDataSourceImpl - - override fun getFhirEngine() = TestingUtils.TestFhirEngineImpl - - override fun getDownloadWorkManager() = TestingUtils.TestDownloadManagerImpl() - - override fun getConflictResolver() = AcceptRemoteConflictResolver -} From 32ac753c18a275a4a4e1f8c332d5b7b325593685 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Sun, 4 Sep 2022 14:29:37 +0530 Subject: [PATCH 02/13] Remove the `SyncJob` interface --- .../android/fhir/demo/FhirApplication.kt | 4 +- .../google/android/fhir/demo/MainActivity.kt | 8 +- .../fhir/demo/MainActivityViewModel.kt | 34 +- .../android/fhir/demo/PatientListFragment.kt | 16 +- ...eriodicSyncWorker.kt => FhirSyncWorker.kt} | 2 +- .../android/fhir/sync/FhirSynchronizer.kt | 2 + .../java/com/google/android/fhir/sync/Sync.kt | 86 ++-- .../com/google/android/fhir/sync/SyncJob.kt | 44 -- .../google/android/fhir/sync/SyncJobImpl.kt | 137 ------- .../google/android/fhir/sync/SyncJobTest.kt | 376 ------------------ .../android/fhir/sync/TestSyncWorker.kt | 33 -- 11 files changed, 56 insertions(+), 686 deletions(-) rename demo/src/main/java/com/google/android/fhir/demo/data/{FhirPeriodicSyncWorker.kt => FhirSyncWorker.kt} (93%) delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/SyncJob.kt delete mode 100644 engine/src/main/java/com/google/android/fhir/sync/SyncJobImpl.kt delete mode 100644 engine/src/test/java/com/google/android/fhir/sync/SyncJobTest.kt delete mode 100644 engine/src/test/java/com/google/android/fhir/sync/TestSyncWorker.kt diff --git a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt index d12aa42427..2162d35724 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/FhirApplication.kt @@ -23,7 +23,7 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineConfiguration import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.ServerConfiguration -import com.google.android.fhir.demo.data.FhirPeriodicSyncWorker +import com.google.android.fhir.demo.data.FhirSyncWorker import com.google.android.fhir.sync.Sync import com.google.android.fhir.sync.remote.HttpLogger import timber.log.Timber @@ -52,7 +52,7 @@ class FhirApplication : Application() { ) ) ) - Sync.oneTimeSync(this) + Sync.oneTimeSync(this) } private fun constructFhirEngine(): FhirEngine { diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt index 9ce53a4e58..1b5185b470 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt @@ -24,7 +24,9 @@ import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout +import com.google.android.fhir.demo.data.FhirSyncWorker import com.google.android.fhir.demo.databinding.ActivityMainBinding +import com.google.android.fhir.sync.Sync const val MAX_RESOURCE_COUNT = 20 @@ -40,7 +42,7 @@ class MainActivity : AppCompatActivity() { initActionBar() initNavigationDrawer() observeLastSyncTime() - viewModel.updateLastSyncTimestamp() + // viewModel.updateLastSyncTimestamp() } override fun onBackPressed() { @@ -60,7 +62,7 @@ class MainActivity : AppCompatActivity() { fun openNavigationDrawer() { binding.drawer.openDrawer(GravityCompat.START) - viewModel.updateLastSyncTimestamp() + // viewModel.updateLastSyncTimestamp() } private fun initActionBar() { @@ -78,7 +80,7 @@ class MainActivity : AppCompatActivity() { private fun onNavigationItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_sync -> { - viewModel.poll() + Sync.oneTimeSync(this) true } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index dc14b17ab2..5de57c6685 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt @@ -1,5 +1,5 @@ /* - * Copyright 2021 Google LLC + * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,23 +17,20 @@ package com.google.android.fhir.demo import android.app.Application -import android.text.format.DateFormat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.work.Constraints -import com.google.android.fhir.demo.data.FhirPeriodicSyncWorker +import com.google.android.fhir.demo.data.FhirSyncWorker import com.google.android.fhir.sync.PeriodicSyncConfiguration import com.google.android.fhir.sync.RepeatInterval import com.google.android.fhir.sync.State import com.google.android.fhir.sync.Sync -import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch /** View model for [MainActivity]. */ @@ -43,41 +40,20 @@ class MainActivityViewModel(application: Application, private val state: SavedSt val lastSyncTimestampLiveData: LiveData get() = _lastSyncTimestampLiveData - private val job = Sync.basicSyncJob(application.applicationContext) private val _pollState = MutableSharedFlow() val pollState: Flow get() = _pollState init { - poll() - } - - /** Requests periodic sync. */ - fun poll() { viewModelScope.launch { - job.poll( + Sync.periodicSync( + application.applicationContext, PeriodicSyncConfiguration( syncConstraints = Constraints.Builder().build(), repeat = RepeatInterval(interval = 15, timeUnit = TimeUnit.MINUTES) - ), - FhirPeriodicSyncWorker::class.java + ) ) .collect { _pollState.emit(it) } } } - - /** Emits last sync time. */ - fun updateLastSyncTimestamp() { - val formatter = - DateTimeFormatter.ofPattern( - if (DateFormat.is24HourFormat(getApplication())) formatString24 else formatString12 - ) - _lastSyncTimestampLiveData.value = - job.lastSyncTimestamp()?.toLocalDateTime()?.format(formatter) ?: "" - } - - companion object { - private const val formatString24 = "yyyy-MM-dd HH:mm:ss" - private const val formatString12 = "yyyy-MM-dd hh:mm:ss a" - } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt index 3b195e92ed..6218bfc95d 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt @@ -44,7 +44,6 @@ import com.google.android.fhir.FhirEngine import com.google.android.fhir.demo.PatientListViewModel.PatientListViewModelFactory import com.google.android.fhir.demo.databinding.FragmentPatientListBinding import com.google.android.fhir.sync.State -import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import timber.log.Timber @@ -91,13 +90,10 @@ class PatientListFragment : Fragment() { } ) - patientListViewModel.liveSearchedPatients.observe( - viewLifecycleOwner, - { - Timber.d("Submitting ${it.count()} patient records") - adapter.submitList(it) - } - ) + patientListViewModel.liveSearchedPatients.observe(viewLifecycleOwner) { + Timber.d("Submitting ${it.count()} patient records") + adapter.submitList(it) + } patientListViewModel.patientCount.observe( viewLifecycleOwner, @@ -164,13 +160,13 @@ class PatientListFragment : Fragment() { is State.Finished -> { Timber.i("Sync: ${it::class.java.simpleName} at ${it.result.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp() + // mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) } is State.Failed -> { Timber.i("Sync: ${it::class.java.simpleName} at ${it.result.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - mainActivityViewModel.updateLastSyncTimestamp() + // mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) } else -> Timber.i("Sync: Unknown state.") diff --git a/demo/src/main/java/com/google/android/fhir/demo/data/FhirPeriodicSyncWorker.kt b/demo/src/main/java/com/google/android/fhir/demo/data/FhirSyncWorker.kt similarity index 93% rename from demo/src/main/java/com/google/android/fhir/demo/data/FhirPeriodicSyncWorker.kt rename to demo/src/main/java/com/google/android/fhir/demo/data/FhirSyncWorker.kt index d7dd1f7ab8..413036c16b 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/data/FhirPeriodicSyncWorker.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/data/FhirSyncWorker.kt @@ -23,7 +23,7 @@ import com.google.android.fhir.sync.AcceptLocalConflictResolver import com.google.android.fhir.sync.DownloadWorkManager import com.google.android.fhir.sync.FhirSyncWorker -class FhirPeriodicSyncWorker(appContext: Context, workerParams: WorkerParameters) : +class FhirSyncWorker(appContext: Context, workerParams: WorkerParameters) : FhirSyncWorker(appContext, workerParams) { override fun getDownloadWorkManager(): DownloadWorkManager { 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 7f0bffba05..f1e79cfda6 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 @@ -72,6 +72,8 @@ internal class FhirSynchronizer( } private suspend fun setSyncState(result: Result): Result { + + // todo: emit this properly instead of using datastore? datastoreUtil.writeLastSyncTimestamp(result.timestamp) when (result) { 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 index 4b31e83a7c..ae85b357e9 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -17,63 +17,29 @@ package com.google.android.fhir.sync import android.content.Context +import androidx.lifecycle.asFlow import androidx.work.Data import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.sync.download.DownloaderImpl -import com.google.android.fhir.sync.upload.BundleUploader -import com.google.android.fhir.sync.upload.LocalChangesPaginator -import com.google.android.fhir.sync.upload.TransactionBundleGenerator -import org.hl7.fhir.r4.model.ResourceType +import androidx.work.hasKeyWithValueOfType +import com.google.android.fhir.OffsetDateTimeTypeAdapter +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import java.time.OffsetDateTime +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.mapNotNull object Sync { - fun basicSyncJob(context: Context): SyncJob { - return SyncJobImpl(context) - } - - /** - * Does a one time sync based on [ResourceSearchParams]. Returns a [Result] that tells caller - * whether process was Success or Failure. In case of failure, caller needs to take care of the - * retry - */ - // TODO: Check if this api is required anymore since we have SyncJob.run to do the same work. - suspend fun oneTimeSync( - context: Context, - fhirEngine: FhirEngine, - downloadManager: DownloadWorkManager, - uploadConfiguration: UploadConfiguration = UploadConfiguration(), - resolver: ConflictResolver - ): Result { - return FhirEngineProvider.getDataSource(context)?.let { - FhirSynchronizer( - context, - fhirEngine, - BundleUploader( - it, - TransactionBundleGenerator.getDefault(), - LocalChangesPaginator.create(uploadConfiguration) - ), - DownloaderImpl(it, downloadManager), - resolver - ) - .synchronize() - } - ?: Result.Error( - listOf( - ResourceSyncException( - ResourceType.Bundle, - IllegalStateException( - "FhirEngineConfiguration.ServerConfiguration is not set. Call FhirEngineProvider.init to initialize with appropriate configuration." - ) - ) - ) - ) - } + val gson: Gson = + GsonBuilder() + .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeTypeAdapter().nullSafe()) + .create() /** * Starts a one time sync based on [FhirSyncWorker]. In case of a failure, [RetryConfiguration] @@ -90,22 +56,40 @@ object Sync { createOneTimeWorkRequest(retryConfiguration, W::class.java) ) } + /** * Starts a periodic sync based on [FhirSyncWorker]. It takes [PeriodicSyncConfiguration] to * determine the sync frequency and [RetryConfiguration] to guide the retry mechanism. Caller can * set [retryConfiguration] to [null] to stop retry. */ + @ExperimentalCoroutinesApi inline fun periodicSync( context: Context, periodicSyncConfiguration: PeriodicSyncConfiguration - ) { + ): Flow { + val flow = + WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkLiveData(SyncWorkType.DOWNLOAD.workerName) + .asFlow() + .flatMapConcat { it.asFlow() } + .mapNotNull { workInfo -> + workInfo.progress + .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType("StateType") } + ?.let { + val state = it.getString("StateType")!! + val stateData = it.getString("State") + gson.fromJson(stateData, Class.forName(state)) as State + } + } WorkManager.getInstance(context) .enqueueUniquePeriodicWork( SyncWorkType.DOWNLOAD.workerName, - ExistingPeriodicWorkPolicy.KEEP, + ExistingPeriodicWorkPolicy.REPLACE, createPeriodicWorkRequest(periodicSyncConfiguration, W::class.java) ) + + return flow } @PublishedApi diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncJob.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncJob.kt deleted file mode 100644 index 9e02a9af45..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncJob.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright 2022 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.WorkInfo -import com.google.android.fhir.FhirEngine -import java.time.OffsetDateTime -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow - -interface SyncJob { - @ExperimentalCoroutinesApi - fun poll( - periodicSyncConfiguration: PeriodicSyncConfiguration, - clazz: Class - ): Flow - - suspend fun run( - fhirEngine: FhirEngine, - downloadManager: DownloadWorkManager, - resolver: ConflictResolver, - subscribeTo: MutableSharedFlow?, - uploadConfiguration: UploadConfiguration = UploadConfiguration() - ): Result - - fun workInfoFlow(): Flow - fun stateFlow(): Flow - fun lastSyncTimestamp(): OffsetDateTime? -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncJobImpl.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncJobImpl.kt deleted file mode 100644 index 574f3c2cec..0000000000 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncJobImpl.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright 2022 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 android.content.Context -import androidx.lifecycle.asFlow -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.hasKeyWithValueOfType -import com.google.android.fhir.DatastoreUtil -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.OffsetDateTimeTypeAdapter -import com.google.android.fhir.sync.download.DownloaderImpl -import com.google.android.fhir.sync.upload.BundleUploader -import com.google.android.fhir.sync.upload.LocalChangesPaginator -import com.google.android.fhir.sync.upload.TransactionBundleGenerator -import com.google.gson.GsonBuilder -import java.time.OffsetDateTime -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.asFlow -import kotlinx.coroutines.flow.flatMapConcat -import kotlinx.coroutines.flow.mapNotNull -import org.hl7.fhir.r4.model.ResourceType -import timber.log.Timber - -class SyncJobImpl(private val context: Context) : SyncJob { - private val syncWorkType = SyncWorkType.DOWNLOAD_UPLOAD - private val gson = - GsonBuilder() - .registerTypeAdapter(OffsetDateTime::class.java, OffsetDateTimeTypeAdapter().nullSafe()) - .create() - - /** Periodically sync the data with given configuration for given worker class */ - @ExperimentalCoroutinesApi - override fun poll( - periodicSyncConfiguration: PeriodicSyncConfiguration, - clazz: Class - ): Flow { - val workerUniqueName = syncWorkType.workerName - - Timber.d("Configuring polling for $workerUniqueName") - - val periodicWorkRequest = Sync.createPeriodicWorkRequest(periodicSyncConfiguration, clazz) - val workManager = WorkManager.getInstance(context) - - val flow = stateFlow() - - workManager.enqueueUniquePeriodicWork( - workerUniqueName, - ExistingPeriodicWorkPolicy.REPLACE, - periodicWorkRequest - ) - - return flow - } - - override fun stateFlow(): Flow { - return workInfoFlow().mapNotNull { convertToState(it) } - } - - override fun lastSyncTimestamp(): OffsetDateTime? { - return DatastoreUtil(context).readLastSyncTimestamp() - } - - override fun workInfoFlow(): Flow { - return WorkManager.getInstance(context) - .getWorkInfosForUniqueWorkLiveData(syncWorkType.workerName) - .asFlow() - .flatMapConcat { it.asFlow() } - } - - private fun convertToState(workInfo: WorkInfo): State? { - return workInfo.progress - .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType("StateType") } - ?.let { - val state = it.getString("StateType")!! - val stateData = it.getString("State") - gson.fromJson(stateData, Class.forName(state)) as State - } - } - - /** - * Run fhir synchronizer immediately with default sync params configured on initialization and - * subscribe to given flow - */ - override suspend fun run( - fhirEngine: FhirEngine, - downloadManager: DownloadWorkManager, - resolver: ConflictResolver, - subscribeTo: MutableSharedFlow?, - uploadConfiguration: UploadConfiguration - ): Result { - return FhirEngineProvider.getDataSource(context)?.let { - FhirSynchronizer( - context, - fhirEngine, - BundleUploader( - it, - TransactionBundleGenerator.getDefault(), - LocalChangesPaginator.create(uploadConfiguration) - ), - DownloaderImpl(it, downloadManager), - resolver - ) - .apply { if (subscribeTo != null) subscribe(subscribeTo) } - .synchronize() - } - ?: Result.Error( - listOf( - ResourceSyncException( - ResourceType.Bundle, - IllegalStateException( - "FhirEngineConfiguration.ServerConfiguration is not set. Call FhirEngineProvider.init to initialize with appropriate configuration." - ) - ) - ) - ) - } -} diff --git a/engine/src/test/java/com/google/android/fhir/sync/SyncJobTest.kt b/engine/src/test/java/com/google/android/fhir/sync/SyncJobTest.kt deleted file mode 100644 index 6f6309c002..0000000000 --- a/engine/src/test/java/com/google/android/fhir/sync/SyncJobTest.kt +++ /dev/null @@ -1,376 +0,0 @@ -/* - * Copyright 2022 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 android.content.Context -import android.os.Build -import android.util.Log -import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import androidx.test.core.app.ApplicationProvider -import androidx.work.Configuration -import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequestBuilder -import androidx.work.WorkInfo -import androidx.work.WorkManager -import androidx.work.impl.utils.SynchronousExecutor -import androidx.work.testing.WorkManagerTestInitHelper -import com.google.android.fhir.DatastoreUtil -import com.google.android.fhir.FhirEngine -import com.google.android.fhir.FhirEngineProvider -import com.google.android.fhir.db.Database -import com.google.android.fhir.impl.FhirEngineImpl -import com.google.android.fhir.resource.TestingUtils -import com.google.common.truth.Truth.assertThat -import java.time.OffsetDateTime -import java.util.concurrent.TimeUnit -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.runBlockingTest -import org.hl7.fhir.r4.model.Bundle -import org.junit.After -import org.junit.Before -import org.junit.Ignore -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.mockito.MockedStatic -import org.mockito.Mockito -import org.mockito.kotlin.any -import org.mockito.kotlin.anyOrNull -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.robolectric.RobolectricTestRunner -import org.robolectric.annotation.Config -import org.robolectric.annotation.LooperMode - -@ExperimentalCoroutinesApi -@RunWith(RobolectricTestRunner::class) -@Config(sdk = [Build.VERSION_CODES.P]) -@LooperMode(LooperMode.Mode.PAUSED) -class SyncJobTest { - private val context: Context = ApplicationProvider.getApplicationContext() - private val datastoreUtil = DatastoreUtil(context) - - private lateinit var workManager: WorkManager - private lateinit var fhirEngine: FhirEngine - - private val database = mock() - private val dataSource = mock() - - @get:Rule var instantExecutorRule = InstantTaskExecutorRule() - - private lateinit var syncJob: SyncJob - private lateinit var mock: MockedStatic - - @Before - fun setup() { - fhirEngine = FhirEngineImpl(database, context) - syncJob = Sync.basicSyncJob(context) - - val config = - Configuration.Builder() - .setMinimumLoggingLevel(Log.DEBUG) - .setExecutor(SynchronousExecutor()) - .build() - - // Initialize WorkManager for instrumentation tests. - WorkManagerTestInitHelper.initializeTestWorkManager(context, config) - workManager = WorkManager.getInstance(context) - mock = Mockito.mockStatic(FhirEngineProvider::class.java) - whenever(FhirEngineProvider.getDataSource(anyOrNull())).thenReturn(dataSource) - } - - @After - fun tearDown() { - mock.close() - } - - @Test - fun `should poll accurately with given delay`() = runBlockingTest { - val worker = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() - - // Get flows return by work manager wrapper - val workInfoFlow = syncJob.workInfoFlow() - val stateFlow = syncJob.stateFlow() - - val workInfoList = mutableListOf() - val stateList = mutableListOf() - - // Convert flows to list to assert later - val job1 = launch { workInfoFlow.toList(workInfoList) } - val job2 = launch { stateFlow.toList(stateList) } - - workManager - .enqueueUniquePeriodicWork( - SyncWorkType.DOWNLOAD_UPLOAD.workerName, - ExistingPeriodicWorkPolicy.REPLACE, - worker - ) - .result - .get() - - Thread.sleep(5000) - - assertThat(workInfoList.map { it.state }) - .containsAtLeast( - WorkInfo.State.ENQUEUED, // Waiting for turn - WorkInfo.State.RUNNING, // Worker launched - WorkInfo.State.RUNNING, // Progresses emitted Started, InProgress..State.Success - WorkInfo.State.ENQUEUED // Waiting again for next turn - ) - .inOrder() - - // States are Started, InProgress .... , Finished (Success) - assertThat(stateList.map { it::class.java }).contains(State.Finished::class.java) - - val success = (stateList[stateList.size - 1] as State.Finished).result - assertThat(success.timestamp).isEqualTo(datastoreUtil.readLastSyncTimestamp()) - - job1.cancel() - job2.cancel() - } - - @Test - fun `should run synchronizer and emit states accurately in sequence`() = runBlockingTest { - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - whenever(dataSource.download(any())) - .thenReturn(Bundle().apply { type = Bundle.BundleType.SEARCHSET }) - - val res = mutableListOf() - - val flow = MutableSharedFlow() - val job = launch { flow.collect { res.add(it) } } - - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImpl(), - AcceptRemoteConflictResolver, - flow - ) - - // State transition for successful job as below - // Started, InProgress, Finished (Success) - assertThat(res.map { it::class.java }) - .containsExactly( - State.Started::class.java, - State.InProgress::class.java, - State.Finished::class.java - ) - .inOrder() - - val success = (res[2] as State.Finished).result - - assertThat(success.timestamp).isEqualTo(datastoreUtil.readLastSyncTimestamp()) - - job.cancel() - } - - @Test - fun `should run synchronizer and emit with error accurately in sequence`() = runBlockingTest { - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - whenever(dataSource.download(any())).thenThrow(IllegalStateException::class.java) - - val res = mutableListOf() - - val flow = MutableSharedFlow() - - val job = launch { flow.collect { res.add(it) } } - - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImpl(), - AcceptRemoteConflictResolver, - flow - ) - // State transition for failed job as below - // Started, InProgress, Glitch, Failed (Error) - assertThat(res.map { it::class.java }) - .containsExactly( - State.Started::class.java, - State.InProgress::class.java, - State.Glitch::class.java, - State.Failed::class.java - ) - .inOrder() - - val error = (res[3] as State.Failed).result - - assertThat(error.timestamp).isEqualTo(datastoreUtil.readLastSyncTimestamp()) - - assertThat(error.exceptions[0].exception) - .isInstanceOf(java.lang.IllegalStateException::class.java) - - job.cancel() - } - - @Test - @Ignore("https://github.com/google/android-fhir/issues/1464") - fun `sync time should update on every sync call`() = runBlockingTest { - val worker1 = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() - - // Get flows return by work manager wrapper - val stateFlow1 = syncJob.stateFlow() - - val stateList1 = mutableListOf() - - // Convert flows to list to assert later - val job1 = launch { stateFlow1.toList(stateList1) } - - val currentTimeStamp: OffsetDateTime = OffsetDateTime.now() - workManager - .enqueueUniquePeriodicWork( - SyncWorkType.DOWNLOAD_UPLOAD.workerName, - ExistingPeriodicWorkPolicy.REPLACE, - worker1 - ) - .result - .get() - Thread.sleep(5000) - val firstSyncResult = (stateList1[stateList1.size - 1] as State.Finished).result - assertThat(firstSyncResult.timestamp).isGreaterThan(currentTimeStamp) - assertThat(datastoreUtil.readLastSyncTimestamp()!!).isGreaterThan(currentTimeStamp) - job1.cancel() - - // Run sync for second time - val worker2 = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES).build() - val stateFlow2 = syncJob.stateFlow() - val stateList2 = mutableListOf() - val job2 = launch { stateFlow2.toList(stateList2) } - workManager - .enqueueUniquePeriodicWork( - SyncWorkType.DOWNLOAD_UPLOAD.workerName, - ExistingPeriodicWorkPolicy.REPLACE, - worker2 - ) - .result - .get() - Thread.sleep(5000) - val secondSyncResult = (stateList2[stateList2.size - 1] as State.Finished).result - assertThat(secondSyncResult.timestamp).isGreaterThan(firstSyncResult.timestamp) - assertThat(datastoreUtil.readLastSyncTimestamp()!!).isGreaterThan(firstSyncResult.timestamp) - job2.cancel() - } - - @Test - fun `while loop in download keeps running after first exception`() = runBlockingTest { - whenever(dataSource.download(any())) - .thenReturn(Bundle()) - .thenThrow(RuntimeException("test")) - .thenThrow(RuntimeException("anotherOne")) - - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - - val res = mutableListOf() - - val flow = MutableSharedFlow() - - val job = launch { flow.collect { res.add(it) } } - - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImplWithQueue( - listOf("Patient/bob", "Encounter/doc", "Observation/obs") - ), - AcceptRemoteConflictResolver, - flow - ) - - assertThat(res.map { it::class.java }) - .containsExactly( - State.Started::class.java, - State.InProgress::class.java, - State.Glitch::class.java, - State.Failed::class.java - ) - .inOrder() - - val error = (res[3] as State.Failed).result - - assertThat(error.exceptions.size).isEqualTo(2) - - assertThat(error.exceptions[0].exception).isInstanceOf(java.lang.RuntimeException::class.java) - assertThat(error.exceptions[0].exception.message).isEqualTo("test") - assertThat(error.exceptions[1].exception.message).isEqualTo("anotherOne") - - job.cancel() - } - - @Test - fun `number of resources loaded equals number of resources in TestDownloaderImpl`() = - runBlockingTest { - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - whenever(dataSource.download(any())).thenReturn(Bundle()) - - val res = mutableListOf() - - val flow = MutableSharedFlow() - - val job = launch { flow.collect { res.add(it) } } - - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImplWithQueue( - listOf("Patient/bob", "Encounter/doc", "Observation/obs") - ), - AcceptRemoteConflictResolver, - flow - ) - - assertThat(res.map { it::class.java }) - .containsExactly( - State.Started::class.java, - State.InProgress::class.java, - State.Finished::class.java - ) - .inOrder() - job.cancel() - - verify(dataSource, times(3)).download(any()) - } - - @Test - fun `should fail when there data source is null`() = runBlockingTest { - whenever(FhirEngineProvider.getDataSource(anyOrNull())).thenReturn(null) - whenever(database.getAllLocalChanges()).thenReturn(listOf()) - whenever(dataSource.download(any())) - .thenReturn(Bundle().apply { type = Bundle.BundleType.SEARCHSET }) - - val res = mutableListOf() - val flow = MutableSharedFlow() - val job = launch { flow.collect { res.add(it) } } - - val result = - syncJob.run( - fhirEngine, - TestingUtils.TestDownloadManagerImplWithQueue(), - AcceptRemoteConflictResolver, - flow - ) - - assertThat(res).isEmpty() - assertThat(result).isInstanceOf(Result.Error::class.java) - assertThat((result as Result.Error).exceptions.first().exception) - .isInstanceOf(IllegalStateException::class.java) - - job.cancel() - } -} diff --git a/engine/src/test/java/com/google/android/fhir/sync/TestSyncWorker.kt b/engine/src/test/java/com/google/android/fhir/sync/TestSyncWorker.kt deleted file mode 100644 index 26e04fd2b2..0000000000 --- a/engine/src/test/java/com/google/android/fhir/sync/TestSyncWorker.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Copyright 2022 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 android.content.Context -import androidx.work.WorkerParameters -import com.google.android.fhir.resource.TestingUtils - -class TestSyncWorker(appContext: Context, workerParams: WorkerParameters) : - FhirSyncWorker(appContext, workerParams) { - - override fun getDataSource() = TestingUtils.TestDataSourceImpl - - override fun getFhirEngine() = TestingUtils.TestFhirEngineImpl - - override fun getDownloadWorkManager() = TestingUtils.TestDownloadManagerImpl() - - override fun getConflictResolver() = AcceptRemoteConflictResolver -} From fc61b088081baac18d32a9e161bdcd449973eb6f Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Tue, 4 Oct 2022 15:10:18 +0100 Subject: [PATCH 03/13] Run spotless --- .../java/com/google/android/fhir/demo/PatientListFragment.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt index 6218bfc95d..5562315c5b 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt @@ -123,8 +123,7 @@ class PatientListFragment : Fragment() { } } requireActivity() - .onBackPressedDispatcher - .addCallback( + .onBackPressedDispatcher.addCallback( viewLifecycleOwner, object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { From 5131bcde929691db3314bc8e345da8e8e3f05704 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Tue, 4 Oct 2022 18:15:15 +0100 Subject: [PATCH 04/13] Use worker's class name as the unique identifier for the job --- .../android/fhir/demo/PatientListFragment.kt | 5 +-- .../java/com/google/android/fhir/sync/Sync.kt | 40 ++++++++++--------- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt index 5562315c5b..dc870e056b 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt @@ -62,10 +62,9 @@ class PatientListFragment : Fragment() { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View? { + ): View { _binding = FragmentPatientListBinding.inflate(inflater, container, false) - val view = binding.root - return view + return binding.root } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { 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 index ae85b357e9..f6328a4994 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -48,13 +48,15 @@ object Sync { inline fun oneTimeSync( context: Context, retryConfiguration: RetryConfiguration? = defaultRetryConfiguration - ) { + ): Flow { + val flow = getStateFlow(context) WorkManager.getInstance(context) .enqueueUniqueWork( - SyncWorkType.DOWNLOAD.workerName, + W::class.java.name, ExistingWorkPolicy.KEEP, createOneTimeWorkRequest(retryConfiguration, W::class.java) ) + return flow } /** @@ -67,31 +69,31 @@ object Sync { context: Context, periodicSyncConfiguration: PeriodicSyncConfiguration ): Flow { - val flow = - WorkManager.getInstance(context) - .getWorkInfosForUniqueWorkLiveData(SyncWorkType.DOWNLOAD.workerName) - .asFlow() - .flatMapConcat { it.asFlow() } - .mapNotNull { workInfo -> - workInfo.progress - .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType("StateType") } - ?.let { - val state = it.getString("StateType")!! - val stateData = it.getString("State") - gson.fromJson(stateData, Class.forName(state)) as State - } - } - + val flow = getStateFlow(context) WorkManager.getInstance(context) .enqueueUniquePeriodicWork( - SyncWorkType.DOWNLOAD.workerName, + W::class.java.name, ExistingPeriodicWorkPolicy.REPLACE, createPeriodicWorkRequest(periodicSyncConfiguration, W::class.java) ) - return flow } + inline fun getStateFlow(context: Context) = + WorkManager.getInstance(context) + .getWorkInfosForUniqueWorkLiveData(W::class.java.name) + .asFlow() + .flatMapConcat { it.asFlow() } + .mapNotNull { workInfo -> + workInfo.progress + .takeIf { it.keyValueMap.isNotEmpty() && it.hasKeyWithValueOfType("StateType") } + ?.let { + val state = it.getString("StateType")!! + val stateData = it.getString("State") + gson.fromJson(stateData, Class.forName(state)) as State + } + } + @PublishedApi internal inline fun createOneTimeWorkRequest( retryConfiguration: RetryConfiguration?, From e34a31aea34b7a495187d11bdb2777657cb0d21d Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Thu, 3 Nov 2022 14:58:22 +0000 Subject: [PATCH 05/13] Refactor result API --- .../fhir/demo/MainActivityViewModel.kt | 8 ++- .../android/fhir/demo/PatientListFragment.kt | 16 ++--- .../android/fhir/sync/FhirSyncWorker.kt | 21 ++---- .../android/fhir/sync/FhirSynchronizer.kt | 65 ++++++++----------- .../java/com/google/android/fhir/sync/Sync.kt | 46 ++++++------- .../google/android/fhir/sync/SyncJobStatus.kt | 30 +++++++++ 6 files changed, 101 insertions(+), 85 deletions(-) create mode 100644 engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index 5de57c6685..ec0cf53a55 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt @@ -26,22 +26,24 @@ import androidx.work.Constraints import com.google.android.fhir.demo.data.FhirSyncWorker import com.google.android.fhir.sync.PeriodicSyncConfiguration import com.google.android.fhir.sync.RepeatInterval -import com.google.android.fhir.sync.State import com.google.android.fhir.sync.Sync +import com.google.android.fhir.sync.SyncJobStatus import java.util.concurrent.TimeUnit +import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch /** View model for [MainActivity]. */ +@OptIn(InternalCoroutinesApi::class) class MainActivityViewModel(application: Application, private val state: SavedStateHandle) : AndroidViewModel(application) { private val _lastSyncTimestampLiveData = MutableLiveData() val lastSyncTimestampLiveData: LiveData get() = _lastSyncTimestampLiveData - private val _pollState = MutableSharedFlow() - val pollState: Flow + private val _pollState = MutableSharedFlow() + val pollState: Flow get() = _pollState init { diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt index dc870e056b..ae2c2799cd 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt @@ -43,7 +43,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.fhir.FhirEngine import com.google.android.fhir.demo.PatientListViewModel.PatientListViewModelFactory import com.google.android.fhir.demo.databinding.FragmentPatientListBinding -import com.google.android.fhir.sync.State +import com.google.android.fhir.sync.SyncJobStatus import kotlinx.coroutines.launch import timber.log.Timber @@ -147,22 +147,22 @@ class PatientListFragment : Fragment() { mainActivityViewModel.pollState.collect { Timber.d("onViewCreated: pollState Got status $it") when (it) { - is State.Started -> { + is SyncJobStatus.Started -> { Timber.i("Sync: ${it::class.java.simpleName}") fadeInTopBanner() } - is State.InProgress -> { + is SyncJobStatus.InProgress -> { Timber.i("Sync: ${it::class.java.simpleName} with ${it.resourceType?.name}") fadeInTopBanner() } - is State.Finished -> { - Timber.i("Sync: ${it::class.java.simpleName} at ${it.result.timestamp}") + is SyncJobStatus.Finished -> { + Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) // mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) } - is State.Failed -> { - Timber.i("Sync: ${it::class.java.simpleName} at ${it.result.timestamp}") + is SyncJobStatus.Failed -> { + Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) // mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) @@ -209,7 +209,7 @@ class PatientListFragment : Fragment() { } } - private fun fadeOutTopBanner(state: State) { + private fun fadeOutTopBanner(state: SyncJobStatus) { if (topBanner.visibility == View.VISIBLE) { syncStatus.text = state::class.java.simpleName.uppercase() val animation = AnimationUtils.loadAnimation(topBanner.context, R.anim.fade_out) diff --git a/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt b/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt index ecb2d7d420..501acc3fb7 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/FhirSyncWorker.kt @@ -24,8 +24,6 @@ import androidx.work.workDataOf import com.google.android.fhir.FhirEngine import com.google.android.fhir.FhirEngineProvider import com.google.android.fhir.OffsetDateTimeTypeAdapter -import com.google.android.fhir.sync.Result.Error -import com.google.android.fhir.sync.Result.Success import com.google.android.fhir.sync.download.DownloaderImpl import com.google.android.fhir.sync.upload.BundleUploader import com.google.android.fhir.sync.upload.LocalChangesPaginator @@ -75,7 +73,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter ) ) - val flow = MutableSharedFlow() + val flow = MutableSharedFlow() val job = CoroutineScope(Dispatchers.IO).launch { @@ -83,7 +81,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter // now send Progress to work manager so caller app can listen setProgress(buildWorkData(it)) - if (it is State.Finished || it is State.Failed) { + if (it is SyncJobStatus.Finished || it is SyncJobStatus.Failed) { this@launch.cancel() } } @@ -104,7 +102,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter ) .apply { subscribe(flow) } .synchronize() - val output = buildOutput(result) + val output = buildWorkData(result) // await/join is needed to collect states completely kotlin.runCatching { job.join() }.onFailure(Timber::w) @@ -119,7 +117,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter */ val retries = inputData.getInt(MAX_RETRIES_ALLOWED, 0) return when { - result is Success -> { + result is SyncJobStatus.Finished -> { Result.success(output) } retries > runAttemptCount -> { @@ -131,14 +129,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter } } - private fun buildOutput(result: com.google.android.fhir.sync.Result): Data { - return when (result) { - is Success -> buildWorkData(State.Finished(result)) - is Error -> buildWorkData(State.Failed(result)) - } - } - - private fun buildWorkData(state: State): Data { + private fun buildWorkData(state: SyncJobStatus): Data { return workDataOf( // send serialized state and type so that consumer can convert it back "StateType" to state::class.java.name, @@ -151,7 +142,7 @@ abstract class FhirSyncWorker(appContext: Context, workerParams: WorkerParameter } /** - * Exclusion strategy for [Gson] that handles field exclusions for [State] returned by + * Exclusion strategy for [Gson] that handles field exclusions for [SyncJobStatus] returned by * FhirSynchronizer. It should skip serializing the exceptions to avoid exceeding WorkManager * WorkData limit * @see ) : Result() -} - -sealed class State { - object Started : State() - - data class InProgress(val resourceType: ResourceType?) : State() - data class Glitch(val exceptions: List) : State() - - data class Finished(val result: Result.Success) : State() - data class Failed(val result: Result.Error) : State() + class Success : SyncResult() + data class Error(val exceptions: List) : SyncResult() } data class ResourceSyncException(val resourceType: ResourceType, val exception: Exception) @@ -52,14 +42,14 @@ internal class FhirSynchronizer( private val downloader: Downloader, private val conflictResolver: ConflictResolver ) { - private var syncState: MutableSharedFlow? = null + private var syncState: MutableSharedFlow? = null private val datastoreUtil = DatastoreUtil(context) private fun isSubscribed(): Boolean { return syncState != null } - fun subscribe(flow: MutableSharedFlow) { + fun subscribe(flow: MutableSharedFlow) { if (isSubscribed()) { throw IllegalStateException("Already subscribed to a flow") } @@ -67,46 +57,47 @@ internal class FhirSynchronizer( this.syncState = flow } - private suspend fun setSyncState(state: State) { + private suspend fun setSyncState(state: SyncJobStatus) { syncState?.emit(state) } - private suspend fun setSyncState(result: Result): Result { - + private suspend fun setSyncState(result: SyncResult): SyncJobStatus { // todo: emit this properly instead of using datastore? datastoreUtil.writeLastSyncTimestamp(result.timestamp) - when (result) { - is Result.Success -> setSyncState(State.Finished(result)) - is Result.Error -> setSyncState(State.Failed(result)) - } + val state = + when (result) { + is SyncResult.Success -> SyncJobStatus.Finished() + is SyncResult.Error -> SyncJobStatus.Failed(result.exceptions) + } - return result + setSyncState(state) + return state } - suspend fun synchronize(): Result { - setSyncState(State.Started) + suspend fun synchronize(): SyncJobStatus { + setSyncState(SyncJobStatus.Started()) return listOf(download(), upload()) - .filterIsInstance() + .filterIsInstance() .flatMap { it.exceptions } .let { if (it.isEmpty()) { - setSyncState(Result.Success()) + setSyncState(SyncResult.Success()) } else { - setSyncState(Result.Error(it)) + setSyncState(SyncResult.Error(it)) } } } - private suspend fun download(): Result { + private suspend fun download(): SyncResult { val exceptions = mutableListOf() fhirEngine.syncDownload(conflictResolver) { flow { downloader.download(it).collect { when (it) { is DownloadState.Started -> { - setSyncState(State.InProgress(it.type)) + setSyncState(SyncJobStatus.InProgress(it.type)) } is DownloadState.Success -> { emit(it.resources) @@ -119,14 +110,14 @@ internal class FhirSynchronizer( } } return if (exceptions.isEmpty()) { - Result.Success() + SyncResult.Success() } else { - setSyncState(State.Glitch(exceptions)) - Result.Error(exceptions) + setSyncState(SyncJobStatus.Glitch(exceptions)) + SyncResult.Error(exceptions) } } - private suspend fun upload(): Result { + private suspend fun upload(): SyncResult { val exceptions = mutableListOf() fhirEngine.syncUpload { list -> flow { @@ -139,10 +130,10 @@ internal class FhirSynchronizer( } } return if (exceptions.isEmpty()) { - Result.Success() + SyncResult.Success() } else { - setSyncState(State.Glitch(exceptions)) - Result.Error(exceptions) + setSyncState(SyncJobStatus.Glitch(exceptions)) + SyncResult.Error(exceptions) } } } 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 index f6328a4994..783d5b242a 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -42,14 +42,18 @@ object Sync { .create() /** - * Starts a one time sync based on [FhirSyncWorker]. In case of a failure, [RetryConfiguration] - * will guide the retry mechanism. Caller can set [retryConfiguration] to [null] to stop retry. + * Starts a one time sync job based on [FhirSyncWorker]. + * + * Use the returned [Flow] to get updates of the sync job. Alternatively, use [getWorkerInfo] with the same [FhirSyncWorker] to retrieve the status of the job. + * + * @param retryConfiguration configuration to guide the retry mechanism, or `null` to stop retry. + * @return a [Flow] of [SyncJobStatus] */ inline fun oneTimeSync( context: Context, retryConfiguration: RetryConfiguration? = defaultRetryConfiguration - ): Flow { - val flow = getStateFlow(context) + ): Flow { + val flow = getWorkerInfo(context) WorkManager.getInstance(context) .enqueueUniqueWork( W::class.java.name, @@ -60,16 +64,20 @@ object Sync { } /** - * Starts a periodic sync based on [FhirSyncWorker]. It takes [PeriodicSyncConfiguration] to - * determine the sync frequency and [RetryConfiguration] to guide the retry mechanism. Caller can - * set [retryConfiguration] to [null] to stop retry. + * Starts a periodic sync job based on [FhirSyncWorker]. + * + * Use the returned [Flow] to get updates of the sync job. Alternatively, use [getWorkerInfo] with the same [FhirSyncWorker] to retrieve the status of the job. + * + * @param periodicSyncConfiguration configuration to determine the sync frequency and retry + * mechanism + * @return a [Flow] of [SyncJobStatus] */ @ExperimentalCoroutinesApi inline fun periodicSync( context: Context, periodicSyncConfiguration: PeriodicSyncConfiguration - ): Flow { - val flow = getStateFlow(context) + ): Flow { + val flow = getWorkerInfo(context) WorkManager.getInstance(context) .enqueueUniquePeriodicWork( W::class.java.name, @@ -79,7 +87,8 @@ object Sync { return flow } - inline fun getStateFlow(context: Context) = + /** Gets the worker info for the [FhirSyncWorker] */ + inline fun getWorkerInfo(context: Context) = WorkManager.getInstance(context) .getWorkInfosForUniqueWorkLiveData(W::class.java.name) .asFlow() @@ -90,7 +99,7 @@ object Sync { ?.let { val state = it.getString("StateType")!! val stateData = it.getString("State") - gson.fromJson(stateData, Class.forName(state)) as State + gson.fromJson(stateData, Class.forName(state)) as SyncJobStatus } } @@ -120,10 +129,10 @@ object Sync { ): PeriodicWorkRequest { val periodicWorkRequestBuilder = PeriodicWorkRequest.Builder( - clazz, - periodicSyncConfiguration.repeat.interval, - periodicSyncConfiguration.repeat.timeUnit - ) + clazz, + periodicSyncConfiguration.repeat.interval, + periodicSyncConfiguration.repeat.timeUnit + ) .setConstraints(periodicSyncConfiguration.syncConstraints) periodicSyncConfiguration.retryConfiguration?.let { @@ -139,10 +148,3 @@ object Sync { return periodicWorkRequestBuilder.build() } } - -/** Defines different types of synchronisation workers: download and upload */ -enum class SyncWorkType(val workerName: String) { - DOWNLOAD_UPLOAD("fhir-engine-download-upload-worker"), - DOWNLOAD("download"), - UPLOAD("upload") -} diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt new file mode 100644 index 0000000000..188d46a8f7 --- /dev/null +++ b/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2022 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.time.OffsetDateTime +import org.hl7.fhir.r4.model.ResourceType + +sealed class SyncJobStatus { + val timestamp: OffsetDateTime = OffsetDateTime.now() + + class Started : SyncJobStatus() + data class InProgress(val resourceType: ResourceType?) : SyncJobStatus() + data class Glitch(val exceptions: List) : SyncJobStatus() + class Finished : SyncJobStatus() + data class Failed(val exceptions: List) : SyncJobStatus() +} From 9559f547f9252618f9fb21e2fc2e117ce0223c91 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Thu, 3 Nov 2022 15:08:50 +0000 Subject: [PATCH 06/13] Run spotless apply --- .../main/java/com/google/android/fhir/sync/Sync.kt | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 index 783d5b242a..ff627e8e68 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -44,7 +44,8 @@ object Sync { /** * Starts a one time sync job based on [FhirSyncWorker]. * - * Use the returned [Flow] to get updates of the sync job. Alternatively, use [getWorkerInfo] with the same [FhirSyncWorker] to retrieve the status of the job. + * Use the returned [Flow] to get updates of the sync job. Alternatively, use [getWorkerInfo] with + * the same [FhirSyncWorker] to retrieve the status of the job. * * @param retryConfiguration configuration to guide the retry mechanism, or `null` to stop retry. * @return a [Flow] of [SyncJobStatus] @@ -66,7 +67,8 @@ object Sync { /** * Starts a periodic sync job based on [FhirSyncWorker]. * - * Use the returned [Flow] to get updates of the sync job. Alternatively, use [getWorkerInfo] with the same [FhirSyncWorker] to retrieve the status of the job. + * Use the returned [Flow] to get updates of the sync job. Alternatively, use [getWorkerInfo] with + * the same [FhirSyncWorker] to retrieve the status of the job. * * @param periodicSyncConfiguration configuration to determine the sync frequency and retry * mechanism @@ -129,10 +131,10 @@ object Sync { ): PeriodicWorkRequest { val periodicWorkRequestBuilder = PeriodicWorkRequest.Builder( - clazz, - periodicSyncConfiguration.repeat.interval, - periodicSyncConfiguration.repeat.timeUnit - ) + clazz, + periodicSyncConfiguration.repeat.interval, + periodicSyncConfiguration.repeat.timeUnit + ) .setConstraints(periodicSyncConfiguration.syncConstraints) periodicSyncConfiguration.retryConfiguration?.let { From e580702af01e3958dc2fb1fb4d73c51f0af6cc35 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Thu, 3 Nov 2022 15:26:07 +0000 Subject: [PATCH 07/13] Add API to retrieve last updated timestamp --- .../google/android/fhir/demo/MainActivity.kt | 4 ++-- .../android/fhir/demo/MainActivityViewModel.kt | 18 ++++++++++++++++++ .../java/com/google/android/fhir/sync/Sync.kt | 6 ++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt index 1b5185b470..b0d654b512 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt @@ -42,7 +42,7 @@ class MainActivity : AppCompatActivity() { initActionBar() initNavigationDrawer() observeLastSyncTime() - // viewModel.updateLastSyncTimestamp() + viewModel.updateLastSyncTimestamp() } override fun onBackPressed() { @@ -62,7 +62,7 @@ class MainActivity : AppCompatActivity() { fun openNavigationDrawer() { binding.drawer.openDrawer(GravityCompat.START) - // viewModel.updateLastSyncTimestamp() + viewModel.updateLastSyncTimestamp() } private fun initActionBar() { diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index ec0cf53a55..e243777e00 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt @@ -17,6 +17,8 @@ package com.google.android.fhir.demo import android.app.Application +import android.text.format.DateFormat +import android.text.format.DateFormat.is24HourFormat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -28,6 +30,7 @@ import com.google.android.fhir.sync.PeriodicSyncConfiguration import com.google.android.fhir.sync.RepeatInterval import com.google.android.fhir.sync.Sync import com.google.android.fhir.sync.SyncJobStatus +import java.time.format.DateTimeFormatter import java.util.concurrent.TimeUnit import kotlinx.coroutines.InternalCoroutinesApi import kotlinx.coroutines.flow.Flow @@ -58,4 +61,19 @@ class MainActivityViewModel(application: Application, private val state: SavedSt .collect { _pollState.emit(it) } } } + + /** Emits last sync time. */ + fun updateLastSyncTimestamp() { + val formatter = + DateTimeFormatter.ofPattern( + if (DateFormat.is24HourFormat(getApplication())) formatString24 else formatString12 + ) + _lastSyncTimestampLiveData.value = + Sync.getLastSyncTimestamp(getApplication())?.toLocalDateTime()?.format(formatter) ?: "" + } + + companion object { + private const val formatString24 = "yyyy-MM-dd HH:mm:ss" + private const val formatString12 = "yyyy-MM-dd hh:mm:ss a" + } } 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 index ff627e8e68..f4c0a906ab 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -25,6 +25,7 @@ import androidx.work.OneTimeWorkRequest import androidx.work.PeriodicWorkRequest import androidx.work.WorkManager import androidx.work.hasKeyWithValueOfType +import com.google.android.fhir.DatastoreUtil import com.google.android.fhir.OffsetDateTimeTypeAdapter import com.google.gson.Gson import com.google.gson.GsonBuilder @@ -105,6 +106,11 @@ object Sync { } } + /** Gets the timestamp of the last sync job. */ + fun getLastSyncTimestamp(context: Context): OffsetDateTime? { + return DatastoreUtil(context).readLastSyncTimestamp() + } + @PublishedApi internal inline fun createOneTimeWorkRequest( retryConfiguration: RetryConfiguration?, From 183ad42cec517019c5fe37efea7d691f558867fe Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Fri, 4 Nov 2022 17:51:04 +0000 Subject: [PATCH 08/13] Uncomment accidentally commented code --- .../java/com/google/android/fhir/demo/PatientListFragment.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt index ae2c2799cd..eda7a1e5fd 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt @@ -158,13 +158,13 @@ class PatientListFragment : Fragment() { is SyncJobStatus.Finished -> { Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - // mainActivityViewModel.updateLastSyncTimestamp() + mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) } is SyncJobStatus.Failed -> { Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}") patientListViewModel.searchPatientsByName(searchView.query.toString().trim()) - // mainActivityViewModel.updateLastSyncTimestamp() + mainActivityViewModel.updateLastSyncTimestamp() fadeOutTopBanner(it) } else -> Timber.i("Sync: Unknown state.") From b2908803753e2d83ae6efc74c37f747abc42bda8 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Fri, 4 Nov 2022 18:35:33 +0000 Subject: [PATCH 09/13] Add documentation to sync job status --- .../java/com/google/android/fhir/sync/SyncJobStatus.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt b/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt index 188d46a8f7..30b68fadd1 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/SyncJobStatus.kt @@ -22,9 +22,18 @@ import org.hl7.fhir.r4.model.ResourceType sealed class SyncJobStatus { val timestamp: OffsetDateTime = OffsetDateTime.now() + /** Sync job has been started on the client but the syncing is not necessarily in progress. */ class Started : SyncJobStatus() + + /** Syncing in progress with the server. */ data class InProgress(val resourceType: ResourceType?) : SyncJobStatus() + + /** Glitched but sync job is being retried. */ data class Glitch(val exceptions: List) : SyncJobStatus() + + /** Sync job finished successfully. */ class Finished : SyncJobStatus() + + /** Sync job failed. */ data class Failed(val exceptions: List) : SyncJobStatus() } From df05eff7e647deb86dc77c79b5af69e40e4b88fc Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Fri, 4 Nov 2022 18:36:38 +0000 Subject: [PATCH 10/13] Use KEEP policy for periodic sync --- engine/src/main/java/com/google/android/fhir/sync/Sync.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f4c0a906ab..77d007dc07 100644 --- a/engine/src/main/java/com/google/android/fhir/sync/Sync.kt +++ b/engine/src/main/java/com/google/android/fhir/sync/Sync.kt @@ -84,7 +84,7 @@ object Sync { WorkManager.getInstance(context) .enqueueUniquePeriodicWork( W::class.java.name, - ExistingPeriodicWorkPolicy.REPLACE, + ExistingPeriodicWorkPolicy.KEEP, createPeriodicWorkRequest(periodicSyncConfiguration, W::class.java) ) return flow From 5f875c214e3cfc25508806513e62d57d3ac05cdf Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Tue, 8 Nov 2022 13:16:46 +0000 Subject: [PATCH 11/13] Wrap one time sync in a function --- .../com/google/android/fhir/demo/MainActivity.kt | 13 ++++--------- .../android/fhir/demo/MainActivityViewModel.kt | 9 +++++---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt index b0d654b512..7121ba7aed 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivity.kt @@ -24,9 +24,7 @@ import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.view.GravityCompat import androidx.drawerlayout.widget.DrawerLayout -import com.google.android.fhir.demo.data.FhirSyncWorker import com.google.android.fhir.demo.databinding.ActivityMainBinding -import com.google.android.fhir.sync.Sync const val MAX_RESOURCE_COUNT = 20 @@ -80,7 +78,7 @@ class MainActivity : AppCompatActivity() { private fun onNavigationItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.menu_sync -> { - Sync.oneTimeSync(this) + viewModel.triggerOneTimeSync() true } } @@ -89,11 +87,8 @@ class MainActivity : AppCompatActivity() { } private fun observeLastSyncTime() { - viewModel.lastSyncTimestampLiveData.observe( - this, - { - binding.navigationView.getHeaderView(0).findViewById(R.id.last_sync_tv).text = it - } - ) + viewModel.lastSyncTimestampLiveData.observe(this) { + binding.navigationView.getHeaderView(0).findViewById(R.id.last_sync_tv).text = it + } } } diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index e243777e00..e09809748e 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt @@ -18,11 +18,9 @@ package com.google.android.fhir.demo import android.app.Application import android.text.format.DateFormat -import android.text.format.DateFormat.is24HourFormat import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import androidx.work.Constraints import com.google.android.fhir.demo.data.FhirSyncWorker @@ -39,8 +37,7 @@ import kotlinx.coroutines.launch /** View model for [MainActivity]. */ @OptIn(InternalCoroutinesApi::class) -class MainActivityViewModel(application: Application, private val state: SavedStateHandle) : - AndroidViewModel(application) { +class MainActivityViewModel(application: Application) : AndroidViewModel(application) { private val _lastSyncTimestampLiveData = MutableLiveData() val lastSyncTimestampLiveData: LiveData get() = _lastSyncTimestampLiveData @@ -62,6 +59,10 @@ class MainActivityViewModel(application: Application, private val state: SavedSt } } + fun triggerOneTimeSync() { + Sync.oneTimeSync(application.applicationContext).collect { _pollState.emit(it) } + } + /** Emits last sync time. */ fun updateLastSyncTimestamp() { val formatter = From c8b4b50f27267b8e82b1b20115d42ea2c27daeb5 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Tue, 8 Nov 2022 13:57:47 +0000 Subject: [PATCH 12/13] Fix application context --- .../com/google/android/fhir/demo/MainActivityViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index e09809748e..a0388fbac5 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt @@ -60,7 +60,11 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica } fun triggerOneTimeSync() { - Sync.oneTimeSync(application.applicationContext).collect { _pollState.emit(it) } + viewModelScope.launch { + Sync.oneTimeSync(getApplication()).collect { + _pollState.emit(it) + } + } } /** Emits last sync time. */ From 2c892543a68f310404392aa94f231dfe6adc14e9 Mon Sep 17 00:00:00 2001 From: Jing Tang Date: Tue, 8 Nov 2022 14:16:34 +0000 Subject: [PATCH 13/13] Run spotless --- .../com/google/android/fhir/demo/MainActivityViewModel.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt index a0388fbac5..1ebf6cdc9b 100644 --- a/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt +++ b/demo/src/main/java/com/google/android/fhir/demo/MainActivityViewModel.kt @@ -61,9 +61,7 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica fun triggerOneTimeSync() { viewModelScope.launch { - Sync.oneTimeSync(getApplication()).collect { - _pollState.emit(it) - } + Sync.oneTimeSync(getApplication()).collect { _pollState.emit(it) } } }