SDK Mobile · v1.0

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.

💡
Pourquoi Stream Chat Le client mobile ne parle jamais directement au moteur IA. Vous ne manipulez que le SDK Stream — envoi et réception de messages. Les réponses du bot arrivent comme des messages chat standards avec des pièces jointes optionnelles (quick replies, cartes, etc.).

02Architecture

Le flux complet d'un message implique quatre acteurs :

01
App Mobile
Envoie le message utilisateur via le SDK Stream
02
Serveur Stream
Persiste le message, déclenche le webhook
03
Backend GPSAI
Appelle l'AI Engine, formate la réponse
04
Réponse du bot
Poussée vers le mobile en temps réel

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

build.gradle (Module)
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"
}
Podfile
target 'YourApp' do
  use_frameworks!

  # Stream Chat SDK
  pod 'StreamChat', '~> 4.0'
  pod 'StreamChatUI', '~> 4.0'
end
Package.swift (SPM alternative)
.package(
  url: "https://github.com/GetStream/stream-chat-swift",
  from: "4.0.0"
)

Permissions requises

AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/> <!-- voice only -->
Info.plist
<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.

POST {BACKEND_URL}/chatbot_stream_backend.php?action=token

Corps de la requête

JSON
{
  "user_id": "user_42",        // "user_" + gpsUserId
  "gps_user_id": "42"          // raw GPS user ID
}

Réponse

JSON
{
  "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

Java · ChatbotActivity.java
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();
}
Swift
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)
}
Ne pas mettre le token en cache entre utilisateurs Chaque 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.

Java
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());
        }
    });
}
Swift
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 canalmessaging
ID du canalgps_chat_user_{gpsUserId}
Membresuser_{gpsUserId} + user_1 (le bot)
Données extra{ "gps_user_id": "42", "name": "Assistant GPS" }
Java
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();
            }
        });
}
Swift
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)
    }
}
gps_user_id doit être renseigné Si 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.

Java
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);
            }
        });
}
Swift
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.

🔀
Pourquoi deux pistes Stream n'héberge pas le fichier audio — il porte seulement des métadonnées. L'audio vit sur l'appareil pour la lecture locale et sur le backend pour le traitement IA. Cela garde les messages Stream légers et fonctionne sans CDN public.

Flow d'enregistrement

  1. Tap sur le micro → enregistrement vers un emplacement permanent (filesDir/voice_messages/)
  2. Échantillonnage des amplitudes audio toutes les 80 ms (pour la waveform)
  3. Stop → envoi du message Stream avec extraData + POST du base64 au backend
  4. Le backend transfère à l'AI Engine, reçoit transcript + réponse du bot, poste les deux dans le canal

Enregistrement de l'audio

Java · VoiceRecorder.java
// 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);
Swift · using AVAudioRecorder
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)

POST {BACKEND_URL}/chatbot_stream_backend.php?action=voice
JSON · Corps de la requête (piste base64)
{
  "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 :

JSON · extraData du message Stream (piste UI)
{
  "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 extraDataRô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.
Java · sendVoiceMessage()
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();
}
Swift
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)
    }
}
Ne supprimez pas le fichier local Le message Stream le référence par chemin absolu. Si vous supprimez le fichier après l'envoi, la bulle vocale ne pourra plus être lue. Planifiez un nettoyage seulement pour les très vieux enregistrements (ex : > 7 jours) via un job en arrière-plan.

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.

