Compare commits

...

3 Commits

24 changed files with 398 additions and 24 deletions

View File

@ -8,7 +8,7 @@ plugins {
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.hilt) alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.serialization)
} }
@ -60,7 +60,10 @@ android {
isShrinkResources = true isShrinkResources = true
signingConfig = signingConfigs.getByName(BuildType.RELEASE.name) signingConfig = signingConfigs.getByName(BuildType.RELEASE.name)
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
} }
} }
compileOptions { compileOptions {
@ -92,6 +95,9 @@ dependencies {
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose) implementation(libs.hilt.navigation.compose)
implementation(libs.matomo) implementation(libs.matomo)
implementation(libs.clarity)
implementation(libs.ga4)
// Compose // Compose
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))

View File

@ -15,12 +15,16 @@ plugins {
// Agp // Agp
alias(libs.plugins.android.library) apply false alias(libs.plugins.android.library) apply false
//Hilt // Hilt
alias(libs.plugins.hilt) apply false alias(libs.plugins.hilt) apply false
// Kotlin serialization
alias(libs.plugins.serialization) apply false
//Kotlin serialization // Firebase crashlytics
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.firebaseCrashlytics) apply false
// Google services
alias(libs.plugins.googleServices) apply false
} }

View File

@ -45,7 +45,7 @@ dependencies {
ksp(libs.hilt.compiler) ksp(libs.hilt.compiler)
// Serialization // Serialization
implementation(libs.serializationJson) implementation(libs.kotlin.serialization)
// Retrofit // Retrofit
api(libs.retrofit) api(libs.retrofit)

View File

@ -0,0 +1,98 @@
package fr.openium.consentium.api
import android.content.Context
import dagger.hilt.android.EntryPointAccessors
import fr.openium.consentium.api.model.PurposeChoice
import fr.openium.consentium.api.model.PurposeIdentifier
import fr.openium.consentium.api.model.PurposeStatus
import fr.openium.consentium.api.model.VendorIdentifier
import fr.openium.consentium.api.model.VendorStatus
import fr.openium.consentium.api.state.FetchConsentiumState
import fr.openium.consentium.api.state.SetConsentiumState
import fr.openium.consentium.data.di.RepositoryEntryPoint
import fr.openium.consentium.data.repository.ConsentiumRepository
import fr.openium.consentium.data.repository.ConsentiumRepositoryGetResponse
import fr.openium.consentium.data.repository.ConsentiumRepositorySetResponse
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class Consentium(
context: Context,
val applicationId: String,
) {
private val _fetchConsentState = MutableStateFlow<FetchConsentiumState>(FetchConsentiumState.Idle)
val fetchConsentState by lazy { _fetchConsentState.asStateFlow() }
private val _setConsentState = MutableStateFlow<SetConsentiumState>(SetConsentiumState.Idle)
val setConsentState by lazy { _setConsentState.asStateFlow() }
private val consentiumRepository: ConsentiumRepository by lazy {
val appContext = context.applicationContext
val entryPoint = EntryPointAccessors.fromApplication(appContext, RepositoryEntryPoint::class.java)
entryPoint.provideConsentiumRepository()
}
fun getConsentStatus(purpose: PurposeIdentifier): PurposeStatus {
return when (val state = fetchConsentState.value) {
is FetchConsentiumState.Valid -> {
val purposeChoice = state.purposes.find { it.identifier == purpose }
purposeChoice?.isAccepted ?: PurposeStatus.UNKNOWN
}
else -> PurposeStatus.UNKNOWN
}
}
fun getConsentStatus(purpose: PurposeIdentifier, vendorIdentifier: VendorIdentifier): VendorStatus {
return when (val state = fetchConsentState.value) {
is FetchConsentiumState.Valid -> {
val vendorChoice = state.purposes.find { it.identifier == purpose }
?.vendors?.find { it.identifier == vendorIdentifier }
when (vendorChoice?.isAccepted) {
true -> VendorStatus.ACCEPTED
false -> VendorStatus.REJECTED
else -> VendorStatus.UNKNOWN
}
}
else -> VendorStatus.UNKNOWN
}
}
suspend fun fetchConsents() {
_fetchConsentState.value = FetchConsentiumState.Loading
try {
when (val consentResponse = consentiumRepository.getConsents(applicationId)) {
ConsentiumRepositoryGetResponse.Error -> _fetchConsentState.value = FetchConsentiumState.Error
is ConsentiumRepositoryGetResponse.GetConsentsSuccess -> {
val areConsentsValid = consentResponse.isValid
if (areConsentsValid) {
_fetchConsentState.value = FetchConsentiumState.Valid(purposes = consentResponse.purposes)
} else {
_fetchConsentState.value = FetchConsentiumState.Invalid
}
}
}
} catch (e: Exception) {
_fetchConsentState.value = FetchConsentiumState.Error
}
}
suspend fun setConsents(
consent: List<PurposeChoice>,
) {
_setConsentState.value = SetConsentiumState.Loading
try {
when (consentiumRepository.setConsents(applicationId, consent)) {
ConsentiumRepositorySetResponse.Error -> _setConsentState.value = SetConsentiumState.Error
ConsentiumRepositorySetResponse.SetConsentsSuccess -> _setConsentState.value = SetConsentiumState.Success
}
} catch (e: Exception) {
_setConsentState.value = SetConsentiumState.Error
}
}
}

View File

@ -0,0 +1,12 @@
package fr.openium.consentium.api.adapter
import fr.openium.consentium.api.model.Purpose
import fr.openium.consentium.api.model.PurposeIdentifier
import fr.openium.consentium.data.remote.model.GetConsent
internal fun GetConsent.PurposeDTO.toPurpose() = Purpose(
identifier = PurposeIdentifier.fromString(identifier),
isRequired = isRequired,
isAccepted = isAccepted.toPurposeStatus(),
vendors = vendors?.map { it.toVendor() } ?: emptyList(),
)

View File

@ -0,0 +1,10 @@
package fr.openium.consentium.api.adapter
import fr.openium.consentium.api.model.PurposeChoice
import fr.openium.consentium.data.remote.model.PatchConsent
internal fun PurposeChoice.toPatchConsentPurposeDTO() = PatchConsent.PurposeDTO(
identifier = purposeIdentifier.toString(),
isAccepted = isAccepted,
vendors = vendors.map { it.toPatchConsentVendorDTO() }
)

View File

@ -0,0 +1,10 @@
package fr.openium.consentium.api.adapter
import fr.openium.consentium.api.model.PurposeStatus
import fr.openium.consentium.data.remote.model.GetConsent
internal fun GetConsent.PurposeStatusDTO.toPurposeStatus() = when (this) {
GetConsent.PurposeStatusDTO.ACCEPTED -> PurposeStatus.ACCEPTED
GetConsent.PurposeStatusDTO.REJECTED -> PurposeStatus.REJECTED
GetConsent.PurposeStatusDTO.NOT_DEFINED -> PurposeStatus.NOT_DEFINED
}

View File

@ -0,0 +1,11 @@
package fr.openium.consentium.api.adapter
import fr.openium.consentium.api.model.Vendor
import fr.openium.consentium.api.model.VendorIdentifier
import fr.openium.consentium.data.remote.model.GetConsent
internal fun GetConsent.VendorDTO.toVendor() = Vendor(
identifier = VendorIdentifier.fromString(identifier),
isAccepted = isAccepted,
isRequired = isRequired,
)

View File

@ -0,0 +1,9 @@
package fr.openium.consentium.api.adapter
import fr.openium.consentium.api.model.VendorChoice
import fr.openium.consentium.data.remote.model.PatchConsent
internal fun VendorChoice.toPatchConsentVendorDTO() = PatchConsent.VendorDTO(
identifier = vendorIdentifier.toString(),
isAccepted = isAccepted,
)

View File

@ -0,0 +1,8 @@
package fr.openium.consentium.api.model
data class Purpose(
val identifier: PurposeIdentifier,
val isRequired: Boolean,
val isAccepted: PurposeStatus,
val vendors: List<Vendor>,
)

View File

@ -0,0 +1,7 @@
package fr.openium.consentium.api.model
data class PurposeChoice(
val purposeIdentifier: PurposeIdentifier,
val isAccepted: Boolean,
val vendors: List<VendorChoice>,
)

View File

@ -0,0 +1,24 @@
package fr.openium.consentium.api.model
enum class PurposeIdentifier {
REQUIRED,
AUDIENCE,
ADS,
UNKNOWN;
companion object {
fun fromString(string: String): PurposeIdentifier = when (string) {
"purpose-required" -> REQUIRED
"purpose-audience" -> AUDIENCE
"purpose-ads" -> ADS
else -> UNKNOWN
}
}
override fun toString(): String = when (this) {
REQUIRED -> "purpose-required"
AUDIENCE -> "purpose-audience"
ADS -> "purpose-ads"
else -> "purpose-unknown"
}
}

View File

@ -0,0 +1,8 @@
package fr.openium.consentium.api.model
enum class PurposeStatus {
ACCEPTED,
REJECTED,
NOT_DEFINED,
UNKNOWN,
}

View File

@ -0,0 +1,7 @@
package fr.openium.consentium.api.model
data class Vendor(
val identifier: VendorIdentifier,
val isAccepted: Boolean,
val isRequired: Boolean,
)

View File

@ -0,0 +1,6 @@
package fr.openium.consentium.api.model
data class VendorChoice(
val vendorIdentifier: VendorIdentifier,
val isAccepted: Boolean,
)

View File

@ -0,0 +1,30 @@
package fr.openium.consentium.api.model
enum class VendorIdentifier {
CRASHLYTICS,
MATOMO,
GA4,
PIANO,
CLARITY,
UNKNOWN;
companion object {
fun fromString(string: String): VendorIdentifier = when (string) {
"vendor-clarity" -> CLARITY
"vendor-crashlytics" -> CRASHLYTICS
"vendor-matomo" -> MATOMO
"vendor-ga4" -> GA4
"vendor-piano" -> PIANO
else -> UNKNOWN
}
}
override fun toString(): String = when (this) {
CLARITY -> "vendor-clarity"
CRASHLYTICS -> "vendor-crashlytics"
MATOMO -> "vendor-matomo"
GA4 -> "vendor-ga4"
PIANO -> "vendor-piano"
else -> "vendor-unknown"
}
}

View File

@ -0,0 +1,7 @@
package fr.openium.consentium.api.model
enum class VendorStatus {
ACCEPTED,
REJECTED,
UNKNOWN,
}

View File

@ -0,0 +1,17 @@
package fr.openium.consentium.api.state
import fr.openium.consentium.api.model.Purpose
sealed interface FetchConsentiumState {
data object Idle : FetchConsentiumState
data object Loading : FetchConsentiumState
data object Invalid : FetchConsentiumState
data class Valid(val purposes: List<Purpose>) : FetchConsentiumState
data object Error : FetchConsentiumState
}

View File

@ -0,0 +1,13 @@
package fr.openium.consentium.api.state
sealed interface SetConsentiumState {
data object Idle : SetConsentiumState
data object Loading : SetConsentiumState
data object Success : SetConsentiumState
data object Error : SetConsentiumState
}

View File

@ -0,0 +1,12 @@
package fr.openium.consentium.data.di
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import fr.openium.consentium.data.repository.ConsentiumRepository
@EntryPoint
@InstallIn(SingletonComponent::class)
internal interface RepositoryEntryPoint {
fun provideConsentiumRepository(): ConsentiumRepository
}

View File

@ -2,6 +2,7 @@ package fr.openium.consentium.data.remote
import fr.openium.consentium.data.remote.model.GetConsent import fr.openium.consentium.data.remote.model.GetConsent
import fr.openium.consentium.data.remote.model.PatchConsent import fr.openium.consentium.data.remote.model.PatchConsent
import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.PATCH import retrofit2.http.PATCH
@ -13,13 +14,12 @@ internal interface ConsentiumApi {
suspend fun getConsents( suspend fun getConsents(
applicationId: String, applicationId: String,
installationId: String, installationId: String,
): GetConsent.GetConsentPayloadDTO ): Response<GetConsent.GetConsentPayloadDTO>
@PATCH("/consents") @PATCH("/consents")
suspend fun setConsents( suspend fun setConsents(
applicationId: String, applicationId: String,
installationId: String,
@Body patchConsent: PatchConsent.PatchConsentPayloadDTO, @Body patchConsent: PatchConsent.PatchConsentPayloadDTO,
): Any ): Response<Any>
} }

