4. WebSockets
Le plus simple c'est que les WebSockets passent par OkHttp qui fournit tout : le client WS, le WebSocketListener et un dispatcher (pool de threads) partagé tant que tu réutilises le même OkHttpClient.
2. Mini-protocole d’événements (JSON)
On simule un format très simple côté serveur, avec une clé event et un payload :
Autres exemples qu’on gérera :
{ "event": "system.joined", "payload": { "user": "Bob" } }{ "event": "system.left", "payload": { "user": "Eve" } }{ "event": "error", "payload": { "message": "Something went wrong" } }
Pour tester en local sans backend, tu peux utiliser
wss://echo.websocket.org: il renvoie exactement ce que tu envoies. Donc si tu envoies le JSON ci-dessus, tu recevras le même JSON et ton client pourra le parser.
3. Modèles
// ChatModels.kt
data class ChatMessage(
val author: String,
val text: String,
val isSystem: Boolean = false
)
Ce modèle simple représente un message de chat, qu’il soit utilisateur ou système.
4. Listener avec dispatch par event
Ce listener dispatche les messages reçus en fonction de la clé event, et appelle les callbacks passés en paramètre dans un CoroutineScope (généralement celui du ViewModel).
// ChatWebSocketListener.kt
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONObject
class ChatWebSocketListener(
private val scope: CoroutineScope,
private val onEventMessage: (ChatMessage) -> Unit,
private val onEventError: (String) -> Unit,
private val onOpenChanged: (Boolean) -> Unit
) : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// Notify UI that the socket is open
scope.launch { onOpenChanged(true) }
}
override fun onMessage(webSocket: WebSocket, text: String) {
// Dispatch by "event" key
scope.launch {
try {
val root = JSONObject(text)
when (root.optString("event")) {
"chat.message" -> {
val p = root.optJSONObject("payload")
val author = p?.optString("author").orEmpty()
val body = p?.optString("text").orEmpty()
onEventMessage(ChatMessage(author = author, text = body, isSystem = false))
}
"system.joined" -> {
val user = root.optJSONObject("payload")?.optString("user").orEmpty()
onEventMessage(ChatMessage(author = "system", text = "$user joined", isSystem = true))
}
"system.left" -> {
val user = root.optJSONObject("payload")?.optString("user").orEmpty()
onEventMessage(ChatMessage(author = "system", text = "$user left", isSystem = true))
}
"error" -> {
val msg = root.optJSONObject("payload")?.optString("message").orEmpty()
onEventError(msg.ifBlank { "Unknown error" })
}
else -> {
// Unknown event → ignore or show as system debug
}
}
} catch (t: Throwable) {
onEventError(t.message ?: "Failed to parse message")
}
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
scope.launch { onOpenChanged(false) }
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
scope.launch {
onOpenChanged(false)
onEventError(t.message ?: "WebSocket failure")
}
}
}
5. Repository : connect / send / observe
Le repository encapsule : ouverture/fermeture de socket, envoi de messages JSON, et exposes des flux simples.
// ChatRepository.kt
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.logging.HttpLoggingInterceptor
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class ChatRepository(
private val client: OkHttpClient = defaultClient(),
private val wsUrl: String = "wss://echo.websocket.org"
) {
private var webSocket: WebSocket? = null
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
private val _messages = MutableStateFlow<List<ChatMessage>>(emptyList())
val messages: StateFlow<List<ChatMessage>> = _messages.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
fun connect(scope: kotlinx.coroutines.CoroutineScope) {
// Close previous if any
disconnect()
val request = Request.Builder().url(wsUrl).build()
val listener = ChatWebSocketListener(
scope = scope,
onEventMessage = { msg -> _messages.update { it + msg } },
onEventError = { err -> _lastError.value = err },
onOpenChanged = { isOpen -> _connected.value = isOpen }
)
webSocket = client.newWebSocket(request, listener)
}
fun disconnect(code: Int = 1000, reason: String = "client closing") {
webSocket?.close(code, reason)
webSocket = null
_connected.value = false
}
/** Send a chat message event:
* { "event":"chat.message", "payload": { "author": "...", "text": "..." } }
*/
fun sendChatMessage(author: String, text: String): Boolean {
val msg = JSONObject()
.put("event", "chat.message")
.put("payload", JSONObject().put("author", author).put("text", text))
.toString()
return webSocket?.send(msg) ?: false
}
/** Optionally simulate system events locally (useful for demos). */
fun simulateJoined(user: String) {
_messages.update { it + ChatMessage(author = "system", text = "$user joined", isSystem = true) }
}
private fun defaultClient(): OkHttpClient {
val logging = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC }
return OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.SECONDS) // keep WS open
.pingInterval(20, TimeUnit.SECONDS)
.build()
}
}
Note : on n’appelle pas
client.dispatcher.executorService.shutdown()ici, car le client peut être réutilisé ailleurs dans l’app.
6. ViewModel
ViewModel : Il se contente d’orchestrer la connexion et d’exposer les flux du repository.
Il est composé de trois flux : connected, messages et lastError, ainsi que des fonctions connect(), disconnect() et send().
// ChatViewModel.kt
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.StateFlow
class ChatViewModel(
private val repo: ChatRepository
) : ViewModel() {
val connected: StateFlow<Boolean> = repo.connected
val messages: StateFlow<List<ChatMessage>> = repo.messages
val lastError: StateFlow<String?> = repo.lastError
fun connect() = repo.connect(viewModelScope)
fun disconnect() = repo.disconnect()
fun send(author: String, text: String) {
val ok = repo.sendChatMessage(author = author, text = text)
if (!ok) {
// Optionally show an error
}
}
override fun onCleared() {
super.onCleared()
repo.disconnect()
}
}
7. Écran Compose
UI simple avec un bouton Connect/Disconnect, une liste de messages, un champ texte + bouton Send.
// ChatScreen.kt
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@Composable
fun ChatScreen(viewModel: ChatViewModel, selfName: String = "Me") {
val connected by viewModel.connected.collectAsStateWithLifecycle()
val messages by viewModel.messages.collectAsStateWithLifecycle()
val lastError by viewModel.lastError.collectAsStateWithLifecycle()
var input by remember { mutableStateOf("") }
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(
onClick = { if (!connected) viewModel.connect() else viewModel.disconnect() }
) { Text(if (connected) "Disconnect" else "Connect") }
if (lastError != null) {
Text("Error: $lastError", color = MaterialTheme.colorScheme.error)
}
}
Spacer(Modifier.height(12.dp))
LazyColumn(modifier = Modifier.weight(1f)) {
items(messages) { m ->
val prefix = if (m.isSystem) "[system]" else "${m.author}:"
Text(text = "$prefix ${m.text}")
}
}
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
label = { Text("Message") }
)
Button(
onClick = {
if (input.isNotBlank()) {
viewModel.send(author = selfName, text = input.trim())
input = ""
}
},
enabled = connected
) { Text("Send") }
}
}
}
8. Récap : chemin complet de la donnée
- Socket reçoit un JSON →
ChatWebSocketListener.onMessage. - Dispatch par
event→ on mappe enChatMessage. - Repository expose
messages: StateFlow<List<ChatMessage>>+connected+lastError. - ViewModel forward ces flux et propose
connect() / disconnect() / send(). - Compose observe les flows et affiche la liste + input.