Java · VoicePlayerManager (singleton)
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);
});
Swift · VoicePlayerManager (singleton)
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

  1. Reçoit audio_base64 + channel_id + user_id
  2. Transfère l'audio base64 à l'AI Engine via POST /chatbot/message (l'audio remplace le texte dans le champ message)
  3. L'AI Engine retourne { transcript: "...", message: "...", suggestions: [...] }
  4. Le backend poste la transcription comme message Stream depuis l'utilisateur ("🎙️ {transcript}") pour qu'elle apparaisse dans l'historique
  5. Le backend poste la réponse du bot comme message Stream depuis user_1
🎙
Format audio requis AAC dans un conteneur MPEG-4, mono, 16 kHz, 64 kbps. Gardez les enregistrements sous 60 secondes. Le client mobile doit faire respecter 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éthodeEndpointRô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.

Exemple de message bot · corps markdown
📍 *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 :

Java · vérifier l'expéditeur
boolean isBot = "user_1".equals(message.getUser().getId());

Ou en Swift :

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 :

Java · routing dans votre ViewHolderFactory
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.

TypeContenuUI 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").

01
AI Engine
Retourne suggestions dans la réponse
02
Backend
Normalise et injecte quick_replies dans le message Stream
03
Android
BotHolder peuple le ChipGroup dynamiquement
04
Tap
Envoie value + déclenche le typing indicator

Format 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.

JSON · réponse AI Engine
{
  "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 chipCas d'usageComportement 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
💡
Distinction 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.

PHP · formatAiEngineResponse() — extraction des suggestions
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").

JSON · forme finale dans le message Stream
{
  "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.

XML · item_message_bot.xml (extrait)
<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 radius20dp (pilule)
Padding12dp horizontal · 6dp vertical
Text size13sp
Hauteur32dp 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.

Java · BotHolder — peuplement des chips
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);
    }
}
Toujours appeler 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.

Java · handleChipClick avec WeakReference + Context unwrap
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;
}
🔑
Pourquoi 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

Swift · QuickRepliesView dans bot bubble
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.

01
AI Engine
Retourne action:"form" + descripteur dans context.form
02
Backend
Allège (« slim ») le form sous 5 KB et l'injecte dans form_data
03
Android
FormHolder construit les champs / cartes dynamiquement
04
Submit
POST ?action=form_submit → réponse du bot via webhook
Limite de 5 KB sur les données custom Stream Stream refuse (HTTP 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".

Java · GpsMessageViewHolderFactory.getItemViewType()
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).

ChampTypeRô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_typeRendu Android
textEditText une ligne
textareaEditText multi-lignes (3–5 lignes)
select / vehicle_selectSpinner (placeholder « — » en première position)
fileBouton « 📎 اختر صورة » → image picker → base64
JSON · form_data (réclamation, slim)
{
  "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 }
    ]
  }
}
Direction RTL automatique Pour une réclamation, l'app détecte la langue à partir du 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'itemTypeRendu
promo_idstring/intRequisValeur envoyée au submit
titlestringTitre de la carte (gras)
reservedbooleanCarte grisée + badge « محجوز »
imgstringNom de fichier seul — reconstruit avec img_base
discountnumberOptionnelBadge vert « -N% »
pricenumberOptionnelPrix affiché
validity_endstringOptionnel« صالح حتى … »
detailsarrayOptionnelLignes à puces sous le titre
🖼
Images : nom de fichier, pas URL complète Pour économiser des octets, le slim ne transmet que le basename de l'image dans 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).
JSON · form_data (promo, slim avec détails)
{
  "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.

PHP · slimFormForStream() — item promo
$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;
📏
Tailles mesurées 21 promos avec détails compacts ≈ 4900 B (sous la limite 5120). Sans détails ≈ 4200 B. Une réclamation ≈ 800 B. Le backend logue chaque taille via 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.

POST {BACKEND_URL}/chatbot_stream_backend.php?action=form_submit
JSON · corps du submit
{
  "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
  }
}
ChampRôle
parameters.endpointRequisRecopié depuis form_data.endpoint
parameters.*Promo : promo_id seul. Réclamation : un champ par name
imageOptionnelData URL base64 si un champ file a été rempli
Déduplication du submit Un double-tap ou un retry réseau peut envoyer le même submit deux fois → le bot répondrait en double. Le backend pose un verrou (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énarioSymptômeSolution
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
Résilience réseau Le SDK Stream a un cache hors-ligne et un retry intégrés. N'ajoutez pas votre propre couche de retry — laissez-le gérer la reconnexion.

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

ConstanteDétectionLayout 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 :

Java · GpsMessageViewHolderFactory
// 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)ClickableSpan avec 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).

Type générique des ViewHolders Les holders étendent 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 :

Swift · Custom cell registry
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()
    }
}

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

Java · Affichage de l'indicateur
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 :

Java · Observer pour auto-hide
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.

Timeout de sécurité recommandé Si la requête échoue côté backend ou si l'AI Engine ne répond jamais, l'indicateur reste affiché indéfiniment. Ajoutez un Handler.postDelayed(this::hideTypingIndicator, 30_000) juste après showTypingIndicator() pour qu'il disparaisse au pire après 30 secondes.

Équivalent iOS

Swift · Local typing indicator
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()
    }
}