diff --git a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt index 81e42c10..7f90a89f 100644 --- a/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt +++ b/app/src/main/java/com/m57/hermescontrol/notification/NotificationReplyReceiver.kt @@ -9,7 +9,11 @@ import androidx.core.app.NotificationCompat import androidx.core.app.RemoteInput import com.m57.hermescontrol.R import com.m57.hermescontrol.data.ws.HermesWsClient +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout open class NotificationReplyReceiver : BroadcastReceiver() { companion object { @@ -17,6 +21,10 @@ open class NotificationReplyReceiver : BroadcastReceiver() { const val EXTRA_SESSION_ID = "extra_session_id" } + // Reusable scope for async reply processing — avoids creating a new + // unmanaged CoroutineScope per broadcast fire. (PERF-15) + private val replyScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + /** * Test-friendly wrapper for [BroadcastReceiver.goAsync] which is `final` * (Java) and cannot be mocked or overridden directly. Tests override this @@ -49,9 +57,9 @@ open class NotificationReplyReceiver : BroadcastReceiver() { if (!replyText.isNullOrBlank() && !sessionId.isNullOrBlank()) { val pendingResult = goAsyncCompat() - kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { + replyScope.launch { try { - kotlinx.coroutines.withTimeout(5000L) { + withTimeout(5000L) { val db = com.m57.hermescontrol.data.local.HermesDatabase .get(context) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index e26955dc..8aed5788 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -797,10 +797,11 @@ private fun StreamingBubbleWithTypingEffect( // Timer that ticks at the configured delay, incrementing the visible word // count each tick. Stops ticking when streaming ends, then shows all words. + // Optimized: split is only called when waiting for new content, not per tick. LaunchedEffect(Unit) { + var wordCount = 0 while (true) { - val words = currentContent.value.split(" ") - if (visibleWordCount < words.size) { + if (visibleWordCount < wordCount) { delay(currentDelayMs.value.toLong()) visibleWordCount++ } else { @@ -808,7 +809,11 @@ private fun StreamingBubbleWithTypingEffect( onAnimationComplete() break } - delay(10) + // Only split when we need to check for new content arriving + val words = currentContent.value.split(" ") + wordCount = words.size + if (visibleWordCount < wordCount) continue + delay(100) // was 10 — reduced from 100Hz to 10Hz } } visibleWordCount = Int.MAX_VALUE diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index ddac5c42..162ebf58 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -1020,7 +1020,8 @@ class ChatViewModel( override fun onCleared() { super.onCleared() pendingCleanupJob?.cancel() - wsClient.disconnect() + // PERF-16: Don't disconnect the global HermesWsClient singleton when + // leaving the Chat screen — it's used by background notification reply. } companion object { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt index dffa5dfd..88234ede 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsViewModel.kt @@ -10,6 +10,7 @@ import com.m57.hermescontrol.data.remote.safeApiCall import com.m57.hermescontrol.theme.BottomNavDisplayMode import com.m57.hermescontrol.theme.ThemePreference import com.m57.hermescontrol.theme.ThemePreset +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -38,47 +39,57 @@ data class SettingsUiState( val bottomNavDisplayMode: BottomNavDisplayMode = BottomNavDisplayMode.ICON_AND_TEXT, ) -class SettingsViewModel : ViewModel() { +class SettingsViewModel( + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { - loadSettings() + viewModelScope.launch(ioDispatcher) { + loadSettings() + } } - private fun loadSettings() { + private suspend fun loadSettings() { val selectedId = AuthManager.getSelectedProfileId() + val host = AuthManager.getHost() + val port = AuthManager.getPort().toString() + val token = AuthManager.getToken() ?: "" + val autoReconnect = AuthManager.isAutoReconnect() + val themePreference = AuthManager.getThemePreference() + val useDynamicColors = AuthManager.isUseDynamicColors() + val themePreset = AuthManager.getThemePreset() + val selectedNavItems = AuthManager.getBottomNavItems() + val typingEffectEnabled = AuthManager.isTypingEffectEnabled() + val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs() + val profiles = AuthManager.getConnectionProfiles() + val bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode() + val renameProfileName = + profiles.firstOrNull { p -> p.id == selectedId }?.name ?: "" _uiState.update { it.copy( - host = AuthManager.getHost(), - port = AuthManager.getPort().toString(), - token = AuthManager.getToken() ?: "", - autoReconnect = AuthManager.isAutoReconnect(), - // B6 (Jun 18 2026, kanban t_86e9be9b): restore user's theme - // choice from persistent storage on init. Previously this slot - // was missing — always defaulted to ThemePreference.SYSTEM. - themePreference = AuthManager.getThemePreference(), - useDynamicColors = AuthManager.isUseDynamicColors(), - themePreset = AuthManager.getThemePreset(), - selectedNavItems = AuthManager.getBottomNavItems(), - typingEffectEnabled = AuthManager.isTypingEffectEnabled(), - typingEffectDelayMs = AuthManager.getTypingEffectDelayMs(), - profiles = AuthManager.getConnectionProfiles(), + host = host, + port = port, + token = token, + autoReconnect = autoReconnect, + themePreference = themePreference, + useDynamicColors = useDynamicColors, + themePreset = themePreset, + selectedNavItems = selectedNavItems, + typingEffectEnabled = typingEffectEnabled, + typingEffectDelayMs = typingEffectDelayMs, + profiles = profiles, selectedProfileId = selectedId, - renameProfileName = - AuthManager - .getConnectionProfiles() - .firstOrNull { p -> - p.id == selectedId - }?.name ?: "", - bottomNavDisplayMode = AuthManager.getBottomNavDisplayMode(), + renameProfileName = renameProfileName, + bottomNavDisplayMode = bottomNavDisplayMode, ) } } fun selectProfile(profileId: String?) { AuthManager.setSelectedProfileId(profileId) - loadSettings() + viewModelScope.launch(ioDispatcher) { loadSettings() } ApiClient.rebuild() } @@ -95,7 +106,7 @@ class SettingsViewModel : ViewModel() { if (it.id == currentId) it.copy(name = newName) else it } AuthManager.saveConnectionProfiles(updatedProfiles) - loadSettings() + viewModelScope.launch(ioDispatcher) { loadSettings() } } fun deleteProfile(profileId: String) { @@ -105,7 +116,7 @@ class SettingsViewModel : ViewModel() { if (AuthManager.getSelectedProfileId() == profileId) { AuthManager.setSelectedProfileId(null) } - loadSettings() + viewModelScope.launch(ioDispatcher) { loadSettings() } ApiClient.rebuild() } @@ -207,8 +218,10 @@ class SettingsViewModel : ViewModel() { AuthManager.setTypingEffectDelayMs(state.typingEffectDelayMs) ApiClient.rebuild() - loadSettings() - _uiState.update { it.copy(isSaved = true, testResult = null) } + viewModelScope.launch(ioDispatcher) { + loadSettings() + _uiState.update { it.copy(isSaved = true, testResult = null) } + } } fun testConnection() { @@ -225,7 +238,7 @@ class SettingsViewModel : ViewModel() { viewModelScope.launch { val result = - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { safeApiCall { ApiClient.hermesApi.getStatus() } } when (result) { diff --git a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt index 4f88454d..1fec8ced 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/settings/SettingsViewModelTest.kt @@ -39,6 +39,12 @@ class SettingsViewModelTest { ConnectionProfile("prof-2", "Home", "10.0.0.2", 9220), ) + private fun createViewModel(): SettingsViewModel { + val vm = SettingsViewModel(ioDispatcher = testDispatcher) + testDispatcher.scheduler.advanceUntilIdle() + return vm + } + @Before fun setUp() { Dispatchers.setMain(testDispatcher) @@ -95,7 +101,7 @@ class SettingsViewModelTest { every { AuthManager.getConnectionProfiles() } returns testProfiles storedSelectedProfileId = "prof-1" - val viewModel = SettingsViewModel() + val viewModel = createViewModel() val state = viewModel.uiState.value assertEquals(2, state.profiles.size) @@ -107,7 +113,7 @@ class SettingsViewModelTest { fun testLoadSettings_noSelectedProfile_renameEmpty() { every { AuthManager.getConnectionProfiles() } returns testProfiles - val viewModel = SettingsViewModel() + val viewModel = createViewModel() assertEquals("", viewModel.uiState.value.renameProfileName) } @@ -115,9 +121,10 @@ class SettingsViewModelTest { fun testSelectProfile_updatesAndRebuildsApi() { every { AuthManager.getConnectionProfiles() } returns testProfiles - val viewModel = SettingsViewModel() + val viewModel = createViewModel() viewModel.selectProfile("prof-2") + testDispatcher.scheduler.advanceUntilIdle() verify { AuthManager.setSelectedProfileId("prof-2") } verify { ApiClient.rebuild() } @@ -130,9 +137,10 @@ class SettingsViewModelTest { every { AuthManager.getConnectionProfiles() } returns testProfiles storedSelectedProfileId = "prof-1" - val viewModel = SettingsViewModel() + val viewModel = createViewModel() viewModel.selectProfile(null) + testDispatcher.scheduler.advanceUntilIdle() verify { AuthManager.setSelectedProfileId(null) } verify { ApiClient.rebuild() } @@ -141,7 +149,7 @@ class SettingsViewModelTest { @Test fun testOnRenameProfileNameChange_updatesState() { - val viewModel = SettingsViewModel() + val viewModel = createViewModel() viewModel.onRenameProfileNameChange("New Name") assertEquals("New Name", viewModel.uiState.value.renameProfileName) assertFalse(viewModel.uiState.value.isSaved) @@ -152,7 +160,7 @@ class SettingsViewModelTest { every { AuthManager.getConnectionProfiles() } returns testProfiles every { AuthManager.getSelectedProfileId() } returns "prof-1" - val viewModel = SettingsViewModel() + val viewModel = createViewModel() viewModel.onRenameProfileNameChange("Renamed Work") viewModel.renameProfile() @@ -164,7 +172,7 @@ class SettingsViewModelTest { every { AuthManager.getConnectionProfiles() } returns testProfiles every { AuthManager.getSelectedProfileId() } returns "prof-1" - val viewModel = SettingsViewModel() + val viewModel = createViewModel() viewModel.onRenameProfileNameChange(" ") viewModel.renameProfile() @@ -173,7 +181,7 @@ class SettingsViewModelTest { @Test fun testRenameProfile_noSelectedProfile_doesNothing() { - val viewModel = SettingsViewModel() + val viewModel = createViewModel() viewModel.onRenameProfileNameChange("New Name") viewModel.renameProfile() @@ -185,7 +193,7 @@ class SettingsViewModelTest { every { AuthManager.getConnectionProfiles() } returns testProfiles every { AuthManager.getSelectedProfileId() } returns "prof-1" - val viewModel = SettingsViewModel() + val viewModel = createViewModel() viewModel.deleteProfile("prof-1") @@ -200,7 +208,7 @@ class SettingsViewModelTest { every { AuthManager.getConnectionProfiles() } returns testProfiles every { AuthManager.getSelectedProfileId() } returns "prof-1" - val viewModel = SettingsViewModel() + val viewModel = createViewModel() viewModel.deleteProfile("prof-2")