From a40222ae283270ea37c85c5b9d887fde6a228d1c Mon Sep 17 00:00:00 2001 From: Matthias Urhahn <matthias.urhahn@sap.com> Date: Mon, 14 Dec 2020 12:45:41 +0100 Subject: [PATCH] Fix crash when accessing TEK history without tracing enabled (EXPOSUREAPP-4284) (#1886) * Fix crash when trying to update TEK history without tracing enabled. Refactor tracing permission requests and then re-use the tracing permission requests within the TEK updater logic. * Fix test regressions. * Implement additional unit tests for TEKHistoryUpdater.kt * Additional unit tests for ENF related permission requests. * Additional tests for TEKHistory Updater/TracingPermission callbacks. * Additional tests for TracingPermissionHelper.kt handleActivityResult calls. --- .../submission/ui/SubmissionTestFragment.kt | 12 +- .../ui/SubmissionTestFragmentViewModel.kt | 66 +++--- .../nearby/TracingPermissionHelper.kt | 81 +++---- .../data/tekhistory/TEKHistoryUpdater.kt | 146 ++++++++----- .../tracing/ui/TracingConsentDialog.kt | 25 +++ .../OnboardingTracingFragmentViewModel.kt | 22 +- .../SubmissionTestResultAvailableFragment.kt | 7 + .../SubmissionTestResultAvailableViewModel.kt | 61 +++--- ...ltPositiveOtherWarningNoConsentFragment.kt | 8 + ...tPositiveOtherWarningNoConsentViewModel.kt | 48 +++-- .../settings/SettingsTracingFragment.kt | 27 +-- .../SettingsTracingFragmentViewModel.kt | 65 +++--- .../nearby/TracingPermissionHelperTest.kt | 198 ++++++++++++++++-- .../data/tekhistory/TEKHistoryUpdaterTest.kt | 173 ++++++++++++++- ...missionTestResultAvailableViewModelTest.kt | 10 +- 15 files changed, 683 insertions(+), 266 deletions(-) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/TracingConsentDialog.kt diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/submission/ui/SubmissionTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/submission/ui/SubmissionTestFragment.kt index 8a4c1ede0..8e13984ff 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/submission/ui/SubmissionTestFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/submission/ui/SubmissionTestFragment.kt @@ -9,6 +9,7 @@ import androidx.recyclerview.widget.LinearLayoutManager import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.FragmentTestSubmissionBinding import de.rki.coronawarnapp.test.menu.ui.TestMenuItem +import de.rki.coronawarnapp.tracing.ui.TracingConsentDialog import de.rki.coronawarnapp.util.di.AutoInject import de.rki.coronawarnapp.util.lists.diffutil.update import de.rki.coronawarnapp.util.ui.observe2 @@ -56,10 +57,19 @@ class SubmissionTestFragment : Fragment(R.layout.fragment_test_submission), Auto } binding.apply { - tekStorageUpdate.setOnClickListener { vm.updateStorage(requireActivity()) } + tekStorageUpdate.setOnClickListener { vm.updateStorage() } tekStorageClear.setOnClickListener { vm.clearStorage() } tekStorageEmail.setOnClickListener { vm.emailTEKs() } } + vm.permissionRequestEvent.observe2(this) { permissionRequest -> + permissionRequest.invoke(requireActivity()) + } + vm.showTracingConsentDialog.observe2(this) { consentResult -> + TracingConsentDialog(requireContext()).show( + onConsentGiven = { consentResult(true) }, + onConsentDeclined = { consentResult(false) } + ) + } } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/submission/ui/SubmissionTestFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/submission/ui/SubmissionTestFragmentViewModel.kt index 030989be8..f57f63744 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/submission/ui/SubmissionTestFragmentViewModel.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/submission/ui/SubmissionTestFragmentViewModel.kt @@ -10,7 +10,6 @@ import com.squareup.inject.assisted.AssistedInject import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.submission.data.tekhistory.TEKHistoryStorage import de.rki.coronawarnapp.submission.data.tekhistory.TEKHistoryUpdater -import de.rki.coronawarnapp.submission.data.tekhistory.TEKHistoryUpdater.UpdateResult import de.rki.coronawarnapp.util.coroutine.DispatcherProvider import de.rki.coronawarnapp.util.serialization.BaseGson import de.rki.coronawarnapp.util.ui.SingleLiveEvent @@ -25,7 +24,7 @@ import java.util.UUID class SubmissionTestFragmentViewModel @AssistedInject constructor( dispatcherProvider: DispatcherProvider, private val tekHistoryStorage: TEKHistoryStorage, - private val tekHistoryUpdater: TEKHistoryUpdater, + tekHistoryUpdaterFactory: TEKHistoryUpdater.Factory, @BaseGson baseGson: Gson ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { @@ -33,12 +32,37 @@ class SubmissionTestFragmentViewModel @AssistedInject constructor( setPrettyPrinting() }.create() + private val tekHistoryUpdater = tekHistoryUpdaterFactory.create(object : TEKHistoryUpdater.Callback { + override fun onTEKAvailable(teks: List<TemporaryExposureKey>) { + Timber.d("TEKs are available: %s", teks) + } + + override fun onTEKPermissionDeclined() { + Timber.d("Permission were declined.") + } + + override fun onTracingConsentRequired(onConsentResult: (given: Boolean) -> Unit) { + showTracingConsentDialog.postValue(onConsentResult) + } + + override fun onPermissionRequired(permissionRequest: (Activity) -> Unit) { + permissionRequestEvent.postValue(permissionRequest) + } + + override fun onError(error: Throwable) { + errorEvents.postValue(error) + } + }) + val errorEvents = SingleLiveEvent<Throwable>() private val internalToken = MutableStateFlow(LocalData.registrationToken()) val currentTestId = internalToken.asLiveData() val shareTEKsEvent = SingleLiveEvent<TEKExport>() + val permissionRequestEvent = SingleLiveEvent<(Activity) -> Unit>() + val showTracingConsentDialog = SingleLiveEvent<(Boolean) -> Unit>() + val tekHistory: LiveData<List<TEKHistoryItem>> = tekHistoryStorage.tekData .map { items -> items.flatMap { batch -> @@ -55,22 +79,6 @@ class SubmissionTestFragmentViewModel @AssistedInject constructor( .map { historyItems -> historyItems.sortedBy { it.obtainedAt } } .asLiveData(context = dispatcherProvider.Default) - init { - tekHistoryUpdater.callback = object : TEKHistoryUpdater.Callback { - override fun onTEKAvailable(teks: List<TemporaryExposureKey>) { - Timber.d("TEKs are available: %s", teks) - } - - override fun onPermissionDeclined() { - Timber.d("Permission were declined.") - } - - override fun onError(error: Throwable) { - errorEvents.postValue(error) - } - } - } - fun scrambleRegistrationToken() { LocalData.registrationToken(UUID.randomUUID().toString()) internalToken.value = LocalData.registrationToken() @@ -81,10 +89,8 @@ class SubmissionTestFragmentViewModel @AssistedInject constructor( internalToken.value = LocalData.registrationToken() } - fun updateStorage(activity: Activity) { - tekHistoryUpdater.updateTEKHistoryOrRequestPermission { permissionRequest -> - permissionRequest.invoke(activity) - } + fun updateStorage() { + tekHistoryUpdater.updateTEKHistoryOrRequestPermission() } fun clearStorage() { @@ -105,21 +111,9 @@ class SubmissionTestFragmentViewModel @AssistedInject constructor( } fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { - val result = tekHistoryUpdater.handleActivityResult(requestCode, resultCode, data) - Timber.d("tekHistoryUpdater.handleActivityResult(): %s", result) - - if (result == UpdateResult.PERMISSION_AVAILABLE) { - launch { - try { - tekHistoryUpdater.updateHistoryOrThrow() - } catch (e: Exception) { - Timber.e(e, "updateHistoryOrThrow() threw :O") - errorEvents.postValue(e) - } - } + return tekHistoryUpdater.handleActivityResult(requestCode, resultCode, data).also { + Timber.d("tekHistoryUpdater.handleActivityResult(): %s", it) } - - return result != UpdateResult.UNKNOWN_RESULT } @AssistedInject.Factory diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/TracingPermissionHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/TracingPermissionHelper.kt index 96d331d90..a7c968e5c 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/TracingPermissionHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/TracingPermissionHelper.kt @@ -2,91 +2,96 @@ package de.rki.coronawarnapp.nearby import android.app.Activity import android.content.Intent +import androidx.annotation.VisibleForTesting +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.util.coroutine.AppScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber -import javax.inject.Inject -class TracingPermissionHelper @Inject constructor( +class TracingPermissionHelper @AssistedInject constructor( + @Assisted private val callback: Callback, private val enfClient: ENFClient, @AppScope private val scope: CoroutineScope ) { - var callback: Callback? = null - - fun startTracing( - onUserPermissionRequired: (permissionRequest: (Activity) -> Unit) -> Unit - ) { + fun startTracing() { scope.launch { if (enfClient.isTracingEnabled.first()) { - callback?.onUpdateTracingStatus(true) + callback.onUpdateTracingStatus(true) } else { - enableTracing(onUserPermissionRequired) + if (isConsentGiven()) { + enableTracing() + } else { + callback.onTracingConsentRequired { given: Boolean -> + Timber.tag(TAG).d("Consent result: $given") + if (given) enableTracing() + } + } } } } - private fun enableTracing( - onUserPermissionRequired: ((permissionRequest: (Activity) -> Unit) -> Unit)? - ) { + private fun enableTracing() { enfClient.setTracing( true, - onSuccess = { callback?.onUpdateTracingStatus(true) }, - onError = { callback?.onError(it) }, + onSuccess = { callback.onUpdateTracingStatus(true) }, + onError = { callback.onError(it) }, onPermissionRequired = { status -> - if (onUserPermissionRequired != null) { - val permissionRequestTrigger: (Activity) -> Unit = { - status.startResolutionForResult(it, TRACING_PERMISSION_REQUESTCODE) - } - onUserPermissionRequired(permissionRequestTrigger) - } else { - callback?.onError( - IllegalStateException("Permission were granted but we are still not allowed to enable tracing.") - ) + Timber.tag(TAG).d("Permission is required, starting user resolution.") + val permissionRequestTrigger: (Activity) -> Unit = { + status.startResolutionForResult(it, TRACING_PERMISSION_REQUESTCODE) } + callback.onPermissionRequired(permissionRequestTrigger) } ) } - fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?): UpdateResult { + fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { Timber.v( "handleActivityResult(requesutCode=%d, resultCode=%d, data=%s)", requestCode, resultCode, data ) if (requestCode != TRACING_PERMISSION_REQUESTCODE) { Timber.tag(TAG).w("Not our request code ($requestCode): %s", data) - return UpdateResult.UNKNOWN_RESULT + return false } - return if (resultCode == Activity.RESULT_OK) { + if (resultCode == Activity.RESULT_OK) { Timber.tag(TAG).w("User granted permission (== RESULT_OK): %s", data) - - enableTracing(null) - UpdateResult.PERMISSION_AVAILABLE + enableTracing() } else { Timber.tag(TAG).w("User declined permission (!= RESULT_OK): %s", data) - - callback?.onUpdateTracingStatus(false) - UpdateResult.PERMISSION_DECLINED + callback.onUpdateTracingStatus(false) } + return true } - enum class UpdateResult { - PERMISSION_AVAILABLE, - PERMISSION_DECLINED, - UNKNOWN_RESULT + private fun isConsentGiven(): Boolean { + val firstTracingActivationAt = LocalData.initialTracingActivationTimestamp() + Timber.tag(TAG).v("isConsentGiven(): First tracing activationat: %d", firstTracingActivationAt) + return firstTracingActivationAt != null } interface Callback { fun onUpdateTracingStatus(isTracingEnabled: Boolean) - + fun onTracingConsentRequired(onConsentResult: (given: Boolean) -> Unit) + fun onPermissionRequired(permissionRequest: (Activity) -> Unit) fun onError(error: Throwable) } companion object { private const val TAG = "TracingPermissionHelper" - const val TRACING_PERMISSION_REQUESTCODE = 3010 + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal const val TRACING_PERMISSION_REQUESTCODE = 3010 + } + + @AssistedInject.Factory + interface Factory { + fun create(callback: Callback): TracingPermissionHelper } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/data/tekhistory/TEKHistoryUpdater.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/data/tekhistory/TEKHistoryUpdater.kt index e8391c10c..0ffae4187 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/data/tekhistory/TEKHistoryUpdater.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/data/tekhistory/TEKHistoryUpdater.kt @@ -2,73 +2,86 @@ package de.rki.coronawarnapp.submission.data.tekhistory import android.app.Activity import android.content.Intent +import androidx.annotation.VisibleForTesting import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.nearby.TracingPermissionHelper import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.coroutine.AppScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber import java.util.UUID -import javax.inject.Inject -class TEKHistoryUpdater @Inject constructor( +class TEKHistoryUpdater @AssistedInject constructor( + @Assisted val callback: Callback, private val tekHistoryStorage: TEKHistoryStorage, private val timeStamper: TimeStamper, private val enfClient: ENFClient, + private val tracingPermissionHelperFactory: TracingPermissionHelper.Factory, @AppScope private val scope: CoroutineScope ) { - var callback: Callback? = null - - fun updateTEKHistoryOrRequestPermission( - onUserPermissionRequired: (permissionRequest: (Activity) -> Unit) -> Unit - ) { - scope.launch { - enfClient.getTEKHistoryOrRequestPermission( - onTEKHistoryAvailable = { - updateHistoryAndTriggerCallback(it) - }, - onPermissionRequired = { status -> - val permissionRequestTrigger: (Activity) -> Unit = { - status.startResolutionForResult(it, TEK_PERMISSION_REQUESTCODE) - } - onUserPermissionRequired(permissionRequestTrigger) + private val tracingPermissionHelper by lazy { + tracingPermissionHelperFactory.create(object : TracingPermissionHelper.Callback { + override fun onUpdateTracingStatus(isTracingEnabled: Boolean) { + if (isTracingEnabled) { + updateTEKHistoryOrRequestPermission() + } else { + Timber.tag(TAG).w("Can't start TEK update, tracing permission was declined.") } - ) - } - } + } - suspend fun updateHistoryOrThrow(): List<TemporaryExposureKey> { - return updateTEKHistory() + override fun onTracingConsentRequired(onConsentResult: (given: Boolean) -> Unit) = + callback.onTracingConsentRequired(onConsentResult) + + override fun onPermissionRequired(permissionRequest: (Activity) -> Unit) = + callback.onPermissionRequired(permissionRequest) + + override fun onError(error: Throwable) = callback.onError(error) + }) } - fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?): UpdateResult { - if (requestCode != TEK_PERMISSION_REQUESTCODE) { - Timber.tag(TAG).w("Not our request code ($requestCode): %s", data) - return UpdateResult.UNKNOWN_RESULT - } - return if (resultCode == Activity.RESULT_OK) { - Timber.tag(TAG).d("Permission granted (== RESULT_OK): %s", data) - updateHistoryAndTriggerCallback() - UpdateResult.PERMISSION_AVAILABLE - } else { - Timber.tag(TAG).i("Permission declined (!= RESULT_OK): %s", data) - callback?.onPermissionDeclined() - UpdateResult.PERMISSION_UNAVAILABLE + fun updateTEKHistoryOrRequestPermission() { + scope.launch { + if (!enfClient.isTracingEnabled.first()) { + Timber.tag(TAG).w("Tracing is disabled, enabling...") + tracingPermissionHelper.startTracing() + } else { + updateTEKHistoryInternal() + } } } + private suspend fun updateTEKHistoryInternal() { + enfClient.getTEKHistoryOrRequestPermission( + onTEKHistoryAvailable = { + Timber.tag(TAG).d("TEKS were directly available.") + updateHistoryAndTriggerCallback(it) + }, + onPermissionRequired = { status -> + Timber.tag(TAG).d("TEK request requires user resolution.") + val permissionRequestTrigger: (Activity) -> Unit = { + status.startResolutionForResult(it, TEK_PERMISSION_REQUEST) + } + callback.onPermissionRequired(permissionRequestTrigger) + } + ) + } + private fun updateHistoryAndTriggerCallback(availableTEKs: List<TemporaryExposureKey>? = null) { scope.launch { try { val result = updateTEKHistory(availableTEKs) - callback?.onTEKAvailable(result) + callback.onTEKAvailable(result) } catch (e: Exception) { - callback?.onError(e) + callback.onError(e) } } } @@ -80,15 +93,15 @@ class TEKHistoryUpdater @Inject constructor( val teks = availableTEKs ?: enfClient.getTEKHistory() Timber.i("Permission are available, storing TEK history.") - tekHistoryStorage.storeTEKData( - TEKHistoryStorage.TEKBatch( - batchId = UUID.randomUUID().toString(), - obtainedAt = timeStamper.nowUTC, - keys = teks + teks.also { + tekHistoryStorage.storeTEKData( + TEKHistoryStorage.TEKBatch( + batchId = UUID.randomUUID().toString(), + obtainedAt = timeStamper.nowUTC, + keys = teks + ) ) - ) - - teks + } } return try { deferred.await() @@ -99,20 +112,45 @@ class TEKHistoryUpdater @Inject constructor( } } - enum class UpdateResult { - PERMISSION_AVAILABLE, - PERMISSION_UNAVAILABLE, - UNKNOWN_RESULT - } + fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + val isTracingPermissionRequest = tracingPermissionHelper.handleActivityResult(requestCode, resultCode, data) + if (isTracingPermissionRequest) { + Timber.tag(TAG).d("Was tracing permission request, will try TEK update if tracing is now enabled.") + return true + } - companion object { - private const val TAG = "TEKHistoryUpdater" - const val TEK_PERMISSION_REQUESTCODE = 3011 + if (requestCode != TEK_PERMISSION_REQUEST) { + Timber.tag(TAG).w("Not our request code ($requestCode): %s", data) + return false + } + + if (resultCode == Activity.RESULT_OK) { + Timber.tag(TAG).d("We got TEK permission, now updating history.") + updateHistoryAndTriggerCallback() + } else { + Timber.tag(TAG).i("Permission declined (!= RESULT_OK): %s", data) + callback.onTEKPermissionDeclined() + } + return true } interface Callback { fun onTEKAvailable(teks: List<TemporaryExposureKey>) - fun onPermissionDeclined() + fun onTEKPermissionDeclined() + fun onTracingConsentRequired(onConsentResult: (given: Boolean) -> Unit) + fun onPermissionRequired(permissionRequest: (Activity) -> Unit) fun onError(error: Throwable) } + + @AssistedInject.Factory + interface Factory { + fun create(callback: Callback): TEKHistoryUpdater + } + + companion object { + private const val TAG = "TEKHistoryUpdater" + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal const val TEK_PERMISSION_REQUEST = 3011 + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/TracingConsentDialog.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/TracingConsentDialog.kt new file mode 100644 index 000000000..3c3fff652 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/TracingConsentDialog.kt @@ -0,0 +1,25 @@ +package de.rki.coronawarnapp.tracing.ui + +import android.content.Context +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.util.DialogHelper + +class TracingConsentDialog(private val context: Context) { + + fun show( + onConsentGiven: () -> Unit, + onConsentDeclined: () -> Unit + ) { + val dialog = DialogHelper.DialogInstance( + context = context, + title = R.string.onboarding_tracing_headline_consent, + message = R.string.onboarding_tracing_body_consent, + positiveButton = R.string.onboarding_button_enable, + negativeButton = R.string.onboarding_button_cancel, + cancelable = true, + positiveButtonFunction = { onConsentGiven() }, + negativeButtonFunction = { onConsentDeclined() } + ) + DialogHelper.showDialog(dialog) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/onboarding/OnboardingTracingFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/onboarding/OnboardingTracingFragmentViewModel.kt index 078e462f9..1185467f2 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/onboarding/OnboardingTracingFragmentViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/onboarding/OnboardingTracingFragmentViewModel.kt @@ -18,7 +18,7 @@ import timber.log.Timber class OnboardingTracingFragmentViewModel @AssistedInject constructor( private val interoperabilityRepository: InteroperabilityRepository, - private val tracingPermissionHelper: TracingPermissionHelper, + tracingPermissionHelperFactory: TracingPermissionHelper.Factory, dispatcherProvider: DispatcherProvider ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { @@ -27,19 +27,27 @@ class OnboardingTracingFragmentViewModel @AssistedInject constructor( val routeToScreen: SingleLiveEvent<OnboardingNavigationEvents> = SingleLiveEvent() val permissionRequestEvent = SingleLiveEvent<(Activity) -> Unit>() - init { - tracingPermissionHelper.callback = object : TracingPermissionHelper.Callback { + private val tracingPermissionHelper = + tracingPermissionHelperFactory.create(object : TracingPermissionHelper.Callback { override fun onUpdateTracingStatus(isTracingEnabled: Boolean) { if (isTracingEnabled) { routeToScreen.postValue(OnboardingNavigationEvents.NavigateToOnboardingTest) } } + override fun onTracingConsentRequired(onConsentResult: (given: Boolean) -> Unit) { + // Tracing consent is given implicitly on this screen. + onConsentResult(true) + } + + override fun onPermissionRequired(permissionRequest: (Activity) -> Unit) { + permissionRequestEvent.postValue(permissionRequest) + } + override fun onError(error: Throwable) { Timber.e(error, "Failed to activate tracing during onboarding.") } - } - } + }) fun saveInteroperabilityUsed() { interoperabilityRepository.saveInteroperabilityUsed() @@ -65,9 +73,7 @@ class OnboardingTracingFragmentViewModel @AssistedInject constructor( } fun onActivateTracingClicked() { - tracingPermissionHelper.startTracing { permissionRequest -> - permissionRequestEvent.postValue(permissionRequest) - } + tracingPermissionHelper.startTracing() } fun showCancelDialog() { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/resultavailable/SubmissionTestResultAvailableFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/resultavailable/SubmissionTestResultAvailableFragment.kt index cee30acba..3beb73ddb 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/resultavailable/SubmissionTestResultAvailableFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/resultavailable/SubmissionTestResultAvailableFragment.kt @@ -8,6 +8,7 @@ import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.FragmentSubmissionTestResultAvailableBinding +import de.rki.coronawarnapp.tracing.ui.TracingConsentDialog import de.rki.coronawarnapp.util.DialogHelper import de.rki.coronawarnapp.util.di.AutoInject import de.rki.coronawarnapp.util.ui.doNavigate @@ -65,6 +66,12 @@ class SubmissionTestResultAvailableFragment : Fragment(R.layout.fragment_submiss vm.showPermissionRequest.observe2(this) { permissionRequest -> permissionRequest.invoke(requireActivity()) } + vm.showTracingConsentDialog.observe2(this) { onConsentResult -> + TracingConsentDialog(requireContext()).show( + onConsentGiven = { onConsentResult(true) }, + onConsentDeclined = { onConsentResult(false) } + ) + } } override fun onResume() { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/resultavailable/SubmissionTestResultAvailableViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/resultavailable/SubmissionTestResultAvailableViewModel.kt index a1100166a..880901adc 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/resultavailable/SubmissionTestResultAvailableViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/resultavailable/SubmissionTestResultAvailableViewModel.kt @@ -19,8 +19,8 @@ import timber.log.Timber class SubmissionTestResultAvailableViewModel @AssistedInject constructor( dispatcherProvider: DispatcherProvider, - private val tekHistoryUpdater: TEKHistoryUpdater, - private val submissionRepository: SubmissionRepository + tekHistoryUpdaterFactory: TEKHistoryUpdater.Factory, + submissionRepository: SubmissionRepository ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { val routeToScreen = SingleLiveEvent<NavDirections>() @@ -29,33 +29,42 @@ class SubmissionTestResultAvailableViewModel @AssistedInject constructor( val consent = consentFlow.asLiveData(dispatcherProvider.Default) val showPermissionRequest = SingleLiveEvent<(Activity) -> Unit>() val showCloseDialog = SingleLiveEvent<Unit>() + val showTracingConsentDialog = SingleLiveEvent<(Boolean) -> Unit>() - init { - submissionRepository.refreshDeviceUIState(refreshTestResult = false) + private val tekHistoryUpdater = tekHistoryUpdaterFactory.create(object : TEKHistoryUpdater.Callback { + override fun onTEKAvailable(teks: List<TemporaryExposureKey>) { + routeToScreen.postValue( + SubmissionTestResultAvailableFragmentDirections + .actionSubmissionTestResultAvailableFragmentToSubmissionTestResultConsentGivenFragment() + ) + } - tekHistoryUpdater.callback = object : TEKHistoryUpdater.Callback { - override fun onTEKAvailable(teks: List<TemporaryExposureKey>) { - routeToScreen.postValue( - SubmissionTestResultAvailableFragmentDirections - .actionSubmissionTestResultAvailableFragmentToSubmissionTestResultConsentGivenFragment() - ) - } + override fun onTEKPermissionDeclined() { + routeToScreen.postValue( + SubmissionTestResultAvailableFragmentDirections + .actionSubmissionTestResultAvailableFragmentToSubmissionTestResultNoConsentFragment() + ) + } - override fun onPermissionDeclined() { - routeToScreen.postValue( - SubmissionTestResultAvailableFragmentDirections - .actionSubmissionTestResultAvailableFragmentToSubmissionTestResultNoConsentFragment() - ) - } + override fun onTracingConsentRequired(onConsentResult: (given: Boolean) -> Unit) { + showTracingConsentDialog.postValue(onConsentResult) + } - override fun onError(error: Throwable) { - Timber.e(error, "Failed to update TEKs.") - error.report( - exceptionCategory = ExceptionCategory.EXPOSURENOTIFICATION, - prefix = "SubmissionTestResultAvailableViewModel" - ) - } + override fun onPermissionRequired(permissionRequest: (Activity) -> Unit) { + showPermissionRequest.postValue(permissionRequest) } + + override fun onError(error: Throwable) { + Timber.e(error, "Failed to update TEKs.") + error.report( + exceptionCategory = ExceptionCategory.EXPOSURENOTIFICATION, + prefix = "SubmissionTestResultAvailableViewModel" + ) + } + }) + + init { + submissionRepository.refreshDeviceUIState(refreshTestResult = false) } fun goBack() { @@ -81,9 +90,7 @@ class SubmissionTestResultAvailableViewModel @AssistedInject constructor( fun proceed() { launch { if (consentFlow.first()) { - tekHistoryUpdater.updateTEKHistoryOrRequestPermission { permissionRequest -> - showPermissionRequest.postValue(permissionRequest) - } + tekHistoryUpdater.updateTEKHistoryOrRequestPermission() } else { routeToScreen.postValue( SubmissionTestResultAvailableFragmentDirections diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningNoConsentFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningNoConsentFragment.kt index 75d164304..eab307688 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningNoConsentFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningNoConsentFragment.kt @@ -7,6 +7,7 @@ import android.view.accessibility.AccessibilityEvent import androidx.fragment.app.Fragment import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.FragmentSubmissionNoConsentPositiveOtherWarningBinding +import de.rki.coronawarnapp.tracing.ui.TracingConsentDialog import de.rki.coronawarnapp.util.DialogHelper import de.rki.coronawarnapp.util.di.AutoInject import de.rki.coronawarnapp.util.ui.doNavigate @@ -69,6 +70,13 @@ class SubmissionResultPositiveOtherWarningNoConsentFragment : viewModel.countryList.observe2(this) { binding.countryList.countries = it } + + viewModel.showTracingConsentDialog.observe2(this) { onConsentResult -> + TracingConsentDialog(requireContext()).show( + onConsentGiven = { onConsentResult(true) }, + onConsentDeclined = { onConsentResult(false) } + ) + } } override fun onResume() { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningNoConsentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningNoConsentViewModel.kt index dd6496f0b..bd5ff6c8c 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningNoConsentViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningNoConsentViewModel.kt @@ -21,8 +21,8 @@ import timber.log.Timber class SubmissionResultPositiveOtherWarningNoConsentViewModel @AssistedInject constructor( dispatcherProvider: DispatcherProvider, private val enfClient: ENFClient, - private val tekHistoryUpdater: TEKHistoryUpdater, - private val interoperabilityRepository: InteroperabilityRepository + tekHistoryUpdaterFactory: TEKHistoryUpdater.Factory, + interoperabilityRepository: InteroperabilityRepository ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { val routeToScreen = SingleLiveEvent<NavDirections>() @@ -34,25 +34,33 @@ class SubmissionResultPositiveOtherWarningNoConsentViewModel @AssistedInject con val countryList = interoperabilityRepository.countryList .asLiveData(context = dispatcherProvider.Default) - init { - tekHistoryUpdater.callback = object : TEKHistoryUpdater.Callback { - override fun onTEKAvailable(teks: List<TemporaryExposureKey>) { - routeToScreen.postValue( - SubmissionResultPositiveOtherWarningNoConsentFragmentDirections - .actionSubmissionResultPositiveOtherWarningNoConsentFragmentToSubmissionResultReadyFragment() - ) - } + val showTracingConsentDialog = de.rki.coronawarnapp.ui.SingleLiveEvent<(Boolean) -> Unit>() - override fun onPermissionDeclined() { - // stay on screen - } + private val tekHistoryUpdater = tekHistoryUpdaterFactory.create(object : TEKHistoryUpdater.Callback { + override fun onTEKAvailable(teks: List<TemporaryExposureKey>) { + routeToScreen.postValue( + SubmissionResultPositiveOtherWarningNoConsentFragmentDirections + .actionSubmissionResultPositiveOtherWarningNoConsentFragmentToSubmissionResultReadyFragment() + ) + } - override fun onError(error: Throwable) { - Timber.e(error, "Couldn't access temporary exposure key history.") - error.report(ExceptionCategory.EXPOSURENOTIFICATION, "Failed to obtain TEKs.") - } + override fun onTEKPermissionDeclined() { + // stay on screen } - } + + override fun onTracingConsentRequired(onConsentResult: (given: Boolean) -> Unit) { + showTracingConsentDialog.postValue(onConsentResult) + } + + override fun onPermissionRequired(permissionRequest: (Activity) -> Unit) { + showPermissionRequest.postValue(permissionRequest) + } + + override fun onError(error: Throwable) { + Timber.e(error, "Couldn't access temporary exposure key history.") + error.report(ExceptionCategory.EXPOSURENOTIFICATION, "Failed to obtain TEKs.") + } + }) fun onBackPressed() { routeToScreen.postValue( @@ -64,9 +72,7 @@ class SubmissionResultPositiveOtherWarningNoConsentViewModel @AssistedInject con fun onConsentButtonClicked() { launch { if (enfClient.isTracingEnabled.first()) { - tekHistoryUpdater.updateTEKHistoryOrRequestPermission { permissionRequest -> - showPermissionRequest.postValue(permissionRequest) - } + tekHistoryUpdater.updateTEKHistoryOrRequestPermission() } else { showEnableTracingEvent.postValue(Unit) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragment.kt index 0e4c74efc..c64c9d4c5 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragment.kt @@ -8,6 +8,7 @@ import androidx.fragment.app.Fragment import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.FragmentSettingsTracingBinding import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient +import de.rki.coronawarnapp.tracing.ui.TracingConsentDialog import de.rki.coronawarnapp.ui.main.MainActivity import de.rki.coronawarnapp.ui.tracing.settings.SettingsTracingFragmentViewModel.Event import de.rki.coronawarnapp.ui.viewmodel.SettingsViewModel @@ -26,7 +27,6 @@ import javax.inject.Inject * * @see SettingsViewModel * @see InternalExposureNotificationClient - * @see InternalExposureNotificationPermissionHelper */ class SettingsTracingFragment : Fragment(R.layout.fragment_settings_tracing), AutoInject { @@ -62,8 +62,13 @@ class SettingsTracingFragment : Fragment(R.layout.fragment_settings_tracing), Au vm.events.observe2(this) { when (it) { is Event.RequestPermissions -> it.permissionRequest.invoke(requireActivity()) - Event.ShowConsentDialog -> showConsentDialog() Event.ManualCheckingDialog -> showManualCheckingRequiredDialog() + is Event.TracingConsentDialog -> { + TracingConsentDialog(requireContext()).show( + onConsentGiven = { it.onConsentResult(true) }, + onConsentDeclined = { it.onConsentResult(false) } + ) + } } } @@ -137,24 +142,6 @@ class SettingsTracingFragment : Fragment(R.layout.fragment_settings_tracing), Au DialogHelper.showDialog(dialog) } - private fun showConsentDialog() { - val dialog = DialogHelper.DialogInstance( - context = requireActivity(), - title = R.string.onboarding_tracing_headline_consent, - message = R.string.onboarding_tracing_body_consent, - positiveButton = R.string.onboarding_button_enable, - negativeButton = R.string.onboarding_button_cancel, - cancelable = true, - positiveButtonFunction = { - vm.requestTracingTurnedOn() - }, - negativeButtonFunction = { - vm.onTracingTurnedOff() - } - ) - DialogHelper.showDialog(dialog) - } - companion object { internal val TAG: String? = SettingsTracingFragment::class.simpleName } 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 c032b9e1d..563d39771 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 @@ -11,7 +11,6 @@ import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient import de.rki.coronawarnapp.nearby.TracingPermissionHelper -import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.tracing.GeneralTracingStatus import de.rki.coronawarnapp.ui.tracing.details.TracingDetailsState import de.rki.coronawarnapp.ui.tracing.details.TracingDetailsStateProvider @@ -33,7 +32,7 @@ class SettingsTracingFragmentViewModel @AssistedInject constructor( tracingDetailsStateProvider: TracingDetailsStateProvider, tracingStatus: GeneralTracingStatus, private val backgroundPrioritization: BackgroundPrioritization, - private val tracingPermissionHelper: TracingPermissionHelper + tracingPermissionHelperFactory: TracingPermissionHelper.Factory ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { val tracingDetailsState: LiveData<TracingDetailsState> = tracingDetailsStateProvider.state @@ -57,8 +56,8 @@ class SettingsTracingFragmentViewModel @AssistedInject constructor( } } - init { - tracingPermissionHelper.callback = object : TracingPermissionHelper.Callback { + private val tracingPermissionHelper = + tracingPermissionHelperFactory.create(object : TracingPermissionHelper.Callback { override fun onUpdateTracingStatus(isTracingEnabled: Boolean) { if (isTracingEnabled) { // check if background processing is switched off, @@ -71,29 +70,34 @@ class SettingsTracingFragmentViewModel @AssistedInject constructor( isTracingSwitchChecked.postValue(isTracingEnabled) } - override fun onError(error: Throwable) { - Timber.w(error, "Failed to start tracing") + override fun onTracingConsentRequired(onConsentResult: (given: Boolean) -> Unit) { + events.postValue(Event.TracingConsentDialog { consentGiven -> + if (!consentGiven) isTracingSwitchChecked.postValue(false) + onConsentResult(consentGiven) + }) } - } - } - private suspend fun turnTracingOff() { - InternalExposureNotificationClient.asyncStop() - BackgroundWorkScheduler.stopWorkScheduler() - } + override fun onPermissionRequired(permissionRequest: (Activity) -> Unit) { + events.postValue(Event.RequestPermissions(permissionRequest)) + } - fun requestTracingTurnedOn() { - tracingPermissionHelper.startTracing { permissionRequest -> - events.postValue(Event.RequestPermissions(permissionRequest)) - } - } + override fun onError(error: Throwable) { + Timber.w(error, "Failed to start tracing") + } + }) fun onTracingToggled(isChecked: Boolean) { try { if (isChecked) { - onTracingTurnedOn() + tracingPermissionHelper.startTracing() } else { - onTracingTurnedOff() + isTracingSwitchChecked.postValue(false) + launch { + if (InternalExposureNotificationClient.asyncIsEnabled()) { + InternalExposureNotificationClient.asyncStop() + BackgroundWorkScheduler.stopWorkScheduler() + } + } } } catch (exception: Exception) { exception.report( @@ -104,34 +108,13 @@ class SettingsTracingFragmentViewModel @AssistedInject constructor( } } - fun onTracingTurnedOff() { - isTracingSwitchChecked.postValue(false) - launch { - if (InternalExposureNotificationClient.asyncIsEnabled()) { - turnTracingOff() - } - } - } - - private fun onTracingTurnedOn() { - // tracing was already activated - if (LocalData.initialTracingActivationTimestamp() != null) { - requestTracingTurnedOn() - } else { - // tracing was never activated - // ask for consent via dialog for initial tracing activation when tracing was not - // activated during onboarding - events.postValue(Event.ShowConsentDialog) - } - } - fun handleActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { tracingPermissionHelper.handleActivityResult(requestCode, resultCode, data) } sealed class Event { data class RequestPermissions(val permissionRequest: (Activity) -> Unit) : Event() - object ShowConsentDialog : Event() + data class TracingConsentDialog(val onConsentResult: (Boolean) -> Unit) : Event() object ManualCheckingDialog : Event() } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/TracingPermissionHelperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/TracingPermissionHelperTest.kt index f40bde5af..de30f6d1d 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/TracingPermissionHelperTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/TracingPermissionHelperTest.kt @@ -1,13 +1,20 @@ package de.rki.coronawarnapp.nearby import android.app.Activity +import com.google.android.gms.common.api.Status +import de.rki.coronawarnapp.storage.LocalData +import io.kotest.matchers.shouldBe +import io.mockk.Called import io.mockk.MockKAnnotations import io.mockk.Runs import io.mockk.coEvery +import io.mockk.coVerifySequence import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.flowOf @@ -24,34 +31,199 @@ class TracingPermissionHelperTest : BaseTest() { MockKAnnotations.init(this) coEvery { enfClient.isTracingEnabled } returns flowOf(false) + coEvery { enfClient.setTracing(any(), any(), any(), any()) } just Runs + + mockkObject(LocalData) + every { LocalData.initialTracingActivationTimestamp() } returns 123L } - fun createInstance(scope: CoroutineScope) = TracingPermissionHelper( + fun createInstance(scope: CoroutineScope, callback: TracingPermissionHelper.Callback) = TracingPermissionHelper( + callback = callback, scope = scope, enfClient = enfClient ) @Test - fun `request is forwarded if tracing is disabled`() = runBlockingTest { -// TODO() + fun `request is not forwarded if tracing is enabled`() = runBlockingTest { + coEvery { enfClient.isTracingEnabled } returns flowOf(true) + + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + instance.startTracing() + + advanceUntilIdle() + + coVerifySequence { + callback.onUpdateTracingStatus(true) + } } @Test - fun `request is not forwarded if tracing is enabled`() = runBlockingTest { - coEvery { enfClient.isTracingEnabled } returns flowOf(true) + fun `if consent is missing then we continue after it was given`() = runBlockingTest { + every { LocalData.initialTracingActivationTimestamp() } returns null - val callback = mockk<TracingPermissionHelper.Callback>() - every { callback.onUpdateTracingStatus(any()) } just Runs + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + val consentCallbackSlot = slot<(Boolean) -> Unit>() + every { callback.onTracingConsentRequired(capture(consentCallbackSlot)) } just Runs + val instance = createInstance(scope = this, callback = callback) - val instance = createInstance(scope = this) - instance.callback = callback + instance.startTracing() - val permissionRequestListener = mockk<(permissionRequest: (Activity) -> Unit) -> Unit>() + consentCallbackSlot.captured(true) - instance.startTracing(permissionRequestListener) + coVerifySequence { + enfClient.isTracingEnabled + enfClient.setTracing( + enable = true, + onSuccess = any(), + onError = any(), + onPermissionRequired = any() + ) + } + } - advanceUntilIdle() + @Test + fun `if consent was declined then we do nothing`() = runBlockingTest { + every { LocalData.initialTracingActivationTimestamp() } returns null + + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + val consentCallbackSlot = slot<(Boolean) -> Unit>() + every { callback.onTracingConsentRequired(capture(consentCallbackSlot)) } just Runs + val instance = createInstance(scope = this, callback = callback) + + instance.startTracing() + + consentCallbackSlot.captured(false) + + coVerifySequence { + enfClient.isTracingEnabled + } + } + + @Test + fun `if tracing is not yet enabled we forward to the enf client`() = runBlockingTest { + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + instance.startTracing() + + coVerifySequence { + enfClient.isTracingEnabled + enfClient.setTracing( + enable = true, + onSuccess = any(), + onError = any(), + onPermissionRequired = any() + ) + } + } + + @Test + fun `permission request is forwarded from enf client`() = runBlockingTest { + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + + val onPermissionRequiredCallback = slot<(Status) -> Unit>() + coEvery { + enfClient.setTracing( + enable = true, + onSuccess = any(), + onError = any(), + onPermissionRequired = capture(onPermissionRequiredCallback) + ) + } just Runs + val instance = createInstance(scope = this, callback = callback) + + instance.startTracing() + onPermissionRequiredCallback.captured.invoke(mockk()) + + coVerifySequence { + enfClient.isTracingEnabled + enfClient.setTracing( + enable = true, + onSuccess = any(), + onError = any(), + onPermissionRequired = onPermissionRequiredCallback.captured + ) + callback.onPermissionRequired(any()) + } + } + + @Test + fun `errors from the enf client are forwarded`() = runBlockingTest { + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + val onErrorCallback = slot<(Throwable) -> Unit>() + coEvery { + enfClient.setTracing( + enable = true, + onSuccess = any(), + onError = capture(onErrorCallback), + onPermissionRequired = any() + ) + } just Runs + val instance = createInstance(scope = this, callback = callback) + + instance.startTracing() + val error = IllegalStateException() + onErrorCallback.captured.invoke(error) + + coVerifySequence { + enfClient.isTracingEnabled + enfClient.setTracing( + enable = true, + onSuccess = any(), + onError = onErrorCallback.captured, + onPermissionRequired = any() + ) + callback.onError(error) + } + } + + @Test + fun `unknown activity results are not consumed`() = runBlockingTest { + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + instance.handleActivityResult(9999, Activity.RESULT_OK, mockk()) shouldBe false + + verify { callback wasNot Called } + } + + @Test + fun `positive activity results lead to new setTracing call`() = runBlockingTest { + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + instance.handleActivityResult( + TracingPermissionHelper.TRACING_PERMISSION_REQUESTCODE, + Activity.RESULT_OK, + mockk() + ) shouldBe true + + coVerifySequence { + enfClient.setTracing( + enable = true, + onSuccess = any(), + onError = any(), + onPermissionRequired = any() + ) + callback wasNot Called + } + } + + @Test + fun `negative activity results lead permission to direct callback`() = runBlockingTest { + val callback = mockk<TracingPermissionHelper.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + instance.handleActivityResult( + TracingPermissionHelper.TRACING_PERMISSION_REQUESTCODE, + Activity.RESULT_CANCELED, + mockk() + ) shouldBe true - verify { callback.onUpdateTracingStatus(true) } + coVerifySequence { + callback.onUpdateTracingStatus(false) + } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/data/tekhistory/TEKHistoryUpdaterTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/data/tekhistory/TEKHistoryUpdaterTest.kt index e927516bd..d195dd54a 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/data/tekhistory/TEKHistoryUpdaterTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/data/tekhistory/TEKHistoryUpdaterTest.kt @@ -1,33 +1,62 @@ package de.rki.coronawarnapp.submission.data.tekhistory +import android.app.Activity +import android.content.Intent +import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.nearby.TracingPermissionHelper import de.rki.coronawarnapp.util.TimeStamper +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe import io.mockk.MockKAnnotations import io.mockk.Runs import io.mockk.coEvery import io.mockk.coVerify +import io.mockk.coVerifySequence +import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifySequence import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.Instant import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import testhelpers.BaseTest class TEKHistoryUpdaterTest : BaseTest() { @MockK lateinit var tekHistoryStorage: TEKHistoryStorage + @MockK lateinit var tracingPermissionHelper: TracingPermissionHelper + @MockK lateinit var tracingPermissionHelperFactory: TracingPermissionHelper.Factory @MockK lateinit var timeStamper: TimeStamper @MockK lateinit var enfClient: ENFClient + private val availableTEKs: List<TemporaryExposureKey> = listOf(mockk()) + @BeforeEach fun setup() { MockKAnnotations.init(this) + every { timeStamper.nowUTC } returns Instant.EPOCH + coEvery { enfClient.getTEKHistoryOrRequestPermission(any(), any()) } just Runs + coEvery { enfClient.isTracingEnabled } returns flowOf(true) + coEvery { enfClient.getTEKHistory() } returns availableTEKs + + coEvery { tekHistoryStorage.storeTEKData(any()) } just Runs + + every { tracingPermissionHelperFactory.create(any()) } returns tracingPermissionHelper + coEvery { tracingPermissionHelper.startTracing() } just Runs + every { tracingPermissionHelper.handleActivityResult(any(), any(), any()) } returns false } - fun createInstance(scope: CoroutineScope) = TEKHistoryUpdater( + fun createInstance(scope: CoroutineScope, callback: TEKHistoryUpdater.Callback) = TEKHistoryUpdater( + callback = callback, scope = scope, + tracingPermissionHelperFactory = tracingPermissionHelperFactory, tekHistoryStorage = tekHistoryStorage, timeStamper = timeStamper, enfClient = enfClient @@ -35,8 +64,10 @@ class TEKHistoryUpdaterTest : BaseTest() { @Test fun `request is forwaded to enf client`() = runBlockingTest { - val instance = createInstance(scope = this) - instance.updateTEKHistoryOrRequestPermission { } + val callback = mockk<TEKHistoryUpdater.Callback>() + val instance = createInstance(scope = this, callback = callback) + + instance.updateTEKHistoryOrRequestPermission() coVerify { enfClient.getTEKHistoryOrRequestPermission( any(), @@ -44,4 +75,140 @@ class TEKHistoryUpdaterTest : BaseTest() { ) } } + + @Test + fun `if tracing is disabled then start tracing`() = runBlockingTest { + coEvery { enfClient.isTracingEnabled } returns flowOf(false) + + every { tracingPermissionHelperFactory.create(any()) } returns tracingPermissionHelper + + val callback = mockk<TEKHistoryUpdater.Callback>() + val instance = createInstance(scope = this, callback = callback) + + instance.updateTEKHistoryOrRequestPermission() + + verify { + tracingPermissionHelper.startTracing() + } + } + + @Test + fun `tracing callbacks are forwarded via tek updater callbacks`() = runBlockingTest { + coEvery { enfClient.isTracingEnabled } returns flowOf(false) + + var tracingCallback: TracingPermissionHelper.Callback? = null + every { tracingPermissionHelperFactory.create(any()) } answers { + tracingCallback = arg(0) + tracingPermissionHelper + } + + val tekUpdaterCallback = mockk<TEKHistoryUpdater.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = tekUpdaterCallback) + + instance.updateTEKHistoryOrRequestPermission() + tracingCallback shouldNotBe null + + val consentRequest: (Boolean) -> Unit = { } + tracingCallback!!.onTracingConsentRequired(consentRequest) + + val permissionRequest: (Activity) -> Unit = { } + tracingCallback!!.onPermissionRequired(permissionRequest) + + verify { + tracingPermissionHelper.startTracing() + tekUpdaterCallback.onTracingConsentRequired(consentRequest) + tekUpdaterCallback.onPermissionRequired(permissionRequest) + } + } + + @Test + fun `tracing permission results are forwarded to the tracing permissionhelper`() = runBlockingTest { + every { tracingPermissionHelper.handleActivityResult(any(), any(), any()) } returns true + val callback = mockk<TEKHistoryUpdater.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + val testIntent = mockk<Intent>() + instance.handleActivityResult( + requestCode = TracingPermissionHelper.TRACING_PERMISSION_REQUESTCODE, + resultCode = Activity.RESULT_OK, + data = testIntent + ) + + verify { + tracingPermissionHelper.handleActivityResult( + requestCode = TracingPermissionHelper.TRACING_PERMISSION_REQUESTCODE, + resultCode = Activity.RESULT_OK, + data = testIntent + ) + } + } + + @Test + fun `TEK activity results processed if not consumed by the tracing permissionhelper`() = runBlockingTest { + every { tracingPermissionHelper.handleActivityResult(any(), any(), any()) } returns false + val callback = mockk<TEKHistoryUpdater.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + val testIntent = mockk<Intent>() + instance.handleActivityResult( + requestCode = TEKHistoryUpdater.TEK_PERMISSION_REQUEST, + resultCode = Activity.RESULT_CANCELED, + data = testIntent + ) shouldBe true + + verifySequence { + tracingPermissionHelper.handleActivityResult( + requestCode = TEKHistoryUpdater.TEK_PERMISSION_REQUEST, + resultCode = Activity.RESULT_CANCELED, + data = testIntent + ) + callback.onTEKPermissionDeclined() + } + } + + @Test + fun `unknown resultcodes are not consumed`() = runBlockingTest { + every { tracingPermissionHelper.handleActivityResult(any(), any(), any()) } returns false + val callback = mockk<TEKHistoryUpdater.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + val testIntent = mockk<Intent>() + instance.handleActivityResult( + requestCode = 123, + resultCode = Activity.RESULT_OK, + data = testIntent + ) shouldBe false + + verify { + tracingPermissionHelper.handleActivityResult( + requestCode = 123, + resultCode = Activity.RESULT_OK, + data = testIntent + ) + } + } + + @Test + fun `positive TEK activity results trigger new update attempt`() = runBlockingTest { + every { tracingPermissionHelper.handleActivityResult(any(), any(), any()) } returns false + val callback = mockk<TEKHistoryUpdater.Callback>(relaxUnitFun = true) + val instance = createInstance(scope = this, callback = callback) + + val testIntent = mockk<Intent>() + instance.handleActivityResult( + requestCode = TEKHistoryUpdater.TEK_PERMISSION_REQUEST, + resultCode = Activity.RESULT_OK, + data = testIntent + ) shouldBe true + + coVerifySequence { + tracingPermissionHelper.handleActivityResult( + requestCode = TEKHistoryUpdater.TEK_PERMISSION_REQUEST, + resultCode = Activity.RESULT_OK, + data = testIntent + ) + enfClient.getTEKHistory() + callback.onTEKAvailable(availableTEKs) + } + } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/testavailable/SubmissionTestResultAvailableViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/testavailable/SubmissionTestResultAvailableViewModelTest.kt index d17811911..52eaba01d 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/testavailable/SubmissionTestResultAvailableViewModelTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/testavailable/SubmissionTestResultAvailableViewModelTest.kt @@ -28,13 +28,15 @@ class SubmissionTestResultAvailableViewModelTest : BaseTest() { @MockK lateinit var submissionRepository: SubmissionRepository @MockK lateinit var tekHistoryUpdater: TEKHistoryUpdater + @MockK lateinit var tekHistoryUpdaterFactory: TEKHistoryUpdater.Factory @BeforeEach fun setUp() { MockKAnnotations.init(this) every { submissionRepository.hasGivenConsentToSubmission } returns flowOf(true) - every { tekHistoryUpdater.callback = any() } just Runs - every { tekHistoryUpdater.updateTEKHistoryOrRequestPermission(any()) } just Runs + + every { tekHistoryUpdaterFactory.create(any()) } returns tekHistoryUpdater + every { tekHistoryUpdater.updateTEKHistoryOrRequestPermission() } just Runs // TODO Check specific behavior every { submissionRepository.refreshDeviceUIState(any()) } just Runs @@ -43,7 +45,7 @@ class SubmissionTestResultAvailableViewModelTest : BaseTest() { private fun createViewModel(): SubmissionTestResultAvailableViewModel = SubmissionTestResultAvailableViewModel( submissionRepository = submissionRepository, dispatcherProvider = TestDispatcherProvider, - tekHistoryUpdater = tekHistoryUpdater + tekHistoryUpdaterFactory = tekHistoryUpdaterFactory ) @AfterEach @@ -89,7 +91,7 @@ class SubmissionTestResultAvailableViewModelTest : BaseTest() { viewModel.proceed() verify { - tekHistoryUpdater.updateTEKHistoryOrRequestPermission(any()) + tekHistoryUpdater.updateTEKHistoryOrRequestPermission() } } -- GitLab