Skip to content
This repository has been archived by the owner on Jun 20, 2023. It is now read-only.

Commit

Permalink
Merge pull request #2344 from corona-warn-app/feature/4321-ppa-main
Browse files Browse the repository at this point in the history
Privacy Preserving Analytics (main-feature-branch) (EXPOSUREAPP-4321)
  • Loading branch information
d4rken authored Feb 15, 2021
2 parents 8062204 + f31a2d3 commit 053d9ca
Show file tree
Hide file tree
Showing 104 changed files with 9,418 additions and 53 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package de.rki.coronawarnapp.ui.onboarding

import androidx.lifecycle.asLiveData
import androidx.test.ext.junit.runners.AndroidJUnit4
import dagger.Module
import dagger.android.ContributesAndroidInjector
import de.rki.coronawarnapp.datadonation.analytics.Analytics
import de.rki.coronawarnapp.datadonation.analytics.common.Districts
import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings
import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
import io.mockk.MockKAnnotations
import io.mockk.coEvery
import io.mockk.every
import io.mockk.impl.annotations.MockK
import io.mockk.spyk
import io.mockk.unmockkAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import testhelpers.BaseUITest
import testhelpers.Screenshot
import testhelpers.SystemUIDemoModeRule
import testhelpers.TestDispatcherProvider
import testhelpers.captureScreenshot
import testhelpers.launchFragment2
import tools.fastlane.screengrab.locale.LocaleTestRule

@RunWith(AndroidJUnit4::class)
class OnboardingAnalyticsFragmentTest : BaseUITest() {

@MockK lateinit var settings: AnalyticsSettings
@MockK lateinit var districts: Districts
@MockK lateinit var analytics: Analytics

private lateinit var viewModel: OnboardingAnalyticsViewModel

@Rule
@JvmField
val localeTestRule = LocaleTestRule()

@get:Rule
val systemUIDemoModeRule = SystemUIDemoModeRule()

@Before
fun setup() {
MockKAnnotations.init(this, relaxed = true)

coEvery { districts.loadDistricts() } returns listOf(Districts.District(
districtId = 11011004,
districtName = "SK Berlin Charlottenburg-Wilmersdorf"
))

viewModel = onboardingAnalyticsViewModelSpy()
with(viewModel) {
every { ageGroup } returns flowOf(PpaData.PPAAgeGroup.AGE_GROUP_0_TO_29).asLiveData()
every { federalState } returns flowOf(PpaData.PPAFederalState.FEDERAL_STATE_BE).asLiveData()
every { district } returns flow { emit(districts.loadDistricts().first()) }.asLiveData()
}

setupMockViewModel(
object : OnboardingAnalyticsViewModel.Factory {
override fun create(): OnboardingAnalyticsViewModel = viewModel
}
)
}

private fun onboardingAnalyticsViewModelSpy() = spyk(
OnboardingAnalyticsViewModel(
settings = settings,
districts = districts,
dispatcherProvider = TestDispatcherProvider(),
analytics = analytics
)
)

@After
fun teardown() {
clearAllViewModels()
unmockkAll()
}

@Test
fun launch_fragment() {
launchFragment2<OnboardingAnalyticsFragment>()
}

@Screenshot
@Test
fun capture_screenshot() {
captureScreenshot<OnboardingAnalyticsFragment>()
}
}

