diff --git a/android/app/src/main/java/com/naviapp/common/repository/ConfigRepository.kt b/android/app/src/main/java/com/naviapp/common/repository/ConfigRepository.kt index 10a433f3a5..1a8e5e96f4 100644 --- a/android/app/src/main/java/com/naviapp/common/repository/ConfigRepository.kt +++ b/android/app/src/main/java/com/naviapp/common/repository/ConfigRepository.kt @@ -21,9 +21,9 @@ class ConfigRepository constructor(@SuperAppRetroFit private val superAppRetrofitService: RetrofitService) : ResponseCallback() { - suspend fun fetchMqttConfig(target: String, naeScreenName: String) = + suspend fun fetchKruzConfig(target: String, naeScreenName: String) = apiResponseCallback( - superAppRetrofitService.fetchMqttConfig(target), + superAppRetrofitService.fetchKruzConfig(target), metricInfo = MetricInfo.AppMetric(screen = naeScreenName, isNae = { false }), ) diff --git a/android/app/src/main/java/com/naviapp/home/compose/activity/HomePageActivity.kt b/android/app/src/main/java/com/naviapp/home/compose/activity/HomePageActivity.kt index ce34ea847f..0003d32dfd 100644 --- a/android/app/src/main/java/com/naviapp/home/compose/activity/HomePageActivity.kt +++ b/android/app/src/main/java/com/naviapp/home/compose/activity/HomePageActivity.kt @@ -169,7 +169,6 @@ import com.naviapp.payment.states.BottomSheetInfoV2State import com.naviapp.payment.viewmodel.PaymentVM import com.naviapp.registration.helper.isLocationPermissionGranted import com.naviapp.registration.helper.isReadSmsPermissionGranted -import com.naviapp.registration.usecase.FcmTopicUseCase import com.naviapp.registration.viewmodel.RegistrationVM import com.naviapp.registration.viewmodel.UploadUserDataUseCase import com.naviapp.screenOverlay.handler.ScreenOverlayEffectHandler @@ -209,8 +208,6 @@ class HomePageActivity : @Inject lateinit var userDataUseCase: UploadUserDataUseCase - @Inject lateinit var fcmTopicUseCase: FcmTopicUseCase - @Inject lateinit var screenNavigator: ScreenNavigator @Inject lateinit var permissionsManager: PermissionsManager @@ -294,7 +291,6 @@ class HomePageActivity : AppLoadTimerMapper.initActivityStartTime() installSplashScreen() super.onCreate(savedInstanceState) - fcmTopicUseCase.attachFcmTopics() redirectionUseCase.redirectToDestination(homeVM) enableEdgeToEdge( statusBarStyle = SystemBarStyle.light(Color.TRANSPARENT, Color.TRANSPARENT) diff --git a/android/app/src/main/java/com/naviapp/launcher/vm/LauncherVM.kt b/android/app/src/main/java/com/naviapp/launcher/vm/LauncherVM.kt index 7c3323df1f..f3b311682a 100644 --- a/android/app/src/main/java/com/naviapp/launcher/vm/LauncherVM.kt +++ b/android/app/src/main/java/com/naviapp/launcher/vm/LauncherVM.kt @@ -46,7 +46,7 @@ import com.naviapp.models.request.SaphyraDeviceDetails import com.naviapp.models.request.SaphyraRequestData import com.naviapp.models.response.FirebaseRefreshAuthTokenResponse 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.OS_ANDROID import com.naviapp.utils.EMPTY @@ -62,7 +62,7 @@ open class LauncherVM @Inject constructor( private val configRepository: ConfigRepository, - private val mqttSdkInitUseCase: MqttSdkInitUseCase, + private val realTimeMessagingInitUseCase: RealTimeMessagingInitUseCase, ) : ViewModel() { private val registerRepository = RegisterRepository() @@ -105,7 +105,7 @@ constructor( } private fun initMqttSDK(naeScreenName: String) { - mqttSdkInitUseCase.initMqttSdk(naeScreenName = naeScreenName) + realTimeMessagingInitUseCase.initSdks(naeScreenName = naeScreenName) } fun setFirebaseAppInstanceId() { diff --git a/android/app/src/main/java/com/naviapp/network/retrofit/RetrofitService.kt b/android/app/src/main/java/com/naviapp/network/retrofit/RetrofitService.kt index e6fc45a432..8581c21d04 100644 --- a/android/app/src/main/java/com/naviapp/network/retrofit/RetrofitService.kt +++ b/android/app/src/main/java/com/naviapp/network/retrofit/RetrofitService.kt @@ -234,7 +234,7 @@ interface RetrofitService { ): Response @GET("/kruz/proxy/config") - suspend fun fetchMqttConfig( + suspend fun fetchKruzConfig( @Header("X-Target") channel: String ): Response> diff --git a/android/app/src/main/java/com/naviapp/registration/usecase/FcmTopicUseCase.kt b/android/app/src/main/java/com/naviapp/registration/usecase/FcmTopicUseCase.kt index affc7f023f..464c1eeaf0 100644 --- a/android/app/src/main/java/com/naviapp/registration/usecase/FcmTopicUseCase.kt +++ b/android/app/src/main/java/com/naviapp/registration/usecase/FcmTopicUseCase.kt @@ -11,6 +11,7 @@ import com.google.firebase.messaging.FirebaseMessaging import com.google.gson.Gson import com.navi.analytics.utils.NaviTrackEvent import com.navi.base.cache.di.NaviCommonGson +import com.navi.base.sharedpref.PreferenceManager import com.navi.base.utils.isNotNullAndNotEmpty import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper import com.navi.common.firebaseremoteconfig.FirebaseRemoteConfigHelper.FCM_TOPIC_LIST @@ -22,12 +23,18 @@ import com.naviapp.utils.Constants.UNKNOWN_ERROR 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 - * modules. + * This class handles: + * - 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 @Inject @@ -37,22 +44,68 @@ constructor( @NaviCommonGson private val gson: Gson, ) { - /** Attaches FCM topics by fetching the topic list and subscribing to the enabled topics. */ - fun attachFcmTopics() { - val topicList = fetchFcmTopicList() ?: return - topicList.list - ?.filter { it.topicName.isNotNullAndNotEmpty() } - ?.partition { it.enable } - ?.let { (enabled, disabled) -> - disabled.forEach { unsubscribeToTopic(it.topicName.orEmpty()) } - enabled.forEach { subscribeToTopic(it.topicName.orEmpty()) } - } + /** + * Entry point for attaching FCM topics. + * + * If a list of strings is provided, it will override previous topics. Otherwise, it falls back + * to remote config-controlled topic subscriptions. + * + * @param topics Optional comma-separated list of topic names. + */ + suspend fun attachFcmTopics(topics: List? = 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) { + 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? { 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. */ @@ -84,18 +137,18 @@ constructor( ) } } - .addOnFailureListener { exception -> - trackFcmEvent(FCM_SUBSCRIPTION_FAILED, topicName, exception.toString()) + .addOnFailureListener { + trackFcmEvent(FCM_SUBSCRIPTION_FAILED, topicName, it.toString()) } .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. */ - private fun unsubscribeToTopic(topicName: String) { + private fun unsubscribeFromTopic(topicName: String) { firebaseMessaging .unsubscribeFromTopic(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) { val eventData = mutableMapOf(TOPIC to topicName) errorMessage?.let { eventData[ERROR] = it } @@ -117,10 +178,13 @@ constructor( } 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_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_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" } } diff --git a/android/app/src/main/java/com/naviapp/registration/usecase/MqttSdkInitUseCase.kt b/android/app/src/main/java/com/naviapp/registration/usecase/MqttSdkInitUseCase.kt deleted file mode 100644 index 9985ac517e..0000000000 --- a/android/app/src/main/java/com/naviapp/registration/usecase/MqttSdkInitUseCase.kt +++ /dev/null @@ -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) - } -} diff --git a/android/app/src/main/java/com/naviapp/registration/usecase/RealTimeMessagingInitUseCase.kt b/android/app/src/main/java/com/naviapp/registration/usecase/RealTimeMessagingInitUseCase.kt new file mode 100644 index 0000000000..3cd7512bbf --- /dev/null +++ b/android/app/src/main/java/com/naviapp/registration/usecase/RealTimeMessagingInitUseCase.kt @@ -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?) { + 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" + } +} diff --git a/android/app/src/test/kotlin/com/naviapp/registration/usecase/FcmTopicUseCaseTest.kt b/android/app/src/test/kotlin/com/naviapp/registration/usecase/FcmTopicUseCaseTest.kt index c84962615e..4953f00b37 100644 --- a/android/app/src/test/kotlin/com/naviapp/registration/usecase/FcmTopicUseCaseTest.kt +++ b/android/app/src/test/kotlin/com/naviapp/registration/usecase/FcmTopicUseCaseTest.kt @@ -19,6 +19,7 @@ import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.spyk import io.mockk.verify +import kotlinx.coroutines.test.runTest import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -38,7 +39,7 @@ class FcmTopicUseCaseTest { } @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 fcmTopicList = FcmTopicList(listOf(FcmTopicData(topicName, enable = true))) diff --git a/android/navi-mqtt/src/main/java/com/navi/mqtt/model/MqttSdkInitParams.kt b/android/navi-mqtt/src/main/java/com/navi/mqtt/model/MqttSdkInitParams.kt index d406dc7737..4134df9f8d 100644 --- a/android/navi-mqtt/src/main/java/com/navi/mqtt/model/MqttSdkInitParams.kt +++ b/android/navi-mqtt/src/main/java/com/navi/mqtt/model/MqttSdkInitParams.kt @@ -1,6 +1,6 @@ /* * - * * Copyright © 2024 by Navi Technologies Limited + * * Copyright © 2024-2025 by Navi Technologies Limited * * All rights reserved. Strictly confidential * */ @@ -14,6 +14,7 @@ data class MqttSdkInitParams( val username: String? = null, val password: String? = null, val clientId: String? = null, + val fcmTopics: List? = null, @SerializedName("port") val port: Int? = null, @SerializedName("scheme") val scheme: String? = null, @SerializedName("keepAlive") val keepAlive: Int? = 30,