NTP-50672 | Sohan | FCM Broadcast unsubsciption logic changes. (#15665)

This commit is contained in:
Sohan Reddy Atukula
2025-04-11 12:44:57 +05:30
committed by GitHub
parent 1ebe408f48
commit 3dc917620f
9 changed files with 217 additions and 131 deletions

View File

@@ -21,9 +21,9 @@ class ConfigRepository
constructor(@SuperAppRetroFit private val superAppRetrofitService: RetrofitService) : constructor(@SuperAppRetroFit private val superAppRetrofitService: RetrofitService) :
ResponseCallback() { ResponseCallback() {
suspend fun fetchMqttConfig(target: String, naeScreenName: String) = suspend fun fetchKruzConfig(target: String, naeScreenName: String) =
apiResponseCallback( apiResponseCallback(
superAppRetrofitService.fetchMqttConfig(target), superAppRetrofitService.fetchKruzConfig(target),
metricInfo = MetricInfo.AppMetric(screen = naeScreenName, isNae = { false }), metricInfo = MetricInfo.AppMetric(screen = naeScreenName, isNae = { false }),
) )

View File

@@ -169,7 +169,6 @@ import com.naviapp.payment.states.BottomSheetInfoV2State
import com.naviapp.payment.viewmodel.PaymentVM import com.naviapp.payment.viewmodel.PaymentVM
import com.naviapp.registration.helper.isLocationPermissionGranted import com.naviapp.registration.helper.isLocationPermissionGranted
import com.naviapp.registration.helper.isReadSmsPermissionGranted import com.naviapp.registration.helper.isReadSmsPermissionGranted
import com.naviapp.registration.usecase.FcmTopicUseCase
import com.naviapp.registration.viewmodel.RegistrationVM import com.naviapp.registration.viewmodel.RegistrationVM
import com.naviapp.registration.viewmodel.UploadUserDataUseCase import com.naviapp.registration.viewmodel.UploadUserDataUseCase
import com.naviapp.screenOverlay.handler.ScreenOverlayEffectHandler import com.naviapp.screenOverlay.handler.ScreenOverlayEffectHandler
@@ -209,8 +208,6 @@ class HomePageActivity :
@Inject lateinit var userDataUseCase: UploadUserDataUseCase @Inject lateinit var userDataUseCase: UploadUserDataUseCase
@Inject lateinit var fcmTopicUseCase: FcmTopicUseCase
@Inject lateinit var screenNavigator: ScreenNavigator @Inject lateinit var screenNavigator: ScreenNavigator
@Inject lateinit var permissionsManager: PermissionsManager @Inject lateinit var permissionsManager: PermissionsManager
@@ -294,7 +291,6 @@ class HomePageActivity :
AppLoadTimerMapper.initActivityStartTime() AppLoadTimerMapper.initActivityStartTime()
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
fcmTopicUseCase.attachFcmTopics()
redirectionUseCase.redirectToDestination(homeVM) redirectionUseCase.redirectToDestination(homeVM)
enableEdgeToEdge( enableEdgeToEdge(
statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT)

View File

@@ -46,7 +46,7 @@ import com.naviapp.models.request.SaphyraDeviceDetails
import com.naviapp.models.request.SaphyraRequestData import com.naviapp.models.request.SaphyraRequestData
import com.naviapp.models.response.FirebaseRefreshAuthTokenResponse import com.naviapp.models.response.FirebaseRefreshAuthTokenResponse
import com.naviapp.registration.repositories.RegisterRepository import com.naviapp.registration.repositories.RegisterRepository
import com.naviapp.registration.usecase.MqttSdkInitUseCase import com.naviapp.registration.usecase.RealTimeMessagingInitUseCase
import com.naviapp.utils.Constants.IS_PERMISSION_REQUIRED_ON_HOME import com.naviapp.utils.Constants.IS_PERMISSION_REQUIRED_ON_HOME
import com.naviapp.utils.Constants.OS_ANDROID import com.naviapp.utils.Constants.OS_ANDROID
import com.naviapp.utils.EMPTY import com.naviapp.utils.EMPTY
@@ -62,7 +62,7 @@ open class LauncherVM
@Inject @Inject
constructor( constructor(
private val configRepository: ConfigRepository, private val configRepository: ConfigRepository,
private val mqttSdkInitUseCase: MqttSdkInitUseCase, private val realTimeMessagingInitUseCase: RealTimeMessagingInitUseCase,
) : ViewModel() { ) : ViewModel() {
private val registerRepository = RegisterRepository() private val registerRepository = RegisterRepository()
@@ -105,7 +105,7 @@ constructor(
} }
private fun initMqttSDK(naeScreenName: String) { private fun initMqttSDK(naeScreenName: String) {
mqttSdkInitUseCase.initMqttSdk(naeScreenName = naeScreenName) realTimeMessagingInitUseCase.initSdks(naeScreenName = naeScreenName)
} }
fun setFirebaseAppInstanceId() { fun setFirebaseAppInstanceId() {

View File

@@ -234,7 +234,7 @@ interface RetrofitService {
): Response<BranchSDKResponse> ): Response<BranchSDKResponse>
@GET("/kruz/proxy/config") @GET("/kruz/proxy/config")
suspend fun fetchMqttConfig( suspend fun fetchKruzConfig(
@Header("X-Target") channel: String @Header("X-Target") channel: String
): Response<GenericResponse<MqttSdkInitParams>> ): Response<GenericResponse<MqttSdkInitParams>>

View File

@@ -11,6 +11,7 @@ import com.google.firebase.messaging.FirebaseMessaging
import com.google.gson.Gson import com.google.gson.Gson
import com.navi.analytics.utils.NaviTrackEvent import com.navi.analytics.utils.NaviTrackEvent
import com.navi.base.cache.di.NaviCommonGson import com.navi.base.cache.di.NaviCommonGson
import com.navi.base.sharedpref.PreferenceManager
import com.navi.base.utils.isNotNullAndNotEmpty import com.navi.base.utils.isNotNullAndNotEmpty
import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper
import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.FCM_TOPIC_LIST import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.FCM_TOPIC_LIST
@@ -22,12 +23,18 @@ import com.naviapp.utils.Constants.UNKNOWN_ERROR
import javax.inject.Inject import javax.inject.Inject
/** /**
* Use case for managing Firebase Cloud Messaging (FCM) topic subscriptions. * Use case responsible for managing Firebase Cloud Messaging (FCM) topic subscriptions.
* *
* **Note:** This is currently used only for performance testing. We will improve this for all * This class handles:
* modules. * - Subscribing and unsubscribing to FCM topics based on provided strings or Remote Config.
* - Tracking success/failure/cancellation events for analytics.
* - Storing subscribed topics in shared preferences to manage diffs on next run.
* *
* @property firebaseMessaging The FirebaseMessaging instance used for topic subscription. * Currently used for performance testing, but structured for future expansion.
*
* @property firebaseRemoteConfigHelper Helper for fetching Remote Config values.
* @property firebaseMessaging FirebaseMessaging instance for topic operations.
* @property gson Gson instance for parsing remote config topic list JSON.
*/ */
class FcmTopicUseCase class FcmTopicUseCase
@Inject @Inject
@@ -37,22 +44,68 @@ constructor(
@NaviCommonGson private val gson: Gson, @NaviCommonGson private val gson: Gson,
) { ) {
/** Attaches FCM topics by fetching the topic list and subscribing to the enabled topics. */ /**
fun attachFcmTopics() { * Entry point for attaching FCM topics.
val topicList = fetchFcmTopicList() ?: return *
topicList.list * If a list of strings is provided, it will override previous topics. Otherwise, it falls back
?.filter { it.topicName.isNotNullAndNotEmpty() } * to remote config-controlled topic subscriptions.
?.partition { it.enable } *
?.let { (enabled, disabled) -> * @param topics Optional comma-separated list of topic names.
disabled.forEach { unsubscribeToTopic(it.topicName.orEmpty()) } */
enabled.forEach { subscribeToTopic(it.topicName.orEmpty()) } suspend fun attachFcmTopics(topics: List<String>? = null) {
} if (topics != null) {
processManualTopics(topics)
} else {
processRemoteConfigTopics()
}
} }
/** /**
* Fetches the list of FCM topics from Firebase Remote Config. * Parses the provided comma-separated string and performs:
* - Subscriptions to new topics
* - Unsubscriptions from old topics not in the new list
* - Preference storage to persist current state
* *
* @return The FcmTopicList object if parsing is successful, otherwise null. * @param topics list of topic names.
*/
private fun processManualTopics(topics: List<String>) {
val newTopics = topics.map { it.trim() }.filter { it.isNotEmpty() }.toSet()
val oldTopics = PreferenceManager.getHashSet(KEY_FCM_SUBSCRIBED_TOPICS) ?: emptySet()
val toUnsubscribe = oldTopics - newTopics
newTopics.forEach { subscribeToTopic(it) }
toUnsubscribe.forEach { unsubscribeFromTopic(it) }
PreferenceManager.setHashSet(KEY_FCM_SUBSCRIBED_TOPICS, HashSet(newTopics))
}
/**
* Fetches topic subscription config from Firebase Remote Config and:
* - Subscribes to enabled topics
* - Unsubscribes from disabled ones
* - Ignores empty or invalid topic names
*/
private fun processRemoteConfigTopics() {
val topicList = fetchFcmTopicList()
if (topicList?.list.isNullOrEmpty()) {
NaviTrackEvent.trackEvent(FCM_SUBSCRIPTION_LIST_EMPTY)
return
}
topicList?.list?.let { list ->
val (enabledTopics, disabledTopics) =
list.filter { it.topicName.isNotNullAndNotEmpty() }.partition { it.enable }
disabledTopics.forEach { unsubscribeFromTopic(it.topicName.orEmpty()) }
enabledTopics.forEach { subscribeToTopic(it.topicName.orEmpty()) }
}
}
/**
* Fetches and parses the FCM topic list JSON from Firebase Remote Config.
*
* @return Parsed [FcmTopicList] if successful, else null.
*/ */
private fun fetchFcmTopicList(): FcmTopicList? { private fun fetchFcmTopicList(): FcmTopicList? {
return try { return try {
@@ -67,7 +120,7 @@ constructor(
} }
/** /**
* Subscribes to a specific FCM topic and tracks events based on the result. * Subscribes to a specific FCM topic and tracks analytics events based on outcome.
* *
* @param topicName The name of the topic to subscribe to. * @param topicName The name of the topic to subscribe to.
*/ */
@@ -84,18 +137,18 @@ constructor(
) )
} }
} }
.addOnFailureListener { exception -> .addOnFailureListener {
trackFcmEvent(FCM_SUBSCRIPTION_FAILED, topicName, exception.toString()) trackFcmEvent(FCM_SUBSCRIPTION_FAILED, topicName, it.toString())
} }
.addOnCanceledListener { trackFcmEvent(FCM_SUBSCRIPTION_CANCELED, topicName) } .addOnCanceledListener { trackFcmEvent(FCM_SUBSCRIPTION_CANCELED, topicName) }
} }
/** /**
* Unsubscribes from a specific FCM topic and tracks events based on the result. * Unsubscribes from a specific FCM topic and tracks analytics events based on outcome.
* *
* @param topicName The name of the topic to unsubscribe from. * @param topicName The name of the topic to unsubscribe from.
*/ */
private fun unsubscribeToTopic(topicName: String) { private fun unsubscribeFromTopic(topicName: String) {
firebaseMessaging firebaseMessaging
.unsubscribeFromTopic(topicName) .unsubscribeFromTopic(topicName)
.addOnSuccessListener { trackFcmEvent(FCM_UNSUBSCRIBE_SUCCESS, topicName) } .addOnSuccessListener { trackFcmEvent(FCM_UNSUBSCRIBE_SUCCESS, topicName) }
@@ -108,8 +161,16 @@ constructor(
) )
} }
} }
.addOnCanceledListener { trackFcmEvent(FCM_UNSUBSCRIBE_CANCELED, topicName) }
} }
/**
* Tracks a Firebase-related event for subscriptions or unsubscriptions.
*
* @param eventName The name of the event to track.
* @param topicName The topic this event is related to.
* @param errorMessage Optional error message if the event failed.
*/
private fun trackFcmEvent(eventName: String, topicName: String, errorMessage: String? = null) { private fun trackFcmEvent(eventName: String, topicName: String, errorMessage: String? = null) {
val eventData = mutableMapOf(TOPIC to topicName) val eventData = mutableMapOf(TOPIC to topicName)
errorMessage?.let { eventData[ERROR] = it } errorMessage?.let { eventData[ERROR] = it }
@@ -117,10 +178,13 @@ constructor(
} }
companion object { companion object {
private const val FCM_SUBSCRIPTION_SUCCESS = "dev_fcm_subscription_success"
private const val FCM_UNSUBSCRIBE_SUCCESS = "dev_fcm_unsubscribe_success" private const val FCM_UNSUBSCRIBE_SUCCESS = "dev_fcm_unsubscribe_success"
private const val FCM_UNSUBSCRIBE_FAILED = "dev_fcm_unsubscribe_failed" private const val FCM_UNSUBSCRIBE_FAILED = "dev_fcm_unsubscribe_failed"
private const val FCM_UNSUBSCRIBE_CANCELED = "dev_fcm_unsubscribe_canceled"
private const val FCM_SUBSCRIPTION_SUCCESS = "dev_fcm_subscription_success"
private const val FCM_SUBSCRIPTION_FAILED = "dev_fcm_subscription_failed" private const val FCM_SUBSCRIPTION_FAILED = "dev_fcm_subscription_failed"
private const val FCM_SUBSCRIPTION_CANCELED = "dev_fcm_subscription_canceled" private const val FCM_SUBSCRIPTION_CANCELED = "dev_fcm_subscription_canceled"
private const val FCM_SUBSCRIPTION_LIST_EMPTY = "dev_fcm_subscription_list_empty"
private const val KEY_FCM_SUBSCRIBED_TOPICS = "fcm_subscribed_topics"
} }
} }