View File

@ -16,7 +16,7 @@ internal sealed interface GetConsent {
data class PurposeDTO( data class PurposeDTO(
@SerialName("identifier") val identifier: String, @SerialName("identifier") val identifier: String,
@SerialName("isRequired") val isRequired: Boolean, @SerialName("isRequired") val isRequired: Boolean,
@SerialName("isAccepted") val isAccepted: PurposeStatus, @SerialName("isAccepted") val isAccepted: PurposeStatusDTO,
@SerialName("vendors") val vendors: List<VendorDTO>? = null, @SerialName("vendors") val vendors: List<VendorDTO>? = null,
) )
@ -28,7 +28,7 @@ internal sealed interface GetConsent {
) )
@Serializable @Serializable
enum class PurposeStatus { enum class PurposeStatusDTO {
@SerialName("ACCEPTED") @SerialName("ACCEPTED")
ACCEPTED, ACCEPTED,

View File

@ -1,7 +1,12 @@
package fr.openium.consentium.data.repository package fr.openium.consentium.data.repository
import fr.openium.consentium.api.adapter.toPatchConsentPurposeDTO
import fr.openium.consentium.api.adapter.toPurpose
import fr.openium.consentium.api.model.Purpose
import fr.openium.consentium.api.model.PurposeChoice
import fr.openium.consentium.data.local.ConsentiumDataStore import fr.openium.consentium.data.local.ConsentiumDataStore
import fr.openium.consentium.data.remote.ConsentiumApi import fr.openium.consentium.data.remote.ConsentiumApi
import fr.openium.consentium.data.remote.model.PatchConsent
import javax.inject.Inject import javax.inject.Inject
internal class ConsentiumRepository @Inject constructor( internal class ConsentiumRepository @Inject constructor(
@ -9,5 +14,63 @@ internal class ConsentiumRepository @Inject constructor(
private val consentiumDataStore: ConsentiumDataStore, private val consentiumDataStore: ConsentiumDataStore,
) { ) {
suspend fun getConsents(applicationId: String): ConsentiumRepositoryGetResponse {
val installationId = consentiumDataStore.getInstallationUniqueId()
val consentsResponse = consentiumApi.getConsents(applicationId, installationId)
return try {
val consentsBody = if (consentsResponse.isSuccessful) {
consentsResponse.body() ?: throw Exception()
} else {
throw Exception()
}
return ConsentiumRepositoryGetResponse.GetConsentsSuccess(
isValid = consentsBody.isValid,
purposes = consentsBody.purposes?.map { purpose -> purpose.toPurpose() } ?: emptyList()
)
} catch (e: Exception) {
ConsentiumRepositoryGetResponse.Error
}
}
suspend fun setConsents(
applicationId: String,
consents: List<PurposeChoice>,
): ConsentiumRepositorySetResponse {
val installationId = consentiumDataStore.getInstallationUniqueId()
val setConsentResponse = consentiumApi.setConsents(
applicationId = applicationId,
patchConsent = PatchConsent.PatchConsentPayloadDTO(
installationId = installationId,
purposes = consents.map { it.toPatchConsentPurposeDTO() },
)
)
return if (setConsentResponse.isSuccessful) {
ConsentiumRepositorySetResponse.SetConsentsSuccess
} else {
ConsentiumRepositorySetResponse.Error
}
}
}
internal interface ConsentiumRepositoryGetResponse {
data object Error : ConsentiumRepositoryGetResponse
data class GetConsentsSuccess(
val isValid: Boolean,
val purposes: List<Purpose>,
) : ConsentiumRepositoryGetResponse
}
internal interface ConsentiumRepositorySetResponse {
data object Error : ConsentiumRepositorySetResponse
data object SetConsentsSuccess : ConsentiumRepositorySetResponse
} }

View File

@ -27,8 +27,8 @@ timber = "5.0.1"
material = "1.12.0" material = "1.12.0"
# Serialization # Serialization
serialization = "2.0.21" serialization = "2.0.0"
jsonSerialization = "1.7.3" jsonSerialization = "1.7.1"
# Retrofit # Retrofit
retrofit = "2.11.0" retrofit = "2.11.0"
@ -48,18 +48,25 @@ preferencesDataStore = "1.1.1"
# Plugins # Plugins
agp = "8.7.3" agp = "8.7.3"
kotlin = "2.0.0" kotlin = "2.0.0"
ksp = "2.0.0-1.0.23" ksp = "2.0.0-1.0.24"
junitVersion = "1.2.1" junitVersion = "1.2.1"
googleServicesPlugin = "4.4.2"
# Matomo # Matomo
matomo = "4.3" matomo = "4.3"
# Serialization # Compose navigation
serialization = "1.7.1"
navigationCompose = "2.8.2" navigationCompose = "2.8.2"
# Crashlytics
firebaseCrashlyticsPlugin = "3.0.2"
firebaseCrashlyticsKtx = "19.2.0"
# Clarity
clarityVersion = "1.3.2"
# GA4
ga4 = "22.1.2"
[libraries] [libraries]
@ -91,11 +98,8 @@ compose-material3 = { group = "androidx.compose.material3", name = "material3" }
# Material # Material
material = { group = "com.google.android.material", name = "material", version.ref = "material" } material = { group = "com.google.android.material", name = "material", version.ref = "material" }
# Matomo
matomo = { module = "com.github.matomo-org:matomo-sdk-android", version.ref = "matomo" }
# Kotlin serizalization # Kotlin serizalization
kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "serialization" } kotlin-serialization = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "jsonSerialization" }
# Compose navigation # Compose navigation
androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCompose" } androidx-navigation-common-ktx = { group = "androidx.navigation", name = "navigation-common-ktx", version.ref = "navigationCompose" }
@ -105,9 +109,6 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
# Preferences DataStore # Preferences DataStore
preferencesDataStore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferencesDataStore" } preferencesDataStore = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "preferencesDataStore" }
# Json serialization
serializationJson = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jsonSerialization" }
# Retrofit # Retrofit
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofitConverter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" } retrofitConverter = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
@ -123,6 +124,15 @@ test-androidx-junit = { group = "androidx.test.ext", name = "junit-ktx", version
test-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } test-espresso = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
# Matomo
matomo = { module = "com.github.matomo-org:matomo-sdk-android", version.ref = "matomo" }
# Clarity
clarity = { group = "com.microsoft.clarity", name = "clarity", version.ref = "clarityVersion" }
# GA4 (Firebase Analytics)
ga4 = { module = "com.google.firebase:firebase-analytics", version.ref = "ga4" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
@ -131,6 +141,8 @@ ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" }
firebaseCrashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" }
googleServices = { id = "com.google.gms.google-services", version.ref = "googleServicesPlugin" }
[bundles] [bundles]
androidx = ["androidx-core-ktx", "androidx-activity-compose"] androidx = ["androidx-core-ktx", "androidx-activity-compose"]