Commit 87b7717e authored by stopcovid@lunabee.com's avatar stopcovid@lunabee.com
Browse files

Update to 3.7.4

- Refacto OKHttp
parent 51d74086
......@@ -11,7 +11,6 @@
package com.lunabeestudio.analytics.manager
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.core.util.AtomicFile
import androidx.lifecycle.LifecycleObserver
......@@ -25,6 +24,7 @@ import com.lunabeestudio.analytics.extension.toAPI
import com.lunabeestudio.analytics.extension.toDomain
import com.lunabeestudio.analytics.extension.toProto
import com.lunabeestudio.analytics.model.AnalyticsResult
import com.lunabeestudio.analytics.model.AnalyticsServiceName
import com.lunabeestudio.analytics.model.AppEventName
import com.lunabeestudio.analytics.model.AppInfos
import com.lunabeestudio.analytics.model.HealthEventName
......@@ -41,9 +41,13 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import retrofit2.HttpException
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Calendar
......@@ -52,51 +56,38 @@ import java.util.Locale
import java.util.UUID
import kotlin.random.Random
object AnalyticsManager : LifecycleObserver {
private const val FOLDER_NAME: String = "TacAnalytics"
private const val FILE_NAME_APP_EVENTS: String = "app_events"
private const val FILE_NAME_APP_ERRORS: String = "app_errors"
private const val FILE_NAME_HEALTH_EVENTS: String = "heath_events"
private const val SHARED_PREFS_NAME: String = "TacAnalytics"
private const val ANALYTICS_REPORT_MIN_DELAY: Long = 500L
private const val ANALYTICS_REPORT_MAX_DELAY: Long = 2000L
class AnalyticsManager(okHttpClient: OkHttpClient, context: Context) : LifecycleObserver {
private val dateFormat: DateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.FRANCE)
private val analyticsServerManager = AnalyticsServerManager(okHttpClient)
private lateinit var sharedPreferences: SharedPreferences
fun isOptIn(context: Context): Boolean = getSharedPrefs(context).isOptIn
fun setIsOptIn(context: Context, isOptIn: Boolean) {
getSharedPrefs(context).isOptIn = isOptIn
}
fun requestDeleteAnalytics(context: Context) {
reportAppEvent(context, AppEventName.e17)
getSharedPrefs(context).deleteAnalyticsAfterNextStatus = true
}
private val sharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
fun init(context: Context) {
getSharedPrefs(context).apply {
init {
sharedPreferences.apply {
if (installationUUID == null) {
installationUUID = UUID.randomUUID().toString()
}
}
}
private fun getSharedPrefs(context: Context): SharedPreferences {
if (!AnalyticsManager::sharedPreferences.isInitialized) {
sharedPreferences = context.getSharedPreferences(SHARED_PREFS_NAME, Context.MODE_PRIVATE)
}
return sharedPreferences
fun isOptIn(): Boolean = sharedPreferences.isOptIn
fun setIsOptIn(isOptIn: Boolean) {
sharedPreferences.isOptIn = isOptIn
}
fun register(context: Context) {
getSharedPrefs(context).installationUUID = UUID.randomUUID().toString()
fun requestDeleteAnalytics(context: Context) {
reportAppEvent(context, AppEventName.e17)
sharedPreferences.deleteAnalyticsAfterNextStatus = true
}
fun register() {
sharedPreferences.installationUUID = UUID.randomUUID().toString()
}
fun unregister(context: Context) {
getSharedPrefs(context).apply {
sharedPreferences.apply {
installationUUID = null
proximityStartTime = null
proximityActiveDuration = 0L
......@@ -105,27 +96,27 @@ object AnalyticsManager : LifecycleObserver {
reset(context)
}
fun proximityDidStart(context: Context) {
getSharedPrefs(context).proximityStartTime = System.currentTimeMillis()
fun proximityDidStart() {
sharedPreferences.proximityStartTime = System.currentTimeMillis()
}
fun proximityDidStop(context: Context) {
getSharedPrefs(context).apply {
proximityActiveDuration = getProximityActiveDuration(context)
fun proximityDidStop() {
sharedPreferences.apply {
proximityActiveDuration = getProximityActiveDuration()
proximityStartTime = null
}
}
fun statusDidSucceed(context: Context) {
reportAppEvent(context, AppEventName.e16)
getSharedPrefs(context).apply {
sharedPreferences.apply {
statusSuccessCount += 1
}
}
private fun getProximityActiveDuration(context: Context): Long {
val oldDuration = getSharedPrefs(context).proximityActiveDuration
val addedDuration = System.currentTimeMillis() - (getSharedPrefs(context).proximityStartTime ?: System.currentTimeMillis())
private fun getProximityActiveDuration(): Long {
val oldDuration = sharedPreferences.proximityActiveDuration
val addedDuration = System.currentTimeMillis() - (sharedPreferences.proximityStartTime ?: System.currentTimeMillis())
return oldDuration + addedDuration
}
......@@ -135,7 +126,7 @@ object AnalyticsManager : LifecycleObserver {
analyticsInfosProvider: AnalyticsInfosProvider,
token: String
) {
if (robertManager.configuration.isAnalyticsOn && getSharedPrefs(context).isOptIn) {
if (robertManager.configuration.isAnalyticsOn && sharedPreferences.isOptIn) {
val receivedHelloMessagesCount = robertManager.getLocalProximityCount()
sendAppAnalytics(context, analyticsInfosProvider, token, receivedHelloMessagesCount)
delay(Random.nextLong(ANALYTICS_REPORT_MIN_DELAY, ANALYTICS_REPORT_MAX_DELAY))
......@@ -143,7 +134,7 @@ object AnalyticsManager : LifecycleObserver {
} else {
reset(context)
}
if (getSharedPrefs(context).deleteAnalyticsAfterNextStatus) {
if (sharedPreferences.deleteAnalyticsAfterNextStatus) {
sendDeleteAnalytics(context, analyticsInfosProvider, token)
}
}
......@@ -154,18 +145,17 @@ object AnalyticsManager : LifecycleObserver {
token: String,
receivedHelloMessagesCount: Int
) {
val appInfos = getAppInfos(context, analyticsInfosProvider, receivedHelloMessagesCount)
val appInfos = getAppInfos(analyticsInfosProvider, receivedHelloMessagesCount)
val appEvents = getAppEvents(context)
val appErrors = getErrors(context.filesDir)
val sendAnalyticsRQ = SendAppAnalyticsRQ(
installationUuid = getSharedPrefs(context).installationUUID ?: UUID.randomUUID().toString(),
installationUuid = sharedPreferences.installationUUID ?: UUID.randomUUID().toString(),
infos = appInfos,
events = appEvents.toAPI(),
errors = appErrors.toAPI()
)
withContext(Dispatchers.IO) {
val result = AnalyticsServerManager.sendAnalytics(
context,
val result = analyticsServerManager.sendAnalytics(
analyticsInfosProvider.getBaseUrl(),
analyticsInfosProvider.getApiVersion(),
token,
......@@ -194,7 +184,7 @@ object AnalyticsManager : LifecycleObserver {
token: String
) {
val healthEvents = getHealthEvents(context)
val healthInfos = getHealthInfos(context, robertManager, analyticsInfosProvider)
val healthInfos = getHealthInfos(robertManager, analyticsInfosProvider)
val sendAnalyticsRQ = SendHealthAnalyticsRQ(
installationUuid = UUID.randomUUID().toString(),
infos = healthInfos,
......@@ -202,8 +192,7 @@ object AnalyticsManager : LifecycleObserver {
errors = emptyList()
)
withContext(Dispatchers.IO) {
val result = AnalyticsServerManager.sendAnalytics(
context,
val result = analyticsServerManager.sendAnalytics(
analyticsInfosProvider.getBaseUrl(),
analyticsInfosProvider.getApiVersion(),
token,
......@@ -231,28 +220,50 @@ object AnalyticsManager : LifecycleObserver {
token: String
) {
withContext(Dispatchers.IO) {
getSharedPrefs(context).installationUUID?.let { installationUUID ->
val result = AnalyticsServerManager.deleteAnalytics(
context,
sharedPreferences.installationUUID?.let { installationUUID ->
val apiVersion = analyticsInfosProvider.getApiVersion()
val result = analyticsServerManager.deleteAnalytics(
analyticsInfosProvider.getBaseUrl(),
analyticsInfosProvider.getApiVersion(),
apiVersion,
token,
installationUUID,
)
when (result) {
is AnalyticsResult.Success -> {
withContext(Dispatchers.Main) {
getSharedPrefs(context).deleteAnalyticsAfterNextStatus = false
sharedPreferences.deleteAnalyticsAfterNextStatus = false
}
}
is AnalyticsResult.Failure -> {
Timber.e(result.error)
val error = result.error
Timber.e(error)
if (error is HttpException) {
reportWSError(
context.filesDir,
AnalyticsServiceName.ANALYTICS,
apiVersion,
error.code(),
error.message()
)
} else if (error?.isNoInternetException() == false) {
reportWSError(
context.filesDir,
AnalyticsServiceName.ANALYTICS,
apiVersion,
(error as? HttpException)?.code() ?: 0,
error.message
)
}
}
}
}
}
}
private fun Exception.isNoInternetException(): Boolean = this is SocketTimeoutException
|| this is IOException
|| this is UnknownHostException
fun reset(context: Context) {
resetAppEvents(context)
resetHealthEvents(context)
......@@ -262,7 +273,7 @@ object AnalyticsManager : LifecycleObserver {
@Suppress("BlockingMethodInNonBlockingContext")
@Synchronized
fun reportAppEvent(context: Context, eventName: AppEventName, desc: String? = null) {
if (getSharedPrefs(context).isOptIn) {
if (sharedPreferences.isOptIn) {
CoroutineScope(Dispatchers.IO).launch {
val timestampedEventList = getAppEvents(context).toMutableList()
timestampedEventList += TimestampedEvent(eventName.name, dateFormat.format(roundedHourDate()), desc ?: "")
......@@ -275,7 +286,7 @@ object AnalyticsManager : LifecycleObserver {
@Suppress("BlockingMethodInNonBlockingContext")
@Synchronized
fun reportHealthEvent(context: Context, eventName: HealthEventName, desc: String? = null) {
if (getSharedPrefs(context).isOptIn) {
if (sharedPreferences.isOptIn) {
CoroutineScope(Dispatchers.IO).launch {
val timestampedEventList = getHealthEvents(context).toMutableList()
timestampedEventList += TimestampedEvent(eventName.name, dateFormat.format(roundedHourDate()), desc ?: "")
......@@ -287,8 +298,8 @@ object AnalyticsManager : LifecycleObserver {
@Suppress("BlockingMethodInNonBlockingContext")
@Synchronized
fun reportWSError(context: Context, filesDir: File, wsName: String, wsVersion: String, errorCode: Int, desc: String? = null) {
if (getSharedPrefs(context).isOptIn) {
fun reportWSError(filesDir: File, wsName: String, wsVersion: String, errorCode: Int, desc: String? = null) {
if (sharedPreferences.isOptIn) {
if (desc?.contains("No address associated with hostname") != true) {
CoroutineScope(Dispatchers.IO).launch {
val name = "ERR-${wsName.uppercase(Locale.getDefault())}-${wsVersion.uppercase(Locale.getDefault())}-$errorCode"
......@@ -311,7 +322,6 @@ object AnalyticsManager : LifecycleObserver {
}
private fun getAppInfos(
context: Context,
infosProvider: AnalyticsInfosProvider,
receivedHelloMessagesCount: Int
): AppInfos {
......@@ -326,20 +336,19 @@ object AnalyticsManager : LifecycleObserver {
placesCount = infosProvider.getPlacesCount(),
formsCount = infosProvider.getFormsCount(),
certificatesCount = infosProvider.getCertificatesCount(),
statusSuccessCount = getSharedPrefs(context).statusSuccessCount,
statusSuccessCount = sharedPreferences.statusSuccessCount,
userHasAZipcode = infosProvider.userHaveAZipCode(),
)
}
private fun getHealthInfos(
context: Context,
robertManager: AnalyticsRobertManager,
infosProvider: AnalyticsInfosProvider
): HealthInfos {
return HealthInfos(
type = 1,
os = "Android",
secondsTracingActivated = getProximityActiveDuration(context) / 1000L,
secondsTracingActivated = getProximityActiveDuration() / 1000L,
riskLevel = robertManager.atRiskStatus?.riskLevel,
dateSample = infosProvider.getDateSample()?.let { dateFormat.format(Date(it)) },
dateFirstSymptoms = infosProvider.getDateFirstSymptom()?.let { dateFormat.format(Date(it)) },
......@@ -413,4 +422,14 @@ object AnalyticsManager : LifecycleObserver {
private fun <T> executeActionOnAtomicFile(action: () -> T): T {
return action()
}
companion object {
private const val FOLDER_NAME: String = "TacAnalytics"
private const val FILE_NAME_APP_EVENTS: String = "app_events"
private const val FILE_NAME_APP_ERRORS: String = "app_errors"
private const val FILE_NAME_HEALTH_EVENTS: String = "heath_events"
private const val SHARED_PREFS_NAME: String = "TacAnalytics"
private const val ANALYTICS_REPORT_MIN_DELAY: Long = 500L
private const val ANALYTICS_REPORT_MAX_DELAY: Long = 2000L
}
}
......@@ -16,6 +16,7 @@ import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Header
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
......@@ -26,17 +27,20 @@ internal interface AnalyticsApi {
suspend fun sendAppAnalytics(
@Path("apiVersion") apiVersion: String,
@Body body: SendAppAnalyticsRQ,
@Header("Authorization") bearerToken: String,
): Response<ResponseBody>
@POST("api/{apiVersion}/analytics")
suspend fun sendHealthAnalytics(
@Path("apiVersion") apiVersion: String,
@Body body: SendHealthAnalyticsRQ,
@Header("Authorization") bearerToken: String,
): Response<ResponseBody>
@DELETE("api/{apiVersion}/analytics")
suspend fun deleteAnalytics(
@Path("apiVersion") apiVersion: String,
@Query("installationUuid") installationUuid: String,
@Header("Authorization") bearerToken: String,
): Response<ResponseBody>
}
\ No newline at end of file
......@@ -10,67 +10,37 @@
package com.lunabeestudio.analytics.network
import android.content.Context
import android.os.Build
import com.lunabeestudio.analytics.BuildConfig
import com.lunabeestudio.analytics.manager.AnalyticsManager
import com.lunabeestudio.analytics.model.AnalyticsResult
import com.lunabeestudio.analytics.model.AnalyticsServiceName
import com.lunabeestudio.analytics.network.model.SendAnalyticsRQ
import com.lunabeestudio.analytics.network.model.SendAppAnalyticsRQ
import com.lunabeestudio.analytics.network.model.SendHealthAnalyticsRQ
import okhttp3.ConnectionSpec
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.TlsVersion
import okhttp3.logging.HttpLoggingInterceptor
import okhttp3.tls.HandshakeCertificates
import retrofit2.HttpException
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import timber.log.Timber
import java.io.IOException
import java.net.SocketTimeoutException
import java.net.UnknownHostException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
internal object AnalyticsServerManager {
internal class AnalyticsServerManager(private val okHttpClient: OkHttpClient) {
private fun getRetrofit(context: Context, baseUrl: String, token: String): AnalyticsApi {
return Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(MoshiConverterFactory.create())
.client(
OkHttpClient.Builder().apply {
if (!BuildConfig.DEBUG) {
val requireTls12 = ConnectionSpec.Builder(ConnectionSpec.RESTRICTED_TLS)
.tlsVersions(TlsVersion.TLS_1_2)
.build()
connectionSpecs(listOf(requireTls12))
}
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.N) {
val certificates: HandshakeCertificates = HandshakeCertificates.Builder()
.addTrustedCertificate(certificateFromString(context, "certigna_services"))
.build()
sslSocketFactory(certificates.sslSocketFactory(), certificates.trustManager)
}
addInterceptor(getDefaultHeaderInterceptor(token))
addInterceptor(getLogInterceptor())
callTimeout(30L, TimeUnit.SECONDS)
connectTimeout(30L, TimeUnit.SECONDS)
readTimeout(30L, TimeUnit.SECONDS)
writeTimeout(30L, TimeUnit.SECONDS)
}.build()
)
.build()
.create(AnalyticsApi::class.java)
var cachedApi: Pair<String, AnalyticsApi>? = null
private fun getRetrofit(baseUrl: String): AnalyticsApi {
val cachedApi = cachedApi
return if (cachedApi?.first == baseUrl) {
cachedApi.second
} else {
Retrofit.Builder()
.baseUrl(baseUrl)
.addConverterFactory(MoshiConverterFactory.create())
.client(okHttpClient)
.build()
.create(AnalyticsApi::class.java).also { api ->
this.cachedApi = baseUrl to api
}
}
}
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun sendAnalytics(
context: Context,
baseUrl: String,
apiVersion: String,
token: String,
......@@ -78,13 +48,15 @@ internal object AnalyticsServerManager {
): AnalyticsResult {
return try {
val result = when (sendAnalyticsRQ) {
is SendAppAnalyticsRQ -> getRetrofit(context, baseUrl, token).sendAppAnalytics(
is SendAppAnalyticsRQ -> getRetrofit(baseUrl).sendAppAnalytics(
apiVersion,
sendAnalyticsRQ
sendAnalyticsRQ,
"Bearer $token",
)
is SendHealthAnalyticsRQ -> getRetrofit(context, baseUrl, token).sendHealthAnalytics(
is SendHealthAnalyticsRQ -> getRetrofit(baseUrl).sendHealthAnalytics(
apiVersion,
sendAnalyticsRQ
sendAnalyticsRQ,
"Bearer $token",
)
}
if (result.isSuccessful) {
......@@ -99,68 +71,24 @@ internal object AnalyticsServerManager {
@Suppress("BlockingMethodInNonBlockingContext")
suspend fun deleteAnalytics(
context: Context,
baseUrl: String,
apiVersion: String,
token: String,
installationUuid: String,
): AnalyticsResult {
return try {
val result = getRetrofit(context, baseUrl, token).deleteAnalytics(apiVersion, installationUuid)
val result = getRetrofit(baseUrl).deleteAnalytics(
apiVersion,
installationUuid,
"Bearer $token",
)
if (result.isSuccessful) {
AnalyticsResult.Success()
} else {
AnalyticsManager.reportWSError(
context,
context.filesDir,
AnalyticsServiceName.ANALYTICS,
apiVersion,
result.code(),
result.message()
)
AnalyticsResult.Failure(HttpException(result))
}
} catch (e: Exception) {
if (!e.isNoInternetException()) {
AnalyticsManager.reportWSError(
context,
context.filesDir,
AnalyticsServiceName.ANALYTICS,
apiVersion,
(e as? HttpException)?.code() ?: 0,
e.message
)
}
AnalyticsResult.Failure(e)
}
}
private fun Exception.isNoInternetException(): Boolean = this is SocketTimeoutException
|| this is IOException
|| this is UnknownHostException
private fun certificateFromString(context: Context, fileName: String): X509Certificate {
return CertificateFactory.getInstance("X.509").generateCertificate(
context.resources.openRawResource(
context.resources.getIdentifier(
fileName,
"raw", context.packageName
)
)
) as X509Certificate
}
private fun getDefaultHeaderInterceptor(token: String): Interceptor = Interceptor { chain ->
val request = chain.request()
.newBuilder().apply {
addHeader("Accept", "application/json")
addHeader("Content-Type", "application/json")
addHeader("Authorization", "Bearer $token")
}.build()
chain.proceed(request)
}
private fun getLogInterceptor(): HttpLoggingInterceptor = HttpLoggingInterceptor { message -> Timber.v(message) }.apply {
level = HttpLoggingInterceptor.Level.BODY
}
}
......@@ -163,4 +163,4 @@ object ConfigConstant {
const val HUAWEI: String = "appmarket://details?id=fr.gouv.android.stopcovid"
const val WEBSITE: String = "https://bonjour.tousanticovid.gouv.fr"
}
}
\ No newline at end of file
}
......@@ -35,5 +35,4 @@ enum class EnvConstant {
abstract val serverPublicKey: String
abstract val dccCertificatesFilename: String
abstract val conversionBaseUrl: String
}
\ No newline at end of file
}
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*
* Authors
* ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Created by Lunabee Studio / Date - 2021/27/8 - for the TOUS-ANTI-COVID project
*/
package com.lunabeestudio.stopcovid.coreui
import androidx.lifecycle.LiveData
import com.lunabeestudio.robert.utils.Event
import com.lunabeestudio.stopcovid.coreui.manager.LocalizedStrings
interface LocalizedApplication {
suspend fun initializeStrings()
val localizedStrings: LocalizedStrings
val liveLocalizedStrings: LiveData<Event<LocalizedStrings>>
}
\ No newline at end of file