@Module
abstract class OnboardingAnalyticsFragmentTestModule {
@ContributesAndroidInjector
abstract fun onboardingAnalyticsFragment(): OnboardingAnalyticsFragment
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import de.rki.coronawarnapp.ui.contactdiary.ContactDiaryOnboardingFragmentTestMo
import de.rki.coronawarnapp.ui.contactdiary.ContactDiaryOverviewFragmentTestModule
import de.rki.coronawarnapp.ui.contactdiary.ContactDiaryPersonListFragmentTestModule
import de.rki.coronawarnapp.ui.main.home.HomeFragmentTestModule
import de.rki.coronawarnapp.ui.onboarding.OnboardingAnalyticsFragmentTestModule
import de.rki.coronawarnapp.ui.onboarding.OnboardingDeltaInteroperabilityFragmentTestModule
import de.rki.coronawarnapp.ui.onboarding.OnboardingFragmentTestModule
import de.rki.coronawarnapp.ui.onboarding.OnboardingNotificationsTestModule
Expand Down Expand Up @@ -41,6 +42,7 @@ import de.rki.coronawarnapp.ui.tracing.TracingDetailsFragmentTestTestModule
OnboardingPrivacyTestModule::class,
OnboardingTestFragmentModule::class,
OnboardingTracingFragmentTestModule::class,
OnboardingAnalyticsFragmentTestModule::class,
// Submission
SubmissionDispatcherTestModule::class,
SubmissionTanTestModule::class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package de.rki.coronawarnapp.datadonation.analytics.storage

import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
import javax.inject.Inject

class DefaultLastAnalyticsSubmissionLogger @Inject constructor() : LastAnalyticsSubmissionLogger {
override suspend fun storeAnalyticsData(analyticsProto: PpaData.PPADataAndroid) {
// Do not store past analytics submissions in Production
}

override suspend fun getLastAnalyticsData(): LastAnalyticsSubmission? = null
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package de.rki.coronawarnapp.datadonation.analytics.storage

import android.content.Context
import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.TypeAdapter
import com.google.gson.stream.JsonReader
import com.google.gson.stream.JsonWriter
import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
import de.rki.coronawarnapp.util.TimeStamper
import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
import de.rki.coronawarnapp.util.di.AppContext
import de.rki.coronawarnapp.util.serialization.BaseGson
import de.rki.coronawarnapp.util.serialization.adapter.InstantAdapter
import de.rki.coronawarnapp.util.serialization.fromJson
import de.rki.coronawarnapp.util.serialization.toJson
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.decodeBase64
import okio.ByteString.Companion.toByteString
import org.joda.time.Instant
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import javax.inject.Inject

class DefaultLastAnalyticsSubmissionLogger @Inject constructor(
@AppContext private val context: Context,
private val dispatcherProvider: DispatcherProvider,
@BaseGson private val baseGson: Gson,
private val timeStamper: TimeStamper
) : LastAnalyticsSubmissionLogger {
private val analyticsDir = File(context.cacheDir, "analytics_storage")
private val analyticsFile = File(analyticsDir, "last_analytics.bin")

private val gson by lazy {
baseGson.newBuilder()
.registerTypeAdapter(Instant::class.java, InstantAdapter())
.registerTypeAdapter(PpaData.PPADataAndroid::class.java, PPADataAndroidAdapter())
.create()
}

override suspend fun storeAnalyticsData(analyticsProto: PpaData.PPADataAndroid) =
withContext(dispatcherProvider.IO) {
if (!analyticsDir.exists()) {
analyticsDir.mkdirs()
}

val dataObject = LastAnalyticsSubmission(
timestamp = timeStamper.nowUTC,
ppaDataAndroid = analyticsProto
)

try {
gson.toJson(dataObject, analyticsFile)
} catch (e: Exception) {
Timber.e(e, "Failed to store analytics data.")
}
}

override suspend fun getLastAnalyticsData(): LastAnalyticsSubmission? = withContext(dispatcherProvider.IO) {
try {
gson.fromJson<LastAnalyticsSubmission>(analyticsFile)?.also {
requireNotNull(it.ppaDataAndroid)
requireNotNull(it.timestamp)
}
} catch (e: Exception) {
Timber.e(e, "Couldn't load analytics data.")
null
}
}

companion object {
class PPADataAndroidAdapter : TypeAdapter<PpaData.PPADataAndroid>() {
override fun write(out: JsonWriter, value: PpaData.PPADataAndroid?) {
if (value == null) out.nullValue()
else value.toByteArray()?.toByteString()?.base64().let { out.value(it) }
}

override fun read(reader: JsonReader): PpaData.PPADataAndroid? = when (reader.peek()) {
JSONObject.NULL -> reader.nextNull().let { null }
else -> {
val raw = reader.nextString().decodeBase64()?.toByteArray()
if (raw == null) {
throw JsonParseException("Can't decode base64 ByteArray")
} else {
PpaData.PPADataAndroid.parseFrom(raw)
}
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,16 @@ class DataDonationTestFragment : Fragment(R.layout.fragment_test_datadonation),
}
}

vm.currentAnalyticsData.observe2(this) {
binding.analyticsBody.text = it.toString()
}

binding.apply {
safetynetCreateReport.setOnClickListener { vm.createSafetyNetReport() }
safetynetCopyJws.setOnClickListener { vm.copyJWS() }
analyticsCollect.setOnClickListener { vm.collectAnalyticsData() }
analyticsCopy.setOnClickListener { vm.copyAnalytics() }
analyticsSubmit.setOnClickListener { vm.submitAnalytics() }
}

vm.copyJWSEvent.observe2(this) { jws ->
Expand All @@ -56,6 +63,19 @@ class DataDonationTestFragment : Fragment(R.layout.fragment_test_datadonation),
startActivity(intent)
}

vm.copyAnalyticsEvent.observe2(this) { analytics ->
val intent = ShareCompat.IntentBuilder.from(requireActivity()).apply {
setType("text/plain")
setSubject("Analytics")
setText(analytics)
}.createChooserIntent()
startActivity(intent)
}

vm.infoEvents.observe2(this) {
Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show()
}

vm.currentValidation.observe2(this) { items ->
if (items?.first == null) {
binding.safetynetRequirementsBody.text = "No validation yet."
Expand All @@ -71,15 +91,19 @@ class DataDonationTestFragment : Fragment(R.layout.fragment_test_datadonation),
}
}
}

binding.apply {
safetynetRequirementsCasually.setOnClickListener { vm.validateSafetyNetCasually() }
safetynetRequirementsStrict.setOnClickListener { vm.validateSafetyNetStrict() }
}

vm.errorEvents.observe2(this) {
Toast.makeText(requireContext(), it.toString(), Toast.LENGTH_LONG).show()
vm.lastAnalyticsData.observe2(this) {
binding.analyticsLastSubmitBody.text =
it?.toString() ?: "No analytics were successfully submitted until now"
}

vm.checkLastAnalytics()

binding.oneTimePasswordBody.text = vm.otp

vm.surveyConfig.observe2(this) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import de.rki.coronawarnapp.appconfig.AppConfigProvider
import de.rki.coronawarnapp.appconfig.SafetyNetRequirementsContainer
import de.rki.coronawarnapp.datadonation.analytics.Analytics
import de.rki.coronawarnapp.datadonation.analytics.storage.LastAnalyticsSubmission
import de.rki.coronawarnapp.datadonation.analytics.storage.LastAnalyticsSubmissionLogger
import de.rki.coronawarnapp.datadonation.safetynet.CWASafetyNet
import de.rki.coronawarnapp.datadonation.safetynet.DeviceAttestation
import de.rki.coronawarnapp.datadonation.safetynet.SafetyNetClientWrapper
Expand All @@ -14,6 +17,7 @@ import de.rki.coronawarnapp.datadonation.safetynet.errorMsgRes
import de.rki.coronawarnapp.datadonation.storage.OTPRepository
import de.rki.coronawarnapp.datadonation.survey.SurveyException
import de.rki.coronawarnapp.datadonation.survey.errorMsgRes
import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
import de.rki.coronawarnapp.util.ui.SingleLiveEvent
import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
Expand All @@ -27,21 +31,30 @@ class DataDonationTestFragmentViewModel @AssistedInject constructor(
dispatcherProvider: DispatcherProvider,
private val safetyNetClientWrapper: SafetyNetClientWrapper,
private val secureRandom: SecureRandom,
private val analytics: Analytics,
private val lastAnalyticsSubmissionLogger: LastAnalyticsSubmissionLogger,
private val cwaSafetyNet: CWASafetyNet,
otpRepository: OTPRepository,
appConfigProvider: AppConfigProvider
private val appConfigProvider: AppConfigProvider
) : CWAViewModel(dispatcherProvider = dispatcherProvider) {

val infoEvents = SingleLiveEvent<String>()

private val currentReportInternal = MutableStateFlow<SafetyNetClientWrapper.Report?>(null)
val currentReport = currentReportInternal.asLiveData(context = dispatcherProvider.Default)

private val currentValidationInternal =
MutableStateFlow<Pair<SafetyNetRequirementsContainer?, Throwable?>?>(null)
val currentValidation = currentValidationInternal.asLiveData(context = dispatcherProvider.Default)

val errorEvents = SingleLiveEvent<Throwable>()
val copyJWSEvent = SingleLiveEvent<String>()

private val currentAnalyticsDataInternal = MutableStateFlow<PpaData.PPADataAndroid?>(null)
val currentAnalyticsData = currentAnalyticsDataInternal.asLiveData(context = dispatcherProvider.Default)
val copyAnalyticsEvent = SingleLiveEvent<String>()

private val lastAnalyticsDataInternal = MutableStateFlow<LastAnalyticsSubmission?>(null)
val lastAnalyticsData = lastAnalyticsDataInternal.asLiveData(context = dispatcherProvider.Default)

val otp: String = otpRepository.otpAuthorizationResult?.toString() ?: "No OTP generated and authorized yet"

val surveyConfig = appConfigProvider.currentConfig
Expand All @@ -66,7 +79,7 @@ class DataDonationTestFragmentViewModel @AssistedInject constructor(
currentReportInternal.value = report
} catch (e: Exception) {
Timber.e(e, "attest() failed.")
errorEvents.postValue(e)
infoEvents.postValue(e.toString())
}
}
}
Expand Down Expand Up @@ -110,6 +123,39 @@ class DataDonationTestFragmentViewModel @AssistedInject constructor(
}
}

fun collectAnalyticsData() = launch {
try {
val ppaDataAndroid = PpaData.PPADataAndroid.newBuilder()
analytics.collectContributions(ppaDataBuilder = ppaDataAndroid)
currentAnalyticsDataInternal.value = ppaDataAndroid.build()
} catch (e: Exception) {
Timber.e(e, "collectContributions() failed.")
infoEvents.postValue(e.toString())
}
}

fun submitAnalytics() = launch {
infoEvents.postValue("Starting Analytics Submission")
val analyticsConfig = appConfigProvider.getAppConfig().analytics
analytics.submitAnalyticsData(analyticsConfig)
infoEvents.postValue("Analytics Submission Done")
checkLastAnalytics()
}

fun copyAnalytics() = launch {
val value = currentAnalyticsData.value?.toString() ?: ""
copyAnalyticsEvent.postValue(value)
}

fun checkLastAnalytics() = launch {
try {
lastAnalyticsDataInternal.value = lastAnalyticsSubmissionLogger.getLastAnalyticsData()
} catch (e: Exception) {
Timber.e(e, "checkLastAnalytics() failed.")
infoEvents.postValue(e.toString())
}
}

fun selectSafetyNetExceptionType(type: SafetyNetException.Type) {
currentSafetyNetExceptionTypeInternal.value = type
}
Expand Down
Loading

0 comments on commit 053d9ca

Please sign in to comment.