Skip to content

Commit

Permalink
Merge pull request #1946 from Infomaniak/feature-flag-bimi
Browse files Browse the repository at this point in the history
Feature flag BIMI
  • Loading branch information
NicolasBourdin88 committed Jul 3, 2024
2 parents d9cbcf0 + ecbc35b commit 5b6daff
Show file tree
Hide file tree
Showing 10 changed files with 92 additions and 55 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -364,8 +364,8 @@ object ApiRepository : ApiRepositoryCore() {
"output" to "mail",
)

fun checkFeatureFlag(featureFlag: FeatureFlag, currentMailboxUuid: String): ApiResponse<Map<String, Boolean>> {
return callApi(ApiRoutes.featureFlag(featureFlag.apiName, currentMailboxUuid), GET)
fun getFeatureFlags(currentMailboxUuid: String): ApiResponse<List<String>> {
return callApi(ApiRoutes.featureFlags(currentMailboxUuid), GET)
}

fun getCredentialsPassword(): ApiResponse<InfomaniakPassword> = runCatching {
Expand Down
4 changes: 2 additions & 2 deletions app/src/main/java/com/infomaniak/mail/data/api/ApiRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ object ApiRoutes {
return "?mailbox_uuid=$mailboxUuid"
}

fun featureFlag(featureName: String, mailboxUuid: String): String {
return "$MAIL_API/api/feature-flag/check/${featureName}${mailboxUuidParameter(mailboxUuid)}"
fun featureFlags(mailboxUuid: String): String {
return "$MAIL_API/api/feature-flag/check${mailboxUuidParameter(mailboxUuid)}"
}

fun ping(): String {
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/com/infomaniak/mail/data/models/Bimi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class Bimi() : EmbeddedRealmObject, Parcelable {
this.isCertified = isCertified
}

fun isDisplayable(): Boolean = isCertified && svgContentUrl?.isNotEmpty() == true
fun isDisplayable(isBimiEnabled: Boolean): Boolean = isBimiEnabled && isCertified && svgContentUrl?.isNotEmpty() == true

companion object : Parceler<Bimi> {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,5 @@ package com.infomaniak.mail.data.models
// The field apiName is also used to store the enum in Realm
enum class FeatureFlag(val apiName: String) {
AI("ai-mail-composer"),
BIMI("bimi"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,9 @@ class Mailbox : RealmObject {
return _featureFlags.contains(featureFlag.apiName)
}

fun add(featureFlag: FeatureFlag): Boolean {
return _featureFlags.add(featureFlag.apiName)
}

fun remove(featureFlag: FeatureFlag): Boolean {
return _featureFlags.remove(featureFlag.apiName)
fun setFeatureFlags(featureFlags: List<String>) = with(_featureFlags){
clear()
addAll(featureFlags)
}
}
}
2 changes: 1 addition & 1 deletion app/src/main/java/com/infomaniak/mail/ui/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -345,7 +345,7 @@ class MainViewModel @Inject constructor(

private fun updateFeatureFlag(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) {
SentryLog.d(TAG, "Force refresh Features flags")
sharedUtils.updateAiFeatureFlag(mailbox.objectId, mailbox.uuid)
sharedUtils.updateFeatureFlags(mailbox.objectId, mailbox.uuid)
}

private fun updateExternalMailInfo(mailbox: Mailbox) = viewModelScope.launch(ioCoroutineContext) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ class AiViewModel @Inject constructor(
}

fun updateFeatureFlag(mailboxObjectId: String, mailboxUuid: String) = viewModelScope.launch(ioCoroutineContext) {
sharedUtils.updateAiFeatureFlag(mailboxObjectId, mailboxUuid)
sharedUtils.updateFeatureFlags(mailboxObjectId, mailboxUuid)
}

fun isHistoryEmpty(): Boolean = history.excludingContextMessage().isEmpty()
Expand Down
10 changes: 4 additions & 6 deletions app/src/main/java/com/infomaniak/mail/utils/SharedUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshCa
import com.infomaniak.mail.data.cache.mailboxContent.RefreshController.RefreshMode
import com.infomaniak.mail.data.cache.mailboxContent.SignatureController
import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController
import com.infomaniak.mail.data.models.FeatureFlag
import com.infomaniak.mail.data.models.mailbox.Mailbox
import com.infomaniak.mail.data.models.message.Message
import com.infomaniak.mail.data.models.thread.Thread
Expand Down Expand Up @@ -118,12 +117,11 @@ class SharedUtils @Inject constructor(
}
}

fun updateAiFeatureFlag(mailboxObjectId: String, mailboxUuid: String) {
with(ApiRepository.checkFeatureFlag(FeatureFlag.AI, mailboxUuid)) {
fun updateFeatureFlags(mailboxObjectId: String, mailboxUuid: String) {
with(ApiRepository.getFeatureFlags(mailboxUuid)) {
if (isSuccess()) {
val isEnabled = data?.get("is_enabled") == true
mailboxController.updateMailbox(mailboxObjectId) {
if (isEnabled) it.featureFlags.add(FeatureFlag.AI) else it.featureFlags.remove(FeatureFlag.AI)
mailboxController.updateMailbox(mailboxObjectId) { mailbox ->
mailbox.featureFlags.setFeatureFlags(featureFlags = data ?: emptyList())
}
}
}
Expand Down
100 changes: 64 additions & 36 deletions app/src/main/java/com/infomaniak/mail/views/AvatarView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import android.util.AttributeSet
import android.view.LayoutInflater
import android.widget.FrameLayout
import android.widget.ImageView
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import coil.ImageLoader
import coil.imageLoader
Expand All @@ -40,6 +42,7 @@ import com.infomaniak.mail.data.models.correspondent.Correspondent
import com.infomaniak.mail.data.models.correspondent.MergedContact
import com.infomaniak.mail.databinding.ViewAvatarBinding
import com.infomaniak.mail.utils.AccountUtils
import com.infomaniak.mail.utils.Utils
import com.infomaniak.mail.utils.extensions.MergedContactDictionary
import com.infomaniak.mail.utils.extensions.getColorOrNull
import com.infomaniak.mail.utils.extensions.getTransparentColor
Expand All @@ -57,15 +60,22 @@ class AvatarView @JvmOverloads constructor(

private val binding by lazy { ViewAvatarBinding.inflate(LayoutInflater.from(context), this, true) }

private var currentCorrespondent: Correspondent? = null
private var currentBimi: Bimi? = null
private val state = State()

private val mergedContactObserver = Observer<MergedContactDictionary> { contacts ->
val displayType = getAvatarDisplayType(currentCorrespondent, currentBimi)

if (displayType == AvatarDisplayType.CUSTOM_AVATAR || displayType == AvatarDisplayType.INITIALS) {
loadAvatarUsingDictionary(currentCorrespondent!!, contacts)
// We use waitInitMediator over MediatorLiveData because we know both live data will be initialized very quickly anyway
private val avatarMediatorLiveData: LiveData<Pair<MergedContactDictionary, Boolean>> =
if (isInEditMode) {
MutableLiveData()
} else {
Utils.waitInitMediator(avatarMergedContactData.mergedContactLiveData, avatarMergedContactData.isBimiEnabledLiveData)
}

private val avatarUpdateObserver = Observer<Pair<MergedContactDictionary, Boolean>> { (contacts, isBimiEnabled) ->
val (correspondent, bimi) = state
val displayType = getAvatarDisplayType(correspondent, bimi, isBimiEnabled)

if (displayType == AvatarDisplayType.UNKNOWN_CORRESPONDENT) return@Observer
loadAvatarByDisplayType(displayType, correspondent, bimi, contacts)
}

@Inject
Expand All @@ -77,6 +87,8 @@ class AvatarView @JvmOverloads constructor(
return if (isInEditMode) emptyMap() else avatarMergedContactData.mergedContactLiveData.value ?: emptyMap()
}

private val isBimiEnabled: Boolean get() = !isInEditMode && avatarMergedContactData.isBimiEnabledLiveData.value == true

@Inject
lateinit var svgImageLoader: ImageLoader

Expand Down Expand Up @@ -111,13 +123,15 @@ class AvatarView @JvmOverloads constructor(
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (isInEditMode) return // Avoid lateinit property has not been initialized in preview
avatarMergedContactData.mergedContactLiveData.observeForever(mergedContactObserver)

avatarMediatorLiveData.observeForever(avatarUpdateObserver)
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
if (isInEditMode) return // Avoid lateinit property has not been initialized in preview
avatarMergedContactData.mergedContactLiveData.removeObserver(mergedContactObserver)

avatarMediatorLiveData.removeObserver(avatarUpdateObserver)
}

override fun setOnClickListener(onClickListener: OnClickListener?) = binding.root.setOnClickListener(onClickListener)
Expand All @@ -138,55 +152,58 @@ class AvatarView @JvmOverloads constructor(
}

fun loadAvatar(correspondent: Correspondent?, bimi: Bimi? = null) {
val avatarDisplayType = getAvatarDisplayType(correspondent, bimi, isBimiEnabled)

fun loadSimpleCorrespondent(correspondent: Correspondent) {
loadAvatarUsingDictionary(correspondent, contacts = contactsFromViewModel)
currentCorrespondent = correspondent
}

currentBimi = bimi

when (getAvatarDisplayType(correspondent, bimi)) {
AvatarDisplayType.UNKNOWN_CORRESPONDENT -> loadUnknownUserAvatar()
AvatarDisplayType.CUSTOM_AVATAR -> loadSimpleCorrespondent(correspondent!!)
AvatarDisplayType.BIMI -> loadBimiAvatar(ApiRoutes.bimi(bimi!!.svgContentUrl!!), correspondent!!)
AvatarDisplayType.INITIALS -> loadSimpleCorrespondent(correspondent!!)
}
}

private fun getAvatarDisplayType(correspondent: Correspondent?, bimi: Bimi?): AvatarDisplayType {
return when {
correspondent == null -> AvatarDisplayType.UNKNOWN_CORRESPONDENT
correspondent.hasMergedContactAvatar(contactsFromViewModel) -> AvatarDisplayType.CUSTOM_AVATAR
bimi?.isDisplayable() == true -> AvatarDisplayType.BIMI
else -> AvatarDisplayType.INITIALS
}
loadAvatarByDisplayType(avatarDisplayType, correspondent, bimi, contactsFromViewModel)
}

fun loadAvatar(mergedContact: MergedContact) {
binding.avatarImage.baseLoadAvatar(mergedContact)
}

fun loadUnknownUserAvatar() {
currentCorrespondent = null
state.update(correspondent = null, bimi = null)
binding.avatarImage.load(R.drawable.ic_unknown_user_avatar)
}

private fun loadBimiAvatar(bimiUrl: String, correspondent: Correspondent) = with(binding.avatarImage) {
private fun loadBimiAvatar(bimi: Bimi, correspondent: Correspondent) = with(binding.avatarImage) {
state.update(correspondent, bimi)
contentDescription = correspondent.email
currentCorrespondent = null
loadAvatar(
backgroundColor = context.getBackgroundColorBasedOnId(
correspondent.email.hashCode(),
R.array.AvatarColors,
),
avatarUrl = bimiUrl,
avatarUrl = ApiRoutes.bimi(bimi.svgContentUrl!!),
initials = correspondent.initials,
imageLoader = svgImageLoader,
initialsColor = context.getColor(R.color.onColorfulBackground),
)
}

private fun loadAvatarByDisplayType(
avatarDisplayType: AvatarDisplayType,
correspondent: Correspondent?,
bimi: Bimi?,
contacts: MergedContactDictionary,
) {
when (avatarDisplayType) {
AvatarDisplayType.UNKNOWN_CORRESPONDENT -> loadUnknownUserAvatar()
AvatarDisplayType.CUSTOM_AVATAR,
AvatarDisplayType.INITIALS -> loadAvatarUsingDictionary(correspondent!!, contacts, bimi)
AvatarDisplayType.BIMI -> loadBimiAvatar(bimi!!, correspondent!!)
}
}

private fun getAvatarDisplayType(correspondent: Correspondent?, bimi: Bimi?, isBimiEnabled: Boolean): AvatarDisplayType {
return when {
correspondent == null -> AvatarDisplayType.UNKNOWN_CORRESPONDENT
correspondent.hasMergedContactAvatar(contactsFromViewModel) -> AvatarDisplayType.CUSTOM_AVATAR
bimi?.isDisplayable(isBimiEnabled) == true -> AvatarDisplayType.BIMI
else -> AvatarDisplayType.INITIALS
}
}

fun setImageDrawable(drawable: Drawable?) = binding.avatarImage.setImageDrawable(drawable)

private fun searchInMergedContact(correspondent: Correspondent, contacts: MergedContactDictionary): MergedContact? {
Expand All @@ -198,7 +215,8 @@ class AvatarView @JvmOverloads constructor(
return searchInMergedContact(correspondent = this, contacts)?.avatar != null
}

private fun loadAvatarUsingDictionary(correspondent: Correspondent, contacts: MergedContactDictionary) {
private fun loadAvatarUsingDictionary(correspondent: Correspondent, contacts: MergedContactDictionary, bimi: Bimi?) {
state.update(correspondent, bimi)
val mergedContact = searchInMergedContact(correspondent, contacts)
binding.avatarImage.baseLoadAvatar(correspondent = mergedContact ?: correspondent)
}
Expand All @@ -219,6 +237,16 @@ class AvatarView @JvmOverloads constructor(
}
}

private data class State(
var correspondent: Correspondent? = null,
var bimi: Bimi? = null,
) {
fun update(correspondent: Correspondent?, bimi: Bimi?) {
this.correspondent = correspondent
this.bimi = bimi
}
}

enum class AvatarDisplayType {
UNKNOWN_CORRESPONDENT,
CUSTOM_AVATAR,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,19 @@
package com.infomaniak.mail.views.itemViews

import androidx.lifecycle.asLiveData
import com.infomaniak.mail.data.cache.mailboxInfo.MailboxController
import com.infomaniak.mail.data.cache.userInfo.MergedContactController
import com.infomaniak.mail.data.models.FeatureFlag
import com.infomaniak.mail.di.IoDispatcher
import com.infomaniak.mail.utils.AccountUtils
import com.infomaniak.mail.utils.ContactUtils
import com.infomaniak.mail.utils.coroutineContext
import io.realm.kotlin.ext.copyFromRealm
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.mapLatest
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -34,6 +39,7 @@ import javax.inject.Singleton
@Singleton
class AvatarMergedContactData @Inject constructor(
mergedContactController: MergedContactController,
mailboxController: MailboxController,
globalCoroutineScope: CoroutineScope,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
) {
Expand All @@ -43,4 +49,11 @@ class AvatarMergedContactData @Inject constructor(
.getMergedContactsAsync()
.mapLatest { ContactUtils.arrangeMergedContacts(it.list.copyFromRealm()) }
.asLiveData(ioCoroutineContext)

val isBimiEnabledLiveData = mailboxController
.getMailboxAsync(AccountUtils.currentUserId, AccountUtils.currentMailboxId)
.mapLatest { it.obj?.featureFlags?.contains(FeatureFlag.BIMI) }
.filterNotNull()
.distinctUntilChanged()
.asLiveData(ioCoroutineContext)
}

0 comments on commit 5b6daff

Please sign in to comment.