GPSAI Chatbot
Intégration Mobile
Guide d'intégration complet pour embarquer l'assistant conversationnel GPSAI dans votre application Android ou iOS, en utilisant Stream Chat comme couche de transport.
01Vue d'ensemble
GPSAI est un assistant conversationnel arabe/français qui permet aux propriétaires de flotte d'interroger leurs véhicules en langage naturel (« وين voiture 4 توا ؟ », « قداش السرعة »…). Les applications mobiles communiquent avec le bot via Stream Chat ; tout le traitement IA se fait côté serveur et arrive sous forme de messages chat classiques.
02Architecture
Le flux complet d'un message implique quatre acteurs :
Pour le développeur mobile, seules les étapes 01 et
04 sont visibles. Le SDK Stream gère automatiquement
la livraison, l'ordre, le cache hors-ligne, les indicateurs de
saisie et les accusés de lecture.
03Prérequis
Ce dont vous avez besoin de l'équipe GPS Tunisie
- Une URL de base du backend (ex :
http://plat.gps-tunisie.com:8080/api) - Un ID utilisateur GPS authentifié (entier, issu de votre flow de login existant)
- L'ID utilisateur du bot — par défaut
user_1 - Phone utilisateur pour tester
121212
Vous n'avez pas besoin de connaître la clé API Stream — le backend la retourne avec le token à chaque authentification.
04Installer le SDK Stream
dependencies { // Stream Chat UI Components implementation "io.getstream:stream-chat-android-ui-components:6.4.0" implementation "io.getstream:stream-chat-android-offline:6.4.0" implementation "io.getstream:stream-chat-android-state:6.4.0" // HTTP client for auth token call implementation "com.squareup.okhttp3:okhttp:4.12.0" }
target 'YourApp' do use_frameworks! # Stream Chat SDK pod 'StreamChat', '~> 4.0' pod 'StreamChatUI', '~> 4.0' end
.package( url: "https://github.com/GetStream/stream-chat-swift", from: "4.0.0" )
Permissions requises
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.RECORD_AUDIO"/> <!-- voice only -->
<key>NSMicrophoneUsageDescription</key> <string>Used for voice messages with GPSAI assistant</string>
05Obtenir le jeton d'authentification
Avant de se connecter à Stream, demandez un JWT à durée limitée et la clé API publique Stream au backend GPSAI. Passez l'ID utilisateur GPS que vous avez déjà depuis votre propre système de login.
Corps de la requête
{
"user_id": "user_42", // "user_" + gpsUserId
"gps_user_id": "42" // raw GPS user ID
}
Réponse
{
"token": "eyJhbGciOi...", // JWT (24h validity)
"api_key": "nwz6q327uhqy", // public Stream API key
"bot_id": "user_1", // bot user ID for channel
"user_id": "user_42",
"gps_user_id": "42"
}
| Champ | Rôle |
|---|---|
token |
RequisÀ passer à connectUser() dans le SDK Stream. |
api_key |
RequisClé Stream publique pour instancier ChatClient. Ne la hardcodez jamais — lisez-la toujours depuis cette réponse. |
bot_id |
RequisID utilisateur du bot à inclure comme membre lors de la création du canal. |
Implémentation
private void fetchToken() { new Thread(() -> { try { OkHttpClient client = new OkHttpClient(); JSONObject body = new JSONObject(); body.put("user_id", "user_" + gpsUserId); body.put("gps_user_id", gpsUserId); Request request = new Request.Builder() .url(BACKEND_URL + "/chatbot_stream_backend.php?action=token") .post(RequestBody.create( MediaType.parse("application/json"), body.toString())) .build(); Réponse response = client.newCall(request).execute(); JSONObject json = new JSONObject(response.body().string()); String token = json.getString("token"); String apiKey = json.getString("api_key"); String botId = json.getString("bot_id"); runOnUiThread(() -> initChat(apiKey, token, botId)); } catch (Exception e) { Log.e(TAG, "Token fetch failed", e); } }).start(); }
struct GPSAuthRéponse: Decodable { let token: String let apiKey: String let botId: String enum CodingKeys: String, CodingKey { case token case apiKey = "api_key" case botId = "bot_id" } } func fetchToken(gpsUserId: String) async throws -> GPSAuthRéponse { var request = URLRequest(url: URL(string: "\(backendURL)/chatbot_stream_backend.php?action=token")!) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") let body = [ "user_id": "user_\(gpsUserId)", "gps_user_id": gpsUserId ] request.httpBody = try JSONSerialization.data(withJSONObject: body) let (data, _) = try await URLSession.shared.data(for: request) return try JSONDecoder().decode(GPSAuthRéponse.self, from: data) }
gps_user_id a son propre JWT. Si un utilisateur
se déconnecte et qu'un autre se connecte, redemandez un nouveau
token. Le token est valable 24 heures.
06Connecter l'utilisateur à Stream
Avec le token en main, construisez un ChatClient et
connectez l'utilisateur. Stream maintient un WebSocket persistant
pour les mises à jour temps réel.
private void initChat(String apiKey, String token, String botId) { // Required plugins for SDK 6.x StreamOfflinePluginFactory offlinePlugin = new StreamOfflinePluginFactory(getApplicationContext()); StreamStatePluginFactory statePlugin = new StreamStatePluginFactory( new StatePluginConfig(true, true), getApplicationContext()); ChatClient client = new ChatClient.Builder(apiKey, getApplicationContext()) .withPlugins(offlinePlugin, statePlugin) .build(); User user = new User.Builder() .withId("user_" + gpsUserId) .withName("Utilisateur " + gpsUserId) .build(); client.connectUser(user, token).enqueue(result -> { if (result.isSuccess()) { createOrOpenChannel(client, botId); } else { Log.e(TAG, "Connect failed: " + result.errorOrNull()); } }); }
import StreamChat func initChat(auth: GPSAuthRéponse, gpsUserId: String) { let config = ChatClientConfig(apiKey: .init(auth.apiKey)) let client = ChatClient(config: config) let userInfo = UserInfo( id: "user_\(gpsUserId)", name: "Utilisateur \(gpsUserId)" ) client.connectUser( userInfo: userInfo, token: try! Token(rawValue: auth.token) ) { error in if let error = error { print("Connect failed: \(error)") } else { self.openChannel(client: client, botId: auth.botId, gpsUserId: gpsUserId) } } }
07Ouvrir le canal de chat
Créez un canal de type messaging avec un ID déterministe
basé sur l'utilisateur. Le bot doit être ajouté comme membre, et le
gps_user_id doit être placé dans extraData —
le webhook backend le lit pour router correctement les requêtes vers
l'AI Engine.
| Convention | Valeur |
|---|---|
| Type de canal | messaging |
| ID du canal | gps_chat_user_{gpsUserId} |
| Membres | user_{gpsUserId} + user_1 (le bot) |
| Données extra | { "gps_user_id": "42", "name": "Assistant GPS" } |
private void createOrOpenChannel(ChatClient client, String botId) { String channelId = "gps_chat_user_" + gpsUserId; Map<String, Object> extraData = new HashMap<>(); extraData.put("gps_user_id", gpsUserId); extraData.put("name", "Assistant GPS"); client.channel("messaging", channelId) .create(Arrays.asList("user_" + gpsUserId, botId), extraData) .enqueue(result -> { if (result.isSuccess()) { String cid = "messaging:" + channelId; startActivity(CustomChatActivity.newIntent(this, cid)); finish(); } }); }
func openChannel(client: ChatClient, botId: String, gpsUserId: String) { let channelId = ChannelId(type: .messaging, id: "gps_chat_user_\(gpsUserId)") let controller = try! client.channelController( createChannelWithId: channelId, name: "Assistant GPS", members: ["user_\(gpsUserId)", botId], isCurrentUserMember: true, extraData: ["gps_user_id": .string(gpsUserId)] ) controller.synchronize { error in guard error == nil else { return } // Push your ChatViewController with `controller` let chatVC = ChatViewController(channelController: controller) navigationController?.pushViewController(chatVC, animated: true) } }
extraData["gps_user_id"] est absent, le webhook
backend ne peut pas identifier quelle flotte interroger. Le bot
répondra mais les données seront celles de l'utilisateur
1 (fallback par défaut).
08Envoyer des messages
Utilisez l'API standard d'envoi de message du SDK Stream. Le bot répondra automatiquement en 1 à 3 secondes selon la charge de l'AI Engine.
private void sendMessage(String text) { Message message = new Message.Builder() .withText(text) .build(); ChatClient.instance() .channel("messaging", channelId) .sendMessage(message, false) .enqueue(result -> { if (result.isSuccess()) { Log.d(TAG, "Sent: " + text); } }); }
func sendMessage(_ text: String) { channelController.createNewMessage(text: text) { result in switch result { case .success(let messageId): print("Sent: \(messageId)") case .failure(let error): print("Error: \(error)") } } }
Réception des réponses du bot
Pas besoin de polling ni d'appel à un endpoint — le SDK Stream
pousse les messages du bot via le même WebSocket. Bind un
MessageListView (Android) ou utilisez un
ChannelControllerDelegate (iOS) et les nouveaux
messages apparaîtront automatiquement.
09Messages vocaux facultatif
Les messages vocaux utilisent une architecture en deux
pistes parallèles : l'enregistrement est envoyé immédiatement
comme un message Stream portant le chemin du fichier local dans
extraData (pour que l'utilisateur puisse le réécouter
dans le chat), et en parallèle l'audio est encodé en base64 puis
POSTé au backend pour la transcription et le traitement IA. Le
backend injecte ensuite la transcription comme message utilisateur,
puis la réponse du bot.
Flow d'enregistrement
- Tap sur le micro → enregistrement vers un emplacement permanent (
filesDir/voice_messages/) - Échantillonnage des amplitudes audio toutes les 80 ms (pour la waveform)
- Stop → envoi du message Stream avec
extraData+ POST du base64 au backend - Le backend transfère à l'AI Engine, reçoit
transcript+ réponse du bot, poste les deux dans le canal
Enregistrement de l'audio
// MPEG_4 / AAC, 16 kHz mono, 64 kbps — small and Whisper-friendly recorder.setAudioSource(MediaRecorder.AudioSource.MIC); recorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4); recorder.setAudioEncoder(MediaRecorder.AudioEncoder.AAC); recorder.setAudioSamplingRate(16000); recorder.setAudioEncodingBitRate(64000); // Store in filesDir (NOT cacheDir — file must survive for replay) File dir = new File(context.getFilesDir(), "voice_messages"); String path = new File(dir, "voice_" + System.currentTimeMillis() + ".m4a") .getAbsolutePath(); recorder.setOutputFile(path); recorder.prepare(); recorder.start(); // Sample amplitudes every 80ms for the waveform handler.postDelayed(() -> amplitudes.add(recorder.getMaxAmplitude()), 80);
import AVFoundation let settings: [String: Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, AVSampleRateKey: 16000, AVNumberOfChannelsKey: 1, AVEncoderBitRateKey: 64000 ] let dir = FileManager.default .urls(for: .documentDirectory, in: .userDomainMask)[0] .appendingPathComponent("voice_messages") try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) let url = dir.appendingPathComponent("voice_\(Int(Date().timeIntervalSince1970)).m4a") recorder = try AVAudioRecorder(url: url, settings: settings) recorder.isMeteringEnabled = true // for amplitudes recorder.prepareToRecord() recorder.record() // Sample amplitudes every 0.08s Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { _ in recorder.updateMeters() amplitudes.append(Int(recorder.averagePower(forChannel: 0) * 10000)) }
Envoi du message vocal (deux pistes)
{
"user_id": "42",
"channel_id": "gps_chat_user_42",
"audio_base64": "AAAAGGZ0eXBtcDQy..."
}
Le message côté Stream porte les métadonnées de lecture dans extraData :
{
"text": "🎙️ Voice",
"extraData": {
"is_voice": true,
"voice_local_path": "/data/.../voice_1716234567.m4a",
"voice_duration_ms": 4280,
"voice_amplitudes": [1240, 3890, 5120, 4200, 2100, ...]
}
}
| Champ extraData | Rôle |
|---|---|
is_voice |
RequisFlag booléen — le ViewHolder factory l'utilise pour rendre une bulle vocale au lieu d'une bulle texte. |
voice_local_path |
RequisChemin absolu vers le fichier audio local. Utilisé par MediaPlayer / AVAudioPlayer pour la lecture. |
voice_duration_ms |
RequisDurée totale en millisecondes (affichée en 00:04). |
voice_amplitudes |
OptionnelÉchantillons d'amplitude bruts pour la visualisation de la waveform. Chaque valeur est un pic capturé à intervalle de ~80 ms. |
private void sendVoiceMessage(String path, long durationMs, ArrayList<Integer> amps) { // ── TRACK 1: Stream message with extraData (instant UI) ── HashMap<String, Object> extra = new HashMap<>(); extra.put("is_voice", true); extra.put("voice_local_path", path); extra.put("voice_duration_ms", durationMs); extra.put("voice_amplitudes", amps); Message msg = new Message.Builder() .withText("🎙️ Voice") .withExtraData(extra) .build(); ChatClient.instance() .channel(channelType, channelId) .sendMessage(msg, false) .enqueue(r -> {}); // ── TRACK 2: base64 to backend (for AI processing) ── new Thread(() -> { String base64 = VoiceRecorder.fileToBase64(path); JSONObject body = new JSONObject(); body.put("user_id", gpsUserId); body.put("channel_id", channelId); body.put("audio_base64", base64); // POST { user_id, channel_id, audio_base64 } to ?action=voice // ⚠️ Do NOT delete the file here — local playback needs it! }).start(); }
func sendVoiceMessage(path: URL, durationMs: Int, amps: [Int]) { // ── TRACK 1: Stream message with extraData ── let extra: [String: RawJSON] = [ "is_voice": .bool(true), "voice_local_path": .string(path.path), "voice_duration_ms": .number(Double(durationMs)), "voice_amplitudes": .array(amps.map { .number(Double($0)) }) ] channelController.createNewMessage(text: "🎙️ Voice", extraData: extra) // ── TRACK 2: base64 to backend ── Task.detached { let data = try Data(contentsOf: path) let base64 = data.base64EncodedString() var req = URLRequest(url: URL(string: "\(backendURL)/chatbot_stream_backend.php?action=voice")!) req.httpMethod = "POST" req.setValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONSerialization.data(withJSONObject: [ "user_id": gpsUserId, "channel_id": channelId, "audio_base64": base64 ]) _ = try await URLSession.shared.data(for: req) } }
Lecture audio
Quand un message arrive avec extraData.is_voice == true,
affichez une bulle vocale (bouton play + waveform + durée). Au tap,
utilisez un singleton de lecture pour garantir qu'un seul audio
joue à la fois.
public class VoicePlayerManager { private static VoicePlayerManager INSTANCE; private MediaPlayer player; private String currentPath; public boolean toggle(String path, Listener listener) { // Same file → pause/resume; different file → stop old, play new if (path.equals(currentPath) && player != null) { if (player.isPlaying()) { player.pause(); return false; } player.start(); return true; } releaseInternal(); player = new MediaPlayer(); player.setDataSource(path); player.prepare(); player.start(); currentPath = path; return true; } } // In your VoiceUserHolder: btnPlayPause.setOnClickListener(v -> { boolean playing = VoicePlayerManager.get().toggle(localPath, listener); btnPlayPause.setImageResource(playing ? R.drawable.ic_pause : R.drawable.ic_play); });
class VoicePlayerManager { static let shared = VoicePlayerManager() private var player: AVAudioPlayer? private var currentPath: String? func toggle(_ path: String, listener: VoiceListener) -> Bool { if currentPath == path, let p = player { if p.isPlaying { p.pause(); return false } p.play(); return true } player?.stop() player = try? AVAudioPlayer(contentsOf: URL(fileURLWithPath: path)) player?.play() currentPath = path return true } }
Ce que le backend fait avec l'audio
- Reçoit
audio_base64+channel_id+user_id - Transfère l'audio base64 à l'AI Engine via
POST /chatbot/message(l'audio remplace le texte dans le champmessage) - L'AI Engine retourne
{ transcript: "...", message: "...", suggestions: [...] } - Le backend poste la transcription comme message Stream depuis l'utilisateur (
"🎙️ {transcript}") pour qu'elle apparaisse dans l'historique - Le backend poste la réponse du bot comme message Stream depuis
user_1
MIN_RECORDING_MS = 500 pour rejeter les
taps accidentels.
10Référence des endpoints API
Le client mobile n'appelle que ces deux endpoints — toutes les autres interactions passent par le SDK Stream.
| Méthode | Endpoint | Rôle |
|---|---|---|
| POST | ?action=token |
Obtenir JWT + api_key + bot_id |
| POST | ?action=voice |
Uploader l'audio pour transcription & réponse |
11Format des messages
Les réponses du bot arrivent comme des messages Stream standards. Le
champ text contient du Markdown — rendez-le (gras,
italique, liens) pour une meilleure UX.
📍 *voiture 4* 🏠 25 جويلية, معتمدية سيدي حسين, تونس 🏙 المدينة: تونس ⏱ آخر تحديث: 2026-05-20 14:32:18 📊 الحالة: En mouvement (4min 9s) _IMEI sélectionné: 355710091342167_ _Véhicule sélectionné: voiture 4_
Identifiez les messages du bot par leur expéditeur :
boolean isBot = "user_1".equals(message.getUser().getId());
Ou en Swift :
let isBot = message.author.id == "user_1"
Messages vocaux
Un message vocal se détecte par la présence de
extraData.is_voice == true. Le factory doit afficher une
bulle vocale (bouton play + waveform + durée) au lieu de la
bulle texte standard :
if (item instanceof MessageListItem.MessageItem) { Message m = ((MessageListItem.MessageItem) item).getMessage(); boolean isBot = botId.equals(m.getUser().getId()); Object voice = m.getExtraData() != null ? m.getExtraData().get("is_voice") : null; boolean isVoice = voice instanceof Boolean && (Boolean) voice; if (!isBot && isVoice) return TYPE_VOICE_USER; return isBot ? TYPE_BOT : TYPE_USER; }
La transcription postée par le backend après la STT arrive comme un
message texte classique préfixé par 🎙️. Traitez-le
comme n'importe quel message utilisateur — aucun traitement spécial
nécessaire.
12Pièces jointes du bot
Le bot enrichit ses réponses avec des attachments. Chacun
est un objet attachment Stream standard avec un champ type —
affichez-les selon votre design.
| Type | Contenu | UI suggérée |
|---|---|---|
image |
Aperçu OpenStreetMap de la position du véhicule | Image inline, tap → ouvre Maps via title_link |
url |
Lien vers Google Maps | Bouton avec title + tap = ouvre title_link |
Suggestion chips (chipsContainer)
À la fin de chaque réponse, l'AI Engine peut renvoyer une liste de
suggestions cliquables — affichées en pilules sous la bulle du bot
pour relancer la conversation en un tap. Le backend les normalise et
les place dans un champ custom message.quick_replies qui
arrive côté Android via message.extraData.get("quick_replies").
suggestions dans la réponsequick_replies dans le message StreamBotHolder peuple le ChipGroup dynamiquementvalue + déclenche le typing indicatorFormat envoyé par l'AI Engine
L'AI Engine retourne désormais des suggestions structurées
(le format string legacy reste supporté pour compatibilité). Chaque
suggestion porte un champ type qui guide son
comportement à l'écran et à l'envoi.
{
"action": "response",
"message": "السيارة Voiture 4 ماشية بـ 40 كم/س...",
"suggestions": [
{ "id": "s1", "label": "🚀 السرعة", "value": "قداش السرعة", "type": "text_suggestion" },
{ "id": "v4", "label": "Voiture 4", "value": "voiture 4", "type": "vehicle_chip" },
{ "id": "ok", "label": "✅ تأكيد", "value": "CONFIRM_STAGED_ACTION", "type": "action_execute" },
{ "id": "no", "label": "❌ إلغاء", "value": "CANCEL_STAGED_ACTION", "type": "action_cancel" }
]
}
| Type de chip | Cas d'usage | Comportement au tap |
|---|---|---|
text_suggestion |
Relance générique (vitesse, statut, prochaine action…) | Envoie value comme message texte normal |
vehicle_chip |
Désambiguïsation : « Quelle voiture ? » | Envoie le nom du véhicule comme message texte |
action_execute |
Confirmation d'une mutation d'état (couper moteur…) | Envoie CONFIRM_STAGED_ACTION → le serveur applique |
action_cancel |
Annulation d'une mutation en attente | Envoie CANCEL_STAGED_ACTION → le serveur efface |
label vs value
Le label est ce que l'utilisateur voit dans la pilule
(peut contenir emojis, traduction, formatage). Le value
est ce qui est envoyé au backend — souvent une commande normalisée
(CONFIRM_STAGED_ACTION) ou un texte en français/derja
que l'AI Engine comprend mieux que le label affiché.
Normalisation côté backend
Le backend PHP accepte les deux formats de suggestion (legacy string
et nouveau objet) et produit toujours la même structure
quick_replies côté Stream.
foreach ($data['suggestions'] as $suggestion) { // Format 1 : string (legacy) if (is_string($suggestion)) { $actions[] = [ 'type' => 'quick_reply', 'label' => $suggestion, 'value' => $suggestion, 'chip_type' => 'text_suggestion', ]; continue; } // Format 2 : objet structuré { id, label, value, type } if (is_array($suggestion)) { $actions[] = [ 'type' => 'quick_reply', 'label' => $suggestion['label'], 'value' => $suggestion['value'] ?? $suggestion['label'], 'chip_type' => $suggestion['type'] ?? 'text_suggestion', ]; } } // Injection dans le message Stream $payload['message']['quick_replies'] = $quick_replies;
Le champ quick_replies est posé à la racine
du message Stream (pas dans attachments) — Stream le
traite comme custom field, et le SDK Android le restitue intact via
message.getExtraData().get("quick_replies").
{
"text": "السيارة Voiture 4 ماشية بـ 40 كم/س...",
"user_id": "user_1",
"attachments": [ ... ],
"quick_replies": [
{ "label": "🚀 السرعة", "value": "قداش السرعة", "chip_type": "text_suggestion" },
{ "label": "📊 الحالة", "value": "الحالة", "chip_type": "text_suggestion" }
]
}
Layout XML : chipsContainer dans la bulle bot
Le ChipGroup est imbriqué dans la bulle du bot
(item_message_bot.xml) — pas dans la rangée externe
— pour que les chips s'alignent avec la bulle (et non avec l'avatar).
android:visibility="gone" par défaut : il ne s'affiche
que si des suggestions arrivent.
<LinearLayout android:orientation="vertical" android:layout_width="wrap_content"> <!-- Bulle rouge avec le texte du bot --> <TextView android:id="@+id/tvMessage" android:background="@drawable/bg_bubble_bot" android:textColor="#FFFFFF" /> <!-- ChipGroup pour les suggestions --> <com.google.android.material.chip.ChipGroup android:id="@+id/chipsContainer" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:layoutDirection="rtl" android:visibility="gone" app:chipSpacingHorizontal="6dp" app:chipSpacingVertical="6dp" app:singleLine="false" /> </LinearLayout>
Style des chips : pilule blanche, texte rouge
Les chips sont des Chip Material instanciés
programmatiquement avec un style cohérent avec l'identité GPS Tunisie :
fond blanc, texte et bordure dans le rouge de la marque.
| Propriété | Valeur |
|---|---|
| Fond (normal) | #FFFFFF |
| Texte | #C8102E (rouge GPS) |
| Bordure | #E0E0E0 (gris clair, 1dp) |
| Corner radius | 20dp (pilule) |
| Padding | 12dp horizontal · 6dp vertical |
| Text size | 13sp |
| Hauteur | 32dp minimum |
Création dynamique dans BotHolder.bindData()
Quand le BotHolder reçoit un message, il lit le champ
custom quick_replies, vide le ChipGroup de
ses chips précédents (recyclage RecyclerView oblige), puis ajoute
une Chip par suggestion.
private void bindQuickReplies(Message message) { Object raw = message.getExtraData().get("quick_replies"); chipsContainer.removeAllViews(); // recyclage : vider d'abord if (!(raw instanceof List)) { chipsContainer.setVisibility(View.GONE); return; } List<?> replies = (List<?>) raw; if (replies.isEmpty()) { chipsContainer.setVisibility(View.GONE); return; } chipsContainer.setVisibility(View.VISIBLE); for (Object item : replies) { if (!(item instanceof Map)) continue; Map<?, ?> data = (Map<?, ?>) item; String label = String.valueOf(data.get("label")); String value = String.valueOf(data.get("value")); Chip chip = new Chip(itemView.getContext()); chip.setText(label); chip.setTextColor(Color.parseColor("#C8102E")); chip.setChipBackgroundColor(ColorStateList.valueOf(Color.WHITE)); chip.setChipStrokeColor(ColorStateList.valueOf(Color.parseColor("#E0E0E0"))); chip.setChipStrokeWidth(2f); chip.setChipCornerRadius(48f); chip.setClickable(true); chip.setOnClickListener(v -> handleChipClick(value)); chipsContainer.addView(chip); } }
removeAllViews() en premier
Le RecyclerView recycle les ViewHolders. Si vous oubliez de vider
le ChipGroup avant de le repeupler, les chips d'un
ancien message restent affichés sous le nouveau message du bot.
Handler du clic : envoi + typing indicator
Au tap sur une chip, on désactive tous les chips de la bulle (pour
éviter les doubles-taps), on envoie la value comme
message utilisateur, puis on déclenche le typing indicator du bot
pour signaler que la réponse arrive.
private void handleChipClick(String value) { // 1) Désactiver tous les chips de cette bulle for (int i = 0; i < chipsContainer.getChildCount(); i++) { chipsContainer.getChildAt(i).setEnabled(false); } // 2) Envoyer le message comme s'il avait été tapé Message msg = new Message.Builder().withText(value).build(); ChatClient.instance() .channel(channelType, channelId) .sendMessage(msg, false) .enqueue(result -> { if (result.isSuccess()) { // 3) Déclencher le typing indicator du bot CustomChatActivity act = resolveActivity(); if (act != null) act.showTypingIndicator(); } }); } // Récupère l'activity depuis WeakReference, fallback via Context unwrap private CustomChatActivity resolveActivity() { CustomChatActivity act = activityRef.get(); if (act != null) return act; // Fallback : Material wrap le Context dans ContextThemeWrapper Context ctx = itemView.getContext(); while (ctx instanceof ContextWrapper) { if (ctx instanceof CustomChatActivity) return (CustomChatActivity) ctx; ctx = ((ContextWrapper) ctx).getBaseContext(); } return null; }
WeakReference + Context unwrap ?
La factory garde une WeakReference<CustomChatActivity>
pour ne pas fuir l'activity. Si la référence est libérée (rotation,
GC), on tombe sur le fallback : remonter la chaîne
ContextWrapper.getBaseContext() jusqu'à trouver
l'activity — nécessaire car Material Design enveloppe le Context
dans un ContextThemeWrapper qui masque l'activity
directe.
Équivalent iOS
func configure(with message: ChatMessage) { quickRepliesStack.arrangedSubviews.forEach { $0.removeFromSuperview() } guard case let .array(replies) = message.extraData["quick_replies"] else { quickRepliesStack.isHidden = true return } quickRepliesStack.isHidden = false for case let .dictionary(reply) in replies { guard case let .string(label) = reply["label"], case let .string(value) = reply["value"] else { continue } let chip = SuggestionChipButton(label: label) chip.addAction(UIAction { [weak self] _ in self?.delegate?.didTapSuggestion(value: value) }, for: .touchUpInside) quickRepliesStack.addArrangedSubview(chip) } }
13Formulaires dynamiques
Au-delà du texte et des chips, le bot peut renvoyer un
formulaire interactif rendu directement dans une
bulle. Deux cas d'usage existent aujourd'hui : une
réclamation (champs de saisie classiques) et un
catalogue promo (cartes sélectionnables avec image,
remise, prix et validité). Le client mobile détecte ces messages via
un champ custom action_type == "form" et inflate un
layout dédié au lieu de la bulle texte standard.
action:"form" + descripteur dans context.formform_dataFormHolder construit les champs / cartes dynamiquement?action=form_submit → réponse du bot via webhookHTTP 413) tout champ custom de message
dépassant 5 KB. Un catalogue promo complet (images en URL absolue,
toutes les lignes de détail) pèse 9–13 KB. Le backend applique donc
un slim : il ne garde que le strict nécessaire et reconstruit
le reste côté app (voir plus bas).
Détection d'un message formulaire
Le backend pose deux champs custom à la racine du message Stream :
action_type = "form" et form_data (le
descripteur allégé). La factory route vers un type de vue dédié
TYPE_FORM dès qu'elle voit action_type == "form".
Object actionType = m.getExtraData() != null ? m.getExtraData().get("action_type") : null; if ("form".equals(actionType)) return TYPE_FORM; // 1006
Structure du descripteur (form_data)
Le descripteur partage un socle commun, puis diverge selon qu'il
s'agit d'une réclamation (clé fields) ou d'une promo
(clé promo_catalog).
| Champ | Type | Rôle |
|---|---|---|
endpoint |
string |
RequisCible du submit (ex : reclamation/add, promo/reserve) |
title |
string |
OptionnelTitre affiché en haut de la carte |
intro_message |
string |
OptionnelTexte d'introduction sous le titre |
submit_label |
string |
OptionnelLibellé du bouton (défaut : « إرسال ») |
requires_confirmation |
boolean |
OptionnelSi vrai, le serveur renvoie une étape de confirmation après le submit |
fields |
array |
RéclamationListe des champs à saisir |
promo_catalog |
object |
PromoCatalogue de cartes sélectionnables |
Cas 1 — Réclamation (champs de saisie)
Chaque entrée de fields décrit un champ. Les types
supportés couvrent la saisie texte, les listes déroulantes et
l'upload d'image.
field_type | Rendu Android |
|---|---|
text | EditText une ligne |
textarea | EditText multi-lignes (3–5 lignes) |
select / vehicle_select | Spinner (placeholder « — » en première position) |
file | Bouton « 📎 اختر صورة » → image picker → base64 |
{
"action_type": "form",
"form_data": {
"endpoint": "reclamation/add",
"title": "تسجيل شكوى",
"submit_label": "إرسال",
"fields": [
{ "name": "vehicle_id", "field_type": "vehicle_select", "label": "المركبة", "required": true },
{ "name": "issue_type", "field_type": "select", "label": "نوع المشكلة", "required": true },
{ "name": "description","field_type": "textarea", "label": "الوصف", "required": true },
{ "name": "image", "field_type": "file", "label": "صورة", "required": false }
]
}
}
title + intro_message (présence de
caractères arabes U+0600–06FF) et applique
layoutDirection + textDirection RTL aux
champs de saisie. La promo, elle, reste en direction par défaut.
Cas 2 — Catalogue promo (cartes)
Le promo_catalog contient une liste d'items,
chacun rendu comme une carte sélectionnable. Une seule carte peut être
choisie ; son promo_id est envoyé au submit. Les cartes
reserved sont grisées et non cliquables.
| Champ de l'item | Type | Rendu |
|---|---|---|
promo_id | string/int | RequisValeur envoyée au submit |
title | string | Titre de la carte (gras) |
reserved | boolean | Carte grisée + badge « محجوز » |
img | string | Nom de fichier seul — reconstruit avec img_base |
discount | number | OptionnelBadge vert « -N% » |
price | number | OptionnelPrix affiché |
validity_end | string | Optionnel« صالح حتى … » |
details | array | OptionnelLignes à puces sous le titre |
img, plus une
base commune promo_catalog.img_base. L'app reconstruit
l'URL : img.startsWith("http") ? img : img_base + img,
puis charge via Glide (par réflexion, pour ne pas imposer la
dépendance).
{
"action_type": "form",
"form_data": {
"endpoint": "promo/reserve",
"title": "حجز عرض",
"submit_label": "إرسال",
"requires_confirmation": true,
"fields": [
{ "name": "promo_id", "field_type": "select", "required": true, "ui_hidden": true }
],
"promo_catalog": {
"intro": "اختر العرض الذي تريد حجزه.",
"img_base": "http://plat.gps-tunisie.com:8080/img/promos/",
"items": [
{
"promo_id": 34,
"title": "دور العجلة واربح",
"reserved": false,
"img": "wheel_34.png",
"discount": 30,
"validity_end": "2026-06-30",
"details": ["وفّر 30%", "عرض محدود"]
}
]
}
}
}
L'allègement « slim » côté backend
La fonction slimFormForStream() reconstruit un descripteur
minimal. Pour la promo elle ne garde par item que
promo_id, title, reserved,
img (basename), et n'ajoute details,
discount, price, validity_end
que s'ils sont non-vides. Un garde-fou final tronque
à 15 items et retire les détails / images si le JSON dépasse encore
4800 octets.
$row = [
'promo_id' => $item['promo_id'] ?? null,
'title' => $item['title'] ?? '',
'reserved' => (bool)($item['reserved'] ?? false),
'img' => $img, // basename, sans PROMO_IMG_BASE
];
// détails compacts — uniquement si présents (pour rester < 5KB)
if (!empty($item['detail_lines'])) $row['details'] = array_values($item['detail_lines']);
if (isset($item['discount'])) $row['discount'] = $item['discount'];
if (!empty($item['validity_end'])) $row['validity_end'] = $item['validity_end'];
$items[] = $row;
FORM_SLIM_SIZE.
Soumission du formulaire
Le submit ne passe pas par le SDK Stream mais par un POST direct au
backend (?action=form_submit). Le backend relaie à l'AI
Engine, puis poste la réponse du bot dans le canal — qui arrive en
temps réel via le WebSocket comme n'importe quel message.
{
"message_type": "FORM_SUBMIT",
"channel_id": "gps_chat_user_42",
"image": null, // ou "data:image/jpeg;base64,..."
"parameters": {
"endpoint": "promo/reserve", // retiré avant l'appel AI Engine
"promo_id": "34" // ou les champs réclamation
}
}
| Champ | Rôle |
|---|---|
parameters.endpoint | RequisRecopié depuis form_data.endpoint |
parameters.* | Promo : promo_id seul. Réclamation : un champ par name |
image | OptionnelData URL base64 si un champ file a été rempli |
md5(channel_id + endpoint + parameters)) valable 10 s
dans handleFormSubmit() pour ignorer les doublons.
Indicateur de saisie au submit
Comme pour l'envoi texte, on affiche le typing indicator après le
POST réussi, et on le masque automatiquement à l'arrivée de la
réponse du bot (voir section 16).
L'observer doit ignorer le message typing lui-même
(par son id fixe et son flag is_typing)
pour ne pas se masquer prématurément, et un timeout de sécurité de
45 s garantit qu'il disparaît même en cas d'échec réseau.
14Gestion des erreurs
| Scénario | Symptôme | Solution |
|---|---|---|
| Token expiré (24h) | connectUser renvoie une erreur d'auth |
Redemander un token via ?action=token |
| Le canal n'existe pas | 404 sur la requête de canal | Appeler create() avec le bot comme membre |
| Le bot ne répond pas | Message envoyé mais aucune réponse | Vérifier l'URL du webhook backend + dispo de l'AI Engine |
| Upload audio trop volumineux | HTTP 413 | Réduire l'enregistrement à < 60s, encoder AAC mono 16kHz |
15Architecture du rendu : ViewHolderFactory
Le SDK Stream livre une bulle par défaut générique. Pour que GPSAI
ait son identité visuelle (bulles rouges du bot, grises de l'utilisateur,
lecteur vocal, indicateur de saisie), nous remplaçons la factory
standard par GpsMessageViewHolderFactory. Cette classe
décide quel layout afficher pour chaque message.
Les 4 types de messages
| Constante | Détection | Layout XML |
|---|---|---|
TYPE_BOT (1001) |
Expéditeur = user_1 |
item_message_bot.xml (bulle rouge à gauche + chipsContainer pour les suggestions) |
TYPE_USER (1002) |
Tout autre expéditeur, sans flag spécial | item_message_user.xml (bulle grise à droite) |
TYPE_VOICE_USER (1003) |
extraData.is_voice == true |
item_message_voice_user.xml (lecteur audio) |
TYPE_TYPING (1004) |
extraData.is_typing == true |
item_typing_indicator.xml (3 points animés) |
Le pattern de routage
La factory implémente deux méthodes clés :
// 1) Décide quel type de vue utiliser pour chaque message public int getItemViewType(MessageListItem item) { Message m = ((MessageListItem.MessageItem) item).getMessage(); boolean isBot = botId.equals(m.getUser().getId()); // Flags personnalisés depuis extraData Object isTyping = m.getExtraData().get("is_typing"); Object isVoice = m.getExtraData().get("is_voice"); if (isTyping instanceof Boolean && (Boolean) isTyping) return TYPE_TYPING; if (!isBot && isVoice instanceof Boolean && (Boolean) isVoice) return TYPE_VOICE_USER; return isBot ? TYPE_BOT : TYPE_USER; } // 2) Inflate le bon layout selon le type public BaseMessageItemViewHolder createViewHolder(ViewGroup parent, int viewType) { switch (viewType) { case TYPE_BOT: return new BotHolder(inflate(R.layout.item_message_bot)); case TYPE_USER: return new UserHolder(inflate(R.layout.item_message_user)); case TYPE_VOICE_USER: return new VoiceUserHolder(inflate(R.layout.item_message_voice_user)); case TYPE_TYPING: return new TypingHolder(inflate(R.layout.item_typing_indicator)); } return super.createViewHolder(parent, viewType); }
Le parser Markdown intégré
Les bulles texte (bot et utilisateur) supportent le Markdown via la
méthode parseMarkdownWithLinks(). Elle reconnaît deux
patterns dans le texte du message :
- Gras :
**texte**→StyleSpan(BOLD) - Liens :
[texte](url)→ClickableSpanavec couleur + soulignement
La couleur des liens s'adapte au fond de la bulle : jaune
(#FFE082) sur fond rouge bot, bleu
(#1565C0) sur fond gris utilisateur. Quand l'utilisateur
clique sur un lien, le contrôle passe à LinkPreviewHelper
(voir section suivante).
BaseMessageItemViewHolder<MessageListItem>
(et non <MessageListItem.MessageItem>) puis vérifient
instanceof dans bindData(). Sans cette
précaution, le scroll provoque un ClassCastException
quand Stream prefetch un DateSeparatorItem dans une
bulle bot.
Équivalent iOS
Sur iOS avec Stream Chat Swift SDK, le pattern équivalent passe
par ChatMessageListVC.Components et l'enregistrement
de cellules custom :
var components = Components.default // Custom message content view components.messageContentView = GPSAIMessageContentView.self // Dans GPSAIMessageContentView, override layout(options:) // pour router selon message.extraData["is_voice"] / ["is_typing"] override func layout(options: ChatMessageLayoutOptions) { guard let msg = content?.message else { return } if case let .bool(isTyping) = msg.extraData["is_typing"], isTyping { renderTypingBubble() } else if case let .bool(isVoice) = msg.extraData["is_voice"], isVoice { renderVoiceBubble() } else if msg.author.id == "user_1" { renderBotBubble() } else { renderUserBubble() } }
16Aperçu des liens : LinkPreviewHelper
Quand le bot répond avec un lien Markdown — par exemple
[شاهد على الخريطة](https://...?lat=36.8&lng=10.1&name=voiture+4)
— on veut éviter de basculer dans le navigateur. À la place, on intercepte
le clic et on affiche un popup natif directement dans l'app.
ClickableSpan.onClick() appelle LinkPreviewHelper.route()isMapUrl() ou isImageUrl() ou autreRoutage par type d'URL
public static void route(Context context, String url) { if (isMapUrl(url)) openMapPreview(context, url); else if (isImageUrl(url)) openImagePreview(context, url); else openInBrowser(context, url); }
| Type d'URL | Détection | Action |
|---|---|---|
| Carte | google.com/maps, openstreetmap, maps.google |
Dialog avec MapView interactive + infos véhicule |
| Image | Extension .jpg .jpeg .png .gif .webp .bmp |
Dialog plein écran avec Glide |
| Autre | Tout le reste | Fallback : Intent.ACTION_VIEW (navigateur) |
Aperçu carte enrichi
Le backend peut encoder des informations supplémentaires dans les paramètres de l'URL pour que le popup affiche un panneau riche. Le helper extrait automatiquement ces champs et les affiche au-dessus de la carte.
https://maps.google.com/?q=36.806535,10.181532 &name=voiture+4 &speed=42 &status=m &address=Avenue+Habib+Bourguiba%2C+Tunis &time=2026-05-21+09:55:32 &odometer=125483.5 &duration=24+ثانية
| Paramètre | Type | Description |
|---|---|---|
q ou lat + lng |
string |
RequisCoordonnées au format lat,lng ou en paramètres séparés |
name |
string |
OptionnelNom du véhicule (titre du popup) |
speed |
number |
OptionnelVitesse en km/h |
status |
string |
OptionnelCode statut : m (en mouvement), s (arrêté), i (ralenti), o (hors-ligne) |
address |
string |
OptionnelAdresse complète (URL-encoded) |
time |
string |
OptionnelHorodatage de la position |
odometer |
number |
OptionnelCompteur kilométrique total |
duration |
string |
OptionnelDurée dans le statut courant (ex : « 24 ثانية ») |
Personnalisation du marker selon le statut
Le helper choisit automatiquement l'icône du marker GoogleMap selon
le code status, et colore le libellé avec une couleur
sémantique :
| Statut | Icône marker | Couleur du label |
|---|---|---|
m — En mouvement | car_marker_active | ● Vert |
i — Ralenti | car_marker_2 | ● Orange |
s — Arrêté | car_marker_stopped | ● Rouge |
o — Hors-ligne | car_marker_stopped | ● Gris |
Équivalent iOS
extension MessageBubbleView: UITextViewDelegate { func textView(_ textView: UITextView, shouldInteractWith URL: URL, in characterRange: NSRange, interaction: UITextItemInteraction) -> Bool { if LinkPreviewHelper.isMapURL(URL) { LinkPreviewHelper.presentMapPreview(URL, on: viewController) return false // On gère nous-mêmes, pas Safari } if LinkPreviewHelper.isImageURL(URL) { LinkPreviewHelper.presentImagePreview(URL, on: viewController) return false } return true // Fallback Safari } }
MapView dans le dialog nécessite onCreate()
+ onResume() à l'ouverture, et onPause() + onDestroy()
au dismiss. Sans cleanup, vous fuitez de la mémoire à chaque popup.
17Indicateur de saisie
Quand l'utilisateur envoie un message au bot, il y a 1 à 3 secondes d'attente avant la réponse (le temps que Stream → webhook → AI Engine → Stream fasse le tour). Pour donner un retour visuel immédiat, on affiche une bulle « ● ● ● » avec une animation de pulsation, qui disparaît dès que le bot répond.
Architecture choisie
Contrairement à l'événement typing.start natif de Stream
(qui notifie les autres utilisateurs), notre indicateur est local :
c'est un faux message ajouté côté client uniquement, qui ne déclenche
pas de webhook. Cela évite que l'utilisateur se notifie lui-même et
que le bot répliera deux fois.
Le pattern show / hide
public void showTypingIndicator() { if (isTypingVisible) return; isTypingVisible = true; HashMap<String, Object> extra = new HashMap<>(); extra.put("is_typing", true); Message typingMsg = new Message.Builder() .withId(TYPING_MESSAGE_ID) // ID fixe pour le supprimer après .withText("") .withUser(botUser) // expéditeur = bot .withExtraData(extra) .build(); ChatClient.instance() .channel(channelType, channelId) .sendMessage(typingMsg, false) .enqueue(r -> {}); } public void hideTypingIndicator() { if (!isTypingVisible) return; isTypingVisible = false; ChatClient.instance() .channel(channelType, channelId) .deleteMessage(TYPING_MESSAGE_ID, true) // hard delete .enqueue(r -> {}); }
Suppression automatique à la réponse du bot
Plutôt que de demander au backend de signaler « réponse prête »,
on observe le State du MessageListViewModel.
Dès que le dernier message du flux vient du bot, on cache l'indicateur :
messageListViewModel.getState().observe(this, state -> { if (!(state instanceof MessageListViewModel.State.Result)) return; Result result = (Result) state; List<MessageListItem> items = result.getMessageListItem().getItems(); if (items.isEmpty()) return; MessageListItem last = items.get(items.size() - 1); if (last instanceof MessageItem) { Message lastMsg = ((MessageItem) last).getMessage(); if (BOT_ID.equals(lastMsg.getUser().getId())) { hideTypingIndicator(); } } });
Rendu de l'animation
Côté factory, TYPE_TYPING route vers TypingHolder
qui contient un TypingIndicatorView custom. Cette vue
dessine trois points qui pulsent à intervalle décalé via
ObjectAnimator. bindData() redémarre
l'animation à chaque rebind pour gérer la réutilisation du RecyclerView.
Handler.postDelayed(this::hideTypingIndicator, 30_000)
juste après showTypingIndicator() pour qu'il disparaisse
au pire après 30 secondes.
Équivalent iOS
func showTypingIndicator() { guard !isTypingVisible else { return } isTypingVisible = true let extra: [String: RawJSON] = ["is_typing": .bool(true)] channelController.createNewMessage( messageId: typingMessageId, // ID stable text: "", extraData: extra ) } func hideTypingIndicator() { guard isTypingVisible else { return } isTypingVisible = false channelController.deleteMessage(messageId: typingMessageId, hard: true) } // Observer dans ChannelControllerDelegate func channelController(_ ctrl: ChatChannelController, didUpdateMessages changes: [ListChange<ChatMessage>]) { if let last = ctrl.messages.first, last.author.id == "user_1" { hideTypingIndicator() } }