Skip to content

Commit

Permalink
use preference datastore to monitor syncJobStatus. (google#2142)
Browse files Browse the repository at this point in the history
* Update syncJobStatus to preference datastore.

* add fhirdatastore file.

* code cleanup.

* unit tests

* unit test.

* Cancel ongoing coroutine job before launching new oneTimeSync in demo app.

* Fix android test, pass context instead of datastore in fhir engine configuration.

* fix tests.

* update kotlin doc.

* combine work state with syncjobstatus

* Address review comments.

* Address review comments.

* Update OneTimeSyncState and PeriodicSyncState.

* missing file.

* Stores sync job terminal state in the datastore.

* Address review comments.

* Address review comments.

* Address review comments.

* Address review comments.

* code clean up.

* Code clean up.

* Fix test.

* Fix test

* Fix tests.

* Address review comments.

* Address review comments.

* Remove DataStoreUtil.

* Address review comments.

* fix crash.

* refactoring names

* spotless apply

* private access specifier

* Address review comments.

* Address review comment.

* address review comments.

* fix tests.

* review comment.

* Removing existing unwanted call.

* Address review comments.

* Add test to assert succedded sync state.

* failed state was nit emitted.

* Update api doc.

* Address review comments.

* Review address comments.

* Address review comments.

* Address review comment.

* Address review comments.

* Address review comment.

* Address review comments.

* remove unwanted file.

---------

Co-authored-by: Santosh Pingle <spingle@google.com>
  • Loading branch information
2 people authored and hugomilosz committed Jan 17, 2024
1 parent cdb16b7 commit a45509f
Show file tree
Hide file tree
Showing 20 changed files with 833 additions and 183 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022 Google LLC
* Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 Google LLC
* Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -24,13 +24,16 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import androidx.work.Constraints
import com.google.android.fhir.demo.data.DemoFhirSyncWorker
import com.google.android.fhir.sync.CurrentSyncJobStatus
import com.google.android.fhir.sync.PeriodicSyncConfiguration
import com.google.android.fhir.sync.PeriodicSyncJobStatus
import com.google.android.fhir.sync.RepeatInterval
import com.google.android.fhir.sync.Sync
import com.google.android.fhir.sync.SyncJobStatus
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.TimeUnit
import kotlinx.coroutines.InternalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharingStarted
Expand All @@ -44,10 +47,14 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica
val lastSyncTimestampLiveData: LiveData<String>
get() = _lastSyncTimestampLiveData

private val _pollState = MutableSharedFlow<SyncJobStatus>()
val pollState: Flow<SyncJobStatus>
private val _pollState = MutableSharedFlow<CurrentSyncJobStatus>()
val pollState: Flow<CurrentSyncJobStatus>
get() = _pollState

private val _pollPeriodicSyncJobStatus = MutableSharedFlow<PeriodicSyncJobStatus>()
val pollPeriodicSyncJobStatus: Flow<PeriodicSyncJobStatus>
get() = _pollPeriodicSyncJobStatus

init {
viewModelScope.launch {
Sync.periodicSync<DemoFhirSyncWorker>(
Expand All @@ -59,26 +66,34 @@ class MainActivityViewModel(application: Application) : AndroidViewModel(applica
),
)
.shareIn(this, SharingStarted.Eagerly, 10)
.collect { _pollState.emit(it) }
.collect { _pollPeriodicSyncJobStatus.emit(it) }
}
}

private var oneTimeSyncJob: Job? = null

fun triggerOneTimeSync() {
viewModelScope.launch {
Sync.oneTimeSync<DemoFhirSyncWorker>(getApplication())
.shareIn(this, SharingStarted.Eagerly, 10)
.collect { _pollState.emit(it) }
}
// Cancels any ongoing sync job before starting a new one. Since this function may be called
// more than once, not canceling the ongoing job could result in the creation of multiple jobs
// that emit the same object.
oneTimeSyncJob?.cancel()
oneTimeSyncJob =
viewModelScope.launch {
Sync.oneTimeSync<DemoFhirSyncWorker>(getApplication())
.shareIn(this, SharingStarted.Eagerly, 0)
.collect { result -> result.let { _pollState.emit(it) } }
}
}

/** Emits last sync time. */
fun updateLastSyncTimestamp() {
fun updateLastSyncTimestamp(lastSync: OffsetDateTime? = null) {
val formatter =
DateTimeFormatter.ofPattern(
if (DateFormat.is24HourFormat(getApplication())) formatString24 else formatString12,
)
_lastSyncTimestampLiveData.value =
Sync.getLastSyncTimestamp(getApplication())?.toLocalDateTime()?.format(formatter) ?: ""
lastSync?.let { it.toLocalDateTime()?.format(formatter) ?: "" }
?: Sync.getLastSyncTimestamp(getApplication())?.toLocalDateTime()?.format(formatter) ?: ""
}

companion object {
Expand Down
105 changes: 77 additions & 28 deletions demo/src/main/java/com/google/android/fhir/demo/PatientListFragment.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2022-2023 Google LLC
* Copyright 2022-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -44,6 +44,8 @@ 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.CurrentSyncJobStatus
import com.google.android.fhir.sync.LastSyncJobStatus
import com.google.android.fhir.sync.SyncJobStatus
import kotlin.math.roundToInt
import kotlinx.coroutines.launch
Expand Down Expand Up @@ -105,6 +107,7 @@ class PatientListFragment : Fragment() {

searchView = binding.search
topBanner = binding.syncStatusContainer.linearLayoutSyncStatus
topBanner.visibility = View.GONE
syncStatus = binding.syncStatusContainer.tvSyncingStatus
syncPercent = binding.syncStatusContainer.tvSyncingPercent
syncProgress = binding.syncStatusContainer.progressSyncing
Expand Down Expand Up @@ -155,32 +158,68 @@ class PatientListFragment : Fragment() {
mainActivityViewModel.pollState.collect {
Timber.d("onViewCreated: pollState Got status $it")
when (it) {
is SyncJobStatus.Started -> {
Timber.i("Sync: ${it::class.java.simpleName}")
is CurrentSyncJobStatus.Running -> {
Timber.i("Sync: ${it::class.java.simpleName} with data ${it.inProgressSyncJob}")
fadeInTopBanner(it)
}
is SyncJobStatus.InProgress -> {
Timber.i("Sync: ${it::class.java.simpleName} with data $it")
fadeInTopBanner(it)
}
is SyncJobStatus.Finished -> {
is CurrentSyncJobStatus.Succeeded -> {
Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}")
patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
mainActivityViewModel.updateLastSyncTimestamp()
mainActivityViewModel.updateLastSyncTimestamp(it.timestamp)
fadeOutTopBanner(it)
}
is SyncJobStatus.Failed -> {
is CurrentSyncJobStatus.Failed -> {
Timber.i("Sync: ${it::class.java.simpleName} at ${it.timestamp}")
patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
mainActivityViewModel.updateLastSyncTimestamp()
mainActivityViewModel.updateLastSyncTimestamp(it.timestamp)
fadeOutTopBanner(it)
}
else -> {
Timber.i("Sync: Unknown state.")
is CurrentSyncJobStatus.Enqueued -> {
Timber.i("Sync: Enqueued")
patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
mainActivityViewModel.updateLastSyncTimestamp()
fadeOutTopBanner(it)
}
CurrentSyncJobStatus.Cancelled -> TODO()
}
}
}

lifecycleScope.launch {
mainActivityViewModel.pollPeriodicSyncJobStatus.collect {
Timber.d("onViewCreated: pollState Got status ${it.currentSyncJobStatus}")
when (it.currentSyncJobStatus) {
is CurrentSyncJobStatus.Running -> {
Timber.i(
"Sync: ${it.currentSyncJobStatus::class.java.simpleName} with data ${it.currentSyncJobStatus}",
)
fadeInTopBanner(it.currentSyncJobStatus)
}
is CurrentSyncJobStatus.Succeeded -> {
val lastSyncTimestamp =
(it.currentSyncJobStatus as CurrentSyncJobStatus.Succeeded).timestamp
Timber.i(
"Sync: ${it.currentSyncJobStatus::class.java.simpleName} at $lastSyncTimestamp",
)
patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
mainActivityViewModel.updateLastSyncTimestamp(lastSyncTimestamp)
fadeOutTopBanner(it.currentSyncJobStatus)
}
is CurrentSyncJobStatus.Failed -> {
val lastSyncTimestamp =
(it.currentSyncJobStatus as CurrentSyncJobStatus.Failed).timestamp
Timber.i(
"Sync: ${it.currentSyncJobStatus::class.java.simpleName} at $lastSyncTimestamp}",
)
patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
mainActivityViewModel.updateLastSyncTimestamp(lastSyncTimestamp)
fadeOutTopBanner(it.currentSyncJobStatus)
}
is CurrentSyncJobStatus.Enqueued -> {
Timber.i("Sync: Enqueued")
patientListViewModel.searchPatientsByName(searchView.query.toString().trim())
fadeOutTopBanner(it.currentSyncJobStatus)
}
CurrentSyncJobStatus.Cancelled -> TODO()
}
}
}
Expand Down Expand Up @@ -213,7 +252,7 @@ class PatientListFragment : Fragment() {
.navigate(PatientListFragmentDirections.actionPatientListToAddPatientFragment())
}

private fun fadeInTopBanner(state: SyncJobStatus) {
private fun fadeInTopBanner(state: CurrentSyncJobStatus) {
if (topBanner.visibility != View.VISIBLE) {
syncStatus.text = resources.getString(R.string.syncing).uppercase()
syncPercent.text = ""
Expand All @@ -222,25 +261,35 @@ class PatientListFragment : Fragment() {
topBanner.visibility = View.VISIBLE
val animation = AnimationUtils.loadAnimation(topBanner.context, R.anim.fade_in)
topBanner.startAnimation(animation)
} else if (state is SyncJobStatus.InProgress) {
} else if (
state is CurrentSyncJobStatus.Running && state.inProgressSyncJob is SyncJobStatus.InProgress
) {
val inProgressState = state.inProgressSyncJob as? SyncJobStatus.InProgress
val progress =
state
.let { it.completed.toDouble().div(it.total) }
.let { if (it.isNaN()) 0.0 else it }
.times(100)
.roundToInt()
"$progress% ${state.syncOperation.name.lowercase()}ed".also { syncPercent.text = it }
syncProgress.progress = progress
inProgressState
?.let { it.completed.toDouble().div(it.total) }
?.let { if (it.isNaN()) 0.0 else it }
?.times(100)
?.roundToInt()
"$progress% ${inProgressState?.syncOperation?.name?.lowercase()}ed"
.also { syncPercent.text = it }
syncProgress.progress = progress ?: 0
}
}

private fun fadeOutTopBanner(state: SyncJobStatus) {
if (state is SyncJobStatus.Finished) syncPercent.text = ""
syncProgress.visibility = View.GONE
private fun fadeOutTopBanner(state: CurrentSyncJobStatus) {
fadeOutTopBanner(state::class.java.simpleName.uppercase())
}

private fun fadeOutTopBanner(state: LastSyncJobStatus) {
fadeOutTopBanner(state::class.java.simpleName.uppercase())
}

private fun fadeOutTopBanner(statusText: String) {
syncPercent.text = ""
syncProgress.visibility = View.GONE
if (topBanner.visibility == View.VISIBLE) {
"${resources.getString(R.string.sync).uppercase()} ${state::class.java.simpleName.uppercase()}"
.also { syncStatus.text = it }
"${resources.getString(R.string.sync).uppercase()} $statusText".also { syncStatus.text = it }

val animation = AnimationUtils.loadAnimation(topBanner.context, R.anim.fade_out)
topBanner.startAnimation(animation)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright 2023 Google LLC
* Copyright 2023-2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand All @@ -22,6 +22,7 @@ import androidx.benchmark.junit4.measureRepeated
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SdkSuppress
import androidx.work.Data
import androidx.work.ListenableWorker
import androidx.work.WorkerParameters
import androidx.work.testing.TestListenableWorkerBuilder
Expand Down Expand Up @@ -138,7 +139,12 @@ class FhirSyncWorkerBenchmark {
private fun oneTimeSync(numberPatients: Int, numberObservations: Int, numberEncounters: Int) =
runBlocking {
val context: Context = ApplicationProvider.getApplicationContext()
val worker = TestListenableWorkerBuilder<BenchmarkTestOneTimeSyncWorker>(context).build()
val inputData =
Data.Builder()
.putString("sync_status_preferences_datastore_key", "BenchmarkTestOneTimeSyncWorker")
.build()
val worker =
TestListenableWorkerBuilder<BenchmarkTestOneTimeSyncWorker>(context, inputData).build()
setupMockServerDispatcher(numberPatients, numberObservations, numberEncounters)
benchmarkRule.measureRepeated {
runBlocking {
Expand Down
Loading

0 comments on commit a45509f

Please sign in to comment.