From 8085c55c24fa45e9d5926e0f519f117a90867769 Mon Sep 17 00:00:00 2001 From: Matt Hills Date: Thu, 3 Jul 2025 22:14:29 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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)