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

Privacy Preserving Analytics (main-feature-branch) (EXPOSUREAPP-4321) #2344

Merged
merged 22 commits into from
Feb 15, 2021
Merged
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
8367503
Skeleton classes for privacy preserving analytics (EXPOSUREAPP-4753) …
d4rken Jan 29, 2021
2514fbe
Merge pull request #2254 from corona-warn-app/release/1.13.x
kolyaopahle Feb 1, 2021
e9391c0
PPA UserInput Selection Screens (EXPOSUREAPP-4752) (#2270)
d4rken Feb 3, 2021
55274cc
Collect Exposure Risk Metadata (EXPOSUREAPP-4921) (#2255)
kolyaopahle Feb 5, 2021
acb4568
Merge remote-tracking branch 'origin/release/1.13.x' into feature/432…
kolyaopahle Feb 8, 2021
7ba533c
Merge branch 'release/1.13.x' into feature/4321-ppa-main
kolyaopahle Feb 9, 2021
4ef8134
Implement Donor for User Metadata (EXPOSUREAPP-4823) (#2300)
kolyaopahle Feb 9, 2021
0c22185
Remove redundant district prefixes (EXPOSUREAPP-4815) (#2303)
mlenkeit Feb 9, 2021
d3c3537
PPA onboarding screen (EXPOSUREAPP-4815) (#2299)
AlexanderAlferov Feb 10, 2021
1d9dfab
Privacy Preserving Analytics Settings (EXPOSUREAPP-4816) (#2315)
axelherbstreith Feb 10, 2021
7d40940
Privacy-Preserving Analytics Settings Polishing (DEV) (#2322)
axelherbstreith Feb 11, 2021
5c56181
Merge branch 'release/1.13.x' into feature/4321-ppa-main
d4rken Feb 12, 2021
e94acea
Resolve merge regressions (string conflicts).
d4rken Feb 12, 2021
b080029
Submit Analytics Data (EXPOSUREAPP-4823) (#2280)
kolyaopahle Feb 12, 2021
3aa18c4
Merge branch 'release/1.13.x' into feature/4321-ppa-main
d4rken Feb 12, 2021
347861b
Merge branch 'release/1.13.x' into feature/4321-ppa-main
d4rken Feb 12, 2021
2213f5f
Integrate Analytics UI with the analytics backend (EXPOSUREAPP-4823) …
kolyaopahle Feb 15, 2021
bbe310b
Change default toggle state (#2352)
axelherbstreith Feb 15, 2021
ea80d93
Legal text update for ppa (DEV) #2350
harambasicluka Feb 15, 2021
768dbca
Privacy-Preserving Analytics String Update (DEV) (#2351)
axelherbstreith Feb 15, 2021
26a865d
Merge branch 'release/1.13.x' into feature/4321-ppa-main
harambasicluka Feb 15, 2021
f31a2d3
Merge branch 'release/1.13.x' into feature/4321-ppa-main
d4rken Feb 15, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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