View File

@@ -1,97 +0,0 @@
/*
*
* * Copyright © 2024-2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.naviapp.registration.usecase
import com.navi.analytics.utils.NaviTrackEvent
import com.navi.base.utils.BaseUtils
import com.navi.common.model.ModuleName
import com.navi.common.utils.deviceId
import com.navi.common.utils.isValidResponse
import com.navi.mqtt.MqttManager
import com.navi.mqtt.model.MqttSdkInitParams
import com.naviapp.BuildConfig
import com.naviapp.app.NaviApplication
import com.naviapp.common.repository.ConfigRepository
import com.naviapp.utils.COMMA
import com.naviapp.utils.Constants
import com.naviapp.utils.Constants.PAGE_HOME
import com.naviapp.utils.MqttMessageProviderImpl
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MqttSdkInitUseCase
@Inject
constructor(
private val configRepository: ConfigRepository,
private val mqttMessageProviderImpl: MqttMessageProviderImpl,
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
fun initMqttSdk(naeScreenName: String) {
scope.launch {
if (BaseUtils.isUserLoggedIn()) {
val response =
configRepository.fetchMqttConfig(
ModuleName.KRUZ_PROXY.name,
naeScreenName = naeScreenName,
)
response
.takeIf { it.isValidResponse() }
?.data
?.let { data ->
if (data.enable == true) {
val clientIdWithTopics =
if (data.topics.isNullOrEmpty()) Constants.EMPTY
else addClientIdToTopics(data.topics.orEmpty(), deviceId)
MqttManager.init(
NaviApplication.instance,
MqttSdkInitParams(
username = BuildConfig.MQTT_USERNAME,
password = BuildConfig.MQTT_PASSWORD,
clientId = deviceId,
brokerIP = data.brokerIP.orEmpty(),
port =
data.port
?: com.navi.common.utils.Constants.MQTT_DEFAULT_PORT,
scheme = data.scheme.orEmpty(),
keepAlive =
data.keepAlive
?: com.navi.common.utils.Constants.MQTT_KEEP_ALIVE,
topics = clientIdWithTopics,
subscribeQos = data.subscribeQos,
cleanSession = data.cleanSession,
),
)
MqttManager.subscribe(
clientIdWithTopics,
PAGE_HOME,
mqttMessageProviderImpl,
data.subscribeQos,
)
NaviTrackEvent.trackEvent("mqtt_sdk_init_triggered")
} else {
NaviTrackEvent.trackEvent("mqtt_config_enabled_false")
}
}
?: NaviTrackEvent.trackEvent(
"mqtt_config_fetch_failed",
mapOf("error" to response.error?.message.orEmpty()),
)
}
}
}
private fun addClientIdToTopics(data: String, clientId: String): String {
val topics = data.split(COMMA)
val modifiedTopics = topics.map { "$it/$clientId" }
return modifiedTopics.joinToString(COMMA)
}
}

View File

@@ -0,0 +1,121 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.naviapp.registration.usecase
import com.navi.analytics.utils.NaviTrackEvent
import com.navi.base.utils.coroutine.CoroutineManager
import com.navi.common.model.ModuleName
import com.navi.common.utils.deviceId
import com.navi.common.utils.isValidResponse
import com.navi.mqtt.MqttManager
import com.navi.mqtt.model.MqttSdkInitParams
import com.naviapp.BuildConfig
import com.naviapp.app.NaviApplication
import com.naviapp.common.repository.ConfigRepository
import com.naviapp.utils.COMMA
import com.naviapp.utils.Constants
import com.naviapp.utils.Constants.PAGE_HOME
import com.naviapp.utils.Constants.SLASH
import com.naviapp.utils.MqttMessageProviderImpl
import javax.inject.Inject
class RealTimeMessagingInitUseCase
@Inject
constructor(
private val configRepository: ConfigRepository,
private val mqttMessageProviderImpl: MqttMessageProviderImpl,
private val fcmTopicUseCase: FcmTopicUseCase,
) {
fun initSdks(naeScreenName: String) {
CoroutineManager.scope.launchOnIO {
val response =
configRepository.fetchKruzConfig(
ModuleName.KRUZ_PROXY.name,
naeScreenName = naeScreenName,
)
if (!response.isValidResponse()) {
trackError(response.error?.message.orEmpty())
return@launchOnIO
}
val data =
response.data
?: run {
trackError(MSG_RESPONSE_DATA_NULL)
return@launchOnIO
}
setupMqttIfEnabled(data)
setupFcmTopics(data.fcmTopics)
}
}
private fun setupMqttIfEnabled(data: MqttSdkInitParams) {
if (data.enable != true) {
NaviTrackEvent.trackEvent(EVENT_MQTT_CONFIG_DISABLED)
return
}
val clientIdWithTopics =
data.topics?.takeIf { it.isNotEmpty() }?.let { addClientIdToTopics(it, deviceId) }
?: Constants.EMPTY
MqttManager.init(
NaviApplication.instance,
MqttSdkInitParams(
username = BuildConfig.MQTT_USERNAME,
password = BuildConfig.MQTT_PASSWORD,
clientId = deviceId,
brokerIP = data.brokerIP.orEmpty(),
port = data.port ?: com.navi.common.utils.Constants.MQTT_DEFAULT_PORT,
scheme = data.scheme.orEmpty(),
keepAlive = data.keepAlive ?: com.navi.common.utils.Constants.MQTT_KEEP_ALIVE,
topics = clientIdWithTopics,
subscribeQos = data.subscribeQos,
cleanSession = data.cleanSession,
),
)
MqttManager.subscribe(
clientIdWithTopics,
PAGE_HOME,
mqttMessageProviderImpl,
data.subscribeQos,
)
NaviTrackEvent.trackEvent(EVENT_MQTT_SDK_INIT)
}
private suspend fun setupFcmTopics(fcmTopics: List<String>?) {
fcmTopicUseCase.attachFcmTopics(fcmTopics)
}
private fun trackError(error: String) {
NaviTrackEvent.trackEvent(EVENT_CONFIG_FETCH_FAILED, mapOf(KEY_ERROR to error))
}
private fun addClientIdToTopics(data: String, clientId: String): String {
return data.split(COMMA).joinToString(COMMA) {
buildString {
append(it)
append(SLASH)
append(clientId)
}
}
}
companion object {
private const val EVENT_MQTT_SDK_INIT = "mqtt_sdk_init_triggered"
private const val EVENT_MQTT_CONFIG_DISABLED = "mqtt_config_enabled_false"
private const val EVENT_CONFIG_FETCH_FAILED = "dev_kruz_config_fetch_failed"
private const val KEY_ERROR = "error"
private const val MSG_RESPONSE_DATA_NULL = "Response data is null"
}
}

View File

@@ -19,6 +19,7 @@ import io.mockk.mockk
import io.mockk.mockkStatic import io.mockk.mockkStatic
import io.mockk.spyk import io.mockk.spyk
import io.mockk.verify import io.mockk.verify
import kotlinx.coroutines.test.runTest
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@@ -38,7 +39,7 @@ class FcmTopicUseCaseTest {
} }
@Test @Test
fun `attachFcmTopics calls subscribeToTopic when enabled list is not empty`() { fun `attachFcmTopics calls subscribeToTopic when enabled list is not empty`() = runTest {
val topicName = "test_topic" val topicName = "test_topic"
val fcmTopicList = FcmTopicList(listOf(FcmTopicData(topicName, enable = true))) val fcmTopicList = FcmTopicList(listOf(FcmTopicData(topicName, enable = true)))

View File

@@ -1,6 +1,6 @@
/* /*
* *
* * Copyright © 2024 by Navi Technologies Limited * * Copyright © 2024-2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential * * All rights reserved. Strictly confidential
* *
*/ */
@@ -14,6 +14,7 @@ data class MqttSdkInitParams(
val username: String? = null, val username: String? = null,
val password: String? = null, val password: String? = null,
val clientId: String? = null, val clientId: String? = null,
val fcmTopics: List<String>? = null,
@SerializedName("port") val port: Int? = null, @SerializedName("port") val port: Int? = null,
@SerializedName("scheme") val scheme: String? = null, @SerializedName("scheme") val scheme: String? = null,
@SerializedName("keepAlive") val keepAlive: Int? = 30, @SerializedName("keepAlive") val keepAlive: Int? = 30,