Current scope: Kover HTML Report| all classes
|
release.com.greybox.projectmesh.messaging.ui.viewmodels
Coverage Summary for Class: ChatScreenViewModel (release.com.greybox.projectmesh.messaging.ui.viewmodels)
| Class | Method, % | Branch, % | Line, % | Instruction, % |
|---|---|---|---|---|
| ChatScreenViewModel$1 | 0% (0/1) | |||
| ChatScreenViewModel$1$1 | 0% (0/1) | 0% (0/3) | 0% (0/7) | 0% (0/67) |
| ChatScreenViewModel$1$3 | 0% (0/1) | |||
| ChatScreenViewModel$1$initialMessages$1 | 0% (0/1) | 0% (0/1) | 0% (0/2) | 0% (0/19) |
| ChatScreenViewModel$2 | 0% (0/1) | 0% (0/6) | 0% (0/4) | 0% (0/51) |
| ChatScreenViewModel$2$1 | 0% (0/1) | |||
| ChatScreenViewModel$2$1$emit$2 | 0% (0/1) | |||
| ChatScreenViewModel$markConversationAsRead$1 | ||||
| ChatScreenViewModel$sendChatMessage$1 | 0% (0/1) | |||
| ChatScreenViewModel$sendChatMessage$1$delivered$1 | 0% (0/1) | 0% (0/4) | 0% (0/3) | 0% (0/48) |
| ChatScreenViewModel$sendChatMessage$1$delivered$1$1 | 0% (0/1) | 0% (0/2) | 0% (0/2) | 0% (0/35) |
| ChatScreenViewModel$special$$inlined$instance$1 | 0% (0/1) | |||
| ChatScreenViewModel$special$$inlined$instance$default$1 | 0% (0/1) | |||
| ChatScreenViewModel$special$$inlined$instance$default$2 | 0% (0/1) | |||
| ChatScreenViewModel$special$$inlined$instance$default$3 | 0% (0/1) | |||
| ChatScreenViewModel$userEntity$1 | 0% (0/1) | 0% (0/2) | 0% (0/2) | 0% (0/29) |
| Total | 0% (0/19) | 0% (0/18) | 0% (0/20) | 0% (0/249) |
package com.greybox.projectmesh.messaging.ui.viewmodels
import android.net.Uri
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.greybox.projectmesh.GlobalApp
import com.greybox.projectmesh.db.MeshDatabase
import com.greybox.projectmesh.messaging.data.entities.Message
import com.greybox.projectmesh.messaging.ui.models.ChatScreenModel
import com.greybox.projectmesh.testing.TestDeviceService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.kodein.di.DI
import com.greybox.projectmesh.server.AppServer
import com.greybox.projectmesh.server.AppServer.Companion.DEFAULT_PORT
import com.greybox.projectmesh.server.AppServer.OutgoingTransferInfo
import com.greybox.projectmesh.server.AppServer.Status
import com.ustadmobile.meshrabiya.ext.addressToDotNotation
import com.ustadmobile.meshrabiya.ext.requireAddressAsInt
import com.greybox.projectmesh.messaging.utils.ConversationUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.ustadmobile.meshrabiya.vnet.AndroidVirtualNode
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.kodein.di.instance
import java.net.InetAddress
import com.greybox.projectmesh.messaging.repository.ConversationRepository
import com.greybox.projectmesh.user.UserEntity
import android.content.SharedPreferences
import android.util.Log
import androidx.lifecycle.SavedStateHandle
import com.greybox.projectmesh.DeviceStatusManager
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withTimeoutOrNull
import java.net.URI
/**
* ViewModel for the Chat Screen.
*
* Responsible for managing chat messages, device status, and conversation information.
*
* @param di Dependency Injection container to provide required services and repositories.
* @param savedStateHandle Handles saved state, including virtualAddress and conversationId.
*/
class ChatScreenViewModel(
di: DI,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val virtualAddress: InetAddress = savedStateHandle.get<InetAddress>("virtualAddress")!!
// _uiState will be updated whenever there is a change in the UI state
private val ipStr: String = virtualAddress.hostAddress
//get conversation id
private val passedConversationId = savedStateHandle.get<String>("conversationId")
//Get User info
private val userEntity = runBlocking {
GlobalApp.GlobalUserRepo.userRepository.getUserByIp(ipStr)
}
// Use the retrieved user name (fallback to "Unknown" if no user is found)
private val deviceName = userEntity?.name ?: "Unknown"
private val sharedPrefs: SharedPreferences by di.instance(tag = "settings")
private val localUuid = sharedPrefs.getString("UUID", null) ?: "local-user"
private val userUuid: String = when {
passedConversationId != null && passedConversationId.contains("-") -> {
// Extract the UUID that's not the local UUID
val uuids = passedConversationId.split("-")
uuids.find { it != localUuid } ?: "unknown-${virtualAddress.hostAddress}"
}
// Otherwise use the standard logic
TestDeviceService.isOnlineTestDevice(virtualAddress) -> "test-device-uuid"
ipStr == TestDeviceService.TEST_DEVICE_IP_OFFLINE ||
userEntity?.name == TestDeviceService.TEST_DEVICE_NAME_OFFLINE -> "offline-test-device-uuid"
else -> userEntity?.uuid ?: "unknown-${virtualAddress.hostAddress}"
}
private val savedConversationId = savedStateHandle.get<String>("conversationId")
//Log.d("ChatDebug", "GOT CONVERSATION ID FROM SAVED STATE: $savedConversationId")
private val conversationId = passedConversationId ?:
ConversationUtils.createConversationId(localUuid, userUuid)
private val chatName = savedConversationId ?: conversationId
//Log.d("ChatDebug", "USING CHAT NAME: $chatName (saved: $savedConversationId, generated: $conversationId)")
private val addressDotNotation = virtualAddress.requireAddressAsInt().addressToDotNotation()
private val conversationRepository: ConversationRepository by di.instance()
private val _uiState = MutableStateFlow(
ChatScreenModel(
deviceName = deviceName,
virtualAddress = virtualAddress
)
)
// uiState is a read-only property that shows the current UI state
val uiState: Flow<ChatScreenModel> = _uiState.asStateFlow()
// di is used to get the AndroidVirtualNode instance
private val db: MeshDatabase by di.instance()
private val appServer: AppServer by di.instance()
private val _deviceOnlineStatus = MutableStateFlow(false)
val deviceOnlineStatus: StateFlow<Boolean> = _deviceOnlineStatus.asStateFlow()
// launch a coroutine
init {
val savedConversationId = savedStateHandle.get<String>("conversationId")
// If we have a conversation ID from navigation, use it directly
val effectiveChatName = if (savedConversationId != null) {
Log.d("ChatDebug", "USING SAVED CONVERSATION ID: $savedConversationId INSTEAD OF GENERATED: $chatName")
savedConversationId
} else {
chatName
}
viewModelScope.launch {
// Debug logs
Log.d("ChatDebug", "Will query messages with chatName: $chatName")
Log.d("ChatDebug", "Using Conversation ID for messages: $conversationId")
Log.d("ChatDebug", "User UUID: $userUuid")
//check database content in background
withContext(Dispatchers.IO) {
val allMessages = db.messageDao().getAll()
Log.d("ChatDebug", "All messages in database: ${allMessages.size}")
for (msg in allMessages) {
Log.d(
"ChatDebug",
"Message: id=${msg.id}, chat=${msg.chat}, content=${msg.content}, sender=${msg.sender}"
)
}
}
//determine which flow to collect from
val isTestDevice =
(userUuid == "test-device-uuid" || userUuid == "offline-test-device-uuid")
//load messages synchronously for offline access
val initialChatName = chatName // Use consistent chat name
try {
// Get messages immediately without waiting for Flow
val initialMessages = withContext(Dispatchers.IO) {
// We'll need to add this method to MessageDao in Step 3
db.messageDao().getChatMessagesSync(chatName)
}
// Update UI immediately with initial messages
if (initialMessages.isNotEmpty()) {
_uiState.update { prev ->
prev.copy(allChatMessages = initialMessages)
}
Log.d("ChatDebug", "IMMEDIATELY LOADED ${initialMessages.size} MESSAGES FOR OFFLINE ACCESS")
} else {
Log.d("ChatDebug", "NO INITIAL MESSAGES FOUND FOR CHAT: $chatName")
}
} catch (e: Exception) {
Log.e("ChatDebug", "ERROR LOADING INITIAL MESSAGES: ${e.message}", e)
}
val messagesFlow = if (isTestDevice) {
val testDeviceName = when (userUuid) {
"test-device-uuid" -> TestDeviceService.TEST_DEVICE_NAME
"offline-test-device-uuid" -> TestDeviceService.TEST_DEVICE_NAME_OFFLINE
else -> null
}
if (testDeviceName != null) {
Log.d("ChatDebug", "Using multi-name query with: [$chatName, $testDeviceName]")
db.messageDao().getChatMessagesFlowMultipleNames(
listOf(chatName, testDeviceName)
)
} else {
db.messageDao().getChatMessagesFlow(chatName)
}
} else {
db.messageDao().getChatMessagesFlow(chatName)
}
//collect messages from the chosen flow
messagesFlow.collect { newChatMessages ->
Log.d("ChatDebug", "Received ${newChatMessages.size} messages")
_uiState.update { prev ->
prev.copy(allChatMessages = newChatMessages)
}
}
}
viewModelScope.launch {
// If this is a real device (not placeholder address)
if (virtualAddress.hostAddress != "0.0.0.0" &&
virtualAddress.hostAddress != TestDeviceService.TEST_DEVICE_IP_OFFLINE
) {
DeviceStatusManager.deviceStatusMap.collect { statusMap ->
val ipAddress = virtualAddress.hostAddress
val isOnline = statusMap[ipAddress] ?: false
// Only update if status changed
if (_deviceOnlineStatus.value != isOnline) {
Log.d(
"ChatDebug",
"Device status changed: $ipAddress is now ${if (isOnline) "online" else "offline"}"
)
_deviceOnlineStatus.value = isOnline
if (isOnline) {
Log.d("ChatDebug", "Device came back online - refreshing message history")
// Force refresh messages from database
withContext(Dispatchers.IO) {
val refreshedMessages = db.messageDao().getChatMessagesSync(chatName)
_uiState.update { prev ->
prev.copy(
allChatMessages = refreshedMessages,
offlineWarning = null // Clear offline warning
)
}
}
} else {
// Update the UI state with offline warning
_uiState.update { prev ->
prev.copy(
offlineWarning = "Device appears to be offline. Messages will be saved locally."
)
}
}
}
}
}
}
}
private suspend fun markConversationAsRead() {
try {
if (userEntity != null) {
//Create a convo id using both UUIDs
val conversationId =
ConversationUtils.createConversationId(localUuid, userEntity.uuid)
//Mark this conversation as read
conversationRepository.markAsRead(conversationId)
Log.d("ChatScreenViewModel", "Marked conversation as read: $conversationId")
}
} catch (e: Exception) {
Log.e("ChatScreenViewModel", "Error marking conversation as read", e)
}
}
/**
* Sends a chat message to a virtual device.
*
* @param virtualAddress IP address of the target device.
* @param message Message content as String.
* @param file Optional file attachment as [URI].
*/
fun sendChatMessage(
virtualAddress: InetAddress,
message: String,
file: URI?
) {//add file field here
val ipAddress = virtualAddress.hostAddress
val sendTime: Long = System.currentTimeMillis()
//check if device is online first
val isOnline = DeviceStatusManager.isDeviceOnline(ipAddress)
//use same conversationid as chat name
val messageEntity = Message(0, sendTime, message, "Me", chatName, file)
Log.d("ChatDebug", "Sending message to chat: $chatName")
viewModelScope.launch {
//save to local database
db.messageDao().addMessage(messageEntity)
//update convo with the new message
if (userEntity != null) {
try {
//get or create conversation
val remoteUser = UserEntity(
uuid = userUuid,
name = userEntity.name,
address = userEntity.address
)
val conversation = conversationRepository.getOrCreateConversation(
localUuid = localUuid,
remoteUser = remoteUser
)
//update conversation with the message
conversationRepository.updateWithMessage(
conversationId = conversation.id,
message = messageEntity
)
Log.d("ChatScreenViewModel", "Updated conversation with sent message")
} catch (e: Exception) {
Log.e(
"ChatScreenViewModel",
"Failed to update conversation with sent message",
e
)
}
}
if (isOnline) {
try {
// Use withContext to ensure network operations run on IO thread
val delivered = withContext(Dispatchers.IO) {
// Try with a timeout to prevent blocking
withTimeoutOrNull(5000) {
appServer.sendChatMessageWithStatus(virtualAddress, sendTime, message, file)
} ?: false
}
// Update UI based on delivery status
if (!delivered) {
Log.d("ChatDebug", "Message delivery failed")
_uiState.update { prev ->
prev.copy(offlineWarning = "Message delivery failed. Device may be offline.")
}
// Force device status verification
DeviceStatusManager.verifyDeviceStatus(ipAddress)
} else {
Log.d("ChatDebug", "Message delivered successfully")
}
} catch (e: Exception) {
Log.e("ChatScreenViewModel", "Error sending message: ${e.message}", e)
_uiState.update { prev ->
prev.copy(offlineWarning = "Error sending message: ${e.message}")
}
}
} else {
Log.d("ChatScreenViewModel", "Device $ipAddress appears to be offline, message saved locally only")
_uiState.update { prev ->
prev.copy(offlineWarning = "Device appears to be offline. Message saved locally only.")
}
}
}
}
/**
* Adds an outgoing file transfer for a given device.
*
* @param fileUri [Uri] of the file to send.
* @param toAddress IP address of the target device.
* @return [OutgoingTransferInfo] containing details of the transfer.
*/
fun addOutgoingTransfer(fileUri: Uri, toAddress: InetAddress): OutgoingTransferInfo {
return appServer.addOutgoingTransfer(fileUri, toAddress)
}
}