From 383cd8eafb73a99a03c637b1c09c59b2c48596c8 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn <matthias.urhahn@sap.com> Date: Mon, 26 Oct 2020 20:05:27 +0100 Subject: [PATCH] Monitor ENF calculation more closely for better progress feedback (EXPOSUREAPP-2743) (#1473) * Add a `CalcluationTracker` class that can tell us about the ENF's calcluations. * Introduces new reactive data provider "HotData" that allows lazy init and safe updates. * Add calculation time out enforcer (60min), check every 5min. * Show `isRefreshing` when we are either downloading keys or the ENF is still calculating. * Rename "token" to "identifier", we'll likely keep using this class for ENF Window Mode, which has no more "tokens". * Remove "state", we can only know whether it's running and that can be based on the `finishedAt` timestamp. Also improve test readability a bit and added more edge cases when checking timeouts. * Additional test cases for ENF calculation edge cases * We are fine with tracking the last 5 calculations. * Exclude timeouts from `latestFinishedCalculation` * HotData should be named HotDataFlow * Clean up coroutine/flow packages. * Remove additional combineTransform instances, to prevent accidental casting errors in the future. * We should only forward calls to the provider and the calculation tracker if the list of key files is non-empty. * Add unit test to check for GSON data class restoration behavior with transient fields. GSON sets it to false, and does not eval the properties to set them. * Lints, Lints, Lints * Only check the calculation status of the newest submission. The chance for overlapping calculations is rare with batched key submission, and if gives us a lower chance of actually being affected by timeouts. * Reduce timeout for tracked calculations to 15 minutes after discussion with Maximilian. In worst case scenarios no calculation exceeded 7 minutes. * Reduce timeout check interval to 3 minutes, due to lowered overall timeout limit. * Add additional test case that checks that a late result, past timeout, overwrites the timeout. * Create `BaseGson` instance within the DI graph use it. Allows use to later set global settings (pretty print for testers?), or hook up custom serializers app-wide. --- Corona-Warn-App/src/main/AndroidManifest.xml | 1 + .../environment/EnvironmentSetup.kt | 7 +- ...eption.kt => UnknownBroadcastException.kt} | 12 +- .../de/rki/coronawarnapp/nearby/ENFClient.kt | 30 +- .../de/rki/coronawarnapp/nearby/ENFModule.kt | 7 + .../modules/calculationtracker/Calculation.kt | 31 ++ .../calculationtracker/CalculationTracker.kt | 11 + .../CalculationTrackerStorage.kt | 60 ++++ .../DefaultCalculationTracker.kt | 143 ++++++++++ .../receiver/ExposureStateUpdateReceiver.kt | 114 +++++--- .../coronawarnapp/receiver/ReceiverBinder.kt | 7 +- .../storage/TracingRepository.kt | 12 +- .../ui/tracing/card/TracingCardState.kt | 8 +- .../tracing/card/TracingCardStateProvider.kt | 66 +++-- .../ui/tracing/common/BaseTracingState.kt | 2 +- .../ui/tracing/details/TracingDetailsState.kt | 2 +- .../details/TracingDetailsStateProvider.kt | 26 +- .../SettingsTracingFragmentViewModel.kt | 2 +- .../util/BackgroundModeStatus.kt | 2 +- .../rki/coronawarnapp/util/MapExtensions.kt | 5 + .../util/bluetooth/BluetoothProvider.kt | 2 +- .../util/di/ApplicationComponent.kt | 4 +- .../{coroutine => flow}/FlowExtensions.kt | 35 ++- .../coronawarnapp/util/flow/HotDataFlow.kt | 68 +++++ .../coronawarnapp/util/gson/GsonExtensions.kt | 18 ++ .../util/location/LocationProvider.kt | 2 +- .../util/serialization/BaseGson.kt | 8 + .../util/serialization/SerializationModule.kt | 16 ++ .../environment/EnvironmentSetupTest.kt | 7 +- .../rki/coronawarnapp/nearby/ENFClientTest.kt | 163 ++++++++++- .../calculationtracker/CalculationTest.kt | 35 +++ .../CalculationTrackerStorageTest.kt | 133 +++++++++ .../DefaultCalculationTrackerTest.kt | 265 ++++++++++++++++++ .../ExposureStateUpdateReceiverTest.kt | 88 ++++-- .../coronawarnapp/task/TaskControllerTest.kt | 4 +- .../tracing/GeneralTracingStatusTest.kt | 4 +- .../SubmissionCountrySelectViewModelTest.kt | 3 +- .../ui/tracing/card/TracingCardStateTest.kt | 2 +- .../ui/tracing/common/BaseTracingStateTest.kt | 2 +- .../details/TracingDetailsStateTest.kt | 2 +- .../util/bluetooth/BluetoothProviderTest.kt | 4 +- .../util/flow/HotDataFlowTest.kt | 158 +++++++++++ .../util/location/LocationProviderTest.kt | 4 +- .../java/testhelpers/coroutines/FlowTest.kt | 84 ++++-- .../testhelpers/coroutines/TestExtensions.kt | 44 +++ 45 files changed, 1530 insertions(+), 173 deletions(-) rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/{WrongReceiverException.kt => UnknownBroadcastException.kt} (55%) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/MapExtensions.kt rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/{coroutine => flow}/FlowExtensions.kt (51%) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/BaseGson.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt create mode 100644 Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt diff --git a/Corona-Warn-App/src/main/AndroidManifest.xml b/Corona-Warn-App/src/main/AndroidManifest.xml index dad22a4eb..543b20f88 100644 --- a/Corona-Warn-App/src/main/AndroidManifest.xml +++ b/Corona-Warn-App/src/main/AndroidManifest.xml @@ -43,6 +43,7 @@ android:permission="com.google.android.gms.nearby.exposurenotification.EXPOSURE_CALLBACK"> <intent-filter> <action android:name="com.google.android.gms.exposurenotification.ACTION_EXPOSURE_STATE_UPDATED" /> + <action android:name="com.google.android.gms.exposurenotification.ACTION_EXPOSURE_NOT_FOUND" /> </intent-filter> </receiver> diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt index f8937d9b9..1acfb328e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt @@ -2,7 +2,7 @@ package de.rki.coronawarnapp.environment import android.content.Context import androidx.core.content.edit -import com.google.gson.GsonBuilder +import com.google.gson.Gson import com.google.gson.JsonObject import com.google.gson.JsonPrimitive import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.DOWNLOAD @@ -13,13 +13,15 @@ import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.VERIFICATION_KEY import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.serialization.BaseGson import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class EnvironmentSetup @Inject constructor( - @AppContext private val context: Context + @AppContext private val context: Context, + @BaseGson private val gson: Gson ) { enum class EnvKey(val rawKey: String) { @@ -51,7 +53,6 @@ class EnvironmentSetup @Inject constructor( } private val environmentJson: JsonObject by lazy { - val gson = GsonBuilder().create() gson.fromJson(BuildConfigWrap.ENVIRONMENT_JSONDATA, JsonObject::class.java).also { Timber.d("Parsed test environment: %s", it) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/WrongReceiverException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/UnknownBroadcastException.kt similarity index 55% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/WrongReceiverException.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/UnknownBroadcastException.kt index 53026910f..4c5874f2d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/WrongReceiverException.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/UnknownBroadcastException.kt @@ -7,18 +7,10 @@ import de.rki.coronawarnapp.exception.reporting.ReportedException * An Exception thrown when an error occurs inside the Rollback of a Transaction. * * @param action the received action in the BroadcastReceiver - * @param expected the expected action - * @param cause the cause of the error * * @see de.rki.coronawarnapp.receiver.ExposureStateUpdateReceiver */ -class WrongReceiverException( - action: String?, - expected: String, - cause: Throwable -) : ReportedException( +class UnknownBroadcastException(action: String?) : ReportedException( ErrorCodes.WRONG_RECEIVER_PROBLEM.code, - "An error occurred during BroadcastReceiver onReceive function. " + - "Received action was $action, expected action was $expected", - cause + "Our exposure state update receiver received an unknown '$action' type." ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt index 427d236a5..98914070b 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt @@ -4,10 +4,14 @@ package de.rki.coronawarnapp.nearby import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient +import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation +import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider import de.rki.coronawarnapp.nearby.modules.locationless.ScanningSupport import de.rki.coronawarnapp.nearby.modules.tracing.TracingStatus import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.joda.time.Instant import timber.log.Timber import java.io.File import javax.inject.Inject @@ -18,7 +22,8 @@ class ENFClient @Inject constructor( private val googleENFClient: ExposureNotificationClient, private val diagnosisKeyProvider: DiagnosisKeyProvider, private val tracingStatus: TracingStatus, - private val scanningSupport: ScanningSupport + private val scanningSupport: ScanningSupport, + private val calculationTracker: CalculationTracker ) : DiagnosisKeyProvider, TracingStatus, ScanningSupport { // TODO Remove this once we no longer need direct access to the ENF Client, @@ -35,7 +40,15 @@ class ENFClient @Inject constructor( "asyncProvideDiagnosisKeys(keyFiles=%s, configuration=%s, token=%s)", keyFiles, configuration, token ) - return diagnosisKeyProvider.provideDiagnosisKeys(keyFiles, configuration, token) + + return if (keyFiles.isEmpty()) { + Timber.d("No key files submitted, returning early.") + true + } else { + Timber.d("Forwarding %d key files to our DiagnosisKeyProvider.", keyFiles.size) + calculationTracker.trackNewCalaculation(token) + diagnosisKeyProvider.provideDiagnosisKeys(keyFiles, configuration, token) + } } override val isLocationLessScanningSupported: Flow<Boolean> @@ -43,4 +56,17 @@ class ENFClient @Inject constructor( override val isTracingEnabled: Flow<Boolean> get() = tracingStatus.isTracingEnabled + + fun isCurrentlyCalculating(): Flow<Boolean> = calculationTracker.calculations + .map { it.values } + .map { values -> + values.maxBy { it.startedAt }?.isCalculating == true + } + + fun latestFinishedCalculation(): Flow<Calculation?> = + calculationTracker.calculations.map { snapshot -> + snapshot.values + .filter { !it.isCalculating && it.isSuccessful } + .maxByOrNull { it.finishedAt ?: Instant.EPOCH } + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt index ac21df87e..1bf6f5dd7 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt @@ -5,6 +5,8 @@ import com.google.android.gms.nearby.Nearby import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient import dagger.Module import dagger.Provides +import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker +import de.rki.coronawarnapp.nearby.modules.calculationtracker.DefaultCalculationTracker import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DefaultDiagnosisKeyProvider import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider import de.rki.coronawarnapp.nearby.modules.locationless.DefaultScanningSupport @@ -36,4 +38,9 @@ class ENFModule { @Provides fun scanningSupport(scanningSupport: DefaultScanningSupport): ScanningSupport = scanningSupport + + @Singleton + @Provides + fun calculationTracker(calculationTracker: DefaultCalculationTracker): CalculationTracker = + calculationTracker } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt new file mode 100644 index 000000000..13779d6ba --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt @@ -0,0 +1,31 @@ +package de.rki.coronawarnapp.nearby.modules.calculationtracker + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import org.joda.time.Instant + +@Keep +data class Calculation( + @SerializedName("identifier") val identifier: String, + @SerializedName("startedAt") val startedAt: Instant, + @SerializedName("result") val result: Result? = null, + @SerializedName("finishedAt") val finishedAt: Instant? = null +) { + + val isCalculating: Boolean + get() = finishedAt == null + val isSuccessful: Boolean + get() = (result == Result.NO_MATCHES || result == Result.UPDATED_STATE) + + @Keep + enum class Result { + @SerializedName("NO_MATCHES") + NO_MATCHES, + + @SerializedName("UPDATED_STATE") + UPDATED_STATE, + + @SerializedName("TIMEOUT") + TIMEOUT + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt new file mode 100644 index 000000000..30bbc1b25 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.nearby.modules.calculationtracker + +import kotlinx.coroutines.flow.Flow + +interface CalculationTracker { + val calculations: Flow<Map<String, Calculation>> + + fun trackNewCalaculation(identifier: String) + + fun finishCalculation(identifier: String, result: Calculation.Result) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt new file mode 100644 index 000000000..1aaf4d63b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt @@ -0,0 +1,60 @@ +package de.rki.coronawarnapp.nearby.modules.calculationtracker + +import android.content.Context +import com.google.gson.Gson +import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.gson.fromJson +import de.rki.coronawarnapp.util.gson.toJson +import de.rki.coronawarnapp.util.serialization.BaseGson +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CalculationTrackerStorage @Inject constructor( + @AppContext private val context: Context, + @BaseGson private val gson: Gson +) { + private val mutex = Mutex() + private val storageDir by lazy { + File(context.filesDir, "calcuation_tracker").apply { + if (mkdirs()) Timber.v("Created %s", this) + } + } + private val storageFile by lazy { File(storageDir, "calculations.json") } + private var lastCalcuationData: Map<String, Calculation>? = null + + init { + Timber.v("init()") + } + + suspend fun load(): Map<String, Calculation> = mutex.withLock { + return@withLock try { + if (!storageFile.exists()) return@withLock emptyMap() + + gson.fromJson<Map<String, Calculation>>(storageFile).also { + Timber.v("Loaded calculation data: %s", it) + lastCalcuationData = it + } + } catch (e: Exception) { + Timber.e(e, "Failed to load tracked calculations.") + emptyMap() + } + } + + suspend fun save(data: Map<String, Calculation>) = mutex.withLock { + if (lastCalcuationData == data) { + Timber.v("Data didn't change, skipping save.") + return@withLock + } + Timber.v("Storing calculation data: %s", data) + try { + gson.toJson(data, storageFile) + } catch (e: Exception) { + Timber.e(e, "Failed to save tracked calculations.") + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt new file mode 100644 index 000000000..97e1eb95d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt @@ -0,0 +1,143 @@ +package de.rki.coronawarnapp.nearby.modules.calculationtracker + +import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation.Result +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.coroutine.AppScope +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.flow.HotDataFlow +import de.rki.coronawarnapp.util.mutate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.plus +import org.joda.time.Duration +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.min + +@Singleton +class DefaultCalculationTracker @Inject constructor( + @AppScope private val scope: CoroutineScope, + private val dispatcherProvider: DispatcherProvider, + private val storage: CalculationTrackerStorage, + private val timeStamper: TimeStamper +) : CalculationTracker { + + init { + Timber.v("init()") + } + + private val calculationStates: HotDataFlow<Map<String, Calculation>> by lazy { + val setupAutoSave: (HotDataFlow<Map<String, Calculation>>) -> Unit = { hd -> + hd.data + .onStart { Timber.v("Observing calculation changes.") } + .onEach { storage.save(it) } + .launchIn(scope = scope + dispatcherProvider.Default) + } + + val setupTimeoutEnforcer: (HotDataFlow<Map<String, Calculation>>) -> Unit = { hd -> + flow<Unit> { + while (true) { + hd.updateSafely { + Timber.v("Running timeout check on: %s", values) + + val timeNow = timeStamper.nowUTC + Timber.v("Time now: %s", timeNow) + + mutate { + values.filter { it.isCalculating }.toList().forEach { + if (timeNow.isAfter(it.startedAt.plus(TIMEOUT_LIMIT))) { + Timber.w("Calculation timeout on %s", it) + remove(it.identifier) + this[it.identifier] = it.copy( + finishedAt = timeStamper.nowUTC, + result = Result.TIMEOUT + ) + } + } + } + } + + delay(TIMEOUT_CHECK_INTERVALL.millis) + } + }.launchIn(scope + dispatcherProvider.Default) + } + + HotDataFlow( + loggingTag = TAG, + scope = scope, + coroutineContext = dispatcherProvider.Default, + startValueProvider = { storage.load() } + ).also { + setupAutoSave(it) + setupTimeoutEnforcer(it) + } + } + + override val calculations: Flow<Map<String, Calculation>> by lazy { calculationStates.data } + + override fun trackNewCalaculation(identifier: String) { + Timber.i("trackNewCalaculation(token=%s)", identifier) + calculationStates.updateSafely { + mutate { + this[identifier] = Calculation( + identifier = identifier, + startedAt = timeStamper.nowUTC + ) + } + } + } + + override fun finishCalculation(identifier: String, result: Result) { + Timber.i("finishCalculation(token=%s, result=%s)", identifier, result) + calculationStates.updateSafely { + mutate { + val existing = this[identifier] + if (existing != null) { + if (existing.result == Result.TIMEOUT) { + Timber.w("Calculation is late, already hit timeout, still updating.") + } else if (existing.result != null) { + Timber.e("Duplicate callback. Result is already set for calculation!") + } + this[identifier] = existing.copy( + result = result, + finishedAt = timeStamper.nowUTC + ) + } else { + Timber.e( + "Unknown calculation finished (token=%s, result=%s)", + identifier, + result + ) + this[identifier] = Calculation( + identifier = identifier, + result = result, + startedAt = timeStamper.nowUTC, + finishedAt = timeStamper.nowUTC + ) + } + val toKeep = entries + .sortedByDescending { it.value.startedAt } // Keep newest + .subList(0, min(entries.size, MAX_ENTRY_SIZE)) + .map { it.key } + entries.removeAll { entry -> + val remove = !toKeep.contains(entry.key) + if (remove) Timber.v("Removing stale entry: %s", entry) + remove + } + } + } + } + + companion object { + private const val TAG = "DefaultCalculationTracker" + private const val MAX_ENTRY_SIZE = 5 + private val TIMEOUT_CHECK_INTERVALL = Duration.standardMinutes(3) + private val TIMEOUT_LIMIT = Duration.standardMinutes(15) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt index 65f13604d..314a944a7 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt @@ -6,12 +6,23 @@ import android.content.Intent import androidx.work.Data import androidx.work.OneTimeWorkRequest import androidx.work.WorkManager -import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient +import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient.ACTION_EXPOSURE_NOT_FOUND +import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED +import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient.EXTRA_TOKEN +import dagger.android.AndroidInjection import de.rki.coronawarnapp.exception.ExceptionCategory.INTERNAL import de.rki.coronawarnapp.exception.NoTokenException -import de.rki.coronawarnapp.exception.WrongReceiverException +import de.rki.coronawarnapp.exception.UnknownBroadcastException import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker +import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation +import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker +import de.rki.coronawarnapp.util.coroutine.AppScope +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject /** * Receiver to listen to the Exposure Notification Exposure State Updated event. This event will be triggered from the @@ -27,42 +38,77 @@ import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker * */ class ExposureStateUpdateReceiver : BroadcastReceiver() { - companion object { - private val TAG: String? = ExposureStateUpdateReceiver::class.simpleName - } + + @Inject @AppScope lateinit var scope: CoroutineScope + @Inject lateinit var dispatcherProvider: DispatcherProvider + @Inject lateinit var calculationTracker: CalculationTracker + lateinit var context: Context override fun onReceive(context: Context, intent: Intent) { + Timber.tag(TAG).d("onReceive(context=%s, intent=%s)", context, intent) + AndroidInjection.inject(this, context) + this.context = context + val action = intent.action - val expectedAction = ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED - try { - if (expectedAction != action) { - throw WrongReceiverException( - action, - expectedAction, - IllegalArgumentException("wrong action was received") - ) - } + Timber.tag(TAG).v("Looking up action: %s", action) - val token = - intent.getStringExtra(ExposureNotificationClient.EXTRA_TOKEN) - ?: throw NoTokenException( - IllegalArgumentException("no token was found in the intent") - ) - - val workManager = WorkManager.getInstance(context) - workManager.enqueue( - OneTimeWorkRequest.Builder(ExposureStateUpdateWorker::class.java) - .setInputData( - Data.Builder() - .putString(ExposureNotificationClient.EXTRA_TOKEN, token) - .build() - ) - .build() - ) - } catch (e: WrongReceiverException) { - e.report(INTERNAL) - } catch (e: NoTokenException) { - e.report(INTERNAL) + val async = goAsync() + scope.launch(context = dispatcherProvider.Default) { + try { + when (action) { + ACTION_EXPOSURE_STATE_UPDATED -> processStateUpdates(intent) + ACTION_EXPOSURE_NOT_FOUND -> processNotFound(intent) + else -> throw UnknownBroadcastException(action) + } + } catch (e: Exception) { + e.report(INTERNAL) + } finally { + Timber.tag(TAG).i("Finished processing broadcast.") + async.finish() + } } } + + private fun processStateUpdates(intent: Intent) { + Timber.tag(TAG).i("Processing ACTION_EXPOSURE_STATE_UPDATED") + + val workManager = WorkManager.getInstance(context) + + val token = intent.requireToken() + + val data = Data + .Builder() + .putString(EXTRA_TOKEN, token) + .build() + + OneTimeWorkRequest + .Builder(ExposureStateUpdateWorker::class.java) + .setInputData(data) + .build() + .let { workManager.enqueue(it) } + + calculationTracker.finishCalculation( + token, + Calculation.Result.UPDATED_STATE + ) + } + + private fun processNotFound(intent: Intent) { + Timber.tag(TAG).i("Processing ACTION_EXPOSURE_NOT_FOUND") + + val token = intent.requireToken() + + calculationTracker.finishCalculation( + token, + Calculation.Result.NO_MATCHES + ) + } + + private fun Intent.requireToken(): String = getStringExtra(EXTRA_TOKEN).also { + Timber.tag(TAG).v("Extracted token: %s", it) + } ?: throw NoTokenException(IllegalArgumentException("no token was found in the intent")) + + companion object { + private val TAG: String? = ExposureStateUpdateReceiver::class.simpleName + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt index 8d8cf82f4..676692bdb 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt @@ -1,6 +1,11 @@ package de.rki.coronawarnapp.receiver import dagger.Module +import dagger.android.ContributesAndroidInjector @Module -internal abstract class ReceiverBinder +internal abstract class ReceiverBinder { + + @ContributesAndroidInjector + internal abstract fun exposureUpdateReceiver(): ExposureStateUpdateReceiver +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt index 674f90c35..131d74757 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt @@ -4,6 +4,7 @@ import de.rki.coronawarnapp.CoronaWarnApplication import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.TransactionException import de.rki.coronawarnapp.exception.reporting.report +import de.rki.coronawarnapp.nearby.ENFClient import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient import de.rki.coronawarnapp.risk.TimeVariables.getActiveTracingDaysInRetentionPeriod import de.rki.coronawarnapp.timer.TimerHelper @@ -14,6 +15,7 @@ import de.rki.coronawarnapp.util.coroutine.AppScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import org.joda.time.DateTime import org.joda.time.DateTimeZone @@ -34,7 +36,8 @@ import javax.inject.Singleton */ @Singleton class TracingRepository @Inject constructor( - @AppScope private val scope: CoroutineScope + @AppScope private val scope: CoroutineScope, + enfClient: ENFClient ) { private val internalLastTimeDiagnosisKeysFetched = MutableStateFlow<Date?>(null) @@ -55,7 +58,12 @@ class TracingRepository @Inject constructor( // TODO shouldn't access this directly val internalIsRefreshing = MutableStateFlow(false) - val isRefreshing: Flow<Boolean> = internalIsRefreshing + val isRefreshing: Flow<Boolean> = combine( + internalIsRefreshing, + enfClient.isCurrentlyCalculating() + ) { isRefreshing, isCalculating -> + isRefreshing || isCalculating + } /** * Refresh the diagnosis keys. For that isRefreshing is set to true which is displayed in the ui. diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt index 58ff7a9e9..aa993a6b3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt @@ -14,7 +14,7 @@ data class TracingCardState( override val tracingStatus: GeneralTracingStatus.Status, override val riskLevelScore: Int, override val isRefreshing: Boolean, - override val riskLevelLastSuccessfulCalculation: Int, + override val lastRiskLevelScoreCalculated: Int, override val matchedKeyCount: Int, override val daysSinceLastExposure: Int, override val activeTracingDaysInRetentionPeriod: Long, @@ -58,11 +58,11 @@ data class TracingCardState( riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS || riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL ) { - when (riskLevelLastSuccessfulCalculation) { + when (lastRiskLevelScoreCalculated) { RiskLevelConstants.LOW_LEVEL_RISK, RiskLevelConstants.INCREASED_RISK, RiskLevelConstants.UNKNOWN_RISK_INITIAL -> { - val arg = formatRiskLevelHeadline(c, riskLevelLastSuccessfulCalculation, false) + val arg = formatRiskLevelHeadline(c, lastRiskLevelScoreCalculated, false) c.getString(R.string.risk_card_no_calculation_possible_body_saved_risk) .format(arg) } @@ -202,7 +202,7 @@ data class TracingCardState( RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF, RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS, RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> { - when (riskLevelLastSuccessfulCalculation) { + when (lastRiskLevelScoreCalculated) { RiskLevelConstants.LOW_LEVEL_RISK, RiskLevelConstants.INCREASED_RISK, RiskLevelConstants.UNKNOWN_RISK_INITIAL -> { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt index 2bc7ba289..91406137d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt @@ -7,13 +7,12 @@ import de.rki.coronawarnapp.storage.SettingsRepository import de.rki.coronawarnapp.storage.TracingRepository import de.rki.coronawarnapp.tracing.GeneralTracingStatus import de.rki.coronawarnapp.util.BackgroundModeStatus +import de.rki.coronawarnapp.util.flow.combine import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import timber.log.Timber -import java.util.Date import javax.inject.Inject @Reusable @@ -26,43 +25,60 @@ class TracingCardStateProvider @Inject constructor( // TODO Refactor these singletons away val state: Flow<TracingCardState> = combine( - tracingStatus.generalStatus.onEach { Timber.v("tracingStatus: $it") }, - RiskLevelRepository.riskLevelScore.onEach { Timber.v("riskLevelScore: $it") }, + tracingStatus.generalStatus.onEach { + Timber.v("tracingStatus: $it") + }, + RiskLevelRepository.riskLevelScore.onEach { + Timber.v("riskLevelScore: $it") + }, RiskLevelRepository.riskLevelScoreLastSuccessfulCalculated.onEach { Timber.v("riskLevelScoreLastSuccessfulCalculated: $it") }, - tracingRepository.isRefreshing.onEach { Timber.v("isRefreshing: $it") }, - ExposureSummaryRepository.matchedKeyCount.onEach { Timber.v("matchedKeyCount: $it") }, - ExposureSummaryRepository.daysSinceLastExposure.onEach { Timber.v("daysSinceLastExposure: $it") }, + tracingRepository.isRefreshing.onEach { + Timber.v("isRefreshing: $it") + }, + ExposureSummaryRepository.matchedKeyCount.onEach { + Timber.v("matchedKeyCount: $it") + }, + ExposureSummaryRepository.daysSinceLastExposure.onEach { + Timber.v("daysSinceLastExposure: $it") + }, tracingRepository.activeTracingDaysInRetentionPeriod.onEach { Timber.v("activeTracingDaysInRetentionPeriod: $it") }, - tracingRepository.lastTimeDiagnosisKeysFetched.onEach { Timber.v("lastTimeDiagnosisKeysFetched: $it") }, - backgroundModeStatus.isAutoModeEnabled.onEach { Timber.v("isAutoModeEnabled: $it") }, - settingsRepository.isManualKeyRetrievalEnabledFlow.onEach { Timber.v("isManualKeyRetrievalEnabledFlow: $it") }, - settingsRepository.manualKeyRetrievalTimeFlow.onEach { Timber.v("manualKeyRetrievalTimeFlow: $it") } - ) { sources: Array<Any?> -> - val status = sources[0] as GeneralTracingStatus.Status - val riskLevelScore = sources[1] as Int - val riskLevelScoreLastSuccessfulCalculated = sources[2] as Int - val isRefreshing = sources[3] as Boolean - val matchedKeyCount = sources[4] as Int - val daysSinceLastExposure = sources[5] as Int - val activeTracingDaysInRetentionPeriod = sources[6] as Long - val lastTimeDiagnosisKeysFetched = sources[7] as Date? - val isBackgroundJobEnabled = sources[8] as Boolean - val isManualKeyRetrievalEnabled = sources[9] as Boolean - val manualKeyRetrievalTime = sources[10] as Long + tracingRepository.lastTimeDiagnosisKeysFetched.onEach { + Timber.v("lastTimeDiagnosisKeysFetched: $it") + }, + backgroundModeStatus.isAutoModeEnabled.onEach { + Timber.v("isAutoModeEnabled: $it") + }, + settingsRepository.isManualKeyRetrievalEnabledFlow.onEach { + Timber.v("isManualKeyRetrievalEnabledFlow: $it") + }, + settingsRepository.manualKeyRetrievalTimeFlow.onEach { + Timber.v("manualKeyRetrievalTimeFlow: $it") + } + ) { status, + riskLevelScore, + riskLevelScoreLastSuccessfulCalculated, + isRefreshing, + matchedKeyCount, + daysSinceLastExposure, + activeTracingDaysInRetentionPeriod, + lastTimeDiagnosisKeysFetched, + isBackgroundJobEnabled, + isManualKeyRetrievalEnabled, + manualKeyRetrievalTime -> TracingCardState( tracingStatus = status, riskLevelScore = riskLevelScore, isRefreshing = isRefreshing, - riskLevelLastSuccessfulCalculation = riskLevelScoreLastSuccessfulCalculated, + lastRiskLevelScoreCalculated = riskLevelScoreLastSuccessfulCalculated, + lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched, matchedKeyCount = matchedKeyCount, daysSinceLastExposure = daysSinceLastExposure, activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod, - lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched, isBackgroundJobEnabled = isBackgroundJobEnabled, isManualKeyRetrievalEnabled = isManualKeyRetrievalEnabled, manualKeyRetrievalTime = manualKeyRetrievalTime diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt index 9c420aa27..6ba53ca9f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt @@ -13,7 +13,7 @@ abstract class BaseTracingState { abstract val tracingStatus: GeneralTracingStatus.Status abstract val riskLevelScore: Int abstract val isRefreshing: Boolean - abstract val riskLevelLastSuccessfulCalculation: Int + abstract val lastRiskLevelScoreCalculated: Int abstract val matchedKeyCount: Int abstract val daysSinceLastExposure: Int abstract val activeTracingDaysInRetentionPeriod: Long diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt index 2348a0e71..a32b4f8ec 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt @@ -11,7 +11,7 @@ data class TracingDetailsState( override val tracingStatus: GeneralTracingStatus.Status, override val riskLevelScore: Int, override val isRefreshing: Boolean, - override val riskLevelLastSuccessfulCalculation: Int, + override val lastRiskLevelScoreCalculated: Int, override val matchedKeyCount: Int, override val daysSinceLastExposure: Int, override val activeTracingDaysInRetentionPeriod: Long, diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt index 5d2ab8b1d..61627a9a7 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt @@ -7,13 +7,12 @@ import de.rki.coronawarnapp.storage.SettingsRepository import de.rki.coronawarnapp.storage.TracingRepository import de.rki.coronawarnapp.tracing.GeneralTracingStatus import de.rki.coronawarnapp.util.BackgroundModeStatus +import de.rki.coronawarnapp.util.flow.combine import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart import timber.log.Timber -import java.util.Date import javax.inject.Inject @Reusable @@ -38,18 +37,15 @@ class TracingDetailsStateProvider @Inject constructor( backgroundModeStatus.isAutoModeEnabled, settingsRepository.isManualKeyRetrievalEnabledFlow, settingsRepository.manualKeyRetrievalTimeFlow - ) { sources: Array<Any?> -> - val status = sources[0] as GeneralTracingStatus.Status - val riskLevelScore = sources[1] as Int - val riskLevelScoreLastSuccessfulCalculated = sources[2] as Int - val isRefreshing = sources[3] as Boolean - val matchedKeyCount = sources[4] as Int - val daysSinceLastExposure = sources[5] as Int - val activeTracingDaysInRetentionPeriod = sources[6] as Long - val lastTimeDiagnosisKeysFetched = sources[7] as Date? - val isBackgroundJobEnabled = sources[8] as Boolean - val isManualKeyRetrievalEnabled = sources[9] as Boolean - val manualKeyRetrievalTime = sources[10] as Long + ) { status, + riskLevelScore, + riskLevelScoreLastSuccessfulCalculated, + isRefreshing, matchedKeyCount, + daysSinceLastExposure, activeTracingDaysInRetentionPeriod, + lastTimeDiagnosisKeysFetched, + isBackgroundJobEnabled, + isManualKeyRetrievalEnabled, + manualKeyRetrievalTime -> val isAdditionalInformationVisible = riskDetailPresenter.isAdditionalInfoVisible( riskLevelScore, matchedKeyCount @@ -63,7 +59,7 @@ class TracingDetailsStateProvider @Inject constructor( tracingStatus = status, riskLevelScore = riskLevelScore, isRefreshing = isRefreshing, - riskLevelLastSuccessfulCalculation = riskLevelScoreLastSuccessfulCalculated, + lastRiskLevelScoreCalculated = riskLevelScoreLastSuccessfulCalculated, matchedKeyCount = matchedKeyCount, daysSinceLastExposure = daysSinceLastExposure, activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod, diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragmentViewModel.kt index 2aaa447c2..c9a546293 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragmentViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragmentViewModel.kt @@ -8,7 +8,7 @@ import de.rki.coronawarnapp.tracing.GeneralTracingStatus import de.rki.coronawarnapp.ui.tracing.details.TracingDetailsState import de.rki.coronawarnapp.ui.tracing.details.TracingDetailsStateProvider import de.rki.coronawarnapp.util.coroutine.DispatcherProvider -import de.rki.coronawarnapp.util.coroutine.shareLatest +import de.rki.coronawarnapp.util.flow.shareLatest import de.rki.coronawarnapp.util.viewmodel.CWAViewModel import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory import kotlinx.coroutines.flow.SharingStarted diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/BackgroundModeStatus.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/BackgroundModeStatus.kt index a2a5954cd..13ea08dfd 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/BackgroundModeStatus.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/BackgroundModeStatus.kt @@ -2,8 +2,8 @@ package de.rki.coronawarnapp.util import android.content.Context import de.rki.coronawarnapp.util.coroutine.AppScope -import de.rki.coronawarnapp.util.coroutine.shareLatest import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.flow.shareLatest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel import kotlinx.coroutines.channels.awaitClose diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/MapExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/MapExtensions.kt new file mode 100644 index 000000000..14721ecb6 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/MapExtensions.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.util + +inline fun <K, V> Map<K, V>.mutate(block: MutableMap<K, V>.() -> Unit): Map<K, V> { + return toMutableMap().apply(block).toMap() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProvider.kt index 1826556f4..cc38c6f39 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProvider.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProvider.kt @@ -6,8 +6,8 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import de.rki.coronawarnapp.util.coroutine.AppScope -import de.rki.coronawarnapp.util.coroutine.shareLatest import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.flow.shareLatest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt index 307d74ca8..bfb0644bf 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt @@ -36,6 +36,7 @@ import de.rki.coronawarnapp.util.coroutine.CoroutineModule import de.rki.coronawarnapp.util.device.DeviceModule import de.rki.coronawarnapp.util.security.EncryptedPreferencesFactory import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool +import de.rki.coronawarnapp.util.serialization.SerializationModule import de.rki.coronawarnapp.verification.VerificationModule import javax.inject.Singleton @@ -61,7 +62,8 @@ import javax.inject.Singleton VerificationModule::class, PlaybookModule::class, TaskModule::class, - DeviceForTestersModule::class + DeviceForTestersModule::class, + SerializationModule::class ] ) interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/FlowExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt similarity index 51% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/FlowExtensions.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt index 389764028..25b36bc91 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/FlowExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt @@ -1,8 +1,9 @@ -package de.rki.coronawarnapp.util.coroutine +package de.rki.coronawarnapp.util.flow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach @@ -28,3 +29,35 @@ fun <T> Flow<T>.shareLatest( initialValue = null ) .mapNotNull { it } + +@Suppress("UNCHECKED_CAST", "MagicNumber", "LongParameterList") +inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, R> combine( + flow: Flow<T1>, + flow2: Flow<T2>, + flow3: Flow<T3>, + flow4: Flow<T4>, + flow5: Flow<T5>, + flow6: Flow<T6>, + flow7: Flow<T7>, + flow8: Flow<T8>, + flow9: Flow<T9>, + flow10: Flow<T10>, + flow11: Flow<T11>, + crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) -> R +): Flow<R> = combine( + flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10, flow11 +) { args: Array<*> -> + transform( + args[0] as T1, + args[1] as T2, + args[2] as T3, + args[3] as T4, + args[4] as T5, + args[5] as T6, + args[6] as T7, + args[7] as T8, + args[8] as T9, + args[9] as T10, + args[10] as T11 + ) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt new file mode 100644 index 000000000..217a31bf4 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt @@ -0,0 +1,68 @@ +package de.rki.coronawarnapp.util.flow + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.plus +import timber.log.Timber +import kotlin.coroutines.CoroutineContext + +class HotDataFlow<T : Any>( + loggingTag: String, + scope: CoroutineScope, + coroutineContext: CoroutineContext = Dispatchers.Default, + sharingBehavior: SharingStarted = SharingStarted.WhileSubscribed(), + private val startValueProvider: suspend CoroutineScope.() -> T +) { + private val tag = "$loggingTag:HD" + + init { + Timber.tag(tag).v("init()") + } + + private val updateActions = MutableSharedFlow<suspend (T) -> T>( + replay = Int.MAX_VALUE, + extraBufferCapacity = Int.MAX_VALUE, + onBufferOverflow = BufferOverflow.SUSPEND + ) + + private val internalFlow = channelFlow { + var currentValue = startValueProvider().also { + Timber.tag(tag).v("startValue=%s", it) + send(it) + } + + updateActions.collect { updateAction -> + currentValue = updateAction(currentValue).also { + currentValue = it + send(it) + } + } + } + + val data: Flow<T> = internalFlow + .onStart { Timber.tag(tag).v("internal onStart") } + .catch { + Timber.tag(tag).e(it, "internal Error") + throw it + } + .onCompletion { Timber.tag(tag).v("internal onCompletion") } + .shareIn( + scope = scope + coroutineContext, + replay = 1, + started = sharingBehavior + ) + .mapNotNull { it } + + fun updateSafely(update: suspend T.() -> T) = updateActions.tryEmit(update) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt new file mode 100644 index 000000000..b79e725c5 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.util.gson + +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.io.File + +inline fun <reified T> Gson.fromJson(json: String): T = fromJson( + json, + object : TypeToken<T>() {}.type +) + +inline fun <reified T> Gson.fromJson(file: File): T = file.reader().use { + fromJson(it, object : TypeToken<T>() {}.type) +} + +inline fun <reified T> Gson.toJson(data: T, file: File) = file.writer().use { writer -> + toJson(data, writer) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/location/LocationProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/location/LocationProvider.kt index d589b9073..05a8dd998 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/location/LocationProvider.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/location/LocationProvider.kt @@ -7,8 +7,8 @@ import android.content.IntentFilter import android.location.LocationManager import androidx.core.location.LocationManagerCompat import de.rki.coronawarnapp.util.coroutine.AppScope -import de.rki.coronawarnapp.util.coroutine.shareLatest import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.flow.shareLatest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/BaseGson.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/BaseGson.kt new file mode 100644 index 000000000..89c0ba514 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/BaseGson.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.util.serialization + +import javax.inject.Qualifier + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class BaseGson diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt new file mode 100644 index 000000000..e638c3811 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt @@ -0,0 +1,16 @@ +package de.rki.coronawarnapp.util.serialization + +import com.google.gson.Gson +import com.google.gson.GsonBuilder +import dagger.Module +import dagger.Provides +import dagger.Reusable + +@Module +class SerializationModule { + + @BaseGson + @Reusable + @Provides + fun baseGson(): Gson = GsonBuilder().create() +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt index 0bedabb73..092bfa0fd 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt @@ -3,6 +3,7 @@ package de.rki.coronawarnapp.environment import android.content.Context import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType import de.rki.coronawarnapp.util.CWADebug +import de.rki.coronawarnapp.util.serialization.SerializationModule import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations @@ -19,7 +20,6 @@ import testhelpers.preferences.MockSharedPreferences class EnvironmentSetupTest : BaseTest() { @MockK lateinit var context: Context private lateinit var mockPreferences: MockSharedPreferences - private fun createEnvSetup() = EnvironmentSetup(context) @BeforeEach fun setUp() { @@ -43,6 +43,11 @@ class EnvironmentSetupTest : BaseTest() { clearAllMocks() } + private fun createEnvSetup() = EnvironmentSetup( + context = context, + gson = SerializationModule().baseGson() + ) + @Test fun `parsing bad json throws an exception in debug builds`() { every { BuildConfigWrap.ENVIRONMENT_JSONDATA } returns BAD_JSON diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt index 2e92d5b0c..c9ecb45bc 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt @@ -2,21 +2,27 @@ package de.rki.coronawarnapp.nearby import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient +import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation +import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider import de.rki.coronawarnapp.nearby.modules.locationless.ScanningSupport import de.rki.coronawarnapp.nearby.modules.tracing.TracingStatus import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations +import io.mockk.Runs import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.just import io.mockk.mockk import io.mockk.verifySequence +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.toList import kotlinx.coroutines.runBlocking +import org.joda.time.Instant import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -31,11 +37,13 @@ class ENFClientTest : BaseTest() { @MockK lateinit var diagnosisKeyProvider: DiagnosisKeyProvider @MockK lateinit var tracingStatus: TracingStatus @MockK lateinit var scanningSupport: ScanningSupport + @MockK lateinit var calculationTracker: CalculationTracker @BeforeEach fun setup() { MockKAnnotations.init(this) coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns true + every { calculationTracker.trackNewCalaculation(any()) } just Runs } @AfterEach @@ -47,7 +55,8 @@ class ENFClientTest : BaseTest() { googleENFClient = googleENFClient, diagnosisKeyProvider = diagnosisKeyProvider, tracingStatus = tracingStatus, - scanningSupport = scanningSupport + scanningSupport = scanningSupport, + calculationTracker = calculationTracker ) @Test @@ -82,6 +91,23 @@ class ENFClientTest : BaseTest() { } } + @Test + fun `provide diagnosis key call is only forwarded if there are actually key files`() { + val client = createClient() + val keyFiles = emptyList<File>() + val configuration = mockk<ExposureConfiguration>() + val token = "123" + + coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns true + runBlocking { + client.provideDiagnosisKeys(keyFiles, configuration, token) shouldBe true + } + + coVerify(exactly = 0) { + diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) + } + } + @Test fun `tracing status check is forwaded to the right module`() = runBlocking { every { tracingStatus.isTracingEnabled } returns flowOf(true) @@ -105,4 +131,139 @@ class ENFClientTest : BaseTest() { scanningSupport.isLocationLessScanningSupported } } + + @Test + fun `calculation state depends on the last started calculation`() { + runBlocking { + every { calculationTracker.calculations } returns flowOf( + mapOf( + "1" to Calculation( + identifier = "1", + startedAt = Instant.EPOCH, + finishedAt = Instant.EPOCH + ), + "2" to Calculation( + identifier = "2", + startedAt = Instant.EPOCH, + finishedAt = Instant.EPOCH.plus(1) + ) + ) + ) + + createClient().isCurrentlyCalculating().first() shouldBe false + } + + runBlocking { + every { calculationTracker.calculations } returns flowOf( + mapOf( + "1" to Calculation( + identifier = "1", + startedAt = Instant.EPOCH.plus(5), + ), + "2" to Calculation( + identifier = "2", + startedAt = Instant.EPOCH.plus(4), + finishedAt = Instant.EPOCH.plus(1) + ) + ) + ) + + createClient().isCurrentlyCalculating().first() shouldBe true + } + + runBlocking { + every { calculationTracker.calculations } returns flowOf( + mapOf( + "1" to Calculation( + identifier = "1", + startedAt = Instant.EPOCH, + ), + "2" to Calculation( + identifier = "2", + startedAt = Instant.EPOCH, + finishedAt = Instant.EPOCH.plus(2) + ), + "3" to Calculation( + identifier = "3", + startedAt = Instant.EPOCH.plus(1) + ) + ) + ) + + createClient().isCurrentlyCalculating().first() shouldBe true + } + } + + @Test + fun `validate that we only get the last finished calcluation`() { + runBlocking { + every { calculationTracker.calculations } returns flowOf( + mapOf( + "1" to Calculation( + identifier = "1", + startedAt = Instant.EPOCH, + result = Calculation.Result.UPDATED_STATE, + finishedAt = Instant.EPOCH + ), + "2" to Calculation( + identifier = "2", + startedAt = Instant.EPOCH, + result = Calculation.Result.UPDATED_STATE, + finishedAt = Instant.EPOCH.plus(1) + ), + "2-timeout" to Calculation( + identifier = "2-timeout", + startedAt = Instant.EPOCH, + result = Calculation.Result.TIMEOUT, + finishedAt = Instant.EPOCH.plus(2) + ), + "3" to Calculation( + identifier = "3", + result = Calculation.Result.UPDATED_STATE, + startedAt = Instant.EPOCH.plus(2) + ) + ) + ) + + createClient().latestFinishedCalculation().first()!!.identifier shouldBe "2" + } + + runBlocking { + every { calculationTracker.calculations } returns flowOf( + mapOf( + "0" to Calculation( + identifier = "1", + result = Calculation.Result.UPDATED_STATE, + startedAt = Instant.EPOCH.plus(3) + ), + "1-timeout" to Calculation( + identifier = "1-timeout", + startedAt = Instant.EPOCH, + result = Calculation.Result.TIMEOUT, + finishedAt = Instant.EPOCH.plus(3) + ), + "1" to Calculation( + identifier = "1", + startedAt = Instant.EPOCH, + result = Calculation.Result.UPDATED_STATE, + finishedAt = Instant.EPOCH.plus(2) + ), + "2" to Calculation( + identifier = "2", + result = Calculation.Result.UPDATED_STATE, + startedAt = Instant.EPOCH, + ), + "3" to Calculation( + identifier = "3", + startedAt = Instant.EPOCH, + result = Calculation.Result.UPDATED_STATE, + finishedAt = Instant.EPOCH + ) + ) + ) + + createClient().latestFinishedCalculation().first()!!.identifier shouldBe "1" + } + } } + diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt new file mode 100644 index 000000000..7afb7e551 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt @@ -0,0 +1,35 @@ +package de.rki.coronawarnapp.nearby.modules.calculationtracker + +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import org.joda.time.Instant +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class CalculationTest : BaseTest() { + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + @Test + fun `isCalculating flag depends on finishedAt`() { + val initial = Calculation( + identifier = "123", + startedAt = Instant.EPOCH + ) + initial.isCalculating shouldBe true + + val finished = initial.copy(finishedAt = Instant.EPOCH) + finished.isCalculating shouldBe false + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt new file mode 100644 index 000000000..5b1be36e8 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt @@ -0,0 +1,133 @@ +package de.rki.coronawarnapp.nearby.modules.calculationtracker + +import android.content.Context +import com.google.gson.GsonBuilder +import de.rki.coronawarnapp.util.gson.fromJson +import de.rki.coronawarnapp.util.serialization.SerializationModule +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.Instant +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File + +class CalculationTrackerStorageTest : BaseIOTest() { + + @MockK private lateinit var context: Context + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val privateFiles = File(testDir, "files") + private val storageDir = File(privateFiles, "calcuation_tracker") + private val storageFile = File(storageDir, "calculations.json") + + private val gson = GsonBuilder().setPrettyPrinting().create() + + private val demoJsonString = """ + { + "b2b98400-058d-43e6-b952-529a5255248b": { + "identifier": "b2b98400-058d-43e6-b952-529a5255248b", + "startedAt": { + "iMillis": 1234 + } + }, + "aeb15509-fb34-42ce-8795-7a9ae0c2f389": { + "identifier": "aeb15509-fb34-42ce-8795-7a9ae0c2f389", + "startedAt": { + "iMillis": 5678 + }, + "result": "UPDATED_STATE", + "finishedAt": { + "iMillis": 1603473968125 + } + } + } + """.trimIndent() + + private val demoData = run { + val calculation1 = Calculation( + identifier = "b2b98400-058d-43e6-b952-529a5255248b", + startedAt = Instant.ofEpochMilli(1234) + ) + val calculation2 = Calculation( + identifier = "aeb15509-fb34-42ce-8795-7a9ae0c2f389", + startedAt = Instant.ofEpochMilli(5678), + finishedAt = Instant.ofEpochMilli(1603473968125), + result = Calculation.Result.UPDATED_STATE + ) + mapOf( + calculation1.identifier to calculation1, + calculation2.identifier to calculation2 + ) + } + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.filesDir } returns privateFiles + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createStorage() = CalculationTrackerStorage( + context = context, + gson = SerializationModule().baseGson() + ) + + @Test + fun `init is sideeffect free`() = runBlockingTest { + createStorage() + storageDir.exists() shouldBe false + createStorage().save(emptyMap()) + storageDir.exists() shouldBe true + } + + @Test + fun `load empty on non-existing file`() = runBlockingTest { + createStorage().load() shouldBe emptyMap() + } + + @Test + fun `save on unchanged data does nothing`() = runBlockingTest { + storageDir.mkdirs() + storageFile.writeText(demoJsonString) + + createStorage().apply { + load() + + storageFile.delete() + + save(demoData) + storageFile.exists() shouldBe false + } + } + + @Test + fun `saving data creates a json file`() = runBlockingTest { + + createStorage().save(demoData) + storageFile.exists() shouldBe true + + val storedData: Map<String, Calculation> = gson.fromJson(storageFile) + + storedData shouldBe demoData + gson.toJson(storedData) shouldBe demoJsonString + } + + @Test + fun `gson does weird things to property initialization`() { + // This makes sure we are using val-getters, otherwise gson inits our @Ŧransient properties to false + val storedData: Map<String, Calculation> = gson.fromJson(demoJsonString) + storedData.getValue("b2b98400-058d-43e6-b952-529a5255248b").isCalculating shouldBe true + storedData.getValue("aeb15509-fb34-42ce-8795-7a9ae0c2f389").isCalculating shouldBe false + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt new file mode 100644 index 000000000..5ae6cad6a --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt @@ -0,0 +1,265 @@ +package de.rki.coronawarnapp.nearby.modules.calculationtracker + +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.mutate +import io.kotest.matchers.shouldBe +import io.mockk.Called +import io.mockk.MockKAnnotations +import io.mockk.Ordering +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.Duration +import org.joda.time.Instant +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.TestDispatcherProvider +import testhelpers.coroutines.runBlockingTest2 +import java.util.UUID + +class DefaultCalculationTrackerTest : BaseTest() { + + @MockK lateinit var storage: CalculationTrackerStorage + @MockK lateinit var timeStamper: TimeStamper + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + every { timeStamper.nowUTC } returns Instant.EPOCH + coEvery { storage.load() } returns emptyMap() + coEvery { storage.save(any()) } just Runs + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createInstance(scope: CoroutineScope) = DefaultCalculationTracker( + scope = scope, + dispatcherProvider = TestDispatcherProvider, + storage = storage, + timeStamper = timeStamper + ) + + @Test + fun `side effect free init`() = runBlockingTest { + createInstance(scope = this) + verify { storage wasNot Called } + verify { timeStamper wasNot Called } + } + + @Test + fun `data is restored from storage`() = runBlockingTest2(permanentJobs = true) { + val calcData = Calculation( + identifier = UUID.randomUUID().toString(), + startedAt = Instant.EPOCH + ) + val initialData = mapOf(calcData.identifier to calcData) + coEvery { storage.load() } returns initialData + + createInstance(scope = this).calculations.first() shouldBe initialData + } + + @Test + fun `tracking a new calculation`() = runBlockingTest2(permanentJobs = true) { + createInstance(scope = this).apply { + val expectedIdentifier = UUID.randomUUID().toString() + trackNewCalaculation(expectedIdentifier) + + advanceUntilIdle() + + val calculationData = calculations.first() + + calculationData.entries.single().apply { + key shouldBe expectedIdentifier + value shouldBe Calculation( + identifier = expectedIdentifier, + startedAt = Instant.EPOCH + ) + } + + coVerify(ordering = Ordering.ORDERED) { + storage.load() + storage.save(emptyMap()) + timeStamper.nowUTC + storage.save(calculationData) + } + advanceUntilIdle() + } + } + + @Test + fun `finish an existing calcluation`() = runBlockingTest2(permanentJobs = true) { + val calcData = Calculation( + identifier = UUID.randomUUID().toString(), + startedAt = Instant.EPOCH + ) + val initialData = mapOf(calcData.identifier to calcData) + coEvery { storage.load() } returns initialData + + val expectedData = initialData.mutate { + this[calcData.identifier] = this[calcData.identifier]!!.copy( + finishedAt = Instant.EPOCH.plus(1), + result = Calculation.Result.UPDATED_STATE + ) + } + + + every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1) + + createInstance(scope = this).apply { + finishCalculation(calcData.identifier, Calculation.Result.UPDATED_STATE) + + advanceUntilIdle() + + calculations.first() shouldBe expectedData + + coVerify(ordering = Ordering.ORDERED) { + storage.load() + storage.save(any()) + timeStamper.nowUTC + storage.save(expectedData) + } + advanceUntilIdle() + } + } + + @Test + fun `a late calculation overwrites timeout state`() = runBlockingTest2(permanentJobs = true) { + val calcData = Calculation( + identifier = UUID.randomUUID().toString(), + startedAt = Instant.EPOCH, + finishedAt = Instant.EPOCH.plus(1), + result = Calculation.Result.TIMEOUT + ) + val initialData = mapOf(calcData.identifier to calcData) + coEvery { storage.load() } returns initialData + + every { timeStamper.nowUTC } returns Instant.EPOCH.plus(2) + + val expectedData = initialData.mutate { + this[calcData.identifier] = this[calcData.identifier]!!.copy( + finishedAt = Instant.EPOCH.plus(2), + result = Calculation.Result.UPDATED_STATE + ) + } + + createInstance(scope = this).apply { + finishCalculation(calcData.identifier, Calculation.Result.UPDATED_STATE) + + advanceUntilIdle() + + calculations.first() shouldBe expectedData + } + } + + @Test + fun `no more than 10 calcluations are tracked`() = runBlockingTest2(permanentJobs = true) { + val calcData = (1..15L).map { + val calcData = Calculation( + identifier = "$it", + startedAt = Instant.EPOCH.plus(it) + ) + calcData.identifier to calcData + }.toMap() + + coEvery { storage.load() } returns calcData + + every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1) + createInstance(scope = this).apply { + finishCalculation("7", Calculation.Result.UPDATED_STATE) + + advanceUntilIdle() + + val data = calculations.first() + data.size shouldBe 5 + data.values.map { it.identifier }.toList() shouldBe (11..15).map { "$it" }.toList() + } + } + + @Test + fun `15 minute timeout on ongoing calcs`() = runBlockingTest2(permanentJobs = true) { + every { timeStamper.nowUTC } returns Instant.EPOCH + .plus(Duration.standardMinutes(15)) + .plus(2) + + // First half will be in the timeout, last half will be ok + val timeoutOnRunningCalc = Calculation( + identifier = "0", + startedAt = Instant.EPOCH + ) + val timeoutonRunningCalc2 = Calculation( + identifier = "1", + startedAt = Instant.EPOCH.plus(1) + ) + // We shouldn't care for timeouts on finished calculations + val timeoutIgnoresFinishedCalcs = Calculation( + identifier = "2", + startedAt = Instant.EPOCH.plus(1), + finishedAt = Instant.EPOCH.plus(15) + ) + + // This one is right on the edge, testing <= behavior + val timeoutRunningOnEdge = Calculation( + identifier = "3", + startedAt = Instant.EPOCH.plus(2) + ) + + val noTimeoutCalcRunning = Calculation( + identifier = "4", + startedAt = Instant.EPOCH.plus(4) + ) + val noTimeOutCalcFinished = Calculation( + identifier = "5", + startedAt = Instant.EPOCH.plus(5), + finishedAt = Instant.EPOCH.plus(15) + ) + + val calcData = mapOf( + timeoutOnRunningCalc.identifier to timeoutOnRunningCalc, + timeoutonRunningCalc2.identifier to timeoutonRunningCalc2, + timeoutIgnoresFinishedCalcs.identifier to timeoutIgnoresFinishedCalcs, + timeoutRunningOnEdge.identifier to timeoutRunningOnEdge, + noTimeoutCalcRunning.identifier to noTimeoutCalcRunning, + noTimeOutCalcFinished.identifier to noTimeOutCalcFinished, + ) + + coEvery { storage.load() } returns calcData + + createInstance(scope = this).apply { + advanceUntilIdle() + + calculations.first().apply { + size shouldBe 6 + + this["0"] shouldBe timeoutOnRunningCalc.copy( + finishedAt = timeStamper.nowUTC, + result = Calculation.Result.TIMEOUT + ) + this["1"] shouldBe timeoutonRunningCalc2.copy( + finishedAt = timeStamper.nowUTC, + result = Calculation.Result.TIMEOUT + ) + this["2"] shouldBe timeoutIgnoresFinishedCalcs + + this["3"] shouldBe timeoutRunningOnEdge + + + this["4"] shouldBe noTimeoutCalcRunning + this["5"] shouldBe noTimeOutCalcFinished + } + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt index 6ad52b84c..f0802e4c3 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt @@ -1,57 +1,95 @@ package de.rki.coronawarnapp.receiver +import android.app.Application import android.content.Context import android.content.Intent import androidx.work.WorkManager import androidx.work.WorkRequest import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient +import dagger.android.AndroidInjector +import dagger.android.HasAndroidInjector +import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation +import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker +import de.rki.coronawarnapp.util.di.AppInjector import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk +import io.mockk.mockkObject import io.mockk.mockkStatic -import io.mockk.unmockkAll -import io.mockk.verify -import org.junit.After -import org.junit.Before -import org.junit.Test +import io.mockk.verifySequence +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.TestDispatcherProvider /** * ExposureStateUpdateReceiver test. */ -class ExposureStateUpdateReceiverTest { - @MockK - private lateinit var context: Context +class ExposureStateUpdateReceiverTest : BaseTest() { - @MockK - private lateinit var intent: Intent + @MockK private lateinit var context: Context - @Before + @MockK private lateinit var intent: Intent + @MockK private lateinit var workManager: WorkManager + @MockK private lateinit var calculationTracker: CalculationTracker + private val scope = TestCoroutineScope() + + class TestApp : Application(), HasAndroidInjector { + override fun androidInjector(): AndroidInjector<Any> { + // NOOP + return mockk() + } + } + + @BeforeEach fun setUp() { MockKAnnotations.init(this) mockkStatic(WorkManager::class) - every { intent.action } returns ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED every { intent.getStringExtra(ExposureNotificationClient.EXTRA_TOKEN) } returns "token" + + mockkObject(AppInjector) + + val application = mockk<TestApp>() + every { context.applicationContext } returns application + val broadcastReceiverInjector = AndroidInjector<Any> { + it as ExposureStateUpdateReceiver + it.calculationTracker = calculationTracker + it.dispatcherProvider = TestDispatcherProvider + it.scope = scope + } + every { application.androidInjector() } returns broadcastReceiverInjector + + every { WorkManager.getInstance(context) } returns workManager + every { workManager.enqueue(any<WorkRequest>()) } answers { mockk() } } - /** - * Test ExposureStateUpdateReceiver. - */ - @Test - fun testExposureStateUpdateReceiver() { - val wm = mockk<WorkManager>() - every { WorkManager.getInstance(context) } returns wm - every { wm.enqueue(any<WorkRequest>()) } answers { mockk() } + @AfterEach + fun teardown() { + clearAllMocks() + } + @Test + fun `updated state intent`() { + every { intent.action } returns ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED ExposureStateUpdateReceiver().onReceive(context, intent) - verify { - wm.enqueue(any<WorkRequest>()) + verifySequence { + workManager.enqueue(any<WorkRequest>()) + calculationTracker.finishCalculation("token", Calculation.Result.UPDATED_STATE) } } - @After - fun cleanUp() { - unmockkAll() + @Test + fun `no matches intent`() { + every { intent.action } returns ExposureNotificationClient.ACTION_EXPOSURE_NOT_FOUND + ExposureStateUpdateReceiver().onReceive(context, intent) + + verifySequence { + calculationTracker.finishCalculation("token", Calculation.Result.NO_MATCHES) + } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt index e823164d0..c2609308b 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt @@ -143,7 +143,7 @@ class TaskControllerTest : BaseIOTest() { taskState.resultOrThrow shouldBe null } } - val progressCollector = infoRunning.progress.test(scope = this) + val progressCollector = infoRunning.progress.test(startOnScope = this) this.advanceUntilIdle() @@ -153,7 +153,7 @@ class TaskControllerTest : BaseIOTest() { arguments.path.exists() shouldBe true - val lastProgressMessage = progressCollector.values().last().primaryMessage.get(mockk()) + val lastProgressMessage = progressCollector.latestValue!!.primaryMessage.get(mockk()) lastProgressMessage shouldBe arguments.values.last() infoFinished.apply { diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/GeneralTracingStatusTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/GeneralTracingStatusTest.kt index c78e820d6..f7f0ad930 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/GeneralTracingStatusTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/GeneralTracingStatusTest.kt @@ -55,14 +55,14 @@ class GeneralTracingStatusTest : BaseTest() { @Test fun `flow updates work`() = runBlockingTest { - val testCollector = createInstance().generalStatus.test(scope = this) + val testCollector = createInstance().generalStatus.test(startOnScope = this) isBluetoothEnabled.emit(false) isBluetoothEnabled.emit(true) advanceUntilIdle() - testCollector.values() shouldBe listOf( + testCollector.latestValues shouldBe listOf( GeneralTracingStatus.Status.TRACING_ACTIVE, GeneralTracingStatus.Status.BLUETOOTH_DISABLED, GeneralTracingStatus.Status.TRACING_ACTIVE diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionCountrySelectViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionCountrySelectViewModelTest.kt index 6fc0545ed..dc2a0ec37 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionCountrySelectViewModelTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionCountrySelectViewModelTest.kt @@ -5,7 +5,6 @@ import de.rki.coronawarnapp.ui.submission.SubmissionCountry import io.kotest.inspectors.forAll import io.kotest.inspectors.forAtLeastOne import io.kotest.inspectors.forAtMostOne -import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.shouldBe import org.junit.Rule import org.junit.Test @@ -20,7 +19,7 @@ class SubmissionCountrySelectViewModelTest { viewModel.fetchCountries() // TODO: implement proper test one backend is merged - viewModel.countries.value!!.shouldHaveSize(2) + viewModel.countries.value!!.size shouldBe 2 } @Test diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt index a5a0bf6d8..aba526c2f 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt @@ -51,7 +51,7 @@ class TracingCardStateTest : BaseTest() { tracingStatus = tracingStatus, riskLevelScore = riskLevel, isRefreshing = isRefreshing, - riskLevelLastSuccessfulCalculation = riskLevelLastSuccessfulCalculation, + lastRiskLevelScoreCalculated = riskLevelLastSuccessfulCalculation, matchedKeyCount = matchedKeyCount, daysSinceLastExposure = daysSinceLastExposure, activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod, diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt index ce64eba22..7c1f6d463 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt @@ -58,7 +58,7 @@ class BaseTracingStateTest : BaseTest() { override val tracingStatus: GeneralTracingStatus.Status = tracingStatus override val riskLevelScore: Int = riskLevelScore override val isRefreshing: Boolean = isRefreshing - override val riskLevelLastSuccessfulCalculation: Int = riskLevelLastSuccessfulCalculation + override val lastRiskLevelScoreCalculated: Int = riskLevelLastSuccessfulCalculation override val matchedKeyCount: Int = matchedKeyCount override val daysSinceLastExposure: Int = daysSinceLastExposure override val activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt index 3cf9e6593..140638120 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt @@ -53,7 +53,7 @@ class TracingDetailsStateTest : BaseTest() { tracingStatus = tracingStatus, riskLevelScore = riskLevelScore, isRefreshing = isRefreshing, - riskLevelLastSuccessfulCalculation = riskLevelLastSuccessfulCalculation, + lastRiskLevelScoreCalculated = riskLevelLastSuccessfulCalculation, matchedKeyCount = matchedKeyCount, daysSinceLastExposure = daysSinceLastExposure, activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod, diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProviderTest.kt index fc0039c4e..8ada1306d 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProviderTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProviderTest.kt @@ -93,7 +93,7 @@ class BluetoothProviderTest : BaseTest() { fun `system callbacks lead to new emissions with an updated state`() = runBlockingTest { val instance = createInstance() - val testCollector = instance.isBluetoothEnabled.test(scope = this) + val testCollector = instance.isBluetoothEnabled.test(startOnScope = this) lastFilter!!.hasAction(BluetoothAdapter.ACTION_STATE_CHANGED) @@ -103,7 +103,7 @@ class BluetoothProviderTest : BaseTest() { onReceive(mockk(), mockBluetoothIntent(enabled = null)) } - testCollector.values() shouldBe listOf(true, false, true) + testCollector.latestValues shouldBe listOf(true, false, true) instance.isBluetoothEnabled.first() shouldBe true diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt new file mode 100644 index 000000000..f50fa2763 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt @@ -0,0 +1,158 @@ +package de.rki.coronawarnapp.util.flow + +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.instanceOf +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.coroutines.runBlockingTest2 +import testhelpers.coroutines.test +import java.io.IOException +import java.lang.Thread.sleep +import kotlin.concurrent.thread + +class HotDataFlowTest : BaseTest() { + + @Test + fun `init call only happens on first collection`() { + val testScope = TestCoroutineScope() + val hotData = HotDataFlow<String>( + loggingTag = "tag", + scope = testScope, + coroutineContext = Dispatchers.Unconfined, + startValueProvider = { + throw IOException() + } + ) + + testScope.apply { + runBlockingTest2(permanentJobs = true) { + hotData.data.first() + } + uncaughtExceptions.single() shouldBe instanceOf(IOException::class) + } + } + + @Test + fun `subscription ends when no subscriber is collecting, mode WhileSubscribed`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk<suspend CoroutineScope.() -> String>() + coEvery { valueProvider.invoke(any()) } returns "Test" + + val hotData = HotDataFlow( + loggingTag = "tag", + scope = testScope, + coroutineContext = Dispatchers.Unconfined, + startValueProvider = valueProvider, + sharingBehavior = SharingStarted.WhileSubscribed() + ) + + testScope.apply { + runBlockingTest2(permanentJobs = true) { + hotData.data.first() shouldBe "Test" + hotData.data.first() shouldBe "Test" + } + coVerify(exactly = 2) { valueProvider.invoke(any()) } + } + } + + @Test + fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk<suspend CoroutineScope.() -> String>() + coEvery { valueProvider.invoke(any()) } returns "Test" + + val hotData = HotDataFlow( + loggingTag = "tag", + scope = testScope, + coroutineContext = Dispatchers.Unconfined, + startValueProvider = valueProvider, + sharingBehavior = SharingStarted.Lazily + ) + + testScope.apply { + runBlockingTest2(permanentJobs = true) { + hotData.data.first() shouldBe "Test" + hotData.data.first() shouldBe "Test" + } + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + } + + @Test + fun `value updates`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk<suspend CoroutineScope.() -> Long>() + coEvery { valueProvider.invoke(any()) } returns 1 + + val hotData = HotDataFlow( + loggingTag = "tag", + scope = testScope, + startValueProvider = valueProvider, + sharingBehavior = SharingStarted.Lazily + ) + + val testCollector = hotData.data.test(startOnScope = testScope) + testCollector.silent = true + + (1..16).forEach { _ -> + thread { + (1..200).forEach { _ -> + sleep(10) + hotData.updateSafely { + this + 1L + } + } + } + } + + runBlocking { + testCollector.await { list, l -> list.size == 3201 } + testCollector.latestValues shouldBe (1..3201).toList() + } + + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } + + @Test + fun `multiple subscribers share the flow`() { + val testScope = TestCoroutineScope() + val valueProvider = mockk<suspend CoroutineScope.() -> String>() + coEvery { valueProvider.invoke(any()) } returns "Test" + + val hotData = HotDataFlow( + loggingTag = "tag", + scope = testScope, + startValueProvider = valueProvider, + sharingBehavior = SharingStarted.Lazily + ) + + + testScope.runBlockingTest2(permanentJobs = true) { + val sub1 = hotData.data.test().start(scope = this) + val sub2 = hotData.data.test().start(scope = this) + val sub3 = hotData.data.test().start(scope = this) + + hotData.updateSafely { "A" } + hotData.updateSafely { "B" } + hotData.updateSafely { "C" } + + listOf(sub1, sub2, sub3).forEach { + it.await { list, s -> list.size == 4 } + it.latestValues shouldBe listOf("Test", "A", "B", "C") + it.cancel() + } + + hotData.data.first() shouldBe "C" + } + coVerify(exactly = 1) { valueProvider.invoke(any()) } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/location/LocationProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/location/LocationProviderTest.kt index 32c48e901..ed98e17e1 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/location/LocationProviderTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/location/LocationProviderTest.kt @@ -95,7 +95,7 @@ class LocationProviderTest : BaseTest() { mockLocationStatus(enabled = true) - val testCollector = instance.isLocationEnabled.test(scope = this) + val testCollector = instance.isLocationEnabled.test(startOnScope = this) lastFilter!!.hasAction(BluetoothAdapter.ACTION_STATE_CHANGED) @@ -105,7 +105,7 @@ class LocationProviderTest : BaseTest() { onReceive(mockk(), mockLocationChange(enabled = null)) } - testCollector.values() shouldBe listOf(true, false, true) + testCollector.latestValues shouldBe listOf(true, false, true) instance.isLocationEnabled.first() shouldBe true diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt index 8ca8366ca..975a8c48d 100644 --- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt +++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt @@ -3,46 +3,60 @@ package testhelpers.coroutines import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import timber.log.Timber -suspend fun <T> Flow<T>.test( +fun <T> Flow<T>.test( tag: String? = null, - scope: CoroutineScope -): TestCollector<T> = TestCollector(this, scope, tag ?: "FlowTest").apply { - ensureSetup() -} + startOnScope: CoroutineScope +): TestCollector<T> = test(tag ?: "FlowTest").start(scope = startOnScope) + +fun <T> Flow<T>.test( + tag: String? = null +): TestCollector<T> = TestCollector(this, tag ?: "FlowTest") class TestCollector<T>( private val flow: Flow<T>, - private val scope: CoroutineScope, private val tag: String ) { - private val mutex = Mutex() - private var isSetupDone = false - private val collectedValues = mutableListOf<T>() private var error: Throwable? = null private lateinit var job: Job + private val cache = MutableSharedFlow<T>( + replay = Int.MAX_VALUE, + extraBufferCapacity = Int.MAX_VALUE, + onBufferOverflow = BufferOverflow.SUSPEND + ) + private var latestInternal: T? = null + private val collectedValuesMutex = Mutex() + private val collectedValues = mutableListOf<T>() - suspend fun ensureSetup() = mutex.withLock { - if (isSetupDone) return@withLock - isSetupDone = true + var silent = false - Timber.tag(tag).v("Setting up.") + fun start(scope: CoroutineScope) = apply { flow + .buffer(capacity = Int.MAX_VALUE) + .onStart { Timber.tag(tag).v("Setting up.") } + .onCompletion { Timber.tag(tag).d("Final.") } .onEach { - Timber.tag(tag).v("Collecting: %s", it) - collectedValues.add(it) - } - .onCompletion { - Timber.tag(tag).d("Final.") + collectedValuesMutex.withLock { + if (!silent) Timber.tag(tag).v("Collecting: %s", it) + latestInternal = it + collectedValues.add(it) + cache.emit(it) + } } .catch { e -> Timber.tag(tag).w(e, "Caught error.") @@ -52,24 +66,36 @@ class TestCollector<T>( .also { job = it } } + fun emissions(): Flow<T> = cache + + val latestValue: T? + get() = collectedValues.last() + + val latestValues: List<T> + get() = collectedValues + + fun await(condition: (List<T>, T) -> Boolean): T = runBlocking { + emissions().first { + condition(collectedValues, it) + } + } + suspend fun awaitFinal() = apply { - ensureSetup() - job.join() + try { + job.join() + } catch (e: Exception) { + error = e + } } suspend fun assertNoErrors() = apply { - ensureSetup() awaitFinal() require(error == null) { "Error was not null: $error" } } - suspend fun values(): List<T> { - ensureSetup() - return collectedValues - } - - suspend fun cancel() { - ensureSetup() - job.cancelAndJoin() + fun cancel() { + runBlocking { + job.cancelAndJoin() + } } } diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt new file mode 100644 index 000000000..bcf45781d --- /dev/null +++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt @@ -0,0 +1,44 @@ +package testhelpers.coroutines + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.UncompletedCoroutinesError +import kotlinx.coroutines.test.runBlockingTest +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 +fun TestCoroutineScope.runBlockingTest2( + permanentJobs: Boolean = false, + block: suspend TestCoroutineScope.() -> Unit +): Unit = runBlockingTest2( + permanentJobs = permanentJobs, + context = coroutineContext, + testBody = block +) + +fun runBlockingTest2( + permanentJobs: Boolean = false, + context: CoroutineContext = EmptyCoroutineContext, + testBody: suspend TestCoroutineScope.() -> Unit +) { + try { + runBlocking { + try { + runBlockingTest( + context = context, + testBody = testBody + ) + } catch (e: UncompletedCoroutinesError) { + if (!permanentJobs) throw e + } + } + } catch (e: Exception) { + if (!permanentJobs || (e.message != "This job has not completed yet")) { + throw e + } + } +} + + -- GitLab