NTP-47315 | Send message API (#15453)

This commit is contained in:
Ujjwal Kumar
2025-03-20 15:46:24 +05:30
committed by GitHub
parent 4db136b5f6
commit 784e91e6ab
13 changed files with 331 additions and 31 deletions

View File

@@ -75,6 +75,7 @@ dependencies {
api libs.android.play.reviewKtx
api libs.androidx.hilt.navigation.compose
api libs.firebase.analytics
api libs.firebase.crashlytics
api libs.firebase.firestore
api libs.gson
api libs.guava

View File

@@ -1,20 +1,23 @@
/*
*
* * Copyright © 2024 by Navi Technologies Limited
* * Copyright © 2024-2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.base.utils
import com.google.firebase.crashlytics.FirebaseCrashlytics
import com.google.firebase.firestore.DocumentSnapshot
import com.google.firebase.firestore.QuerySnapshot
import com.google.firebase.firestore.ktx.firestore
import com.google.firebase.ktx.Firebase
import javax.inject.Inject
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.tasks.await
/**
@@ -38,6 +41,9 @@ interface FirestoreDataProvider {
/** @throws Exception in case Task is cancelled */
suspend fun getDocumentSnapShotForDocumentPath(documentPath: String): DocumentSnapshot
/** Used this if last node is collection Returns document id if write is successful */
suspend fun writeDocument(collectionPath: String, data: Any): String
}
class FirestoreDataProviderImpl @Inject constructor() : FirestoreDataProvider {
@@ -89,4 +95,16 @@ class FirestoreDataProviderImpl @Inject constructor() : FirestoreDataProvider {
): DocumentSnapshot {
return db.document(documentPath).get().await()
}
override suspend fun writeDocument(collectionPath: String, data: Any): String {
return suspendCancellableCoroutine { continuation ->
db.collection(collectionPath)
.add(data)
.addOnSuccessListener { continuation.resumeWith(Result.success(it.id)) }
.addOnFailureListener {
FirebaseCrashlytics.getInstance().recordException(it)
continuation.resumeWithException(it)
}
}
}
}

View File

@@ -13,6 +13,7 @@ import androidx.room.RawQuery
import androidx.room.Upsert
import androidx.sqlite.db.SupportSQLiteQuery
import com.navi.pay.management.chat.model.view.MessageEntity
import com.navi.pay.management.chat.model.view.MessageStatus
import com.navi.pay.utils.NAVI_PAY_DATABASE_MESSAGES_TABLE_NAME
@Dao
@@ -26,4 +27,9 @@ interface MessagesDao {
@Query("DELETE FROM $NAVI_PAY_DATABASE_MESSAGES_TABLE_NAME") suspend fun deleteAll()
@RawQuery suspend fun deleteRows(query: SupportSQLiteQuery): Int
@Query(
"UPDATE $NAVI_PAY_DATABASE_MESSAGES_TABLE_NAME SET status = :status WHERE messageId = :messageId"
)
suspend fun updateMessageStatus(messageId: String, status: MessageStatus)
}

View File

@@ -0,0 +1,23 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.pay.management.chat.model.network
import com.google.gson.annotations.SerializedName
data class CreateConversationRequest(
@SerializedName("conversation_id") val conversationId: String,
@SerializedName("users") val users: List<Participant>,
@SerializedName("type") val type: String = "P2P",
@SerializedName("tenant") val tenant: String = "UPI",
)
data class Participant(
@SerializedName("user_id") val userId: String,
@SerializedName("role") val role: String = "CUSTOMER",
@SerializedName("user_id_type") val userIdType: String = "EXTERNAL",
)

View File

@@ -0,0 +1,12 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.pay.management.chat.model.network
import com.google.gson.annotations.SerializedName
data class CreateConversationResponse(@SerializedName("conversationId") val conversationId: String)

View File

@@ -7,7 +7,10 @@
package com.navi.pay.management.chat.model.network
import com.google.firebase.firestore.FieldValue
import com.google.gson.annotations.SerializedName
import com.navi.pay.management.chat.model.view.MessageContent.TextContent
import com.navi.pay.management.chat.model.view.MessageEntity
data class MessageSyncResponse(
@SerializedName("messagesPageSize") val messagesPageSize: Int,
@@ -19,7 +22,6 @@ data class MessageItem(
@SerializedName("content") val content: MessageContent,
@SerializedName("realtimeCreatedAt") val realtimeCreatedAt: String,
@SerializedName("messageCreatedAt") val messageCreatedAt: String,
@SerializedName("realtimeMessageId") val realtimeMessageId: String,
@SerializedName("conversationId") val conversationId: String,
)
@@ -28,3 +30,33 @@ data class MessageContent(
@SerializedName("senderMobileNumber") val senderMobileNumber: String,
@SerializedName("text") val text: String,
)
fun MessageEntity.toMessageItem(): MessageItem {
return MessageItem(
messageId = messageId,
content =
MessageContent(
senderExternalId = (content as TextContent).senderExternalId,
senderMobileNumber = content.senderMobileNumber,
text = content.text,
),
realtimeCreatedAt = createdAt.toString(),
messageCreatedAt = updatedAt.toString(),
conversationId = conversationId,
)
}
fun MessageItem.toFirestorePayload(): Map<String, Any> {
return hashMapOf(
"messageId" to messageId,
"content" to
mapOf(
"senderExternalId" to content.senderExternalId,
"senderMobileNumber" to content.senderMobileNumber,
"text" to content.text,
),
"realtimeCreatedAt" to FieldValue.serverTimestamp(),
"messageCreatedAt" to messageCreatedAt,
"conversationId" to conversationId,
)
}

View File

@@ -11,10 +11,13 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.navi.pay.utils.NAVI_PAY_DATABASE_CONVERSATIONS_TABLE_NAME
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
@Entity(tableName = NAVI_PAY_DATABASE_CONVERSATIONS_TABLE_NAME)
data class ConversationEntity(
@PrimaryKey @ColumnInfo(name = "conversationId") val conversationId: String,
@ColumnInfo(name = "lastSyncedTimestamp") val lastSyncedTimestamp: String,
@ColumnInfo(name = "lastSyncedTimestamp")
val lastSyncedTimestamp: String = DateTime(0, DateTimeZone.UTC).toString(),
@ColumnInfo(name = "isFullSyncCompleted") val isFullSyncCompleted: Boolean,
)

View File

@@ -0,0 +1,204 @@
/*
*
* * Copyright © 2025 by Navi Technologies Limited
* * All rights reserved. Strictly confidential
*
*/
package com.navi.pay.management.chat.repository
import com.navi.base.sharedpref.CommonPrefConstants
import com.navi.base.sharedpref.PreferenceManager
import com.navi.base.utils.FirestoreDataProvider
import com.navi.common.checkmate.model.MetricInfo
import com.navi.common.network.models.RepoResult
import com.navi.common.network.models.isSuccessWithData
import com.navi.common.network.retrofit.ResponseCallback
import com.navi.common.utils.log
import com.navi.pay.common.usecase.UpiRequestIdUseCase
import com.navi.pay.common.utils.fetchUserPhoneNumber
import com.navi.pay.management.chat.dao.ConversationsDao
import com.navi.pay.management.chat.dao.MessagesDao
import com.navi.pay.management.chat.model.network.ConversationsResponse
import com.navi.pay.management.chat.model.network.CreateConversationRequest
import com.navi.pay.management.chat.model.network.CreateConversationResponse
import com.navi.pay.management.chat.model.network.MessageSyncRequest
import com.navi.pay.management.chat.model.network.MessageSyncResponse
import com.navi.pay.management.chat.model.network.Participant
import com.navi.pay.management.chat.model.network.toFirestorePayload
import com.navi.pay.management.chat.model.network.toMessageItem
import com.navi.pay.management.chat.model.view.ConversationEntity
import com.navi.pay.management.chat.model.view.MessageContent
import com.navi.pay.management.chat.model.view.MessageEntity
import com.navi.pay.management.chat.model.view.MessageStatus
import com.navi.pay.management.chat.model.view.MessageType
import com.navi.pay.management.common.transaction.model.network.TransactionRole
import com.navi.pay.network.retrofit.NaviPayRetrofitService
import com.navi.pay.utils.NAVI_PAY_CHAT_CONVERSATION_FIREBASE_PATH
import com.navi.pay.utils.NAVI_PAY_CHAT_CONVERSATION_IDENTIFIER
import javax.inject.Inject
import org.joda.time.DateTime
class ChatRepository
@Inject
constructor(
private val conversationsDao: ConversationsDao,
private val naviPayRetrofitService: NaviPayRetrofitService,
private val messagesDao: MessagesDao,
private val requestIdUseCase: UpiRequestIdUseCase,
private val firestoreDataProvider: FirestoreDataProvider,
) : ResponseCallback() {
suspend fun getConversations(
metricInfo: MetricInfo<RepoResult<ConversationsResponse>>
): RepoResult<ConversationsResponse> {
return apiResponseCallback(
response = naviPayRetrofitService.getConversations(),
metricInfo = metricInfo,
)
}
suspend fun insertAllConversations(conversationEntityList: List<ConversationEntity>) =
conversationsDao.insertAll(conversationEntityList = conversationEntityList)
suspend fun getConversationsBySyncStatus(isFullSyncCompleted: Boolean) =
conversationsDao.getConversationsBySyncStatus(isFullSyncCompleted = isFullSyncCompleted)
suspend fun getAllConversationsByIds(conversationIds: List<String>) =
conversationsDao.getAllConversationsByIds(conversationIds = conversationIds)
suspend fun insertAllMessages(messageEntityList: List<MessageEntity>) =
messagesDao.insertAll(messageEntityList = messageEntityList)
suspend fun getMessagesForConversationIdsFromNetwork(
messageSyncRequest: MessageSyncRequest,
metricInfo: MetricInfo<RepoResult<MessageSyncResponse>>,
): RepoResult<MessageSyncResponse> {
return apiResponseCallback(
response = naviPayRetrofitService.getMessages(messageSyncRequest = messageSyncRequest),
metricInfo = metricInfo,
)
}
suspend fun sendMessage(
text: String,
conversationId: String,
receiverExternalId: String,
metricInfo: MetricInfo<RepoResult<CreateConversationResponse>>,
) {
val ownUserExternalId =
PreferenceManager.getStringPreference(CommonPrefConstants.USER_EXTERNAL_ID)
val ownUserPhoneNumber = fetchUserPhoneNumber()
val messageId = requestIdUseCase.execute()
val messageEntity =
MessageEntity(
messageId = messageId,
conversationId = conversationId,
type = MessageType.TEXT,
createdAt = DateTime.now(),
updatedAt = DateTime.now(),
content =
MessageContent.TextContent(
text = text,
senderExternalId = ownUserExternalId!!,
senderMobileNumber = ownUserPhoneNumber,
),
role = TransactionRole.PAYER,
status = MessageStatus.PENDING,
)
messagesDao.insertAll(messageEntityList = listOf(messageEntity))
val createConversationResult =
createConversation(
conversationId = conversationId,
senderExternalId = ownUserExternalId,
receiverExternalId = receiverExternalId,
metricInfo = metricInfo,
)
if (!createConversationResult) {
messagesDao.updateMessageStatus(messageId = messageId, status = MessageStatus.FAILURE)
return
}
writeMessageInFirestore(messageEntity = messageEntity)
}
private suspend fun createConversation(
conversationId: String,
senderExternalId: String,
receiverExternalId: String,
metricInfo: MetricInfo<RepoResult<CreateConversationResponse>>,
): Boolean {
val isConversationAclExist = conversationsDao.get(conversationId = conversationId) != null
if (isConversationAclExist) {
return true
}
val createConversationApiResponse =
apiResponseCallback(
response =
naviPayRetrofitService.createConversation(
createConversationRequest =
CreateConversationRequest(
conversationId = conversationId,
users =
listOf(
Participant(userId = senderExternalId),
Participant(userId = receiverExternalId),
),
)
),
metricInfo = metricInfo,
)
if (createConversationApiResponse.isSuccessWithData()) {
conversationsDao.insertAll(
conversationEntityList =
listOf(
ConversationEntity(
conversationId = conversationId,
isFullSyncCompleted = false,
)
)
)
}
return createConversationApiResponse.isSuccessWithData()
}
private suspend fun writeMessageInFirestore(messageEntity: MessageEntity) {
val conversationPath =
getFirebaseConversationPath(conversationId = messageEntity.conversationId)
try {
firestoreDataProvider.writeDocument(
collectionPath = conversationPath,
data = messageEntity.toMessageItem().toFirestorePayload(),
)
messagesDao.updateMessageStatus(
messageId = messageEntity.messageId,
status = MessageStatus.SUCCESS,
)
} catch (e: Exception) {
e.log()
messagesDao.updateMessageStatus(
messageId = messageEntity.messageId,
status = MessageStatus.FAILURE,
)
}
}
private fun getFirebaseConversationPath(conversationId: String): String {
return NAVI_PAY_CHAT_CONVERSATION_FIREBASE_PATH.replace(
oldValue = NAVI_PAY_CHAT_CONVERSATION_IDENTIFIER,
newValue = conversationId,
)
}
}

View File

@@ -66,15 +66,15 @@ class MessageContentTypeAdapterFactory : TypeAdapterFactory {
out.beginObject()
when (value) {
is MessageContent.TextContent -> {
out.name("type")
out.name("fieldValueType")
out.value(MessageType.TEXT.name)
out.name("content")
out.name("fieldValueContent")
textContentAdapter.write(out, value)
}
is MessageContent.OrderContent -> {
out.name("type")
out.name("fieldValueType")
out.value(MessageType.ORDER.name)
out.name("content")
out.name("fieldValueContent")
orderContentAdapter.write(out, value)
}
else ->
@@ -98,8 +98,8 @@ class MessageContentTypeAdapterFactory : TypeAdapterFactory {
while (reader.hasNext()) {
when (reader.nextName()) {
"type" -> type = reader.nextString()
"content" -> content = JsonParser.parseReader(reader)
"fieldValueType" -> type = reader.nextString()
"fieldValueContent" -> content = JsonParser.parseReader(reader)
else -> reader.skipValue()
}
}

View File

@@ -12,7 +12,7 @@ import com.navi.pay.common.sync.model.view.SyncEntity
import com.navi.pay.common.sync.repository.SyncRepository
import com.navi.pay.common.utils.getMetricInfo
import com.navi.pay.management.chat.model.view.ConversationEntity
import com.navi.pay.management.chat.repository.ConversationsRepository
import com.navi.pay.management.chat.repository.ChatRepository
import com.navi.pay.utils.NAVI_PAY_SYNC_TABLE_CONVERSATIONS_LIST_KEY
import javax.inject.Inject
@@ -20,7 +20,7 @@ import javax.inject.Inject
class SyncConversationsUseCase
@Inject
constructor(
private val conversationsRepository: ConversationsRepository,
private val chatRepository: ChatRepository,
private val syncRepository: SyncRepository,
) {
@@ -36,7 +36,7 @@ constructor(
}
val conversationsApiResponse =
conversationsRepository.getConversations(
chatRepository.getConversations(
metricInfo = getMetricInfo(screenName = screenName, isNae = { false })
)
@@ -44,12 +44,11 @@ constructor(
return
}
conversationsRepository.insertAll(
chatRepository.insertAllConversations(
conversationEntityList =
conversationsApiResponse.data!!.conversations.map {
ConversationEntity(
conversationId = it.conversationId,
lastSyncedTimestamp = "0",
isFullSyncCompleted = false,
)
}

View File

@@ -18,27 +18,19 @@ import com.navi.pay.management.chat.model.view.MessageContent
import com.navi.pay.management.chat.model.view.MessageEntity
import com.navi.pay.management.chat.model.view.MessageStatus
import com.navi.pay.management.chat.model.view.MessageType
import com.navi.pay.management.chat.repository.ConversationsRepository
import com.navi.pay.management.chat.repository.MessagesRepository
import com.navi.pay.management.chat.repository.ChatRepository
import javax.inject.Inject
import org.joda.time.DateTime
import org.joda.time.DateTimeZone
class SyncMessagesUseCase
@Inject
constructor(
private val conversationsRepository: ConversationsRepository,
private val messagesRepository: MessagesRepository,
) {
class SyncMessagesUseCase @Inject constructor(private val chatRepository: ChatRepository) {
private val CONVERSATION_SYNC_BATCH_SIZE = 10
suspend fun execute(preferredConversationIds: List<String> = emptyList(), screenName: String) {
val existingConversationEntitiesFromPreferredConversationIds =
conversationsRepository.getAllConversationsByIds(
conversationIds = preferredConversationIds
)
chatRepository.getAllConversationsByIds(conversationIds = preferredConversationIds)
val existingIdsInDatabase =
existingConversationEntitiesFromPreferredConversationIds.map { it.conversationId }
@@ -56,7 +48,7 @@ constructor(
}
val partiallySyncedExistingConversations =
conversationsRepository.getConversationsBySyncStatus(isFullSyncCompleted = false)
chatRepository.getConversationsBySyncStatus(isFullSyncCompleted = false)
val conversationsMap =
(partiallySyncedExistingConversations +
@@ -100,7 +92,7 @@ constructor(
)
val messageSyncApiResponse =
messagesRepository.getMessagesForConversationIdsFromNetwork(
chatRepository.getMessagesForConversationIdsFromNetwork(
messageSyncRequest = messageSyncRequest,
metricInfo = getMetricInfo(screenName = screenName, isNae = { false }),
)
@@ -155,13 +147,13 @@ constructor(
)
}
messagesRepository.insertAll(messageEntityList = messageEntityListForConversation)
chatRepository.insertAllMessages(messageEntityList = messageEntityListForConversation)
val latestUpdatedAtTimestamp = messageItemList.maxOf { it.messageCreatedAt }
val isFullSyncCompleted = messageItemList.size < messageSyncResponse.messagesPageSize
conversationsRepository.insertAll(
chatRepository.insertAllConversations(
conversationEntityList =
listOf(
ConversationEntity(

View File

@@ -17,6 +17,8 @@ import com.navi.pay.management.blockedusers.model.network.BlockedUsersListRespon
import com.navi.pay.management.blockedusers.model.network.UnblockActionRequest
import com.navi.pay.management.blockedusers.model.network.UnblockActionResponse
import com.navi.pay.management.chat.model.network.ConversationsResponse
import com.navi.pay.management.chat.model.network.CreateConversationRequest
import com.navi.pay.management.chat.model.network.CreateConversationResponse
import com.navi.pay.management.chat.model.network.MessageSyncRequest
import com.navi.pay.management.chat.model.network.MessageSyncResponse
import com.navi.pay.management.collectrequest.model.network.CollectRequestsRequest
@@ -367,11 +369,16 @@ interface NaviPayRetrofitService {
@Path("orderId") orderId: String
): Response<GenericResponse<OrderStatusResponse>>
@GET("/chat/api/v1/conversations")
@GET("/v1/conversations")
suspend fun getConversations(): Response<GenericResponse<ConversationsResponse>>
@POST("/v1/conversations/messages/get")
@POST("/v2/conversations/fetch-messages")
suspend fun getMessages(
@Body messageSyncRequest: MessageSyncRequest
): Response<GenericResponse<MessageSyncResponse>>
@POST("/chat/conversations")
suspend fun createConversation(
@Body createConversationRequest: CreateConversationRequest
): Response<GenericResponse<CreateConversationResponse>>
}

View File

@@ -377,6 +377,9 @@ const val SCRATCH_CARD_BANNER_BG =
"https://public-assets.prod.navi-sa.in/navi-pay/png/scratch_card_banner_bg.png"
const val NAVI_PAY_TNC_URL = "https://navi.com/terms-and-conditions"
const val NAVI_PAY_BANK_LOGOS_V2_URL_BANK_IDENTIFIER_KEY = "bankIdentifier"
const val NAVI_PAY_CHAT_CONVERSATION_FIREBASE_PATH =
"conversations_ntl/{conversationId}/messages_ntl"
const val NAVI_PAY_CHAT_CONVERSATION_IDENTIFIER = "conversationId"
// Navi pay bbps constants
const val NAVI_PAY_BBPS_CATEGORY_ID = "category_id"