diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index a92ab32..215eab6 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 0e63d7a..62fbae1 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index ceb39ae..9938b73 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index 6cfe80b..914b868 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index 08b687c..a6723ad 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 6f9e4db..e733648 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index aa2667a..4db7b06 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/app/src/main/AndroidManifest.xml b/client-mobile/app/src/main/AndroidManifest.xml index 4fe7ec2..3e0e000 100644 --- a/client-mobile/app/src/main/AndroidManifest.xml +++ b/client-mobile/app/src/main/AndroidManifest.xml @@ -11,9 +11,12 @@ + android:theme="@style/Theme.KnotMessenger" + android:networkSecurityConfig="@xml/network_security_config" + android:usesCleartextTraffic="true"> + + + + + + + + + + + + localhost + 127.0.0.1 + 10.0.0.0/8 + 172.16.0.0/12 + 192.168.0.0/16 + + diff --git a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt index ea5b768..c31d18a 100644 --- a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt +++ b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt @@ -107,6 +107,10 @@ class ChatHubClient @Inject constructor() { _events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: "")) }, ReactionEvent::class.java) + conn.on("user_stopped_typing", { data: ReactionEvent -> + _events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: "")) + }, ReactionEvent::class.java) + conn.on("user_online", { userId: String -> _events.tryEmit(ChatEvent.UserOnline(userId)) }, String::class.java) @@ -181,4 +185,18 @@ class ChatHubClient @Inject constructor() { } } } + + fun sendTypingIndicator(chatId: String) { + if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { + hubConnection?.send("typing_start", chatId) + Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId") + } + } + + fun sendUserStoppedTyping(chatId: String) { + if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { + hubConnection?.send("typing_stop", chatId) + Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId") + } + } } diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index fe58ee7..d938bc0 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -100,9 +100,9 @@ class ChatDetailViewModel @Inject constructor( // Ensure SignalR is connected and join the chat room val token = tokenManager.getToken() - if (token != null) { - val baseUrl = serverConfig.getBaseUrl() - signalrClient.connect(baseUrl, token) + val baseUrl = serverConfig.getBaseUrl() + if (token != null && baseUrl.isNotBlank()) { + signalrClient.connect(baseUrl.removeSuffix("/api/"), token) signalrClient.joinChat(chatId) } @@ -230,6 +230,9 @@ class ChatDetailViewModel @Inject constructor( _state.update { it.copy(isTyping = false) } } } + is ChatEvent.UserStoppedTyping -> { + _state.update { it.copy(isTyping = false) } + } is ChatEvent.MessagesRead -> { _state.update { currentState -> val updatedMessages = currentState.messages.map { msg -> @@ -285,6 +288,22 @@ class ChatDetailViewModel @Inject constructor( fun onInputTextChanged(text: String) { _state.update { it.copy(inputText = text) } + + val chatId = currentChatId ?: return + + // Отправляем индикатор набора текста + val now = System.currentTimeMillis() + if (now - lastTypingSentTime > 1000) { + signalrClient.sendTypingIndicator(chatId) + lastTypingSentTime = now + + // Отправляем "остановился печатать" через 3 секунды + typingTimerJob?.cancel() + typingTimerJob = viewModelScope.launch { + delay(3000) + signalrClient.sendUserStoppedTyping(chatId) + } + } } fun sendMessage(onFail: (String) -> Unit = {}) { @@ -298,7 +317,7 @@ class ChatDetailViewModel @Inject constructor( val tempId = "temp_${System.currentTimeMillis()}" val userId = getCurrentUserId() - + // Determine mediaType based on attachments val mediaType = when { pending.isEmpty() -> chats.domain.model.MediaType.TEXT diff --git a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt index 853a791..44fae4c 100644 --- a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt +++ b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt @@ -44,9 +44,11 @@ class ChatListViewModel @Inject constructor( _state.update { it.copy(isStoriesEnabled = isStoriesEnabled) } val token = tokenManager.getToken() - if (token != null) { - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - signalrClient.connect(baseUrl, token) + val baseUrl = serverConfig.getBaseUrl() + + // Подключаемся к SignalR только если есть токен И введён URL сервера + if (token != null && baseUrl.isNotBlank()) { + signalrClient.connect(baseUrl.removeSuffix("/api/"), token) } loadChats() diff --git a/client-mobile/core/network/ServerConfig.kt b/client-mobile/core/network/ServerConfig.kt index f0d7941..fd75a86 100644 --- a/client-mobile/core/network/ServerConfig.kt +++ b/client-mobile/core/network/ServerConfig.kt @@ -1,7 +1,6 @@ package core.network import android.content.Context -import android.content.SharedPreferences import com.google.gson.Gson import core.domain.model.FeaturesConfig import core.domain.model.ServerConfigModel @@ -13,12 +12,12 @@ class ServerConfig @Inject constructor( private val context: Context, private val gson: Gson ) { - private val prefs: SharedPreferences = context.getSharedPreferences("server_settings", Context.MODE_PRIVATE) + private val prefs = context.getSharedPreferences("server_config_prefs", Context.MODE_PRIVATE) companion object { - const val DEFAULT_BASE_URL = "https://your-api.com/api/" private const val KEY_BASE_URL = "base_url" - private const val KEY_CONFIG_DATA = "config_data" + private const val KEY_CONFIG_DATA = "server_config_data" + private const val DEFAULT_BASE_URL = "" // Пустой по умолчанию } fun getBaseUrl(): String { @@ -48,3 +47,4 @@ class ServerConfig @Inject constructor( return feature(getServerConfig().features) } } + diff --git a/client-mobile/core/presentation/settings/SettingsScreen.kt b/client-mobile/core/presentation/settings/SettingsScreen.kt index 5fe80ae..0fd7f7c 100644 --- a/client-mobile/core/presentation/settings/SettingsScreen.kt +++ b/client-mobile/core/presentation/settings/SettingsScreen.kt @@ -32,18 +32,7 @@ fun SettingsScreen( val state by viewModel.state.collectAsState() var baseUrl by remember(state.baseUrl) { mutableStateOf(state.baseUrl) } - val lifecycleOwner = androidx.compose.ui.platform.LocalLifecycleOwner.current - DisposableEffect(lifecycleOwner) { - val observer = androidx.lifecycle.LifecycleEventObserver { _, event -> - if (event == androidx.lifecycle.Lifecycle.Event.ON_RESUME) { - viewModel.loadConfig() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } + // Убран автоматический вызов loadConfig() - теперь только по кнопке Refresh Scaffold( containerColor = Color(0xFF0F0F10), @@ -110,7 +99,9 @@ fun SettingsScreen( focusedIndicatorColor = Color(0xFF3390EC), unfocusedIndicatorColor = Color.Gray.copy(alpha = 0.5f) ), - placeholder = { Text("https://...", color = Color.Gray) } + placeholder = { + Text("Например: http://192.168.1.100:5034/api/", color = Color.Gray.copy(alpha = 0.5f)) + } ) IconButton(onClick = { viewModel.saveBaseUrl(baseUrl) }) { Icon(Icons.Default.Save, contentDescription = null, tint = Color(0xFF3390EC)) diff --git a/client-mobile/core/presentation/settings/SettingsViewModel.kt b/client-mobile/core/presentation/settings/SettingsViewModel.kt index 436b48c..f9ad5a0 100644 --- a/client-mobile/core/presentation/settings/SettingsViewModel.kt +++ b/client-mobile/core/presentation/settings/SettingsViewModel.kt @@ -31,7 +31,7 @@ class SettingsViewModel @Inject constructor( init { refreshState() - loadConfig() + // loadConfig() убран - вызывается только по кнопке Refresh или если URL уже введён } private fun refreshState() { @@ -44,6 +44,13 @@ class SettingsViewModel @Inject constructor( } fun loadConfig() { + // Не загружаем конфиг, если URL не введён + val currentUrl = serverConfig.getBaseUrl() + if (currentUrl.isBlank()) { + android.util.Log.d("SettingsViewModel", "Base URL is empty, skipping config load") + return + } + viewModelScope.launch { _state.update { it.copy(isLoading = true, error = null) } val result = authRepository.fetchConfig() @@ -58,6 +65,16 @@ class SettingsViewModel @Inject constructor( } fun saveBaseUrl(url: String) { + val currentUrl = serverConfig.getBaseUrl() + + // Если URL действительно изменился, очищаем токен и логируем + if (currentUrl != url) { + android.util.Log.d("SettingsViewModel", "Server URL changed: $currentUrl -> $url, clearing token") + viewModelScope.launch { + authRepository.logout() + } + } + serverConfig.setBaseUrl(url) _state.update { it.copy(baseUrl = url) } }