From 4719344ae86e55f56326f383554763a1bc4b7be7 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 20:21:11 -0400 Subject: [PATCH 1/9] refactoring to a proper MVVM --- TODO.md | 75 ++++++++++++ .../java/com/mattintech/lchat/data/Message.kt | 6 +- .../network/WifiAwareManagerSingleton.kt | 20 ---- .../lchat/repository/ChatRepository.kt | 111 ++++++++++++++++++ .../com/mattintech/lchat/ui/ChatFragment.kt | 70 ++++------- .../com/mattintech/lchat/ui/LobbyFragment.kt | 89 +++++++------- .../lchat/ui/adapters/MessageAdapter.kt | 4 +- .../lchat/viewmodel/ChatViewModel.kt | 89 ++++++++++++++ .../lchat/viewmodel/LobbyViewModel.kt | 94 +++++++++++++++ .../lchat/viewmodel/ViewModelFactory.kt | 24 ++++ 10 files changed, 472 insertions(+), 110 deletions(-) create mode 100644 TODO.md delete mode 100644 app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt create mode 100644 app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt create mode 100644 app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt create mode 100644 app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt create mode 100644 app/src/main/java/com/mattintech/lchat/viewmodel/ViewModelFactory.kt diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..e93dfad --- /dev/null +++ b/TODO.md @@ -0,0 +1,75 @@ +# LocalChat (lchat) - Improvement Plan + +## Phase 1: Architecture Foundation + +### 1.1 MVVM with ViewModels and Repository Pattern ✅ +- [x] Create ViewModels for each screen (LobbyViewModel, ChatViewModel) +- [x] Extract business logic from Fragments to ViewModels +- [x] Create Repository layer for data operations +- [x] Implement proper state management with LiveData/StateFlow +- [x] Add ViewModelFactory if needed + +### 1.2 Dependency Injection with Hilt +- [ ] Add Hilt dependencies +- [ ] Set up Hilt modules for WifiAwareManager +- [ ] Convert singletons to proper DI +- [ ] Inject ViewModels using Hilt + +## Phase 2: Core UX Improvements + +### 2.1 Connection Status Management +- [ ] Add connection state to ViewModels +- [ ] Create UI indicator for connection status +- [ ] Show real-time connection state changes +- [ ] Add connection error messages + +### 2.2 User List Feature +- [ ] Track connected users in Repository +- [ ] Add UI to display active users +- [ ] Handle user join/leave events +- [ ] Show user count in chat header + +## Phase 3: Data Persistence + +### 3.1 Room Database Setup +- [ ] Add Room dependencies +- [ ] Create Message and User entities +- [ ] Implement DAOs for data access +- [ ] Create database migrations + +### 3.2 Message Persistence +- [ ] Store messages in Room database +- [ ] Load message history on app restart +- [ ] Implement message sync logic +- [ ] Add message timestamps + +## Phase 4: Reliability Improvements + +### 4.1 Reconnection Handling +- [ ] Detect connection drops +- [ ] Implement exponential backoff retry +- [ ] Preserve message queue during disconnection +- [ ] Auto-reconnect when network available + +### 4.2 Network State Monitoring +- [ ] Monitor WiFi state changes +- [ ] Handle app lifecycle properly +- [ ] Save and restore connection state + +## Phase 5: Advanced Features + +### 5.1 Background Service +- [ ] Create foreground service for persistent connection +- [ ] Handle Doze mode and battery optimization +- [ ] Add notification for active chat +- [ ] Implement proper service lifecycle + +### 5.2 Additional Features +- [ ] Message delivery status +- [ ] Typing indicators +- [ ] File/image sharing support +- [ ] Message encryption improvements + +## Current Status +- 🚀 Starting with Phase 1.1 - MVVM Architecture +- Target: Create a maintainable, testable architecture \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/data/Message.kt b/app/src/main/java/com/mattintech/lchat/data/Message.kt index e429fa5..ade53f4 100644 --- a/app/src/main/java/com/mattintech/lchat/data/Message.kt +++ b/app/src/main/java/com/mattintech/lchat/data/Message.kt @@ -2,9 +2,9 @@ package com.mattintech.lchat.data data class Message( val id: String, - val senderId: String, - val senderName: String, + val userId: String, + val userName: String, val content: String, val timestamp: Long, - val isLocal: Boolean = false + val isOwnMessage: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt deleted file mode 100644 index 7cb4b38..0000000 --- a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManagerSingleton.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.mattintech.lchat.network - -import android.content.Context - -object WifiAwareManagerSingleton { - private var instance: WifiAwareManager? = null - - fun getInstance(context: Context): WifiAwareManager { - if (instance == null) { - instance = WifiAwareManager(context.applicationContext) - instance!!.initialize() - } - return instance!! - } - - fun reset() { - instance?.stop() - instance = null - } -} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt new file mode 100644 index 0000000..7f6fff7 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt @@ -0,0 +1,111 @@ +package com.mattintech.lchat.repository + +import android.content.Context +import com.mattintech.lchat.data.Message +import com.mattintech.lchat.network.WifiAwareManager +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.UUID + +class ChatRepository private constructor(context: Context) { + + companion object { + @Volatile + private var INSTANCE: ChatRepository? = null + + fun getInstance(context: Context): ChatRepository { + return INSTANCE ?: synchronized(this) { + INSTANCE ?: ChatRepository(context.applicationContext).also { INSTANCE = it } + } + } + } + + private val wifiAwareManager = WifiAwareManager(context) + + private val _messages = MutableStateFlow>(emptyList()) + val messages: StateFlow> = _messages.asStateFlow() + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _connectedUsers = MutableStateFlow>(emptyList()) + val connectedUsers: StateFlow> = _connectedUsers.asStateFlow() + + private var messageCallback: ((String, String, String) -> Unit)? = null + private var connectionCallback: ((String, Boolean) -> Unit)? = null + + init { + wifiAwareManager.initialize() + setupWifiAwareCallbacks() + } + + private fun setupWifiAwareCallbacks() { + wifiAwareManager.setMessageCallback { userId, userName, content -> + val message = Message( + id = UUID.randomUUID().toString(), + userId = userId, + userName = userName, + content = content, + timestamp = System.currentTimeMillis(), + isOwnMessage = false + ) + addMessage(message) + messageCallback?.invoke(userId, userName, content) + } + } + + fun startHostMode(roomName: String) { + wifiAwareManager.startHostMode(roomName) + _connectionState.value = ConnectionState.Hosting(roomName) + } + + fun startClientMode() { + wifiAwareManager.startClientMode() + _connectionState.value = ConnectionState.Searching + } + + fun sendMessage(userId: String, userName: String, content: String) { + val message = Message( + id = UUID.randomUUID().toString(), + userId = userId, + userName = userName, + content = content, + timestamp = System.currentTimeMillis(), + isOwnMessage = true + ) + addMessage(message) + wifiAwareManager.sendMessage(userId, userName, content) + } + + private fun addMessage(message: Message) { + _messages.value = _messages.value + message + } + + fun clearMessages() { + _messages.value = emptyList() + } + + fun setMessageCallback(callback: (String, String, String) -> Unit) { + messageCallback = callback + } + + fun setConnectionCallback(callback: (String, Boolean) -> Unit) { + connectionCallback = callback + wifiAwareManager.setConnectionCallback(callback) + } + + fun stop() { + wifiAwareManager.stop() + _connectionState.value = ConnectionState.Disconnected + _connectedUsers.value = emptyList() + } + + sealed class ConnectionState { + object Disconnected : ConnectionState() + object Searching : ConnectionState() + data class Hosting(val roomName: String) : ConnectionState() + data class Connected(val roomName: String) : ConnectionState() + data class Error(val message: String) : ConnectionState() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt index 6f5e186..34cdf47 100644 --- a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt +++ b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt @@ -6,15 +6,16 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.LinearLayoutManager -import com.mattintech.lchat.data.Message import com.mattintech.lchat.databinding.FragmentChatBinding -import com.mattintech.lchat.network.WifiAwareManager -import com.mattintech.lchat.network.WifiAwareManagerSingleton import com.mattintech.lchat.ui.adapters.MessageAdapter import com.mattintech.lchat.utils.LOG_PREFIX -import java.util.UUID +import com.mattintech.lchat.viewmodel.ChatViewModel +import com.mattintech.lchat.viewmodel.ViewModelFactory +import kotlinx.coroutines.launch class ChatFragment : Fragment() { @@ -26,10 +27,8 @@ class ChatFragment : Fragment() { private val binding get() = _binding!! private val args: ChatFragmentArgs by navArgs() - private lateinit var wifiAwareManager: WifiAwareManager + private lateinit var viewModel: ChatViewModel private lateinit var messageAdapter: MessageAdapter - private val messages = mutableListOf() - private val userId = UUID.randomUUID().toString() override fun onCreateView( inflater: LayoutInflater, @@ -45,8 +44,12 @@ class ChatFragment : Fragment() { super.onViewCreated(view, savedInstanceState) Log.d(TAG, "onViewCreated - room: ${args.roomName}, user: ${args.userName}, isHost: ${args.isHost}") + val factory = ViewModelFactory(requireContext()) + viewModel = ViewModelProvider(this, factory)[ChatViewModel::class.java] + viewModel.initialize(args.roomName, args.userName, args.isHost) + setupUI() - setupWifiAware() + observeViewModel() } private fun setupUI() { @@ -68,58 +71,35 @@ class ChatFragment : Fragment() { } } - private fun setupWifiAware() { - wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext()) - - wifiAwareManager.setMessageCallback { senderId, senderName, content -> - Log.d(TAG, "Message received - from: $senderName, content: $content") - val message = Message( - id = UUID.randomUUID().toString(), - senderId = senderId, - senderName = senderName, - content = content, - timestamp = System.currentTimeMillis(), - isLocal = senderId == userId - ) - - activity?.runOnUiThread { - messages.add(message) - messageAdapter.submitList(messages.toList()) - binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1) + private fun observeViewModel() { + lifecycleScope.launch { + viewModel.messages.collect { messages -> + messageAdapter.submitList(messages) + if (messages.isNotEmpty()) { + binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1) + } } } - // No need to start host mode here - already started in LobbyFragment - Log.d(TAG, "Chat setup complete - isHost: ${args.isHost}, room: ${args.roomName}") + lifecycleScope.launch { + viewModel.connectionState.collect { state -> + Log.d(TAG, "Connection state: $state") + // Handle connection state changes if needed + } + } } private fun sendMessage() { val content = binding.messageInput.text?.toString()?.trim() if (content.isNullOrEmpty()) return - val message = Message( - id = UUID.randomUUID().toString(), - senderId = userId, - senderName = args.userName, - content = content, - timestamp = System.currentTimeMillis(), - isLocal = true - ) - - messages.add(message) - messageAdapter.submitList(messages.toList()) - binding.messagesRecyclerView.smoothScrollToPosition(messages.size - 1) - - Log.d(TAG, "Sending message: $content") - wifiAwareManager.sendMessage(userId, args.userName, content) - + viewModel.sendMessage(content) binding.messageInput.text?.clear() } override fun onDestroyView() { super.onDestroyView() Log.d(TAG, "onDestroyView") - // Don't stop WifiAwareManager here - it's shared across fragments _binding = null } } \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt index e72e620..8f476b9 100644 --- a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt +++ b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt @@ -11,8 +11,10 @@ import androidx.lifecycle.ViewModelProvider import androidx.navigation.fragment.findNavController import com.mattintech.lchat.R import com.mattintech.lchat.databinding.FragmentLobbyBinding -import com.mattintech.lchat.network.WifiAwareManager -import com.mattintech.lchat.network.WifiAwareManagerSingleton +import com.mattintech.lchat.viewmodel.LobbyEvent +import com.mattintech.lchat.viewmodel.LobbyState +import com.mattintech.lchat.viewmodel.LobbyViewModel +import com.mattintech.lchat.viewmodel.ViewModelFactory import com.mattintech.lchat.utils.LOG_PREFIX class LobbyFragment : Fragment() { @@ -24,7 +26,7 @@ class LobbyFragment : Fragment() { private var _binding: FragmentLobbyBinding? = null private val binding get() = _binding!! - private lateinit var wifiAwareManager: WifiAwareManager + private lateinit var viewModel: LobbyViewModel override fun onCreateView( inflater: LayoutInflater, @@ -40,10 +42,11 @@ class LobbyFragment : Fragment() { super.onViewCreated(view, savedInstanceState) Log.d(TAG, "onViewCreated") - Log.d(TAG, "Getting WifiAwareManager singleton") - wifiAwareManager = WifiAwareManagerSingleton.getInstance(requireContext()) + val factory = ViewModelFactory(requireContext()) + viewModel = ViewModelProvider(this, factory)[LobbyViewModel::class.java] setupUI() + observeViewModel() } private fun setupUI() { @@ -64,55 +67,61 @@ class LobbyFragment : Fragment() { } binding.actionButton.setOnClickListener { - val userName = binding.nameInput.text?.toString()?.trim() - - if (userName.isNullOrEmpty()) { - Toast.makeText(context, "Please enter your name", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } + val userName = binding.nameInput.text?.toString()?.trim() ?: "" when (binding.modeRadioGroup.checkedRadioButtonId) { R.id.hostRadio -> { - val roomName = binding.roomInput.text?.toString()?.trim() - if (roomName.isNullOrEmpty()) { - Toast.makeText(context, "Please enter a room name", Toast.LENGTH_SHORT).show() - return@setOnClickListener - } - startHostMode(roomName, userName) + val roomName = binding.roomInput.text?.toString()?.trim() ?: "" + viewModel.startHostMode(roomName, userName) } R.id.clientRadio -> { - startClientMode(userName) + viewModel.startClientMode(userName) + } + } + } + } + + private fun observeViewModel() { + viewModel.state.observe(viewLifecycleOwner) { state -> + when (state) { + is LobbyState.Idle -> { + binding.noRoomsText.visibility = View.GONE + } + is LobbyState.Connecting -> { + if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { + binding.noRoomsText.visibility = View.VISIBLE + binding.noRoomsText.text = getString(R.string.connecting) + } + } + is LobbyState.Connected -> { + if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { + val userName = binding.nameInput.text?.toString()?.trim() ?: "" + viewModel.onConnectedToRoom(state.roomName, userName) + } + } + is LobbyState.Error -> { + binding.noRoomsText.visibility = View.VISIBLE + binding.noRoomsText.text = state.message + Toast.makeText(context, state.message, Toast.LENGTH_LONG).show() } } } - wifiAwareManager.setConnectionCallback { roomName, isConnected -> - Log.d(TAG, "Connection callback - room: $roomName, connected: $isConnected") - activity?.runOnUiThread { - if (isConnected && binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { - val userName = binding.nameInput.text?.toString()?.trim() ?: "" - navigateToChat(roomName, userName, false) - } else if (!isConnected && binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { - binding.noRoomsText.text = "Failed to connect to $roomName. Ensure Wi-Fi is enabled on both devices." - Toast.makeText(context, "Connection failed. Check Wi-Fi is enabled.", Toast.LENGTH_LONG).show() + viewModel.events.observe(viewLifecycleOwner) { event -> + when (event) { + is LobbyEvent.NavigateToChat -> { + navigateToChat(event.roomName, event.userName, event.isHost) + viewModel.clearEvent() } + is LobbyEvent.ShowError -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + viewModel.clearEvent() + } + null -> {} } } } - private fun startHostMode(roomName: String, userName: String) { - Log.d(TAG, "Starting host mode - room: $roomName, user: $userName") - wifiAwareManager.startHostMode(roomName) - navigateToChat(roomName, userName, true) - } - - private fun startClientMode(userName: String) { - Log.d(TAG, "Starting client mode - user: $userName") - binding.noRoomsText.visibility = View.VISIBLE - binding.noRoomsText.text = getString(R.string.connecting) - wifiAwareManager.startClientMode() - } - private fun navigateToChat(roomName: String, userName: String, isHost: Boolean) { Log.d(TAG, "Navigating to chat - room: $roomName, user: $userName, isHost: $isHost") val action = LobbyFragmentDirections.actionLobbyToChat( diff --git a/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt b/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt index 66b4d4a..cd89835 100644 --- a/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt +++ b/app/src/main/java/com/mattintech/lchat/ui/adapters/MessageAdapter.kt @@ -35,12 +35,12 @@ class MessageAdapter : ListAdapter(Me ) : RecyclerView.ViewHolder(binding.root) { fun bind(message: Message) { - binding.senderName.text = message.senderName + binding.senderName.text = message.userName binding.messageContent.text = message.content binding.timestamp.text = timeFormat.format(Date(message.timestamp)) val layoutParams = binding.messageCard.layoutParams as ConstraintLayout.LayoutParams - if (message.isLocal) { + if (message.isOwnMessage) { layoutParams.startToStart = ConstraintLayout.LayoutParams.UNSET layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID binding.messageCard.setCardBackgroundColor( diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt new file mode 100644 index 0000000..88407e9 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt @@ -0,0 +1,89 @@ +package com.mattintech.lchat.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mattintech.lchat.data.Message +import com.mattintech.lchat.repository.ChatRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.UUID + +sealed class ChatState { + object Connected : ChatState() + object Disconnected : ChatState() + data class Error(val message: String) : ChatState() +} + +class ChatViewModel( + private val chatRepository: ChatRepository +) : ViewModel() { + + private val _state = MutableLiveData(ChatState.Connected) + val state: LiveData = _state + + val messages: StateFlow> = chatRepository.messages + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + val connectionState = chatRepository.connectionState + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = ChatRepository.ConnectionState.Disconnected + ) + + val connectedUsers = chatRepository.connectedUsers + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = emptyList() + ) + + private var currentUserId: String = "" + private var currentUserName: String = "" + private var currentRoomName: String = "" + private var isHost: Boolean = false + + fun initialize(roomName: String, userName: String, isHost: Boolean) { + this.currentRoomName = roomName + this.currentUserName = userName + this.isHost = isHost + this.currentUserId = UUID.randomUUID().toString() + + // Setup message callback if needed for additional processing + chatRepository.setMessageCallback { userId, userName, content -> + // Can add additional message processing here if needed + } + } + + fun sendMessage(content: String) { + if (content.isBlank()) return + + viewModelScope.launch { + chatRepository.sendMessage(currentUserId, currentUserName, content) + } + } + + fun getRoomInfo(): Triple { + return Triple(currentRoomName, currentUserName, isHost) + } + + fun disconnect() { + viewModelScope.launch { + chatRepository.stop() + _state.value = ChatState.Disconnected + } + } + + override fun onCleared() { + super.onCleared() + disconnect() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt new file mode 100644 index 0000000..fa4fe32 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt @@ -0,0 +1,94 @@ +package com.mattintech.lchat.viewmodel + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.mattintech.lchat.repository.ChatRepository +import kotlinx.coroutines.launch + +sealed class LobbyState { + object Idle : LobbyState() + object Connecting : LobbyState() + data class Connected(val roomName: String) : LobbyState() + data class Error(val message: String) : LobbyState() +} + +sealed class LobbyEvent { + data class NavigateToChat( + val roomName: String, + val userName: String, + val isHost: Boolean + ) : LobbyEvent() + data class ShowError(val message: String) : LobbyEvent() +} + +class LobbyViewModel( + private val chatRepository: ChatRepository +) : ViewModel() { + + private val _state = MutableLiveData(LobbyState.Idle) + val state: LiveData = _state + + private val _events = MutableLiveData() + val events: LiveData = _events + + init { + setupConnectionCallback() + } + + private fun setupConnectionCallback() { + chatRepository.setConnectionCallback { roomName, isConnected -> + viewModelScope.launch { + if (isConnected) { + _state.value = LobbyState.Connected(roomName) + } else { + _state.value = LobbyState.Error("Failed to connect to $roomName. Ensure Wi-Fi is enabled on both devices.") + } + } + } + } + + fun startHostMode(roomName: String, userName: String) { + if (roomName.isBlank()) { + _events.value = LobbyEvent.ShowError("Please enter a room name") + return + } + + if (userName.isBlank()) { + _events.value = LobbyEvent.ShowError("Please enter your name") + return + } + + viewModelScope.launch { + _state.value = LobbyState.Connecting + chatRepository.startHostMode(roomName) + _events.value = LobbyEvent.NavigateToChat(roomName, userName, true) + } + } + + fun startClientMode(userName: String) { + if (userName.isBlank()) { + _events.value = LobbyEvent.ShowError("Please enter your name") + return + } + + viewModelScope.launch { + _state.value = LobbyState.Connecting + chatRepository.startClientMode() + } + } + + fun onConnectedToRoom(roomName: String, userName: String) { + _events.value = LobbyEvent.NavigateToChat(roomName, userName, false) + } + + fun clearEvent() { + _events.value = null + } + + override fun onCleared() { + super.onCleared() + // Clean up resources if needed + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/ViewModelFactory.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/ViewModelFactory.kt new file mode 100644 index 0000000..663ef6d --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/ViewModelFactory.kt @@ -0,0 +1,24 @@ +package com.mattintech.lchat.viewmodel + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.mattintech.lchat.repository.ChatRepository + +class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory { + + private val chatRepository by lazy { ChatRepository.getInstance(context) } + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return when { + modelClass.isAssignableFrom(LobbyViewModel::class.java) -> { + LobbyViewModel(chatRepository) as T + } + modelClass.isAssignableFrom(ChatViewModel::class.java) -> { + ChatViewModel(chatRepository) as T + } + else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") + } + } +} \ No newline at end of file From ccf26b80e8d05f1a9e2c52ffa311dea2729b2111 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 20:38:49 -0400 Subject: [PATCH 2/9] adding connection status --- TODO.md | 10 +-- .../lchat/repository/ChatRepository.kt | 65 ++++++++++++++++++- .../com/mattintech/lchat/ui/ChatFragment.kt | 41 +++++++++++- app/src/main/res/drawable/ic_circle.xml | 5 ++ app/src/main/res/layout/fragment_chat.xml | 49 +++++++++++++- app/src/main/res/values/colors.xml | 6 ++ 6 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 app/src/main/res/drawable/ic_circle.xml diff --git a/TODO.md b/TODO.md index e93dfad..99030cf 100644 --- a/TODO.md +++ b/TODO.md @@ -17,11 +17,11 @@ ## Phase 2: Core UX Improvements -### 2.1 Connection Status Management -- [ ] Add connection state to ViewModels -- [ ] Create UI indicator for connection status -- [ ] Show real-time connection state changes -- [ ] Add connection error messages +### 2.1 Connection Status Management ✅ +- [x] Add connection state to ViewModels +- [x] Create UI indicator for connection status +- [x] Show real-time connection state changes +- [x] Add connection error messages ### 2.2 User List Feature - [ ] Track connected users in Repository diff --git a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt index 7f6fff7..39c8044 100644 --- a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt +++ b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt @@ -3,6 +3,7 @@ package com.mattintech.lchat.repository import android.content.Context import com.mattintech.lchat.data.Message import com.mattintech.lchat.network.WifiAwareManager +import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -35,6 +36,10 @@ class ChatRepository private constructor(context: Context) { private var messageCallback: ((String, String, String) -> Unit)? = null private var connectionCallback: ((String, Boolean) -> Unit)? = null + private var lastActivityTime = System.currentTimeMillis() + private var connectionCheckJob: Job? = null + private val connectionTimeout = 30000L // 30 seconds + init { wifiAwareManager.initialize() setupWifiAwareCallbacks() @@ -51,6 +56,19 @@ class ChatRepository private constructor(context: Context) { isOwnMessage = false ) addMessage(message) + + // Update last activity time + lastActivityTime = System.currentTimeMillis() + + // If we're receiving messages, we must be connected + if (_connectionState.value !is ConnectionState.Connected && + _connectionState.value !is ConnectionState.Hosting) { + when (_connectionState.value) { + is ConnectionState.Hosting -> {} // Keep hosting state + else -> _connectionState.value = ConnectionState.Connected("Active") + } + } + messageCallback?.invoke(userId, userName, content) } } @@ -58,11 +76,13 @@ class ChatRepository private constructor(context: Context) { fun startHostMode(roomName: String) { wifiAwareManager.startHostMode(roomName) _connectionState.value = ConnectionState.Hosting(roomName) + startConnectionMonitoring() } fun startClientMode() { wifiAwareManager.startClientMode() _connectionState.value = ConnectionState.Searching + startConnectionMonitoring() } fun sendMessage(userId: String, userName: String, content: String) { @@ -76,6 +96,15 @@ class ChatRepository private constructor(context: Context) { ) addMessage(message) wifiAwareManager.sendMessage(userId, userName, content) + + // Update last activity time + lastActivityTime = System.currentTimeMillis() + + // If we can send messages, update connection state if needed + if (_connectionState.value is ConnectionState.Disconnected || + _connectionState.value is ConnectionState.Error) { + _connectionState.value = ConnectionState.Connected("Active") + } } private fun addMessage(message: Message) { @@ -92,15 +121,49 @@ class ChatRepository private constructor(context: Context) { fun setConnectionCallback(callback: (String, Boolean) -> Unit) { connectionCallback = callback - wifiAwareManager.setConnectionCallback(callback) + wifiAwareManager.setConnectionCallback { roomName, isConnected -> + if (isConnected) { + _connectionState.value = ConnectionState.Connected(roomName) + } else { + _connectionState.value = ConnectionState.Disconnected + } + callback(roomName, isConnected) + } } fun stop() { + stopConnectionMonitoring() wifiAwareManager.stop() _connectionState.value = ConnectionState.Disconnected _connectedUsers.value = emptyList() } + private fun startConnectionMonitoring() { + connectionCheckJob?.cancel() + connectionCheckJob = GlobalScope.launch { + while (isActive) { + delay(5000) // Check every 5 seconds + val timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime + + // If no activity for 30 seconds and we think we're connected, mark as disconnected + if (timeSinceLastActivity > connectionTimeout) { + when (_connectionState.value) { + is ConnectionState.Connected, + is ConnectionState.Hosting -> { + _connectionState.value = ConnectionState.Disconnected + } + else -> {} // Keep current state + } + } + } + } + } + + private fun stopConnectionMonitoring() { + connectionCheckJob?.cancel() + connectionCheckJob = null + } + sealed class ConnectionState { object Disconnected : ConnectionState() object Searching : ConnectionState() diff --git a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt index 34cdf47..1e6210b 100644 --- a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt +++ b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt @@ -9,8 +9,11 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.navArgs +import androidx.core.content.ContextCompat import androidx.recyclerview.widget.LinearLayoutManager +import com.mattintech.lchat.R import com.mattintech.lchat.databinding.FragmentChatBinding +import com.mattintech.lchat.repository.ChatRepository import com.mattintech.lchat.ui.adapters.MessageAdapter import com.mattintech.lchat.utils.LOG_PREFIX import com.mattintech.lchat.viewmodel.ChatViewModel @@ -50,6 +53,7 @@ class ChatFragment : Fragment() { setupUI() observeViewModel() + updateRoomInfo() } private fun setupUI() { @@ -84,7 +88,7 @@ class ChatFragment : Fragment() { lifecycleScope.launch { viewModel.connectionState.collect { state -> Log.d(TAG, "Connection state: $state") - // Handle connection state changes if needed + updateConnectionStatus(state) } } } @@ -97,6 +101,41 @@ class ChatFragment : Fragment() { binding.messageInput.text?.clear() } + private fun updateRoomInfo() { + val (roomName, _, isHost) = viewModel.getRoomInfo() + binding.roomNameText.text = if (isHost) "Hosting: $roomName" else "Room: $roomName" + } + + private fun updateConnectionStatus(state: ChatRepository.ConnectionState) { + when (state) { + is ChatRepository.ConnectionState.Disconnected -> { + binding.connectionStatusText.text = "Disconnected" + binding.connectionIndicator.backgroundTintList = + ContextCompat.getColorStateList(requireContext(), R.color.disconnected_color) + } + is ChatRepository.ConnectionState.Searching -> { + binding.connectionStatusText.text = "Searching..." + binding.connectionIndicator.backgroundTintList = + ContextCompat.getColorStateList(requireContext(), R.color.connecting_color) + } + is ChatRepository.ConnectionState.Hosting -> { + binding.connectionStatusText.text = "Hosting" + binding.connectionIndicator.backgroundTintList = + ContextCompat.getColorStateList(requireContext(), R.color.hosting_color) + } + is ChatRepository.ConnectionState.Connected -> { + binding.connectionStatusText.text = "Connected" + binding.connectionIndicator.backgroundTintList = + ContextCompat.getColorStateList(requireContext(), R.color.connected_color) + } + is ChatRepository.ConnectionState.Error -> { + binding.connectionStatusText.text = "Error: ${state.message}" + binding.connectionIndicator.backgroundTintList = + ContextCompat.getColorStateList(requireContext(), R.color.disconnected_color) + } + } + } + override fun onDestroyView() { super.onDestroyView() Log.d(TAG, "onDestroyView") diff --git a/app/src/main/res/drawable/ic_circle.xml b/app/src/main/res/drawable/ic_circle.xml new file mode 100644 index 0000000..a6f3dfa --- /dev/null +++ b/app/src/main/res/drawable/ic_circle.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_chat.xml b/app/src/main/res/layout/fragment_chat.xml index 78ce89b..f9dd93a 100644 --- a/app/src/main/res/layout/fragment_chat.xml +++ b/app/src/main/res/layout/fragment_chat.xml @@ -4,13 +4,60 @@ android:layout_width="match_parent" android:layout_height="match_parent"> + + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index f8c6127..c9147a8 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -7,4 +7,10 @@ #FF018786 #FF000000 #FFFFFFFF + + + #4CAF50 + #FFC107 + #F44336 + #2196F3 \ No newline at end of file From ab9069814eec11e15a01b16145ff5559d7953af8 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 20:42:48 -0400 Subject: [PATCH 3/9] updating current status --- TODO.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/TODO.md b/TODO.md index 99030cf..c472a20 100644 --- a/TODO.md +++ b/TODO.md @@ -71,5 +71,11 @@ - [ ] Message encryption improvements ## Current Status -- 🚀 Starting with Phase 1.1 - MVVM Architecture -- Target: Create a maintainable, testable architecture \ No newline at end of file +- ✅ Phase 1.1 - MVVM Architecture - COMPLETED +- ✅ Phase 2.1 - Connection Status Management - COMPLETED +- 🚀 Next: Phase 1.2 (Dependency Injection) or Phase 3 (Data Persistence) + +## Completed Work Summary +1. **MVVM Architecture**: ViewModels, Repository pattern, proper separation of concerns +2. **Connection Status**: Visual indicator with real-time updates, activity-based detection +3. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep \ No newline at end of file From e44ce678739b2d24ebc7214fb8a5406cbbccaca4 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 21:13:23 -0400 Subject: [PATCH 4/9] adding DepedencyInjnection with HILT --- app/build.gradle.kts | 17 +++++++++++++ app/proguard-rules.pro | 15 +++++++++++- app/src/main/AndroidManifest.xml | 1 + .../com/mattintech/lchat/LChatApplication.kt | 11 +++++++++ .../java/com/mattintech/lchat/MainActivity.kt | 2 ++ .../java/com/mattintech/lchat/di/AppModule.kt | 23 ++++++++++++++++++ .../lchat/repository/ChatRepository.kt | 21 +++++++--------- .../com/mattintech/lchat/ui/ChatFragment.kt | 9 ++++--- .../com/mattintech/lchat/ui/LobbyFragment.kt | 10 ++++---- .../lchat/viewmodel/ChatViewModel.kt | 5 +++- .../lchat/viewmodel/LobbyViewModel.kt | 5 +++- .../lchat/viewmodel/ViewModelFactory.kt | 24 ------------------- build.gradle.kts | 3 ++- gradle.properties | 12 +++++++--- 14 files changed, 103 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/com/mattintech/lchat/LChatApplication.kt create mode 100644 app/src/main/java/com/mattintech/lchat/di/AppModule.kt delete mode 100644 app/src/main/java/com/mattintech/lchat/viewmodel/ViewModelFactory.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 63a67e1..aa1e6a5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,12 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("androidx.navigation.safeargs.kotlin") + id("kotlin-kapt") + id("com.google.dagger.hilt.android") +} + +kapt { + correctErrorTypes = true } android { @@ -11,6 +17,12 @@ android { lint { abortOnError = false } + + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } defaultConfig { applicationId = "com.mattintech.lchat" @@ -50,6 +62,7 @@ android { } buildFeatures { viewBinding = true + buildConfig = true } } @@ -66,6 +79,10 @@ dependencies { implementation("androidx.navigation:navigation-fragment-ktx:2.7.6") implementation("androidx.navigation:navigation-ui-ktx:2.7.6") + // Hilt dependencies + implementation("com.google.dagger:hilt-android:2.50") + kapt("com.google.dagger:hilt-compiler:2.50") + testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e64ca74..fe470fa 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -11,4 +11,17 @@ # If you keep the line number information, uncomment this to # hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +#-renamesourcefileattribute SourceFile + +# Keep Application class +-keep class com.mattintech.lchat.LChatApplication { *; } + +# Hilt rules +-keep class dagger.hilt.** { *; } +-keep class javax.inject.** { *; } +-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager { *; } + +# Keep all @HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel annotated classes +-keep @dagger.hilt.android.HiltAndroidApp class * { *; } +-keep @dagger.hilt.android.AndroidEntryPoint class * { *; } +-keep @dagger.hilt.android.lifecycle.HiltViewModel class * { *; } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b8a5d57..f580b27 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ create(modelClass: Class): T { - return when { - modelClass.isAssignableFrom(LobbyViewModel::class.java) -> { - LobbyViewModel(chatRepository) as T - } - modelClass.isAssignableFrom(ChatViewModel::class.java) -> { - ChatViewModel(chatRepository) as T - } - else -> throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.name}") - } - } -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index a6f97bd..fe0a227 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,5 +11,6 @@ buildscript { plugins { id("com.android.application") version "8.9.1" apply false - id("org.jetbrains.kotlin.android") version "1.9.0" apply false + id("org.jetbrains.kotlin.android") version "1.9.22" apply false + id("com.google.dagger.hilt.android") version "2.50" apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index a6dfb2e..06a65ae 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,6 +1,12 @@ # Project-wide Gradle settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED --add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED android.useAndroidX=true android.nonTransitiveRClass=true -android.defaults.buildfeatures.buildconfig=true -android.nonFinalResIds=false \ No newline at end of file +android.nonFinalResIds=false + +# Kapt configuration for better performance +kapt.use.worker.api=true +kapt.incremental.apt=true + +# Hilt configuration +dagger.hilt.android.internal.disableAndroidSuperclassValidation=true \ No newline at end of file From b1f1aa0cadebfa42a1754e73eb9e83813ea08391 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 21:31:35 -0400 Subject: [PATCH 5/9] adding shared prefs to save the users name --- TODO.md | 151 +++++++++++++++--- .../com/mattintech/lchat/ui/LobbyFragment.kt | 20 +++ .../lchat/utils/PreferencesManager.kt | 36 +++++ .../lchat/viewmodel/LobbyViewModel.kt | 21 ++- 4 files changed, 201 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/mattintech/lchat/utils/PreferencesManager.kt diff --git a/TODO.md b/TODO.md index c472a20..36abcc2 100644 --- a/TODO.md +++ b/TODO.md @@ -9,11 +9,27 @@ - [x] Implement proper state management with LiveData/StateFlow - [x] Add ViewModelFactory if needed -### 1.2 Dependency Injection with Hilt -- [ ] Add Hilt dependencies -- [ ] Set up Hilt modules for WifiAwareManager -- [ ] Convert singletons to proper DI -- [ ] Inject ViewModels using Hilt +### 1.2 Dependency Injection with Hilt ✅ +- [x] Add Hilt dependencies +- [x] Set up Hilt modules for WifiAwareManager +- [x] Convert singletons to proper DI +- [x] Inject ViewModels using Hilt + +### 1.3 Room Database Setup +- [ ] Add Room dependencies +- [ ] Create Message and User entities +- [ ] Implement DAOs for data access +- [ ] Create database migrations +- [ ] Store messages in Room database +- [ ] Load message history on app restart +- [ ] Implement message sync logic + +### 1.4 Coroutines & Flow Optimization +- [ ] Convert callbacks to coroutines +- [ ] Use Flow for reactive data streams +- [ ] Implement proper scope management +- [ ] Replace GlobalScope with proper lifecycle scopes +- [ ] Add proper error handling with coroutines ## Phase 2: Core UX Improvements @@ -29,19 +45,50 @@ - [ ] Handle user join/leave events - [ ] Show user count in chat header -## Phase 3: Data Persistence +### 2.3 Enhanced Messaging Features +- [ ] Message status indicators (sent/delivered/read) +- [ ] User presence indicators (online/offline/typing) +- [ ] Message timestamps with proper formatting +- [ ] Offline message queue +- [ ] Message retry mechanism +- [ ] Long press message actions (copy, delete) -### 3.1 Room Database Setup -- [ ] Add Room dependencies -- [ ] Create Message and User entities -- [ ] Implement DAOs for data access -- [ ] Create database migrations +### 2.4 File & Media Sharing +- [ ] Image sharing support +- [ ] File transfer capability +- [ ] Image preview in chat +- [ ] Progress indicators for transfers +- [ ] File size limits and validation -### 3.2 Message Persistence -- [ ] Store messages in Room database -- [ ] Load message history on app restart -- [ ] Implement message sync logic -- [ ] Add message timestamps +## Phase 3: UI/UX Improvements + +### 3.1 Material 3 Design Update +- [ ] Migrate to Material 3 components +- [ ] Implement dynamic color theming +- [ ] Update typography and spacing +- [ ] Add proper elevation and shadows +- [ ] Implement Material You design principles + +### 3.2 Dark Theme & Theming +- [ ] Implement dark theme +- [ ] Add theme toggle in settings +- [ ] System theme detection +- [ ] Custom color schemes +- [ ] Persist theme preference + +### 3.3 Animations & Polish +- [ ] Message send/receive animations +- [ ] Screen transition animations +- [ ] Loading state animations +- [ ] Smooth scrolling improvements +- [ ] Haptic feedback + +### 3.4 Better Error Handling UI +- [ ] User-friendly error messages +- [ ] Retry mechanisms with UI feedback +- [ ] Connection lost/restored snackbars +- [ ] Empty states for no messages/users +- [ ] Inline error states ## Phase 4: Reliability Improvements @@ -56,26 +103,78 @@ - [ ] Handle app lifecycle properly - [ ] Save and restore connection state -## Phase 5: Advanced Features +## Phase 5: Security & Privacy -### 5.1 Background Service +### 5.1 Message Encryption +- [ ] End-to-end encryption implementation +- [ ] Key exchange protocol +- [ ] Message integrity verification +- [ ] Secure key storage +- [ ] Forward secrecy + +### 5.2 Privacy Features +- [ ] Optional username anonymization +- [ ] Message auto-deletion +- [ ] Block/unblock users +- [ ] Private rooms with passwords +- [ ] Data export/import + +## Phase 6: Advanced Features + +### 6.1 Background Service - [ ] Create foreground service for persistent connection - [ ] Handle Doze mode and battery optimization - [ ] Add notification for active chat - [ ] Implement proper service lifecycle +- [ ] Wake lock management -### 5.2 Additional Features -- [ ] Message delivery status -- [ ] Typing indicators -- [ ] File/image sharing support -- [ ] Message encryption improvements +### 6.2 Settings & Preferences +- [ ] Create settings screen +- [ ] Notification preferences +- [ ] Sound/vibration settings +- [ ] Auto-reconnect toggle +- [ ] Message history limits + +## Phase 7: Testing & Quality + +### 7.1 Unit Testing +- [ ] Test ViewModels +- [ ] Test Repository logic +- [ ] Test data transformations +- [ ] Test error scenarios +- [ ] Mock dependencies with Hilt testing + +### 7.2 Integration Testing +- [ ] Test database operations +- [ ] Test network layer +- [ ] Test complete user flows +- [ ] Test state persistence + +### 7.3 UI Testing +- [ ] Espresso tests for main flows +- [ ] Test navigation +- [ ] Test user interactions +- [ ] Screenshot testing +- [ ] Accessibility testing ## Current Status - ✅ Phase 1.1 - MVVM Architecture - COMPLETED +- ✅ Phase 1.2 - Dependency Injection with Hilt - COMPLETED - ✅ Phase 2.1 - Connection Status Management - COMPLETED -- 🚀 Next: Phase 1.2 (Dependency Injection) or Phase 3 (Data Persistence) +- 🚀 Next Priority Options: + - Phase 1.3 - Room Database (Foundation for persistence) + - Phase 2.2 - User List Feature (Core UX) + - Phase 2.3 - Enhanced Messaging (Better UX) + - Phase 3.1 - Material 3 Update (Modern UI) ## Completed Work Summary 1. **MVVM Architecture**: ViewModels, Repository pattern, proper separation of concerns -2. **Connection Status**: Visual indicator with real-time updates, activity-based detection -3. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep \ No newline at end of file +2. **Dependency Injection**: Hilt integration with proper scoping and lifecycle management +3. **Connection Status**: Visual indicator with real-time updates, activity-based detection +4. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep + +## Development Notes +- Architecture foundation (Phase 1) should be completed before moving to advanced features +- UI/UX improvements (Phase 3) can be done in parallel with feature development +- Testing (Phase 7) should be implemented incrementally as features are added +- Security features (Phase 5) are important for production readiness \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt index 2dba077..e9635eb 100644 --- a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt +++ b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt @@ -1,6 +1,8 @@ package com.mattintech.lchat.ui import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher import android.util.Log import android.view.LayoutInflater import android.view.View @@ -48,6 +50,18 @@ class LobbyFragment : Fragment() { } private fun setupUI() { + binding.nameInput.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + + override fun afterTextChanged(s: Editable?) { + s?.toString()?.trim()?.let { name -> + viewModel.saveUserName(name) + } + } + }) + binding.modeRadioGroup.setOnCheckedChangeListener { _, checkedId -> when (checkedId) { R.id.hostRadio -> { @@ -80,6 +94,12 @@ class LobbyFragment : Fragment() { } private fun observeViewModel() { + viewModel.savedUserName.observe(viewLifecycleOwner) { savedName -> + if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) { + binding.nameInput.setText(savedName) + } + } + viewModel.state.observe(viewLifecycleOwner) { state -> when (state) { is LobbyState.Idle -> { diff --git a/app/src/main/java/com/mattintech/lchat/utils/PreferencesManager.kt b/app/src/main/java/com/mattintech/lchat/utils/PreferencesManager.kt new file mode 100644 index 0000000..0b8ca75 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/utils/PreferencesManager.kt @@ -0,0 +1,36 @@ +package com.mattintech.lchat.utils + +import android.content.Context +import android.content.SharedPreferences +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PreferencesManager @Inject constructor( + @ApplicationContext private val context: Context +) { + private val sharedPreferences: SharedPreferences = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + fun saveUserName(name: String) { + if (name.isBlank()) { + clearUserName() + } else { + sharedPreferences.edit().putString(KEY_USER_NAME, name).apply() + } + } + + fun getUserName(): String? { + return sharedPreferences.getString(KEY_USER_NAME, null) + } + + fun clearUserName() { + sharedPreferences.edit().remove(KEY_USER_NAME).apply() + } + + companion object { + private const val PREFS_NAME = "lchat_preferences" + private const val KEY_USER_NAME = "user_name" + } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt index 8e8c90f..6cf60b1 100644 --- a/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mattintech.lchat.repository.ChatRepository +import com.mattintech.lchat.utils.PreferencesManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject @@ -27,7 +28,8 @@ sealed class LobbyEvent { @HiltViewModel class LobbyViewModel @Inject constructor( - private val chatRepository: ChatRepository + private val chatRepository: ChatRepository, + private val preferencesManager: PreferencesManager ) : ViewModel() { private val _state = MutableLiveData(LobbyState.Idle) @@ -36,8 +38,16 @@ class LobbyViewModel @Inject constructor( private val _events = MutableLiveData() val events: LiveData = _events + private val _savedUserName = MutableLiveData() + val savedUserName: LiveData = _savedUserName + init { setupConnectionCallback() + loadSavedUserName() + } + + private fun loadSavedUserName() { + _savedUserName.value = preferencesManager.getUserName() } private fun setupConnectionCallback() { @@ -65,6 +75,7 @@ class LobbyViewModel @Inject constructor( viewModelScope.launch { _state.value = LobbyState.Connecting + preferencesManager.saveUserName(userName) chatRepository.startHostMode(roomName) _events.value = LobbyEvent.NavigateToChat(roomName, userName, true) } @@ -78,11 +89,13 @@ class LobbyViewModel @Inject constructor( viewModelScope.launch { _state.value = LobbyState.Connecting + preferencesManager.saveUserName(userName) chatRepository.startClientMode() } } fun onConnectedToRoom(roomName: String, userName: String) { + preferencesManager.saveUserName(userName) _events.value = LobbyEvent.NavigateToChat(roomName, userName, false) } @@ -90,6 +103,12 @@ class LobbyViewModel @Inject constructor( _events.value = null } + fun saveUserName(name: String) { + if (name.isNotBlank()) { + preferencesManager.saveUserName(name) + } + } + override fun onCleared() { super.onCleared() // Clean up resources if needed From 50d1aae0397e7193e613ce59c8a903b289066992 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 21:43:49 -0400 Subject: [PATCH 6/9] storing chats --- app/build.gradle.kts | 6 +++ .../mattintech/lchat/data/db/LChatDatabase.kt | 15 ++++++++ .../lchat/data/db/dao/MessageDao.kt | 29 +++++++++++++++ .../lchat/data/db/entities/MessageEntity.kt | 16 ++++++++ .../lchat/data/db/mappers/MessageMapper.kt | 27 ++++++++++++++ .../lchat/data/db/migrations/Migrations.kt | 14 +++++++ .../java/com/mattintech/lchat/di/AppModule.kt | 20 ++++++++++ .../lchat/repository/ChatRepository.kt | 37 ++++++++++++++++--- .../lchat/viewmodel/ChatViewModel.kt | 14 +++++-- 9 files changed, 169 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/mattintech/lchat/data/db/LChatDatabase.kt create mode 100644 app/src/main/java/com/mattintech/lchat/data/db/dao/MessageDao.kt create mode 100644 app/src/main/java/com/mattintech/lchat/data/db/entities/MessageEntity.kt create mode 100644 app/src/main/java/com/mattintech/lchat/data/db/mappers/MessageMapper.kt create mode 100644 app/src/main/java/com/mattintech/lchat/data/db/migrations/Migrations.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa1e6a5..1e52234 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,12 @@ dependencies { implementation("com.google.dagger:hilt-android:2.50") kapt("com.google.dagger:hilt-compiler:2.50") + // Room dependencies + val roomVersion = "2.6.1" + implementation("androidx.room:room-runtime:$roomVersion") + implementation("androidx.room:room-ktx:$roomVersion") + kapt("androidx.room:room-compiler:$roomVersion") + testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/app/src/main/java/com/mattintech/lchat/data/db/LChatDatabase.kt b/app/src/main/java/com/mattintech/lchat/data/db/LChatDatabase.kt new file mode 100644 index 0000000..beeeab1 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/data/db/LChatDatabase.kt @@ -0,0 +1,15 @@ +package com.mattintech.lchat.data.db + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.mattintech.lchat.data.db.dao.MessageDao +import com.mattintech.lchat.data.db.entities.MessageEntity + +@Database( + entities = [MessageEntity::class], + version = 1, + exportSchema = false +) +abstract class LChatDatabase : RoomDatabase() { + abstract fun messageDao(): MessageDao +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/data/db/dao/MessageDao.kt b/app/src/main/java/com/mattintech/lchat/data/db/dao/MessageDao.kt new file mode 100644 index 0000000..756422e --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/data/db/dao/MessageDao.kt @@ -0,0 +1,29 @@ +package com.mattintech.lchat.data.db.dao + +import androidx.room.* +import com.mattintech.lchat.data.db.entities.MessageEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface MessageDao { + @Query("SELECT * FROM messages WHERE roomName = :roomName ORDER BY timestamp ASC") + fun getMessagesForRoom(roomName: String): Flow> + + @Query("SELECT * FROM messages WHERE roomName = :roomName ORDER BY timestamp ASC") + suspend fun getMessagesForRoomOnce(roomName: String): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessage(message: MessageEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessages(messages: List) + + @Query("DELETE FROM messages WHERE roomName = :roomName") + suspend fun deleteMessagesForRoom(roomName: String) + + @Query("DELETE FROM messages") + suspend fun deleteAllMessages() + + @Query("SELECT COUNT(*) FROM messages WHERE roomName = :roomName") + suspend fun getMessageCountForRoom(roomName: String): Int +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/data/db/entities/MessageEntity.kt b/app/src/main/java/com/mattintech/lchat/data/db/entities/MessageEntity.kt new file mode 100644 index 0000000..92ab367 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/data/db/entities/MessageEntity.kt @@ -0,0 +1,16 @@ +package com.mattintech.lchat.data.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "messages") +data class MessageEntity( + @PrimaryKey + val id: String, + val roomName: String, + val userId: String, + val userName: String, + val content: String, + val timestamp: Long, + val isOwnMessage: Boolean = false +) \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/data/db/mappers/MessageMapper.kt b/app/src/main/java/com/mattintech/lchat/data/db/mappers/MessageMapper.kt new file mode 100644 index 0000000..6cf80b2 --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/data/db/mappers/MessageMapper.kt @@ -0,0 +1,27 @@ +package com.mattintech.lchat.data.db.mappers + +import com.mattintech.lchat.data.Message +import com.mattintech.lchat.data.db.entities.MessageEntity + +fun MessageEntity.toMessage(): Message { + return Message( + id = id, + userId = userId, + userName = userName, + content = content, + timestamp = timestamp, + isOwnMessage = isOwnMessage + ) +} + +fun Message.toEntity(roomName: String): MessageEntity { + return MessageEntity( + id = id, + roomName = roomName, + userId = userId, + userName = userName, + content = content, + timestamp = timestamp, + isOwnMessage = isOwnMessage + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/data/db/migrations/Migrations.kt b/app/src/main/java/com/mattintech/lchat/data/db/migrations/Migrations.kt new file mode 100644 index 0000000..7340bcb --- /dev/null +++ b/app/src/main/java/com/mattintech/lchat/data/db/migrations/Migrations.kt @@ -0,0 +1,14 @@ +package com.mattintech.lchat.data.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +// Placeholder for future migrations +object Migrations { + // Example migration from version 1 to 2 + // val MIGRATION_1_2 = object : Migration(1, 2) { + // override fun migrate(database: SupportSQLiteDatabase) { + // // Migration code here + // } + // } +} \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/di/AppModule.kt b/app/src/main/java/com/mattintech/lchat/di/AppModule.kt index 109c1cc..17f7e79 100644 --- a/app/src/main/java/com/mattintech/lchat/di/AppModule.kt +++ b/app/src/main/java/com/mattintech/lchat/di/AppModule.kt @@ -1,6 +1,9 @@ package com.mattintech.lchat.di import android.content.Context +import androidx.room.Room +import com.mattintech.lchat.data.db.LChatDatabase +import com.mattintech.lchat.data.db.dao.MessageDao import com.mattintech.lchat.network.WifiAwareManager import dagger.Module import dagger.Provides @@ -20,4 +23,21 @@ object AppModule { ): WifiAwareManager { return WifiAwareManager(context) } + + @Provides + @Singleton + fun provideLChatDatabase( + @ApplicationContext context: Context + ): LChatDatabase { + return Room.databaseBuilder( + context, + LChatDatabase::class.java, + "lchat_database" + ).build() + } + + @Provides + fun provideMessageDao(database: LChatDatabase): MessageDao { + return database.messageDao() + } } \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt index a97a8b9..ff31552 100644 --- a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt +++ b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt @@ -2,26 +2,36 @@ package com.mattintech.lchat.repository import android.content.Context import com.mattintech.lchat.data.Message +import com.mattintech.lchat.data.db.dao.MessageDao +import com.mattintech.lchat.data.db.mappers.toEntity +import com.mattintech.lchat.data.db.mappers.toMessage import com.mattintech.lchat.network.WifiAwareManager import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.* -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.* import java.util.UUID import javax.inject.Inject import javax.inject.Singleton @Singleton class ChatRepository @Inject constructor( - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val wifiAwareManager: WifiAwareManager, + private val messageDao: MessageDao ) { - private val wifiAwareManager = WifiAwareManager(context) + private var currentRoomName: String = "" private val _messages = MutableStateFlow>(emptyList()) val messages: StateFlow> = _messages.asStateFlow() + // Flow that combines in-memory and database messages + fun getMessagesFlow(roomName: String): Flow> { + return messageDao.getMessagesForRoom(roomName) + .map { entities -> entities.map { it.toMessage() } } + .onStart { loadMessagesFromDatabase(roomName) } + } + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) val connectionState: StateFlow = _connectionState.asStateFlow() @@ -69,9 +79,19 @@ class ChatRepository @Inject constructor( } fun startHostMode(roomName: String) { + currentRoomName = roomName wifiAwareManager.startHostMode(roomName) _connectionState.value = ConnectionState.Hosting(roomName) startConnectionMonitoring() + loadMessagesFromDatabase(roomName) + } + + private fun loadMessagesFromDatabase(roomName: String) { + CoroutineScope(Dispatchers.IO).launch { + val storedMessages = messageDao.getMessagesForRoomOnce(roomName) + .map { it.toMessage() } + _messages.value = storedMessages + } } fun startClientMode() { @@ -104,6 +124,11 @@ class ChatRepository @Inject constructor( private fun addMessage(message: Message) { _messages.value = _messages.value + message + + // Save to database + CoroutineScope(Dispatchers.IO).launch { + messageDao.insertMessage(message.toEntity(currentRoomName)) + } } fun clearMessages() { @@ -118,7 +143,9 @@ class ChatRepository @Inject constructor( connectionCallback = callback wifiAwareManager.setConnectionCallback { roomName, isConnected -> if (isConnected) { + currentRoomName = roomName _connectionState.value = ConnectionState.Connected(roomName) + loadMessagesFromDatabase(roomName) } else { _connectionState.value = ConnectionState.Disconnected } diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt index 1d241e2..7337681 100644 --- a/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt @@ -7,9 +7,8 @@ import androidx.lifecycle.viewModelScope import com.mattintech.lchat.data.Message import com.mattintech.lchat.repository.ChatRepository import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.util.UUID import javax.inject.Inject @@ -20,6 +19,7 @@ sealed class ChatState { data class Error(val message: String) : ChatState() } +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class ChatViewModel @Inject constructor( private val chatRepository: ChatRepository @@ -28,7 +28,10 @@ class ChatViewModel @Inject constructor( private val _state = MutableLiveData(ChatState.Connected) val state: LiveData = _state - val messages: StateFlow> = chatRepository.messages + private val _messagesFlow = MutableStateFlow>>(flowOf(emptyList())) + + val messages: StateFlow> = _messagesFlow + .flatMapLatest { it } .stateIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), @@ -60,6 +63,9 @@ class ChatViewModel @Inject constructor( this.isHost = isHost this.currentUserId = UUID.randomUUID().toString() + // Set up messages flow for this room + _messagesFlow.value = chatRepository.getMessagesFlow(roomName) + // Setup message callback if needed for additional processing chatRepository.setMessageCallback { userId, userName, content -> // Can add additional message processing here if needed From 8085c55c24fa45e9d5926e0f519f117a90867769 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 22:14:29 -0400 Subject: [PATCH 7/9] moving to coroutines --- TODO.md | 48 ++-- .../lchat/network/WifiAwareManager.kt | 236 +++++++++++++----- .../lchat/repository/ChatRepository.kt | 124 +++++---- .../com/mattintech/lchat/ui/LobbyFragment.kt | 86 ++++--- .../lchat/viewmodel/ChatViewModel.kt | 12 +- .../lchat/viewmodel/LobbyViewModel.kt | 40 +-- 6 files changed, 369 insertions(+), 177 deletions(-) diff --git a/TODO.md b/TODO.md index 36abcc2..1d5e923 100644 --- a/TODO.md +++ b/TODO.md @@ -15,21 +15,21 @@ - [x] Convert singletons to proper DI - [x] Inject ViewModels using Hilt -### 1.3 Room Database Setup -- [ ] Add Room dependencies -- [ ] Create Message and User entities -- [ ] Implement DAOs for data access -- [ ] Create database migrations -- [ ] Store messages in Room database -- [ ] Load message history on app restart -- [ ] Implement message sync logic +### 1.3 Room Database Setup ✅ +- [x] Add Room dependencies +- [x] Create Message and User entities +- [x] Implement DAOs for data access +- [x] Create database migrations +- [x] Store messages in Room database +- [x] Load message history on app restart +- [x] Implement message sync logic -### 1.4 Coroutines & Flow Optimization -- [ ] Convert callbacks to coroutines -- [ ] Use Flow for reactive data streams -- [ ] Implement proper scope management -- [ ] Replace GlobalScope with proper lifecycle scopes -- [ ] Add proper error handling with coroutines +### 1.4 Coroutines & Flow Optimization ✅ +- [x] Convert callbacks to coroutines +- [x] Use Flow for reactive data streams +- [x] Implement proper scope management +- [x] Replace GlobalScope with proper lifecycle scopes +- [x] Add proper error handling with coroutines ## Phase 2: Core UX Improvements @@ -47,6 +47,16 @@ ### 2.3 Enhanced Messaging Features - [ ] Message status indicators (sent/delivered/read) + - [ ] Add status field to MessageEntity (pending/sent/delivered/failed) + - [ ] Show status icons in message bubbles + - [ ] Update status when delivery confirmed +- [ ] Store-and-forward messaging pattern + - [ ] Save messages with "pending" status initially + - [ ] Implement acknowledgment protocol in WifiAwareManager + - [ ] Update to "sent" only after confirmation received + - [ ] Queue messages when offline/disconnected + - [ ] Auto-retry failed messages with exponential backoff + - [ ] Mark messages as failed after max retries - [ ] User presence indicators (online/offline/typing) - [ ] Message timestamps with proper formatting - [ ] Offline message queue @@ -160,18 +170,22 @@ ## Current Status - ✅ Phase 1.1 - MVVM Architecture - COMPLETED - ✅ Phase 1.2 - Dependency Injection with Hilt - COMPLETED +- ✅ Phase 1.3 - Room Database Setup - COMPLETED +- ✅ Phase 1.4 - Coroutines & Flow Optimization - COMPLETED - ✅ Phase 2.1 - Connection Status Management - COMPLETED - 🚀 Next Priority Options: - - Phase 1.3 - Room Database (Foundation for persistence) - Phase 2.2 - User List Feature (Core UX) - Phase 2.3 - Enhanced Messaging (Better UX) - Phase 3.1 - Material 3 Update (Modern UI) + - Phase 3.2 - Dark Theme & Theming ## Completed Work Summary 1. **MVVM Architecture**: ViewModels, Repository pattern, proper separation of concerns 2. **Dependency Injection**: Hilt integration with proper scoping and lifecycle management -3. **Connection Status**: Visual indicator with real-time updates, activity-based detection -4. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep +3. **Room Database**: Message persistence with proper DAOs and entity mapping +4. **Coroutines & Flow**: Converted callbacks to coroutines, implemented Flow for reactive streams, proper scope management +5. **Connection Status**: Visual indicator with real-time updates, activity-based detection +6. **Sleep/Wake Handling**: Auto-recovery when messages resume after device sleep ## Development Notes - Architecture foundation (Phase 1) should be completed before moving to advanced features diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt index 9d544a4..cc43983 100644 --- a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt +++ b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt @@ -9,7 +9,13 @@ import android.os.Build import android.util.Log import androidx.annotation.RequiresApi import com.mattintech.lchat.utils.LOG_PREFIX +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.* import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine @RequiresApi(Build.VERSION_CODES.O) class WifiAwareManager(private val context: Context) { @@ -26,27 +32,57 @@ class WifiAwareManager(private val context: Context) { private var subscribeDiscoverySession: SubscribeDiscoverySession? = null private val peerHandles = ConcurrentHashMap() + + // Replace callbacks with Flows + private val _messageFlow = MutableSharedFlow>() + val messageFlow: SharedFlow> = _messageFlow.asSharedFlow() + + private val _connectionFlow = MutableSharedFlow>() + val connectionFlow: SharedFlow> = _connectionFlow.asSharedFlow() + + // Keep legacy callbacks for backward compatibility private var messageCallback: ((String, String, String) -> Unit)? = null private var connectionCallback: ((String, Boolean) -> Unit)? = null - private val attachCallback = object : AttachCallback() { - override fun onAttached(session: WifiAwareSession) { - Log.d(TAG, "Wi-Fi Aware attached") - wifiAwareSession = session - } - - override fun onAttachFailed() { - Log.e(TAG, "Wi-Fi Aware attach failed") + // Exception handler for coroutine errors + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + Log.e(TAG, "Coroutine exception: ", throwable) + } + + private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler) + + fun initialize() { + coroutineScope.launch { + initializeAsync() } } - fun initialize() { + suspend fun initializeAsync(): Result = withContext(Dispatchers.IO) { wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager if (wifiAwareManager?.isAvailable == true) { - wifiAwareManager?.attach(attachCallback, null) + attachToWifiAware() } else { - Log.e(TAG, "Wi-Fi Aware is not available") + Result.failure(Exception("Wi-Fi Aware is not available")) + } + } + + private suspend fun attachToWifiAware(): Result = suspendCancellableCoroutine { continuation -> + wifiAwareManager?.attach(object : AttachCallback() { + override fun onAttached(session: WifiAwareSession) { + Log.d(TAG, "Wi-Fi Aware attached") + wifiAwareSession = session + continuation.resume(Result.success(Unit)) + } + + override fun onAttachFailed() { + Log.e(TAG, "Wi-Fi Aware attach failed") + continuation.resume(Result.failure(Exception("Wi-Fi Aware attach failed"))) + } + }, null) + + continuation.invokeOnCancellation { + Log.d(TAG, "Wi-Fi Aware attach cancelled") } } @@ -67,8 +103,15 @@ class WifiAwareManager(private val context: Context) { Log.d(TAG, "Host: Received message: $messageStr") if (messageStr == "CONNECT_REQUEST") { - Log.d(TAG, "Host: Received connection request") - acceptConnection(peerHandle) + Log.d(TAG, "Host: Received connection request from peer") + coroutineScope.launch { + val result = acceptConnectionAsync(peerHandle) + if (result.isSuccess) { + Log.d(TAG, "Host: Successfully accepted connection") + } else { + Log.e(TAG, "Host: Failed to accept connection: ${result.exceptionOrNull()?.message}") + } + } } else { handleIncomingMessage(peerHandle, message) } @@ -103,9 +146,13 @@ class WifiAwareManager(private val context: Context) { subscribeDiscoverySession?.sendMessage(peerHandle, 0, "CONNECT_REQUEST".toByteArray()) // Wait a bit for host to prepare, then connect - android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({ - connectToPeer(peerHandle, roomName) - }, 500) + coroutineScope.launch { + delay(500) + val result = connectToPeerAsync(peerHandle, roomName) + if (result.isFailure) { + Log.e(TAG, "Failed to connect to peer: ${result.exceptionOrNull()?.message}") + } + } } override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) { @@ -115,6 +162,13 @@ class WifiAwareManager(private val context: Context) { } private fun connectToPeer(peerHandle: PeerHandle, roomName: String) { + coroutineScope.launch { + connectToPeerAsync(peerHandle, roomName) + } + } + + private suspend fun connectToPeerAsync(peerHandle: PeerHandle, roomName: String): Result = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> Log.d(TAG, "connectToPeer: Starting connection to room: $roomName") val networkSpecifier = WifiAwareNetworkSpecifier.Builder(subscribeDiscoverySession!!, peerHandle) .setPskPassphrase("lchat-secure-key") @@ -129,62 +183,117 @@ class WifiAwareManager(private val context: Context) { val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - try { - connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: android.net.Network) { - Log.d(TAG, "onAvailable: Network connected for room: $roomName") - connectionCallback?.invoke(roomName, true) + try { + var isResumed = false + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: android.net.Network) { + Log.d(TAG, "onAvailable: Network connected for room: $roomName") + if (!isResumed) { + isResumed = true + continuation.resume(Result.success(Unit)) + } + // Emit to flow and legacy callback + coroutineScope.launch { + _connectionFlow.emit(Pair(roomName, true)) + } + connectionCallback?.invoke(roomName, true) + } + + override fun onLost(network: android.net.Network) { + Log.d(TAG, "onLost: Network lost for room: $roomName") + coroutineScope.launch { + _connectionFlow.emit(Pair(roomName, false)) + } + connectionCallback?.invoke(roomName, false) + } + + override fun onUnavailable() { + Log.e(TAG, "onUnavailable: Network request failed for room: $roomName") + if (!isResumed) { + isResumed = true + continuation.resume(Result.failure(Exception("Network unavailable for room: $roomName"))) + } + coroutineScope.launch { + _connectionFlow.emit(Pair(roomName, false)) + } + connectionCallback?.invoke(roomName, false) + } + + override fun onCapabilitiesChanged(network: android.net.Network, networkCapabilities: NetworkCapabilities) { + Log.d(TAG, "onCapabilitiesChanged: Capabilities changed for room: $roomName") + } + + override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) { + Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName") + } } - override fun onLost(network: android.net.Network) { - Log.d(TAG, "onLost: Network lost for room: $roomName") - connectionCallback?.invoke(roomName, false) - } + connectivityManager.requestNetwork(networkRequest, callback, android.os.Handler(android.os.Looper.getMainLooper())) - override fun onUnavailable() { - Log.e(TAG, "onUnavailable: Network request failed for room: $roomName") - connectionCallback?.invoke(roomName, false) - } + Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName") - override fun onCapabilitiesChanged(network: android.net.Network, networkCapabilities: NetworkCapabilities) { - Log.d(TAG, "onCapabilitiesChanged: Capabilities changed for room: $roomName") + continuation.invokeOnCancellation { + connectivityManager.unregisterNetworkCallback(callback) } - - override fun onLinkPropertiesChanged(network: android.net.Network, linkProperties: android.net.LinkProperties) { - Log.d(TAG, "onLinkPropertiesChanged: Link properties changed for room: $roomName") + } catch (e: Exception) { + Log.e(TAG, "connectToPeer: Failed to request network", e) + continuation.resume(Result.failure(e)) + coroutineScope.launch { + _connectionFlow.emit(Pair(roomName, false)) } - }, android.os.Handler(android.os.Looper.getMainLooper()), 30000) // 30 second timeout - - Log.d(TAG, "connectToPeer: Network request submitted for room: $roomName") - } catch (e: Exception) { - Log.e(TAG, "connectToPeer: Failed to request network", e) - connectionCallback?.invoke(roomName, false) + connectionCallback?.invoke(roomName, false) + } } } - private fun acceptConnection(peerHandle: PeerHandle) { - Log.d(TAG, "acceptConnection: Accepting connection from client") - val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle) - .setPskPassphrase("lchat-secure-key") - .setPort(PORT) - .build() + + private suspend fun acceptConnectionAsync(peerHandle: PeerHandle): Result = withContext(Dispatchers.IO) { + suspendCancellableCoroutine { continuation -> + Log.d(TAG, "acceptConnection: Accepting connection from client") - val networkRequest = NetworkRequest.Builder() - .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) - .setNetworkSpecifier(networkSpecifier) - .build() - - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - connectivityManager.requestNetwork(networkRequest, object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: android.net.Network) { - Log.d(TAG, "Client connected") - peerHandles[peerHandle.toString()] = peerHandle + try { + val networkSpecifier = WifiAwareNetworkSpecifier.Builder(publishDiscoverySession!!, peerHandle) + .setPskPassphrase("lchat-secure-key") + .setPort(PORT) + .build() + + val networkRequest = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI_AWARE) + .setNetworkSpecifier(networkSpecifier) + .build() + + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + var isResumed = false + + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: android.net.Network) { + Log.d(TAG, "Client connected") + peerHandles[peerHandle.toString()] = peerHandle + if (!isResumed) { + isResumed = true + continuation.resume(Result.success(Unit)) + } + } + + override fun onUnavailable() { + Log.e(TAG, "Failed to accept client connection - Check if Wi-Fi is enabled") + if (!isResumed) { + isResumed = true + continuation.resume(Result.failure(Exception("Failed to accept client connection"))) + } + } + } + + connectivityManager.requestNetwork(networkRequest, callback) + + continuation.invokeOnCancellation { + connectivityManager.unregisterNetworkCallback(callback) + } + } catch (e: Exception) { + Log.e(TAG, "acceptConnection: Failed to accept connection", e) + continuation.resume(Result.failure(e)) } - - override fun onUnavailable() { - Log.e(TAG, "Failed to accept client connection - Check if Wi-Fi is enabled") - } - }) + } } private fun handleIncomingMessage(peerHandle: PeerHandle, message: ByteArray) { @@ -192,6 +301,10 @@ class WifiAwareManager(private val context: Context) { val messageStr = String(message) val parts = messageStr.split("|", limit = 3) if (parts.size == 3) { + // Emit to flow and legacy callback + coroutineScope.launch { + _messageFlow.emit(Triple(parts[0], parts[1], parts[2])) + } messageCallback?.invoke(parts[0], parts[1], parts[2]) } } catch (e: Exception) { @@ -226,5 +339,6 @@ class WifiAwareManager(private val context: Context) { subscribeDiscoverySession?.close() wifiAwareSession?.close() peerHandles.clear() + coroutineScope.cancel() } } \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt index ff31552..631b86e 100644 --- a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt +++ b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt @@ -19,6 +19,14 @@ class ChatRepository @Inject constructor( private val wifiAwareManager: WifiAwareManager, private val messageDao: MessageDao ) { + // Exception handler for repository operations + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + android.util.Log.e("ChatRepository", "Repository coroutine exception: ", throwable) + _connectionState.value = ConnectionState.Error(throwable.message ?: "Unknown error") + } + + // Repository scope for background operations + private val repositoryScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler) private var currentRoomName: String = "" @@ -47,37 +55,65 @@ class ChatRepository @Inject constructor( init { wifiAwareManager.initialize() - setupWifiAwareCallbacks() + // Only use Flow-based collection, not callbacks + collectWifiAwareFlows() } - private fun setupWifiAwareCallbacks() { - wifiAwareManager.setMessageCallback { userId, userName, content -> - val message = Message( - id = UUID.randomUUID().toString(), - userId = userId, - userName = userName, - content = content, - timestamp = System.currentTimeMillis(), - isOwnMessage = false - ) - addMessage(message) - - // Update last activity time - lastActivityTime = System.currentTimeMillis() - - // If we're receiving messages, we must be connected - if (_connectionState.value !is ConnectionState.Connected && - _connectionState.value !is ConnectionState.Hosting) { - when (_connectionState.value) { - is ConnectionState.Hosting -> {} // Keep hosting state - else -> _connectionState.value = ConnectionState.Connected("Active") + private fun collectWifiAwareFlows() { + // Collect messages from Flow + repositoryScope.launch { + try { + wifiAwareManager.messageFlow.collect { (userId, userName, content) -> + val message = Message( + id = UUID.randomUUID().toString(), + userId = userId, + userName = userName, + content = content, + timestamp = System.currentTimeMillis(), + isOwnMessage = false + ) + addMessage(message) + + // Update last activity time + lastActivityTime = System.currentTimeMillis() + + // If we're receiving messages, we must be connected + if (_connectionState.value !is ConnectionState.Connected && + _connectionState.value !is ConnectionState.Hosting) { + when (_connectionState.value) { + is ConnectionState.Hosting -> {} // Keep hosting state + else -> _connectionState.value = ConnectionState.Connected("Active") + } } + } + } catch (e: Exception) { + android.util.Log.e("ChatRepository", "Error collecting message flow", e) + } + } + + // Collect connection state from Flow + repositoryScope.launch { + try { + wifiAwareManager.connectionFlow.collect { (roomName, isConnected) -> + if (isConnected) { + currentRoomName = roomName + _connectionState.value = ConnectionState.Connected(roomName) + loadMessagesFromDatabase(roomName) + } else { + _connectionState.value = ConnectionState.Disconnected + } + // Call the legacy callback if set + connectionCallback?.invoke(roomName, isConnected) + } + } catch (e: Exception) { + android.util.Log.e("ChatRepository", "Error collecting connection flow", e) + _connectionState.value = ConnectionState.Error(e.message ?: "Connection error") } - - messageCallback?.invoke(userId, userName, content) } } + // Removed setupWifiAwareCallbacks - now using Flow-based collection only + fun startHostMode(roomName: String) { currentRoomName = roomName wifiAwareManager.startHostMode(roomName) @@ -87,10 +123,16 @@ class ChatRepository @Inject constructor( } private fun loadMessagesFromDatabase(roomName: String) { - CoroutineScope(Dispatchers.IO).launch { - val storedMessages = messageDao.getMessagesForRoomOnce(roomName) - .map { it.toMessage() } - _messages.value = storedMessages + repositoryScope.launch { + try { + val storedMessages = messageDao.getMessagesForRoomOnce(roomName) + .map { it.toMessage() } + _messages.value = storedMessages + } catch (e: Exception) { + android.util.Log.e("ChatRepository", "Error loading messages from database", e) + // Don't crash, just continue with empty messages + _messages.value = emptyList() + } } } @@ -123,11 +165,17 @@ class ChatRepository @Inject constructor( } private fun addMessage(message: Message) { - _messages.value = _messages.value + message + // Add message and sort by timestamp to ensure proper order + _messages.value = (_messages.value + message).sortedBy { it.timestamp } // Save to database - CoroutineScope(Dispatchers.IO).launch { - messageDao.insertMessage(message.toEntity(currentRoomName)) + repositoryScope.launch { + try { + messageDao.insertMessage(message.toEntity(currentRoomName)) + } catch (e: Exception) { + android.util.Log.e("ChatRepository", "Error saving message to database", e) + // Don't crash, message is already in memory + } } } @@ -141,16 +189,7 @@ class ChatRepository @Inject constructor( fun setConnectionCallback(callback: (String, Boolean) -> Unit) { connectionCallback = callback - wifiAwareManager.setConnectionCallback { roomName, isConnected -> - if (isConnected) { - currentRoomName = roomName - _connectionState.value = ConnectionState.Connected(roomName) - loadMessagesFromDatabase(roomName) - } else { - _connectionState.value = ConnectionState.Disconnected - } - callback(roomName, isConnected) - } + // Connection state is now handled by Flow collection } fun stop() { @@ -158,11 +197,12 @@ class ChatRepository @Inject constructor( wifiAwareManager.stop() _connectionState.value = ConnectionState.Disconnected _connectedUsers.value = emptyList() + repositoryScope.cancel() } private fun startConnectionMonitoring() { connectionCheckJob?.cancel() - connectionCheckJob = CoroutineScope(Dispatchers.IO).launch { + connectionCheckJob = repositoryScope.launch { while (isActive) { delay(5000) // Check every 5 seconds val timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime diff --git a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt index e9635eb..464ff9d 100644 --- a/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt +++ b/app/src/main/java/com/mattintech/lchat/ui/LobbyFragment.kt @@ -18,6 +18,10 @@ import com.mattintech.lchat.viewmodel.LobbyState import com.mattintech.lchat.viewmodel.LobbyViewModel import com.mattintech.lchat.utils.LOG_PREFIX import dagger.hilt.android.AndroidEntryPoint +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.launch @AndroidEntryPoint class LobbyFragment : Fragment() { @@ -94,48 +98,60 @@ class LobbyFragment : Fragment() { } private fun observeViewModel() { - viewModel.savedUserName.observe(viewLifecycleOwner) { savedName -> - if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) { - binding.nameInput.setText(savedName) - } - } - - viewModel.state.observe(viewLifecycleOwner) { state -> - when (state) { - is LobbyState.Idle -> { - binding.noRoomsText.visibility = View.GONE - } - is LobbyState.Connecting -> { - if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { - binding.noRoomsText.visibility = View.VISIBLE - binding.noRoomsText.text = getString(R.string.connecting) + // Collect saved user name + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.savedUserName.collect { savedName -> + if (!savedName.isNullOrEmpty() && binding.nameInput.text.isNullOrEmpty()) { + binding.nameInput.setText(savedName) } } - is LobbyState.Connected -> { - if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { - val userName = binding.nameInput.text?.toString()?.trim() ?: "" - viewModel.onConnectedToRoom(state.roomName, userName) - } - } - is LobbyState.Error -> { - binding.noRoomsText.visibility = View.VISIBLE - binding.noRoomsText.text = state.message - Toast.makeText(context, state.message, Toast.LENGTH_LONG).show() - } } } - viewModel.events.observe(viewLifecycleOwner) { event -> - when (event) { - is LobbyEvent.NavigateToChat -> { - navigateToChat(event.roomName, event.userName, event.isHost) - viewModel.clearEvent() + // Collect state + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.state.collect { state -> + when (state) { + is LobbyState.Idle -> { + binding.noRoomsText.visibility = View.GONE + } + is LobbyState.Connecting -> { + if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { + binding.noRoomsText.visibility = View.VISIBLE + binding.noRoomsText.text = getString(R.string.connecting) + } + } + is LobbyState.Connected -> { + if (binding.modeRadioGroup.checkedRadioButtonId == R.id.clientRadio) { + val userName = binding.nameInput.text?.toString()?.trim() ?: "" + viewModel.onConnectedToRoom(state.roomName, userName) + } + } + is LobbyState.Error -> { + binding.noRoomsText.visibility = View.VISIBLE + binding.noRoomsText.text = state.message + Toast.makeText(context, state.message, Toast.LENGTH_LONG).show() + } + } } - is LobbyEvent.ShowError -> { - Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() - viewModel.clearEvent() + } + } + + // Collect events + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + viewModel.events.collect { event -> + when (event) { + is LobbyEvent.NavigateToChat -> { + navigateToChat(event.roomName, event.userName, event.isHost) + } + is LobbyEvent.ShowError -> { + Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() + } + } } - null -> {} } } } diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt index 7337681..798a416 100644 --- a/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/ChatViewModel.kt @@ -1,7 +1,5 @@ package com.mattintech.lchat.viewmodel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mattintech.lchat.data.Message @@ -25,8 +23,8 @@ class ChatViewModel @Inject constructor( private val chatRepository: ChatRepository ) : ViewModel() { - private val _state = MutableLiveData(ChatState.Connected) - val state: LiveData = _state + private val _state = MutableStateFlow(ChatState.Connected) + val state: StateFlow = _state.asStateFlow() private val _messagesFlow = MutableStateFlow>>(flowOf(emptyList())) @@ -85,10 +83,8 @@ class ChatViewModel @Inject constructor( } fun disconnect() { - viewModelScope.launch { - chatRepository.stop() - _state.value = ChatState.Disconnected - } + chatRepository.stop() + _state.value = ChatState.Disconnected } override fun onCleared() { diff --git a/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt index 6cf60b1..befc1ea 100644 --- a/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt +++ b/app/src/main/java/com/mattintech/lchat/viewmodel/LobbyViewModel.kt @@ -1,12 +1,16 @@ package com.mattintech.lchat.viewmodel -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.mattintech.lchat.repository.ChatRepository import com.mattintech.lchat.utils.PreferencesManager import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @@ -32,14 +36,14 @@ class LobbyViewModel @Inject constructor( private val preferencesManager: PreferencesManager ) : ViewModel() { - private val _state = MutableLiveData(LobbyState.Idle) - val state: LiveData = _state + private val _state = MutableStateFlow(LobbyState.Idle) + val state: StateFlow = _state.asStateFlow() - private val _events = MutableLiveData() - val events: LiveData = _events + private val _events = MutableSharedFlow() + val events: SharedFlow = _events.asSharedFlow() - private val _savedUserName = MutableLiveData() - val savedUserName: LiveData = _savedUserName + private val _savedUserName = MutableStateFlow(null) + val savedUserName: StateFlow = _savedUserName.asStateFlow() init { setupConnectionCallback() @@ -64,12 +68,16 @@ class LobbyViewModel @Inject constructor( fun startHostMode(roomName: String, userName: String) { if (roomName.isBlank()) { - _events.value = LobbyEvent.ShowError("Please enter a room name") + viewModelScope.launch { + _events.emit(LobbyEvent.ShowError("Please enter a room name")) + } return } if (userName.isBlank()) { - _events.value = LobbyEvent.ShowError("Please enter your name") + viewModelScope.launch { + _events.emit(LobbyEvent.ShowError("Please enter your name")) + } return } @@ -77,13 +85,15 @@ class LobbyViewModel @Inject constructor( _state.value = LobbyState.Connecting preferencesManager.saveUserName(userName) chatRepository.startHostMode(roomName) - _events.value = LobbyEvent.NavigateToChat(roomName, userName, true) + _events.emit(LobbyEvent.NavigateToChat(roomName, userName, true)) } } fun startClientMode(userName: String) { if (userName.isBlank()) { - _events.value = LobbyEvent.ShowError("Please enter your name") + viewModelScope.launch { + _events.emit(LobbyEvent.ShowError("Please enter your name")) + } return } @@ -96,11 +106,13 @@ class LobbyViewModel @Inject constructor( fun onConnectedToRoom(roomName: String, userName: String) { preferencesManager.saveUserName(userName) - _events.value = LobbyEvent.NavigateToChat(roomName, userName, false) + viewModelScope.launch { + _events.emit(LobbyEvent.NavigateToChat(roomName, userName, false)) + } } fun clearEvent() { - _events.value = null + // SharedFlow doesn't need clearing, events are consumed once } fun saveUserName(name: String) { From 613686989e93ba87e008c77e8389ef0180ca3a66 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Fri, 4 Jul 2025 07:47:27 -0400 Subject: [PATCH 8/9] improving reconnection logic. added keep alive --- .../lchat/network/WifiAwareManager.kt | 255 ++++++++++++++++-- .../lchat/repository/ChatRepository.kt | 99 +++---- .../com/mattintech/lchat/ui/ChatFragment.kt | 2 + 3 files changed, 277 insertions(+), 79 deletions(-) diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt index cc43983..bb5d7f8 100644 --- a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt +++ b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt @@ -24,6 +24,12 @@ class WifiAwareManager(private val context: Context) { private const val TAG = LOG_PREFIX + "WifiAwareManager:" private const val SERVICE_NAME = "lchat" private const val PORT = 8888 + + // Keep-alive constants + private const val KEEP_ALIVE_INTERVAL_MS = 15000L // Send keep-alive every 15 seconds + private const val KEEP_ALIVE_TIMEOUT_MS = 30000L // Consider connection lost after 30 seconds + private const val MESSAGE_TYPE_KEEP_ALIVE = "KEEP_ALIVE" + private const val MESSAGE_TYPE_KEEP_ALIVE_ACK = "KEEP_ALIVE_ACK" } private var wifiAwareManager: android.net.wifi.aware.WifiAwareManager? = null @@ -51,20 +57,38 @@ class WifiAwareManager(private val context: Context) { private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO + exceptionHandler) + // Keep-alive tracking + private val lastPeerActivity = ConcurrentHashMap() + private var keepAliveJob: Job? = null + fun initialize() { coroutineScope.launch { - initializeAsync() + val result = initializeAsync() + if (result.isFailure) { + Log.e(TAG, "Failed to initialize Wi-Fi Aware: ${result.exceptionOrNull()?.message}") + } } } suspend fun initializeAsync(): Result = withContext(Dispatchers.IO) { wifiAwareManager = context.getSystemService(Context.WIFI_AWARE_SERVICE) as? android.net.wifi.aware.WifiAwareManager - if (wifiAwareManager?.isAvailable == true) { - attachToWifiAware() - } else { - Result.failure(Exception("Wi-Fi Aware is not available")) + // Always check if Wi-Fi Aware is available + if (wifiAwareManager?.isAvailable != true) { + Log.e(TAG, "Wi-Fi Aware is not available") + wifiAwareSession = null + return@withContext Result.failure(Exception("Wi-Fi Aware is not available")) } + + // If we already have a session, verify it's still valid + if (wifiAwareSession != null) { + Log.d(TAG, "Wi-Fi Aware already initialized - verifying session is still valid") + // Session is likely still valid + return@withContext Result.success(Unit) + } + + // Need to attach + attachToWifiAware() } private suspend fun attachToWifiAware(): Result = suspendCancellableCoroutine { continuation -> @@ -102,24 +126,56 @@ class WifiAwareManager(private val context: Context) { val messageStr = String(message) Log.d(TAG, "Host: Received message: $messageStr") - if (messageStr == "CONNECT_REQUEST") { - Log.d(TAG, "Host: Received connection request from peer") - coroutineScope.launch { - val result = acceptConnectionAsync(peerHandle) - if (result.isSuccess) { - Log.d(TAG, "Host: Successfully accepted connection") - } else { - Log.e(TAG, "Host: Failed to accept connection: ${result.exceptionOrNull()?.message}") + when (messageStr) { + "CONNECT_REQUEST" -> { + Log.d(TAG, "Host: Received connection request from peer") + coroutineScope.launch { + val result = acceptConnectionAsync(peerHandle) + if (result.isSuccess) { + Log.d(TAG, "Host: Successfully accepted connection") + startKeepAlive() + } else { + Log.e(TAG, "Host: Failed to accept connection: ${result.exceptionOrNull()?.message}") + } } } - } else { - handleIncomingMessage(peerHandle, message) + MESSAGE_TYPE_KEEP_ALIVE -> { + Log.d(TAG, "Host: Received keep-alive from peer") + handleKeepAlive(peerHandle, true) + } + MESSAGE_TYPE_KEEP_ALIVE_ACK -> { + Log.d(TAG, "Host: Received keep-alive ACK from peer") + handleKeepAlive(peerHandle, false) + } + else -> { + handleIncomingMessage(peerHandle, message) + } + } + } + + override fun onSessionTerminated() { + Log.w(TAG, "Host publish session terminated") + publishDiscoverySession = null + stopKeepAlive() + // Emit disconnection event + coroutineScope.launch { + _connectionFlow.emit(Pair("", false)) } } }, null) } fun startClientMode() { + // Close any existing subscribe session before starting a new one + if (subscribeDiscoverySession != null) { + Log.d(TAG, "Closing existing subscribe session before starting new one") + subscribeDiscoverySession?.close() + subscribeDiscoverySession = null + } + + // Clear any stale peer handles + peerHandles.clear() + val config = SubscribeConfig.Builder() .setServiceName(SERVICE_NAME) .build() @@ -145,18 +201,47 @@ class WifiAwareManager(private val context: Context) { Log.d(TAG, "Sending connection request to room: $roomName") subscribeDiscoverySession?.sendMessage(peerHandle, 0, "CONNECT_REQUEST".toByteArray()) + // Update peer activity when discovered + val peerId = peerHandle.toString() + lastPeerActivity[peerId] = System.currentTimeMillis() + // Wait a bit for host to prepare, then connect coroutineScope.launch { delay(500) val result = connectToPeerAsync(peerHandle, roomName) if (result.isFailure) { Log.e(TAG, "Failed to connect to peer: ${result.exceptionOrNull()?.message}") + lastPeerActivity.remove(peerId) } } } override fun onMessageReceived(peerHandle: PeerHandle, message: ByteArray) { - handleIncomingMessage(peerHandle, message) + val messageStr = String(message) + when (messageStr) { + MESSAGE_TYPE_KEEP_ALIVE -> { + Log.d(TAG, "Client: Received keep-alive from host") + handleKeepAlive(peerHandle, true) + } + MESSAGE_TYPE_KEEP_ALIVE_ACK -> { + Log.d(TAG, "Client: Received keep-alive ACK from host") + handleKeepAlive(peerHandle, false) + } + else -> { + handleIncomingMessage(peerHandle, message) + } + } + } + + override fun onSessionTerminated() { + Log.w(TAG, "Client subscribe session terminated") + subscribeDiscoverySession = null + stopKeepAlive() + // Don't clear wifiAwareSession - it's still valid + // Emit disconnection event + coroutineScope.launch { + _connectionFlow.emit(Pair("", false)) + } } }, null) } @@ -197,10 +282,14 @@ class WifiAwareManager(private val context: Context) { _connectionFlow.emit(Pair(roomName, true)) } connectionCallback?.invoke(roomName, true) + // Start keep-alive for client connections + startKeepAlive() } override fun onLost(network: android.net.Network) { Log.d(TAG, "onLost: Network lost for room: $roomName") + // Clear peer handles when connection is lost + peerHandles.remove(roomName) coroutineScope.launch { _connectionFlow.emit(Pair(roomName, false)) } @@ -268,7 +357,9 @@ class WifiAwareManager(private val context: Context) { val callback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: android.net.Network) { Log.d(TAG, "Client connected") - peerHandles[peerHandle.toString()] = peerHandle + val peerId = peerHandle.toString() + peerHandles[peerId] = peerHandle + lastPeerActivity[peerId] = System.currentTimeMillis() if (!isResumed) { isResumed = true continuation.resume(Result.success(Unit)) @@ -298,6 +389,10 @@ class WifiAwareManager(private val context: Context) { private fun handleIncomingMessage(peerHandle: PeerHandle, message: ByteArray) { try { + // Update peer activity on any message + val peerId = peerHandle.toString() + lastPeerActivity[peerId] = System.currentTimeMillis() + val messageStr = String(message) val parts = messageStr.split("|", limit = 3) if (parts.size == 3) { @@ -312,18 +407,35 @@ class WifiAwareManager(private val context: Context) { } } - fun sendMessage(userId: String, userName: String, content: String) { + fun sendMessage(userId: String, userName: String, content: String): Boolean { val message = "$userId|$userName|$content".toByteArray() + var messagesSent = 0 - if (publishDiscoverySession != null) { + if (publishDiscoverySession != null && peerHandles.isNotEmpty()) { peerHandles.values.forEach { peerHandle -> - publishDiscoverySession?.sendMessage(peerHandle, 0, message) + try { + publishDiscoverySession?.sendMessage(peerHandle, messagesSent, message) + messagesSent++ + } catch (e: Exception) { + Log.e(TAG, "Failed to send message to peer", e) + } } - } else if (subscribeDiscoverySession != null) { + } else if (subscribeDiscoverySession != null && peerHandles.isNotEmpty()) { peerHandles.values.forEach { peerHandle -> - subscribeDiscoverySession?.sendMessage(peerHandle, 0, message) + try { + subscribeDiscoverySession?.sendMessage(peerHandle, messagesSent, message) + messagesSent++ + } catch (e: Exception) { + Log.e(TAG, "Failed to send message to peer", e) + } } } + + if (messagesSent == 0) { + Log.w(TAG, "No messages sent - no active session or no peer handles") + } + + return messagesSent > 0 } fun setMessageCallback(callback: (String, String, String) -> Unit) { @@ -334,11 +446,108 @@ class WifiAwareManager(private val context: Context) { connectionCallback = callback } + private fun startKeepAlive() { + keepAliveJob?.cancel() + keepAliveJob = coroutineScope.launch { + while (isActive) { + delay(KEEP_ALIVE_INTERVAL_MS) + sendKeepAlive() + checkPeerActivity() + } + } + } + + private fun stopKeepAlive() { + keepAliveJob?.cancel() + keepAliveJob = null + lastPeerActivity.clear() + } + + private fun sendKeepAlive() { + val message = MESSAGE_TYPE_KEEP_ALIVE.toByteArray() + var messagesSent = 0 + + if (publishDiscoverySession != null && peerHandles.isNotEmpty()) { + peerHandles.forEach { (peerId, peerHandle) -> + try { + publishDiscoverySession?.sendMessage(peerHandle, messagesSent, message) + messagesSent++ + Log.d(TAG, "Sent keep-alive to peer: $peerId") + } catch (e: Exception) { + Log.e(TAG, "Failed to send keep-alive to peer: $peerId", e) + } + } + } else if (subscribeDiscoverySession != null && peerHandles.isNotEmpty()) { + peerHandles.forEach { (peerId, peerHandle) -> + try { + subscribeDiscoverySession?.sendMessage(peerHandle, messagesSent, message) + messagesSent++ + Log.d(TAG, "Sent keep-alive to peer: $peerId") + } catch (e: Exception) { + Log.e(TAG, "Failed to send keep-alive to peer: $peerId", e) + } + } + } + } + + private fun handleKeepAlive(peerHandle: PeerHandle, shouldReply: Boolean) { + // Update last activity for this peer + val peerId = peerHandle.toString() + lastPeerActivity[peerId] = System.currentTimeMillis() + + // Send acknowledgment if requested + if (shouldReply) { + val ackMessage = MESSAGE_TYPE_KEEP_ALIVE_ACK.toByteArray() + try { + if (publishDiscoverySession != null) { + publishDiscoverySession?.sendMessage(peerHandle, 0, ackMessage) + } else if (subscribeDiscoverySession != null) { + subscribeDiscoverySession?.sendMessage(peerHandle, 0, ackMessage) + } + Log.d(TAG, "Sent keep-alive ACK to peer") + } catch (e: Exception) { + Log.e(TAG, "Failed to send keep-alive ACK", e) + } + } + } + + private fun checkPeerActivity() { + val currentTime = System.currentTimeMillis() + val inactivePeers = mutableListOf() + + lastPeerActivity.forEach { (peerId, lastActivity) -> + if (currentTime - lastActivity > KEEP_ALIVE_TIMEOUT_MS) { + Log.w(TAG, "Peer $peerId inactive for too long, considering disconnected") + inactivePeers.add(peerId) + } + } + + // Remove inactive peers + inactivePeers.forEach { peerId -> + lastPeerActivity.remove(peerId) + peerHandles.remove(peerId) + } + + // If all peers are disconnected, emit disconnection event + if (peerHandles.isEmpty() && lastPeerActivity.isNotEmpty()) { + coroutineScope.launch { + _connectionFlow.emit(Pair("", false)) + } + } + } + fun stop() { + Log.d(TAG, "Stopping WifiAwareManager") + stopKeepAlive() publishDiscoverySession?.close() + publishDiscoverySession = null subscribeDiscoverySession?.close() + subscribeDiscoverySession = null + // Close and clear the wifiAwareSession to force re-attachment wifiAwareSession?.close() + wifiAwareSession = null peerHandles.clear() - coroutineScope.cancel() + // Don't cancel the coroutine scope - we need it for future operations + Log.d(TAG, "WifiAwareManager stopped - session cleared for fresh start") } } \ No newline at end of file diff --git a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt index 631b86e..eff04d8 100644 --- a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt +++ b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt @@ -49,9 +49,7 @@ class ChatRepository @Inject constructor( private var messageCallback: ((String, String, String) -> Unit)? = null private var connectionCallback: ((String, Boolean) -> Unit)? = null - private var lastActivityTime = System.currentTimeMillis() - private var connectionCheckJob: Job? = null - private val connectionTimeout = 30000L // 30 seconds + // Keep-alive is now handled by WifiAwareManager init { wifiAwareManager.initialize() @@ -74,9 +72,6 @@ class ChatRepository @Inject constructor( ) addMessage(message) - // Update last activity time - lastActivityTime = System.currentTimeMillis() - // If we're receiving messages, we must be connected if (_connectionState.value !is ConnectionState.Connected && _connectionState.value !is ConnectionState.Hosting) { @@ -116,9 +111,10 @@ class ChatRepository @Inject constructor( fun startHostMode(roomName: String) { currentRoomName = roomName + // Ensure WifiAwareManager is initialized before starting + wifiAwareManager.initialize() wifiAwareManager.startHostMode(roomName) _connectionState.value = ConnectionState.Hosting(roomName) - startConnectionMonitoring() loadMessagesFromDatabase(roomName) } @@ -137,30 +133,45 @@ class ChatRepository @Inject constructor( } fun startClientMode() { - wifiAwareManager.startClientMode() - _connectionState.value = ConnectionState.Searching - startConnectionMonitoring() + // Reset state for fresh start + _messages.value = emptyList() + currentRoomName = "" + + // Ensure WifiAwareManager is initialized before starting + repositoryScope.launch { + // Give Wi-Fi Aware time to stabilize after network changes + delay(500) + wifiAwareManager.initialize() + // Small delay to ensure initialization completes + delay(100) + wifiAwareManager.startClientMode() + _connectionState.value = ConnectionState.Searching + } } fun sendMessage(userId: String, userName: String, content: String) { - val message = Message( - id = UUID.randomUUID().toString(), - userId = userId, - userName = userName, - content = content, - timestamp = System.currentTimeMillis(), - isOwnMessage = true - ) - addMessage(message) - wifiAwareManager.sendMessage(userId, userName, content) - - // Update last activity time - lastActivityTime = System.currentTimeMillis() - - // If we can send messages, update connection state if needed - if (_connectionState.value is ConnectionState.Disconnected || - _connectionState.value is ConnectionState.Error) { - _connectionState.value = ConnectionState.Connected("Active") + // Only allow sending messages if connected or hosting + when (_connectionState.value) { + is ConnectionState.Connected, is ConnectionState.Hosting -> { + val message = Message( + id = UUID.randomUUID().toString(), + userId = userId, + userName = userName, + content = content, + timestamp = System.currentTimeMillis(), + isOwnMessage = true + ) + val sent = wifiAwareManager.sendMessage(userId, userName, content) + if (sent) { + addMessage(message) + } else { + android.util.Log.e("ChatRepository", "Failed to send message - no active connection") + _connectionState.value = ConnectionState.Disconnected + } + } + else -> { + android.util.Log.w("ChatRepository", "Cannot send message - not connected. State: ${_connectionState.value}") + } } } @@ -193,37 +204,13 @@ class ChatRepository @Inject constructor( } fun stop() { - stopConnectionMonitoring() wifiAwareManager.stop() _connectionState.value = ConnectionState.Disconnected _connectedUsers.value = emptyList() - repositoryScope.cancel() - } - - private fun startConnectionMonitoring() { - connectionCheckJob?.cancel() - connectionCheckJob = repositoryScope.launch { - while (isActive) { - delay(5000) // Check every 5 seconds - val timeSinceLastActivity = System.currentTimeMillis() - lastActivityTime - - // If no activity for 30 seconds and we think we're connected, mark as disconnected - if (timeSinceLastActivity > connectionTimeout) { - when (_connectionState.value) { - is ConnectionState.Connected, - is ConnectionState.Hosting -> { - _connectionState.value = ConnectionState.Disconnected - } - else -> {} // Keep current state - } - } - } - } - } - - private fun stopConnectionMonitoring() { - connectionCheckJob?.cancel() - connectionCheckJob = null + // Don't cancel the repository scope - we need it for future operations + // Clear messages when stopping + _messages.value = emptyList() + currentRoomName = "" } sealed class ConnectionState { diff --git a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt index 87559f6..b4f0f09 100644 --- a/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt +++ b/app/src/main/java/com/mattintech/lchat/ui/ChatFragment.kt @@ -138,6 +138,8 @@ class ChatFragment : Fragment() { override fun onDestroyView() { super.onDestroyView() Log.d(TAG, "onDestroyView") + // Disconnect when leaving the chat screen + viewModel.disconnect() _binding = null } } \ No newline at end of file From c6e43a9f96a5879a24b8deee1d166c404aa48f34 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Fri, 4 Jul 2025 07:59:11 -0400 Subject: [PATCH 9/9] fixing conneciton state after a wifi disconnect --- .../lchat/network/WifiAwareManager.kt | 80 +++++++++++++------ .../lchat/repository/ChatRepository.kt | 21 ++--- 2 files changed, 66 insertions(+), 35 deletions(-) diff --git a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt index bb5d7f8..448e44d 100644 --- a/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt +++ b/app/src/main/java/com/mattintech/lchat/network/WifiAwareManager.kt @@ -38,6 +38,8 @@ class WifiAwareManager(private val context: Context) { private var subscribeDiscoverySession: SubscribeDiscoverySession? = null private val peerHandles = ConcurrentHashMap() + private val peerRoomMapping = ConcurrentHashMap() // PeerHandle.toString() -> roomName + private var currentRoom: String? = null // Replace callbacks with Flows private val _messageFlow = MutableSharedFlow>() @@ -111,6 +113,7 @@ class WifiAwareManager(private val context: Context) { } fun startHostMode(roomName: String) { + currentRoom = roomName val config = PublishConfig.Builder() .setServiceName(SERVICE_NAME) .setServiceSpecificInfo(roomName.toByteArray()) @@ -196,6 +199,7 @@ class WifiAwareManager(private val context: Context) { // Store peer handle for this room peerHandles[roomName] = peerHandle + currentRoom = roomName // Send connection request to host Log.d(TAG, "Sending connection request to room: $roomName") @@ -203,7 +207,8 @@ class WifiAwareManager(private val context: Context) { // Update peer activity when discovered val peerId = peerHandle.toString() - lastPeerActivity[peerId] = System.currentTimeMillis() + peerRoomMapping[peerId] = roomName + lastPeerActivity[roomName] = System.currentTimeMillis() // Wait a bit for host to prepare, then connect coroutineScope.launch { @@ -288,8 +293,19 @@ class WifiAwareManager(private val context: Context) { override fun onLost(network: android.net.Network) { Log.d(TAG, "onLost: Network lost for room: $roomName") - // Clear peer handles when connection is lost + // Check if we still have active keep-alive for this room + val lastActivity = lastPeerActivity[roomName] + val currentTime = System.currentTimeMillis() + + if (lastActivity != null && (currentTime - lastActivity) < KEEP_ALIVE_TIMEOUT_MS) { + Log.d(TAG, "Network lost but keep-alive still active for room: $roomName - ignoring disconnect") + // Don't disconnect if keep-alive is still active + return + } + + // Clear peer handles when connection is truly lost peerHandles.remove(roomName) + lastPeerActivity.remove(roomName) coroutineScope.launch { _connectionFlow.emit(Pair(roomName, false)) } @@ -358,8 +374,10 @@ class WifiAwareManager(private val context: Context) { override fun onAvailable(network: android.net.Network) { Log.d(TAG, "Client connected") val peerId = peerHandle.toString() - peerHandles[peerId] = peerHandle - lastPeerActivity[peerId] = System.currentTimeMillis() + val roomName = currentRoom ?: "host" + peerHandles[roomName] = peerHandle + peerRoomMapping[peerId] = roomName + lastPeerActivity[roomName] = System.currentTimeMillis() if (!isResumed) { isResumed = true continuation.resume(Result.success(Unit)) @@ -391,7 +409,11 @@ class WifiAwareManager(private val context: Context) { try { // Update peer activity on any message val peerId = peerHandle.toString() - lastPeerActivity[peerId] = System.currentTimeMillis() + val roomName = peerRoomMapping[peerId] ?: currentRoom + + if (roomName != null) { + lastPeerActivity[roomName] = System.currentTimeMillis() + } val messageStr = String(message) val parts = messageStr.split("|", limit = 3) @@ -468,23 +490,23 @@ class WifiAwareManager(private val context: Context) { var messagesSent = 0 if (publishDiscoverySession != null && peerHandles.isNotEmpty()) { - peerHandles.forEach { (peerId, peerHandle) -> + peerHandles.forEach { (roomName, peerHandle) -> try { publishDiscoverySession?.sendMessage(peerHandle, messagesSent, message) messagesSent++ - Log.d(TAG, "Sent keep-alive to peer: $peerId") + Log.d(TAG, "Sent keep-alive to room: $roomName") } catch (e: Exception) { - Log.e(TAG, "Failed to send keep-alive to peer: $peerId", e) + Log.e(TAG, "Failed to send keep-alive to room: $roomName", e) } } } else if (subscribeDiscoverySession != null && peerHandles.isNotEmpty()) { - peerHandles.forEach { (peerId, peerHandle) -> + peerHandles.forEach { (roomName, peerHandle) -> try { subscribeDiscoverySession?.sendMessage(peerHandle, messagesSent, message) messagesSent++ - Log.d(TAG, "Sent keep-alive to peer: $peerId") + Log.d(TAG, "Sent keep-alive to room: $roomName") } catch (e: Exception) { - Log.e(TAG, "Failed to send keep-alive to peer: $peerId", e) + Log.e(TAG, "Failed to send keep-alive to room: $roomName", e) } } } @@ -493,7 +515,12 @@ class WifiAwareManager(private val context: Context) { private fun handleKeepAlive(peerHandle: PeerHandle, shouldReply: Boolean) { // Update last activity for this peer val peerId = peerHandle.toString() - lastPeerActivity[peerId] = System.currentTimeMillis() + val roomName = peerRoomMapping[peerId] ?: currentRoom + + if (roomName != null) { + lastPeerActivity[roomName] = System.currentTimeMillis() + Log.d(TAG, "Updated keep-alive activity for room: $roomName") + } // Send acknowledgment if requested if (shouldReply) { @@ -513,25 +540,26 @@ class WifiAwareManager(private val context: Context) { private fun checkPeerActivity() { val currentTime = System.currentTimeMillis() - val inactivePeers = mutableListOf() + val inactiveRooms = mutableListOf() - lastPeerActivity.forEach { (peerId, lastActivity) -> + lastPeerActivity.forEach { (roomName, lastActivity) -> if (currentTime - lastActivity > KEEP_ALIVE_TIMEOUT_MS) { - Log.w(TAG, "Peer $peerId inactive for too long, considering disconnected") - inactivePeers.add(peerId) + Log.w(TAG, "Room $roomName inactive for too long, considering disconnected") + inactiveRooms.add(roomName) } } - // Remove inactive peers - inactivePeers.forEach { peerId -> - lastPeerActivity.remove(peerId) - peerHandles.remove(peerId) - } - - // If all peers are disconnected, emit disconnection event - if (peerHandles.isEmpty() && lastPeerActivity.isNotEmpty()) { + // Remove inactive rooms + inactiveRooms.forEach { roomName -> + lastPeerActivity.remove(roomName) + peerHandles.remove(roomName) + + // Clean up peer room mapping + peerRoomMapping.entries.removeIf { it.value == roomName } + + // Emit disconnection event for this room coroutineScope.launch { - _connectionFlow.emit(Pair("", false)) + _connectionFlow.emit(Pair(roomName, false)) } } } @@ -547,6 +575,8 @@ class WifiAwareManager(private val context: Context) { wifiAwareSession?.close() wifiAwareSession = null peerHandles.clear() + peerRoomMapping.clear() + currentRoom = null // Don't cancel the coroutine scope - we need it for future operations Log.d(TAG, "WifiAwareManager stopped - session cleared for fresh start") } diff --git a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt index eff04d8..ae04350 100644 --- a/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt +++ b/app/src/main/java/com/mattintech/lchat/repository/ChatRepository.kt @@ -72,14 +72,7 @@ class ChatRepository @Inject constructor( ) addMessage(message) - // If we're receiving messages, we must be connected - if (_connectionState.value !is ConnectionState.Connected && - _connectionState.value !is ConnectionState.Hosting) { - when (_connectionState.value) { - is ConnectionState.Hosting -> {} // Keep hosting state - else -> _connectionState.value = ConnectionState.Connected("Active") - } - } + // Don't change connection state based on messages - let WifiAwareManager handle it } } catch (e: Exception) { android.util.Log.e("ChatRepository", "Error collecting message flow", e) @@ -90,12 +83,20 @@ class ChatRepository @Inject constructor( repositoryScope.launch { try { wifiAwareManager.connectionFlow.collect { (roomName, isConnected) -> + android.util.Log.d("ChatRepository", "Connection flow update - room: $roomName, connected: $isConnected, current state: ${_connectionState.value}") + if (isConnected) { currentRoomName = roomName - _connectionState.value = ConnectionState.Connected(roomName) + // Only change to connected if we're not already hosting + if (_connectionState.value !is ConnectionState.Hosting) { + _connectionState.value = ConnectionState.Connected(roomName) + } loadMessagesFromDatabase(roomName) } else { - _connectionState.value = ConnectionState.Disconnected + // Only disconnect if the room matches our current room + if (roomName.isEmpty() || roomName == currentRoomName) { + _connectionState.value = ConnectionState.Disconnected + } } // Call the legacy callback if set connectionCallback?.invoke(roomName, isConnected)