Skip to content
This repository has been archived by the owner on Dec 30, 2022. It is now read-only.

Commit

Permalink
feat: analytics (#212)
Browse files Browse the repository at this point in the history
* feat: wip analytics

* fix: rename attestate to attest

* fix: actually use serverDate

* feat: add analytics configuration settings

* feat: add `byAdding` utility method to Date

* feat: advance analytics logic

* chore: remove token validation request

* feat: add ExposureAnalyticsWorker and logic to schedule it

* feat: advance analytics implementation

* feat: wip analytics

* fix: implement missing pieces

* feat: implement retry mechanism with exponential backoff for sending operational info

* feat: add SafetyNetApiKey

* feat: add attestation tests

* fix: add dummy operational info call

* fix: exponential backoff within worker timeout

* feat: wip tests for analytics

* fix: date parsing

* feat: add tests and fixes for analytics

* fix: operationalInfoWithoutExposureSamplingRate value

* feat: wip tests for analytics

* fix: analytics tests

* feat: add test for invalid attestation

* feat: complete analytics tests

* fix: do not require hardware attestation

* feat: add success status on attestation result

* fix: use current date rather than 0 date

* chore: ktlint

* chore: use 1000L rather than converting toLong

* fix: use Dispatchers.Main with GlobalScope

* refactor: move date methods to a CalendarUtils object

* refactor: avoid duplicated Base64 encoder

* fix: import

* chore: add new debug menu entries for analytics

* fix: send analytics only from hardware-backed devices

* fix: avoid context switching in coroutine

* fix: avoid crash on migration

Co-authored-by: Stefano Rodriguez <[email protected]>
Co-authored-by: Marco Uberti <[email protected]>
  • Loading branch information
3 people authored Jul 6, 2020
1 parent 00716c5 commit c984e40
Show file tree
Hide file tree
Showing 28 changed files with 1,656 additions and 39 deletions.
3 changes: 3 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ android {

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true

buildConfigField("String", "SAFETY_NET_API_KEY", appProperties["safetyNetApiKey"])
buildConfigField("String", "SAFETY_NET_TEST_API_KEY", appProperties["safetyNetTestApiKey"])
}

buildTypes {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/*
* Copyright (C) 2020 Presidenza del Consiglio dei Ministri.
* Please refer to the AUTHORS file for more information.
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package it.ministerodellasalute.immuni.attestation

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import it.ministerodellasalute.immuni.BuildConfig
import it.ministerodellasalute.immuni.extensions.attestation.AttestationClient
import it.ministerodellasalute.immuni.extensions.attestation.SafetyNetAttestationClient
import it.ministerodellasalute.immuni.extensions.utils.base64EncodedSha256
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class GoogleAttestationTest {
private val context get() = InstrumentationRegistry.getInstrumentation().targetContext

@Test
fun testAttestationSucceeds() = runBlocking {
val client = SafetyNetAttestationClient(
context = context,
parameters = SafetyNetAttestationClient.AttestationParameters(
apiKey = BuildConfig.SAFETY_NET_TEST_API_KEY,
apkPackageName = context.packageName,
requiresBasicIntegrity = true,
requiresCtsProfile = true,
requiresHardwareAttestation = true
)
)

val response = client.attest("nonce".base64EncodedSha256())

assertTrue(response is AttestationClient.Result.Success)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class KeyMatchingTests {
class KeyMatchingTest {
private val testContext get() = getInstrumentation().context
private val context get() = getInstrumentation().targetContext

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ data class ConfigurationSettings(
@field:Json(name = "support_phone_opening_time") val supportPhoneOpeningTime: String,
@field:Json(name = "support_phone") val supportPhone: String? = null,
@field:Json(name = "support_email") val supportEmail: String? = null,
@field:Json(name = "reopen_reminder") val reopenReminder: Boolean = true
@field:Json(name = "reopen_reminder") val reopenReminder: Boolean = true,
@field:Json(name = "operational_info_with_exposure_sampling_rate") val operationalInfoWithExposureSamplingRate: Double,
@field:Json(name = "operational_info_without_exposure_sampling_rate") val operationalInfoWithoutExposureSamplingRate: Double,
@field:Json(name = "dummy_analytics_waiting_time") val dummyAnalyticsWaitingTime: Int

)

@JsonClass(generateAdapter = true)
Expand Down Expand Up @@ -139,5 +143,8 @@ val defaultSettings = ConfigurationSettings(
supportPhone = null,
supportPhoneOpeningTime = "7",
supportPhoneClosingTime = "22",
reopenReminder = true
reopenReminder = true,
operationalInfoWithExposureSamplingRate = 1.0,
operationalInfoWithoutExposureSamplingRate = 0.6,
dummyAnalyticsWaitingTime = 2_592_000
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright (C) 2020 Presidenza del Consiglio dei Ministri.
* Please refer to the AUTHORS file for more information.
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package it.ministerodellasalute.immuni.api.services

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.*

interface ExposureAnalyticsService {
@JsonClass(generateAdapter = true)
data class OperationalInfoRequest(
@field:Json(name = "province") val province: String,
@field:Json(name = "exposure_permission") val exposurePermission: Int,
@field:Json(name = "bluetooth_active") val bluetoothActive: Int,
@field:Json(name = "notification_permission") val notificationPermission: Int,
@field:Json(name = "exposure_notification") val exposureNotification: Int,
@field:Json(name = "last_risky_exposure_on") val lastRiskyExposureOn: String,
@field:Json(name = "salt") val salt: String,
@field:Json(name = "signed_attestation") val signedAttestation: String
)

@POST("/v1/analytics/google/operational-info")
suspend fun operationalInfo(
@Header("Immuni-Dummy-Data") isDummyData: Int,
@Body body: OperationalInfoRequest
): Response<ResponseBody>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/*
* Copyright (C) 2020 Presidenza del Consiglio dei Ministri.
* Please refer to the AUTHORS file for more information.
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

package it.ministerodellasalute.immuni.config

import android.content.Context
import com.squareup.moshi.Moshi
import it.ministerodellasalute.immuni.R

class ExposureAnalyticsNetworkConfiguration(
context: Context,
override val moshi: Moshi
) : BaseNetworkConfiguration(context, moshi) {
override fun baseUrl(): String {
return context.getString(R.string.analytics_base_url)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,17 +21,21 @@ import android.net.Uri
import it.ministerodellasalute.immuni.R
import it.ministerodellasalute.immuni.debugmenu.DebugMenuConfiguration
import it.ministerodellasalute.immuni.debugmenu.DebugMenuItem
import it.ministerodellasalute.immuni.extensions.activity.toast
import it.ministerodellasalute.immuni.extensions.attestation.AttestationClient
import it.ministerodellasalute.immuni.extensions.lifecycle.AppActivityLifecycleCallbacks
import it.ministerodellasalute.immuni.extensions.utils.base64EncodedSha256
import it.ministerodellasalute.immuni.extensions.utils.byAdding
import it.ministerodellasalute.immuni.logic.exposure.ExposureAnalyticsManager
import it.ministerodellasalute.immuni.logic.exposure.ExposureManager
import it.ministerodellasalute.immuni.logic.exposure.models.ExposureStatus
import it.ministerodellasalute.immuni.logic.exposure.models.ExposureSummary
import it.ministerodellasalute.immuni.logic.exposure.repositories.ExposureReportingRepository
import it.ministerodellasalute.immuni.logic.notifications.AppNotificationManager
import it.ministerodellasalute.immuni.logic.notifications.NotificationType
import it.ministerodellasalute.immuni.logic.worker.WorkerManager
import java.util.Calendar
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.util.*
import kotlinx.coroutines.*
import org.koin.core.KoinComponent
import org.koin.core.inject

Expand All @@ -43,6 +47,8 @@ class ImmuniDebugMenuConfiguration(
private val workerManager: WorkerManager by inject()
private val exposureReportingRepository: ExposureReportingRepository by inject()
private val notificationManger: AppNotificationManager by inject()
private val analyticsManager: ExposureAnalyticsManager by inject()
private val attestationClient: AttestationClient by inject()

/**
* In debug mode enable the debug menu.
Expand Down Expand Up @@ -127,6 +133,64 @@ class ImmuniDebugMenuConfiguration(
}) {},
object : DebugMenuItem("\uD83D\uDD14 Trigger Onboarding Notification", { _, _ ->
notificationManger.triggerNotification(NotificationType.OnboardingNotCompleted)
}) {},
object : DebugMenuItem("\uD83D\uDD14 Send Dummy Analytics", { _, _ ->
GlobalScope.launch(Dispatchers.Main) {
val isSuccess = analyticsManager.sendOperationalInfo(
summary = null,
isDummy = true
)
toast(
context,
"Dummy analytics result: ${if (isSuccess) "success" else "failure"}"
)
}
}) {},
object : DebugMenuItem("\uD83D\uDD14 Send Non-Dummy w/Exposure Analytics", { _, _ ->
GlobalScope.launch(Dispatchers.Main) {
val isSuccess = analyticsManager.sendOperationalInfo(
summary = ExposureSummary(
date = Date(),
lastExposureDate = Date().byAdding(days = -2),
matchedKeyCount = 1,
maximumRiskScore = 100,
highRiskAttenuationDurationMinutes = 15,
mediumRiskAttenuationDurationMinutes = 15,
lowRiskAttenuationDurationMinutes = 15,
riskScoreSum = 50
),
isDummy = false
)
toast(
context,
"Non-dummy w/Exposure analytics result: ${if (isSuccess) "success" else "failure"}"
)
}
}) {},
object : DebugMenuItem("\uD83D\uDD14 Send Non-Dummy w/o Exposure Analytics", { _, _ ->
GlobalScope.launch(Dispatchers.Main) {
val isSuccess = analyticsManager.sendOperationalInfo(
summary = null,
isDummy = false
)
toast(
context,
"Non-dummy w/o exposure analytics result: ${if (isSuccess) "success" else "failure"}"
)
}
}) {},
object : DebugMenuItem("\uD83D\uDD14 Verify Attestation", { _, _ ->
GlobalScope.launch(Dispatchers.Main) {
val attestationResult = attestationClient.attest("FOO".base64EncodedSha256())
when (attestationResult) {
is AttestationClient.Result.Success -> toast(context, "Success")
is AttestationClient.Result.Invalid -> toast(context, "Invalid")
is AttestationClient.Result.Failure -> toast(
context,
"Failure: ${attestationResult.error}"
)
}
}
}) {}
)
}
Expand Down
68 changes: 64 additions & 4 deletions app/src/main/java/it/ministerodellasalute/immuni/koinModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,19 @@ import androidx.lifecycle.SavedStateHandle
import it.ministerodellasalute.immuni.api.services.*
import it.ministerodellasalute.immuni.config.*
import it.ministerodellasalute.immuni.debugmenu.DebugMenu
import it.ministerodellasalute.immuni.extensions.attestation.AttestationClient
import it.ministerodellasalute.immuni.extensions.attestation.SafetyNetAttestationClient
import it.ministerodellasalute.immuni.extensions.lifecycle.AppActivityLifecycleCallbacks
import it.ministerodellasalute.immuni.extensions.lifecycle.AppLifecycleObserver
import it.ministerodellasalute.immuni.extensions.nearby.ExposureNotificationManager
import it.ministerodellasalute.immuni.extensions.notifications.PushNotificationManager
import it.ministerodellasalute.immuni.extensions.storage.KVStorage
import it.ministerodellasalute.immuni.extensions.utils.moshi
import it.ministerodellasalute.immuni.logic.exposure.BaseOperationalInfo
import it.ministerodellasalute.immuni.logic.exposure.ExposureAnalyticsManager
import it.ministerodellasalute.immuni.logic.exposure.ExposureManager
import it.ministerodellasalute.immuni.logic.exposure.models.ExposureStatus
import it.ministerodellasalute.immuni.logic.exposure.repositories.ExposureIngestionRepository
import it.ministerodellasalute.immuni.logic.exposure.repositories.ExposureReportingRepository
import it.ministerodellasalute.immuni.logic.exposure.repositories.ExposureStatusRepository
import it.ministerodellasalute.immuni.logic.exposure.repositories.*
import it.ministerodellasalute.immuni.logic.forceupdate.ForceUpdateManager
import it.ministerodellasalute.immuni.logic.notifications.AppNotificationManager
import it.ministerodellasalute.immuni.logic.settings.ConfigurationSettingsManager
Expand Down Expand Up @@ -103,6 +105,16 @@ val appModule = module {
network.createServiceAPI(ExposureIngestionService::class)
}

/**
* Exposure Analytics Service APIs
*/
single {
val network = Network(
androidContext(), ExposureAnalyticsNetworkConfiguration(androidContext(), get())
)
network.createServiceAPI(ExposureAnalyticsService::class)
}

/**
* Debug Menu module.
*/
Expand Down Expand Up @@ -189,10 +201,14 @@ val appModule = module {
UploadDisabler(get())
}

single {
ExposureNotificationManager(androidContext(), get())
}

single {
ExposureManager(
get(),
ExposureNotificationManager(androidContext(), get()),
get(),
get(),
get(),
get(),
Expand All @@ -201,6 +217,46 @@ val appModule = module {
)
}

single {
ExposureAnalyticsStoreRepository(
KVStorage(
name = "ExposureAnalyticsStoreRepository",
context = androidContext(),
moshi = get(),
cacheInMemory = true,
encrypted = true
)
)
}

single {
ExposureAnalyticsNetworkRepository(get())
}

single<AttestationClient> {
SafetyNetAttestationClient(
androidContext(),
SafetyNetAttestationClient.AttestationParameters(
apiKey = BuildConfig.SAFETY_NET_API_KEY,
apkPackageName = androidContext().packageName,
requiresBasicIntegrity = true,
requiresCtsProfile = true,
requiresHardwareAttestation = true
)
)
}

single {
ExposureAnalyticsManager(
get<ExposureAnalyticsStoreRepository>(),
get(),
get(),
get(),
get(),
{ get() }
)
}

single {
ExposureStatusRepository(
KVStorage(
Expand Down Expand Up @@ -265,6 +321,10 @@ val appModule = module {
immuniMoshi
}

factory {
BaseOperationalInfo(get(), get(), get())
}

// Android ViewModels

viewModel { SetupViewModel(get(), get()) }
Expand Down
Loading

0 comments on commit c984e40

Please sign in to comment.